Skip to content

Commit 3af1c4b

Browse files
committed
Add automated test suite (49 CI + 6 local tests, all passing)
New test files (tests/): - conftest.py: shared fixtures, 'local'/'demo' markers; local tests auto-skip on GitHub CI unless PYFLEXTRKR_TEST_DATA env var is set - test_steiner_func.py: unit tests for background_intensity, make_dilation_step_func, mod_steiner_classification, expand_conv_core - test_echotop_func.py: unit tests for calc_cloud_boundary and echotop_height (1D and 3D height arrays) - test_vertical_coordinate.py: unit tests for standardize_vertical_coordinate covering height/pressure units, scale_factor and units_override options - test_idcells_synthetic.py: end-to-end integration test using a synthetic in-memory NetCDF file; no real data needed - test_regression_local.py: local regression tests that validate full demo outputs (file structure, track counts, coordinate ranges, animations); uses recursive glob to handle startdate_enddate subdirectories - README.md: pytest usage guide for new users CI configuration: - pytest.ini: test discovery and marker registration - requirements-ci.txt: minimal pip-installable deps for CI (numpy, scipy, xarray, pandas, netcdf4, healpix, PyYAML, pytz, cftime, pytest); replaces requirements.txt + requirements-dev.txt in CI to avoid packages that are conda-only (cartopy, xesmf, ffmpeg, etc.) - .github/workflows/test.yml: use requirements-ci.txt; CI installs only what tests need, making builds faster and more reliable Bug fix: - echotop_func.py: calc_cloud_boundary crashed on empty idxcld input; added early return and filter empty sublists from np.split
1 parent 62b6bef commit 3af1c4b

File tree

11 files changed

+1338
-2
lines changed

11 files changed

+1338
-2
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@ jobs:
2828
- name: Install dependencies
2929
run: |
3030
python -m pip install --upgrade pip
31-
pip install -r requirements.txt
31+
pip install -r requirements-ci.txt
3232
pip install -e .
33-
pip install pytest
3433
3534
- name: Run Tests
3635
run: |

