Skip to content

Commit 8415f6d

Browse files
committed
Combine radar sweeps into a logical radar volume.
1 parent 6496432 commit 8415f6d

File tree

2 files changed

+131
-0
lines changed

2 files changed

+131
-0
lines changed

tests/test_util.py

Lines changed: 33 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,32 @@ 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+
print(sweeps[0].ds.sweep_group_name)
557+
volume = util.create_volume(
558+
sweeps=sweeps,
559+
time_coverage_start=time_coverage_start,
560+
time_coverage_end=time_coverage_end,
561+
min_angle=0.5,
562+
max_angle=5.0,
563+
)
564+
np.testing.assert_array_equal(volume.ds.sweep_group_name, [0, 1, 2])
565+
angles = [3.6, 1.6, 1.0]
566+
np.testing.assert_array_equal(volume.ds.sweep_fixed_angle, angles)
567+
sweep_fixed_angle = [
568+
volume[sweep].ds.sweep_fixed_angle.values.item()
569+
for sweep in util.get_sweep_keys(volume)
570+
]
571+
np.testing.assert_array_equal(sweep_fixed_angle, angles)

xradar/util.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@
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__))
3536

3637
import contextlib
38+
import datetime
3739
import functools
3840
import gzip
3941
import importlib.util
@@ -740,3 +742,99 @@ 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: datetime.datetime = None,
750+
time_coverage_end: datetime.datetime = 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+
758+
Parameters
759+
----------
760+
sweeps : list of xr.DataTree
761+
Each DataTree represents one or more radar sweeps.
762+
time_coverage_start : datetime, optional
763+
Minimum start time for sweeps to include.
764+
time_coverage_end : datetime, optional
765+
Maximum start time for sweeps to include.
766+
min_angle : float, optional
767+
Minimum sweep_fixed_angle to include (inclusive).
768+
max_angle : float, optional
769+
Maximum sweep_fixed_angle to include (inclusive).
770+
volume_number : int, default 0
771+
Identifier for the volume, stored in the root node's attributes.
772+
773+
Returns
774+
-------
775+
xr.DataTree
776+
A volume tree with root metadata and child nodes named 'sweep_0', 'sweep_1', etc.
777+
"""
778+
779+
# Step 1: Extract (ds, time, angle) tuples from all sweeps
780+
sweep_entries = []
781+
for dt in sweeps:
782+
for key in get_sweep_keys(dt):
783+
ds = xr.decode_cf(dt[key].ds)
784+
if "time" not in ds or "sweep_fixed_angle" not in ds:
785+
continue # skip malformed sweeps
786+
t0 = ds["time"].min().values.astype("datetime64[ns]").astype("int64")
787+
t0 = datetime.datetime.utcfromtimestamp(t0 / 1e9)
788+
angle = float(ds["sweep_fixed_angle"].item())
789+
sweep_entries.append((ds, t0, angle))
790+
791+
# Step 2: Filter by time bounds
792+
if time_coverage_start is not None:
793+
sweep_entries = [
794+
entry for entry in sweep_entries if entry[1] >= time_coverage_start
795+
]
796+
if time_coverage_end is not None:
797+
sweep_entries = [
798+
entry for entry in sweep_entries if entry[1] <= time_coverage_end
799+
]
800+
801+
# Step 3: Filter by angle bounds
802+
if min_angle is not None:
803+
sweep_entries = [entry for entry in sweep_entries if entry[2] >= min_angle]
804+
if max_angle is not None:
805+
sweep_entries = [entry for entry in sweep_entries if entry[2] <= max_angle]
806+
807+
# Step 4: Sort by time
808+
sweep_entries.sort(key=lambda x: x[1])
809+
810+
if not sweep_entries:
811+
raise ValueError("No sweeps remain after filtering.")
812+
813+
# Step 5: Prepare root metadata
814+
root_ds = sweeps[0].ds.copy(deep=True)
815+
root_ds = root_ds.drop_vars(
816+
["sweep_group_name", "sweep_fixed_angle"], errors="ignore"
817+
)
818+
819+
root_ds["sweep_group_name"] = xr.DataArray(range(len(sweep_entries)), dims="sweep")
820+
root_ds["sweep_fixed_angle"] = xr.DataArray(
821+
[angle for _, _, angle in sweep_entries], dims="sweep"
822+
)
823+
824+
# Step 6: Assign time coverage
825+
def format_zulu(dt: datetime.datetime) -> str:
826+
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
827+
828+
time_coverage_start = time_coverage_start or sweep_entries[0][1]
829+
time_coverage_end = time_coverage_end or sweep_entries[-1][1]
830+
831+
root_ds["time_coverage_start"] = format_zulu(time_coverage_start)
832+
root_ds["time_coverage_end"] = format_zulu(time_coverage_end)
833+
root_ds.attrs["volume_number"] = volume_number
834+
835+
# Step 7: Assemble final tree
836+
volume = xr.DataTree(root_ds, name="root")
837+
for i, (ds, _, _) in enumerate(sweep_entries):
838+
volume[f"sweep_{i}"] = xr.DataTree(ds.copy(deep=True))
839+
840+
return volume

0 commit comments

Comments
 (0)