Skip to content

Commit 8f374b1

Browse files
authored
Merge pull request #125 from rcjackson/csu_kdp
ADD: test_processing.py and necessary lines to pyproject.toml for cython build.
2 parents fcf98ee + af6b2b0 commit 8f374b1

2 files changed

Lines changed: 260 additions & 1 deletion

File tree

‎cmac/tests/test_processing.py‎

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
"""Integration tests covering cmac() and the per-field PPI/RHI quicklooks.
2+
3+
The quicklook tests use ``pytest-mpl`` to compare each generated figure
4+
against a baseline PNG stored under ``cmac/tests/baseline_images/``.
5+
PPI baselines are named ``ppi_<plot>.png`` and RHI baselines
6+
``rhi_<plot>.png``; the prefix is part of the filename (not the
7+
directory) so pytest-mpl's single-directory ``--mpl-generate-path``
8+
mode produces both sets in one pass without collisions.
9+
10+
The bnfcsapr2cfrS3.a1 datastream returns two files per request: the
11+
first is an RHI scan, the second is a PPI scan. Both are processed
12+
through ``cmac()`` and then through the matching ``quicklooks_*``
13+
function.
14+
15+
To (re)generate the baseline images after a deliberate visual change::
16+
17+
pytest cmac/tests/test_processing.py \\
18+
--mpl-generate-path=cmac/tests/baseline_images
19+
20+
To run the comparison (the default once baselines exist)::
21+
22+
pytest cmac/tests/test_processing.py --mpl
23+
24+
Without the ``--mpl`` flag the baseline tests still execute, but
25+
pytest-mpl skips the image comparison and only checks that a Figure was
26+
returned. ARM_USERNAME / ARM_PASSWORD must be set in the environment
27+
because the fixtures download a sample radar volume and sonde from ARM
28+
Data Discovery.
29+
"""
30+
31+
import glob
32+
import os
33+
34+
import act
35+
import pyart
36+
import pytest
37+
import xarray as xr
38+
39+
from cmac import cmac, quicklooks_ppi, quicklooks_rhi
40+
41+
42+
# cmac() processes both scan strategies the same way, so we feed the PPI
43+
# config (the only one with full metadata/field_names/cmac_values entries)
44+
# to both radars. quicklooks_rhi reuses the same config; the PPI plot_values
45+
# has extra lat/lon keys that quicklooks_rhi ignores.
46+
RADAR_CONFIG = "bnf_csapr2_ppi"
47+
48+
BASELINE_DIR = "baseline_images"
49+
50+
# Every plot name produced by quicklooks_ppi(return_figs=True). Must stay
51+
# in lockstep with the dict keys assigned inside cmac_ppi_quicklooks.py.
52+
# snow_rate_ws2012 is conditional on the field being present, so it's not
53+
# in this list — add an explicit test for it once the test data triggers
54+
# the snow-rate branch.
55+
PPI_FIGURE_NAMES = [
56+
"reflectivity",
57+
"cmac_four_panel_plot",
58+
"masked_corrected_reflectivity",
59+
"corrected_reflectivity",
60+
"differential_phase",
61+
"specific_attenuation",
62+
"corrected_differential_phase",
63+
"corrected_specific_diff_phase",
64+
"corrected_velocity",
65+
"rain_rate_A",
66+
"rain_rate_Z",
67+
"rain_rate_Kdp",
68+
"filtered_corrected_differential_phase",
69+
"filtered_corrected_specific_diff_phase",
70+
"specific_differential_attenuation",
71+
"path_integrated_differential_attenuation",
72+
"corrected_differential_reflectivity",
73+
"normalized_coherent_power",
74+
"signal_to_noise_ratio",
75+
]
76+
77+
# Every plot name produced by quicklooks_rhi(return_figs=True). Same
78+
# co-evolution rule as PPI_FIGURE_NAMES. The RHI module produces a
79+
# subset — no rain_rate_Z, rain_rate_Kdp, or snow_rate_ws2012.
80+
RHI_FIGURE_NAMES = [
81+
"reflectivity",
82+
"cmac_four_panel_plot",
83+
"masked_corrected_reflectivity",
84+
"corrected_reflectivity",
85+
"differential_phase",
86+
"specific_attenuation",
87+
"corrected_differential_phase",
88+
"corrected_specific_diff_phase",
89+
"corrected_velocity",
90+
"rain_rate_A",
91+
"filtered_corrected_differential_phase",
92+
"filtered_corrected_specific_diff_phase",
93+
"specific_differential_attenuation",
94+
"path_integrated_differential_attenuation",
95+
"corrected_differential_reflectivity",
96+
"normalized_coherent_power",
97+
"signal_to_noise_ratio",
98+
]
99+
100+
101+
@pytest.fixture(scope="module")
102+
def _downloaded_files(tmp_path_factory):
103+
"""Download the radar + sonde once for the whole module."""
104+
username = os.getenv("ARM_USERNAME")
105+
token = os.getenv("ARM_PASSWORD")
106+
if not username or not token:
107+
pytest.skip("ARM credentials not set")
108+
109+
data_dir = tmp_path_factory.mktemp("cmac_data")
110+
start, end = "2025-05-20T00:00:00", "2025-05-20T00:10:00"
111+
start_sonde, end_sonde = "2025-05-19T21:00:00", "2025-05-20T03:00:00"
112+
113+
act.discovery.download_arm_data(
114+
username, token, "bnfcsapr2cfrS3.a1", start, end,
115+
output=str(data_dir),
116+
)
117+
act.discovery.download_arm_data(
118+
username, token, "bnfsondewnpnM1.b1", start_sonde, end_sonde,
119+
output=str(data_dir),
120+
)
121+
122+
radar_files = sorted(glob.glob(
123+
str(data_dir / "**" / "bnfcsapr2cfrS3*"), recursive=True))
124+
sonde_files = sorted(glob.glob(
125+
str(data_dir / "**" / "bnfsondewnpnM1*"), recursive=True))
126+
assert len(radar_files) >= 2, (
127+
f"expected at least 2 radar files (RHI + PPI), got {len(radar_files)}")
128+
assert sonde_files, "no sonde file downloaded"
129+
130+
# The bnfcsapr2cfrS3.a1 datastream returns: [0]=RHI scan, [1]=PPI scan.
131+
return {
132+
"rhi": radar_files[0],
133+
"ppi": radar_files[1],
134+
"sonde": sonde_files[0],
135+
}
136+
137+
138+
def _run_cmac(radar_path, sonde_path):
139+
radar = pyart.io.read(radar_path)
140+
sonde = xr.open_dataset(sonde_path)
141+
try:
142+
return cmac(
143+
radar, sonde, RADAR_CONFIG,
144+
meta_append="config", verbose=False,
145+
)
146+
finally:
147+
sonde.close()
148+
149+
150+
@pytest.fixture(scope="module")
151+
def cmac_radar_ppi(_downloaded_files):
152+
return _run_cmac(_downloaded_files["ppi"], _downloaded_files["sonde"])
153+
154+
155+
@pytest.fixture(scope="module")
156+
def cmac_radar_rhi(_downloaded_files):
157+
return _run_cmac(_downloaded_files["rhi"], _downloaded_files["sonde"])
158+
159+
160+
@pytest.fixture(scope="module")
161+
def quicklook_figures_ppi(cmac_radar_ppi):
162+
"""Build every PPI quicklook figure once, share across image-compare tests."""
163+
return quicklooks_ppi(
164+
cmac_radar_ppi, RADAR_CONFIG, sweep=0, return_figs=True)
165+
166+
167+
@pytest.fixture(scope="module")
168+
def quicklook_figures_rhi(cmac_radar_rhi):
169+
"""Build every RHI quicklook figure once, share across image-compare tests."""
170+
return quicklooks_rhi(
171+
cmac_radar_rhi, RADAR_CONFIG, sweep=0, return_figs=True)
172+
173+
174+
def test_cmac_processing_ppi(cmac_radar_ppi):
175+
expected_fields = {
176+
"sounding_temperature",
177+
"height",
178+
"velocity_texture",
179+
"gate_id",
180+
"corrected_reflectivity",
181+
"corrected_velocity",
182+
}
183+
missing = expected_fields - set(cmac_radar_ppi.fields)
184+
assert not missing, f"cmac() did not add expected fields: {missing}"
185+
186+
187+
def test_cmac_processing_rhi(cmac_radar_rhi):
188+
expected_fields = {
189+
"sounding_temperature",
190+
"height",
191+
"velocity_texture",
192+
"gate_id",
193+
"corrected_reflectivity",
194+
"corrected_velocity",
195+
}
196+
missing = expected_fields - set(cmac_radar_rhi.fields)
197+
assert not missing, f"cmac() did not add expected fields: {missing}"
198+
199+
200+
def test_quicklook_figure_set_complete_ppi(quicklook_figures_ppi):
201+
"""Catch new PPI plots being added without a baseline test."""
202+
actual = set(quicklook_figures_ppi) - {"snow_rate_ws2012"}
203+
expected = set(PPI_FIGURE_NAMES)
204+
new = actual - expected
205+
missing = expected - actual
206+
assert not new, (
207+
f"new PPI quicklook(s) {new} present in figures dict but no baseline "
208+
f"test — add them to PPI_FIGURE_NAMES and regenerate baselines.")
209+
assert not missing, (
210+
f"PPI quicklook(s) {missing} listed in PPI_FIGURE_NAMES but not "
211+
f"produced by quicklooks_ppi — remove from PPI_FIGURE_NAMES or restore.")
212+
213+
214+
def test_quicklook_figure_set_complete_rhi(quicklook_figures_rhi):
215+
"""Catch new RHI plots being added without a baseline test."""
216+
actual = set(quicklook_figures_rhi)
217+
expected = set(RHI_FIGURE_NAMES)
218+
new = actual - expected
219+
missing = expected - actual
220+
assert not new, (
221+
f"new RHI quicklook(s) {new} present in figures dict but no baseline "
222+
f"test — add them to RHI_FIGURE_NAMES and regenerate baselines.")
223+
assert not missing, (
224+
f"RHI quicklook(s) {missing} listed in RHI_FIGURE_NAMES but not "
225+
f"produced by quicklooks_rhi — remove from RHI_FIGURE_NAMES or restore.")
226+
227+
228+
def _make_baseline_test(scan, name, figures_fixture):
229+
@pytest.mark.mpl_image_compare(
230+
baseline_dir=BASELINE_DIR,
231+
filename=f"{scan}_{name}.png",
232+
tolerance=10,
233+
)
234+
def _test(request):
235+
figures = request.getfixturevalue(figures_fixture)
236+
return figures[name]
237+
_test.__name__ = f"test_quicklook_{scan}_{name}"
238+
_test.__doc__ = (
239+
f"pytest-mpl baseline comparison for the {name!r} {scan.upper()} "
240+
f"quicklook.")
241+
return _test
242+
243+
244+
# Bind one image-compare test per figure into module globals so pytest
245+
# discovers them as test_quicklook_ppi_<name> / test_quicklook_rhi_<name>.
246+
# Baseline filenames are ppi_<name>.png / rhi_<name>.png so the two scan
247+
# strategies share one baseline_dir without colliding.
248+
for _name in PPI_FIGURE_NAMES:
249+
globals()[f"test_quicklook_ppi_{_name}"] = _make_baseline_test(
250+
"ppi", _name, "quicklook_figures_ppi")
251+
for _name in RHI_FIGURE_NAMES:
252+
globals()[f"test_quicklook_rhi_{_name}"] = _make_baseline_test(
253+
"rhi", _name, "quicklook_figures_rhi")
254+
del _name

‎pyproject.toml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,10 @@ test = [
2323
"pytest",
2424
"pytest-mpl",
2525
"xarray",
26-
"act-atmos",
26+
"act-atmos"]
27+
28+
[tool.setuptools]
29+
# Explicitly register the extension modules to be compiled
30+
ext-modules = [
31+
{ name = "cmac.calc_kdp_ray_fir", sources = ["cmac/calc_kdp_ray_fir.pyx"] }
2732
]

0 commit comments

Comments
 (0)