pyflextrkr/echotop_func.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,14 @@ def calc_cloud_boundary(height, idxcld, gap, min_thick):
2121
Cloud-top height for each cloud layer.
2222
"""
2323

24+
# Handle empty idxcld
25+
if len(idxcld) == 0:
26+
return np.zeros(0, dtype=np.float32), np.zeros(0, dtype=np.float32)
27+
2428
# Split idxcld into layers
2529
Layers = np.split(idxcld, np.where(np.diff(idxcld) > gap)[0]+1)
30+
# Filter out empty sublists that np.split can produce
31+
Layers = [L for L in Layers if len(L) > 0]
2632
nLayers = len(Layers)
2733

2834
# Create cloud_base, cloud_top arrays

pytest.ini

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[pytest]
2+
# Default test discovery path
3+
testpaths = tests
4+
5+
# Show short summary for all failures/passes
6+
addopts = -ra -q
7+
8+
# Register custom markers to suppress PytestUnknownMarkWarning
9+
markers =
10+
local: Tests that require locally-mounted data (skipped on GitHub CI).
11+
Set env variable PYFLEXTRKR_TEST_DATA to enable.
12+
demo: Full end-to-end demo run (slow, needs data and multiple workers).
13+
Set env variable PYFLEXTRKR_TEST_DATA to enable.

requirements-ci.txt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Minimal dependencies for running CI tests (GitHub Actions).
2+
# This is intentionally a subset of requirements.txt — it contains only
3+
# what the tested pyflextrkr modules actually import.
4+
#
5+
# Full environment setup (local/HPC): use environment.yml with mamba/conda
6+
# Full dependency list: requirements.txt
7+
8+
# Core scientific stack
9+
numpy
10+
scipy>=1.4
11+
xarray>=0.19
12+
pandas
13+
14+
# NetCDF I/O (imported by pyflextrkr/netcdf_io.py)
15+
netcdf4
16+
17+
# HEALPix (imported by pyflextrkr/hp_utilities.py)
18+
healpix
19+
20+
# Utilities (imported by pyflextrkr/ft_utilities.py)
21+
PyYAML
22+
pytz
23+
cftime
24+
25+
# Testing
26+
pytest>=7.0

tests/README.md

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# PyFLEXTRKR Automated Tests
2+
3+
This directory contains automated tests for PyFLEXTRKR.
4+
Tests are written with [pytest](https://docs.pytest.org/) and are split into two tiers:
5+
6+
| Tier | Files | Runs on | Needs data? |
7+
|------|-------|---------|-------------|
8+
| Unit / synthetic | `test_steiner_func.py`, `test_echotop_func.py`, `test_vertical_coordinate.py`, `test_idcells_synthetic.py`, `test_example.py` | GitHub CI + local | No |
9+
| Local regression | `test_regression_local.py` | Local / HPC only | Yes (demo outputs) |
10+
11+
---
12+
13+
## Quick start
14+
15+
Make sure pytest is installed (once per environment):
16+
```bash
17+
mamba install pytest # or: pip install pytest
18+
```
19+
20+
Run all CI-safe tests from the repo root:
21+
```bash
22+
cd /global/homes/f/feng045/program/PyFLEXTRKR-dev
23+
python -m pytest tests/ -v
24+
```
25+
26+
You should see output like:
27+
```
28+
tests/test_echotop_func.py .......
29+
tests/test_steiner_func.py ..............
30+
tests/test_vertical_coordinate.py ...................
31+
...
32+
49 passed, 6 skipped
33+
```
34+
35+
The 6 `s` (skipped) tests are the local regression tests — they are automatically
36+
skipped unless you set the `PYFLEXTRKR_TEST_DATA` environment variable (see below).
37+
38+
---
39+
40+
## Running a specific test file
41+
42+
```bash
43+
python -m pytest tests/test_steiner_func.py -v
44+
python -m pytest tests/test_echotop_func.py -v
45+
python -m pytest tests/test_vertical_coordinate.py -v
46+
python -m pytest tests/test_idcells_synthetic.py -v
47+
```
48+
49+
## Running a single test by name
50+
51+
Use the `-k` flag with any part of the test name:
52+
```bash
53+
python -m pytest tests/ -v -k "convective" # all tests with 'convective' in the name
54+
python -m pytest tests/ -v -k "echotop"
55+
python -m pytest tests/ -v -k "scale_factor"
56+
```
57+
58+
## Stopping at the first failure
59+
60+
```bash
61+
python -m pytest tests/ -v -x
62+
```
63+
64+
## Show the full error traceback (more detail than default)
65+
66+
```bash
67+
python -m pytest tests/ -v --tb=long
68+
```
69+
70+
---
71+
72+
## Understanding the output symbols
73+
74+
| Symbol | Meaning |
75+
|--------|---------|
76+
| `.` | Test passed |
77+
| `F` | Test failed |
78+
| `s` | Test skipped (e.g., local data not available) |
79+
| `E` | Error during test setup (not a test failure itself) |
80+
81+
---
82+
83+
## Test files explained
84+
85+
### `test_steiner_func.py`
86+
Unit tests for the Steiner convective/stratiform classification functions
87+
(`background_intensity`, `make_dilation_step_func`, `mod_steiner_classification`,
88+
`expand_conv_core`). Uses a small synthetic 50×50 km reflectivity field with one
89+
obvious convective core at the centre — no real radar data needed.
90+
91+
### `test_echotop_func.py`
92+
Unit tests for echo-top height calculation (`calc_cloud_boundary`, `echotop_height`).
93+
Tests both 1D and 3D height coordinate inputs.
94+
95+
### `test_vertical_coordinate.py`
96+
Unit tests for `standardize_vertical_coordinate()` — the function that converts
97+
vertical coordinates to standard units (metres for height, hPa for pressure).
98+
Covers: metres, kilometres, Pascals, hPa, `scale_factor` override, `units_override`,
99+
missing units, and error cases (geopotential height, unknown units).
100+
101+
### `test_idcells_synthetic.py`
102+
End-to-end integration test. Creates a minimal synthetic NetCDF radar file in a
103+
temporary directory, then runs the full cell-identification pipeline:
104+
1. `get_composite_reflectivity_generic` (file reader)
105+
2. `mod_steiner_classification` (convective cell detection)
106+
3. `echotop_height` (echo-top calculation)
107+
4. `expand_conv_core` (cell labelling)
108+
109+
Verifies that the known convective cell in the synthetic data is detected correctly.
110+
No real data needed.
111+
112+
### `test_regression_local.py`
113+
Validates the output of full demo runs (e.g., `demo_cell_nexrad.sh`).
114+
These tests check that track statistics files contain reasonable values
115+
(correct geographic region, positive durations, finite coordinates) and that
116+
quicklook plots and animations were produced.
117+
118+
**These tests are skipped automatically unless you set `PYFLEXTRKR_TEST_DATA`:**
119+
```bash
120+
# Point to the root directory of your demo output data
121+
export PYFLEXTRKR_TEST_DATA=/pscratch/sd/f/feng045/demo
122+
123+
# Run the demo first, then validate its outputs:
124+
bash config/demo_cell_nexrad.sh
125+
python -m pytest tests/test_regression_local.py -v
126+
```
127+
128+
---
129+
130+
## Workflow: running tests after making code changes
131+
132+
```bash
133+
# 1. Make your code changes
134+
# 2. Run all CI-safe tests — should take < 10 seconds
135+
python -m pytest tests/ -v
136+
137+
# 3. If you have local demo data, run regression tests too
138+
export PYFLEXTRKR_TEST_DATA=/pscratch/sd/f/feng045/demo
139+
python -m pytest tests/ -v
140+
```
141+
142+
If any test fails after your changes, the output will show exactly which assertion
143+
failed and what values were produced vs. expected, making it easy to diagnose
144+
whether the change broke existing behaviour or whether the test needs updating.
145+
146+
---
147+
148+
## Adding new tests
149+
150+
Each test file follows the same pattern:
151+
152+
```python
153+
import numpy as np
154+
import pytest
155+
from pyflextrkr.some_module import some_function
156+
157+
def test_my_function_does_something():
158+
# Arrange: set up inputs
159+
my_input = np.array([1, 2, 3])
160+
# Act: call the function
161+
result = some_function(my_input)
162+
# Assert: check the result
163+
assert result == 6, f"Expected 6, got {result}"
164+
```
165+
166+
Group related tests in a class for clarity:
167+
```python
168+
class TestSomeFunction:
169+
def test_normal_case(self): ...
170+
def test_edge_case(self): ...
171+
def test_raises_on_bad_input(self): ...
172+
```
173+
174+
To mark a test as local-only (skipped on CI):
175+
```python
176+
@pytest.mark.local
177+
def test_something_needing_data():
178+
...
179+
```

tests/conftest.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""
2+
conftest.py – shared fixtures and custom pytest markers.
3+
4+
Markers
5+
-------
6+
local Tests that require locally-available data (HPC / workstation only).
7+
Skip automatically on GitHub CI where no data is mounted.
8+
demo Full end-to-end demo run (slow, needs data + multiple CPUs).
9+
10+
Usage
11+
-----
12+
Run everything: pytest tests/
13+
Skip local/demo tests: pytest tests/ -m "not local and not demo"
14+
Run only local tests: pytest tests/ -m local
15+
"""
16+
17+
import os
18+
import numpy as np
19+
import pytest
20+
21+
22+
# ---------------------------------------------------------------------------
23+
# Register custom markers so pytest does not emit warnings
24+
# ---------------------------------------------------------------------------
25+
def pytest_configure(config):
26+
config.addinivalue_line(
27+
"markers",
28+
"local: test requires locally mounted data (skip on GitHub CI)"
29+
)
30+
config.addinivalue_line(
31+
"markers",
32+
"demo: full end-to-end demo run (slow, needs data and multiple workers)"
33+
)
34+
35+
36+
# ---------------------------------------------------------------------------
37+
# Auto-skip local/demo tests when no data root is found
38+
# ---------------------------------------------------------------------------
39+
DATA_ROOT_ENV = "PYFLEXTRKR_TEST_DATA" # set this env-var on HPC/workstation
40+
41+
42+
def pytest_collection_modifyitems(config, items):
43+
data_available = os.environ.get(DATA_ROOT_ENV) is not None
44+
skip_local = pytest.mark.skip(
45+
reason=f"Requires local data: set {DATA_ROOT_ENV} env variable to enable."
46+
)
47+
for item in items:
48+
if "local" in item.keywords and not data_available:
49+
item.add_marker(skip_local)
50+
if "demo" in item.keywords and not data_available:
51+
item.add_marker(skip_local)
52+
53+
54+
# ---------------------------------------------------------------------------
55+
# Shared synthetic-data fixtures (usable by all test files)
56+
# ---------------------------------------------------------------------------
57+
58+
@pytest.fixture
59+
def grid_params():
60+
"""Standard small grid used across tests: 50x50 at 1 km resolution."""
61+
return dict(nx=50, ny=50, dx=1000.0, dy=1000.0)
62+
63+
64+
@pytest.fixture
65+
def synthetic_refl_2d(grid_params):
66+
"""
67+
Simple 2D composite reflectivity field with one obvious convective cell.
68+
69+
Layout (50x50 grid, 1 km spacing):
70+
- Background: 20 dBZ everywhere
71+
- Stratiform ring (r=5..10 km around centre): 30 dBZ
72+
- Convective core (r<5 km around centre): 50 dBZ
73+
"""
74+
nx, ny = grid_params['nx'], grid_params['ny']
75+
cx, cy = nx // 2, ny // 2
76+
77+
yy, xx = np.ogrid[:ny, :nx]
78+
r = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
79+
80+
refl = np.full((ny, nx), 20.0, dtype=np.float32)
81+
refl[(r >= 5) & (r < 10)] = 30.0
82+
refl[r < 5] = 50.0
83+
return refl
84+
85+
86+
@pytest.fixture
87+
def mask_goodvalues(grid_params):
88+
"""All-valid mask matching the default 50x50 grid."""
89+
return np.ones((grid_params['ny'], grid_params['nx']), dtype=int)
90+
91+
92+
@pytest.fixture
93+
def synthetic_dbz3d(grid_params):
94+
"""
95+
Minimal 3D reflectivity DataArray [z, y, x] for echo-top tests.
96+
97+
10 height levels (0..9 km); convective column above the grid centre
98+
has 50 dBZ up to 8 km, 0 dBZ elsewhere.
99+
"""
100+
import xarray as xr
101+
102+
nx, ny = grid_params['nx'], grid_params['ny']
103+
nz = 10
104+
height_1d = np.arange(nz, dtype=np.float32) * 1000.0 # 0..9 km in m
105+
cx, cy = nx // 2, ny // 2
106+
107+
data = np.zeros((nz, ny, nx), dtype=np.float32)
108+
# Convective column: 50 dBZ below 8 km
109+
data[:8, cy - 2:cy + 3, cx - 2:cx + 3] = 50.0
110+
111+
da = xr.DataArray(
112+
data,
113+
dims=['z', 'y', 'x'],
114+
coords={'z': height_1d},
115+
)
116+
return da, height_1d
117+
118+
119+
@pytest.fixture
120+
def steiner_types():
121+
return {
122+
'NO_SURF_ECHO': 1,
123+
'WEAK_ECHO': 2,
124+
'STRATIFORM': 3,
125+
'CONVECTIVE': 4,
126+
}

0 commit comments

Comments
 (0)