|
| 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 |
0 commit comments