Skip to content

Commit 41ab76e

Browse files
committed
Combine radar sweeps into a logical radar volume.
1 parent 6496432 commit 41ab76e

File tree

2 files changed

+127
-0
lines changed

2 files changed

+127
-0
lines changed

tests/test_util.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

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

7+
import datetime
8+
import fnmatch
9+
import random
10+
711
import numpy as np
812
import pytest
913
import xarray as xr
@@ -536,3 +540,34 @@ def test_select_sweep_dataset_vars():
536540

537541
metadata = required_metadata + ["polarization_mode", "nyquist_velocity"]
538542
assert sorted(sweep2.data_vars) == sorted(select + ["quality1"] + metadata)
543+
544+
545+
def test_create_volume():
546+
pattern = "T_PAZ?63_C_LFPW_20230420*"
547+
filenames = list(DATASETS.registry.keys())
548+
files = fnmatch.filter(filenames, pattern)
549+
random.shuffle(files)
550+
sweeps = [io.open_odim_datatree(DATASETS.fetch(fn)) for fn in files]
551+
552+
time_coverage_start = "2023-04-20T06:50:00"
553+
time_coverage_start = datetime.datetime.fromisoformat(time_coverage_start)
554+
time_coverage_end = "2023-04-20T06:55:00"
555+
time_coverage_end = datetime.datetime.fromisoformat(time_coverage_end)
556+
volume = util.create_volume(
557+
sweeps=sweeps,
558+
time_coverage_start=time_coverage_start,
559+
time_coverage_end=time_coverage_end,
560+
min_angle=0.5,
561+
max_angle=5.0,
562+
)
563+
assert volume.ds.time_coverage_start == "2023-04-20T06:50:00Z"
564+
assert volume.ds.time_coverage_end == "2023-04-20T06:55:00Z"
565+
sweep_group_name = [b"sweep_0", b"sweep_1", b"sweep_2"]
566+
np.testing.assert_array_equal(volume.ds.sweep_group_name, sweep_group_name)
567+
angles = [3.6, 1.6, 1.0]
568+
np.testing.assert_array_equal(volume.ds.sweep_fixed_angle, angles)
569+
sweep_fixed_angle = [
570+
volume[sweep].ds.sweep_fixed_angle.values.item()
571+
for sweep in util.get_sweep_keys(volume)
572+
]
573+
np.testing.assert_array_equal(sweep_fixed_angle, angles)

xradar/util.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"get_sweep_dataset_vars",
3030
"get_sweep_metadata_vars",
3131
"select_sweep_dataset_vars",
32+
"create_volume",
3233
]
3334

3435
__doc__ = __doc__.format("\n ".join(__all__))
@@ -513,6 +514,7 @@ def get_sweep_keys(dtree):
513514
keys : list
514515
List of associated keys with sweep_n
515516
"""
517+
516518
sweep_group_keys = []
517519
for key in list(dtree.children):
518520
parts = key.split("_")
@@ -740,3 +742,93 @@ def select_sweep_dataset_vars(sweep, select, ancillary=False, optional_metadata=
740742
sweep_out = sweep[select]
741743

742744
return sweep_out
745+
746+
747+
def create_volume(
748+
sweeps: list[xr.DataTree],
749+
time_coverage_start: np.datetime64 = None,
750+
time_coverage_end: np.datetime64 = None,
751+
min_angle: float = None,
752+
max_angle: float = None,
753+
volume_number: int = 0,
754+
) -> xr.DataTree:
755+
"""
756+
Combine sweeps into a single stacked radar volume with optional time and angle filtering.
757+
All timestamps are handled as np.datetime64[ns] internally.
758+
"""
759+
760+
def format_zulu(t: np.datetime64) -> str:
761+
if np.isnat(t):
762+
raise ValueError("Invalid datetime: NaT encountered")
763+
return str(t).split(".")[0] + "Z"
764+
765+
# Normalize time bounds
766+
if time_coverage_start is not None:
767+
time_coverage_start = np.datetime64(time_coverage_start, "ns")
768+
if time_coverage_end is not None:
769+
time_coverage_end = np.datetime64(time_coverage_end, "ns")
770+
771+
# Step 1: Extract (ds, time, angle) tuples
772+
sweep_entries = []
773+
for dt in sweeps:
774+
for key in get_sweep_keys(dt):
775+
ds = xr.decode_cf(dt[key].ds)
776+
time = ds["time"].values.astype("datetime64[ns]")
777+
mask = time != np.datetime64("NaT", "ns")
778+
time = time[mask]
779+
if time.size == 0:
780+
continue
781+
t0 = time.min()
782+
angle = float(ds["sweep_fixed_angle"].item())
783+
sweep_entries.append((ds, t0, angle))
784+
785+
# Step 2: Filter by time
786+
if time_coverage_start is not None:
787+
sweep_entries = [
788+
entry for entry in sweep_entries if entry[1] >= time_coverage_start
789+
]
790+
if time_coverage_end is not None:
791+
sweep_entries = [
792+
entry for entry in sweep_entries if entry[1] <= time_coverage_end
793+
]
794+
795+
# Step 3: Filter by angle
796+
if min_angle is not None:
797+
sweep_entries = [entry for entry in sweep_entries if entry[2] >= min_angle]
798+
if max_angle is not None:
799+
sweep_entries = [entry for entry in sweep_entries if entry[2] <= max_angle]
800+
801+
# Step 4: Sort by time
802+
sweep_entries.sort(key=lambda x: x[1])
803+
804+
if not sweep_entries:
805+
raise ValueError("No sweeps remain after filtering.")
806+
807+
# Step 5: Prepare root metadata
808+
root_ds = sweeps[0].ds.copy(deep=True)
809+
root_ds = root_ds.drop_vars(
810+
["sweep_group_name", "sweep_fixed_angle"], errors="ignore"
811+
)
812+
813+
root_ds["sweep_group_name"] = xr.DataArray(
814+
np.array([f"sweep_{i}" for i in range(len(sweep_entries))], dtype="S"),
815+
dims="sweep",
816+
)
817+
root_ds["sweep_fixed_angle"] = xr.DataArray(
818+
[angle for _, _, angle in sweep_entries], dims="sweep"
819+
)
820+
821+
# Step 6: Assign time coverage
822+
time_coverage_start = time_coverage_start or sweep_entries[0][1]
823+
time_coverage_end = time_coverage_end or sweep_entries[-1][1]
824+
825+
root_ds["time_coverage_start"] = format_zulu(time_coverage_start)
826+
root_ds["time_coverage_end"] = format_zulu(time_coverage_end)
827+
root_ds.attrs["volume_number"] = volume_number
828+
829+
# Step 7: Assemble final tree
830+
volume = xr.DataTree(root_ds, name="root")
831+
for i, (ds, _, _) in enumerate(sweep_entries):
832+
volume[f"sweep_{i}"] = xr.DataTree(ds.copy(deep=True))
833+
834+
return volume

0 commit comments

Comments
 (0)