Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
cafd4c2
format/fix docstrings
mdeshotel Mar 18, 2026
dec3956
format docstrings in conftest
mdeshotel Mar 18, 2026
4b76797
add tests for geomod and inputforcing
mdeshotel Mar 19, 2026
4e5ef0b
add arg to allow new attributes in actual results
mdeshotel Mar 20, 2026
4f0e102
add grid type arg to dynamically get the correct bmi model class
mdeshotel Mar 20, 2026
61a3ede
add method to convert class attibutes and properties to a dictionary
mdeshotel Mar 20, 2026
93308a1
rename wrf_hydro_geo_meta to geometa
mdeshotel Mar 20, 2026
0b81d4f
map old variable names to new names
mdeshotel Mar 24, 2026
4f3a3aa
add support for serializing functions as string
mdeshotel Mar 24, 2026
2067920
use ordered dict
mdeshotel Mar 26, 2026
02e9f55
add read me for test
mdeshotel Mar 27, 2026
aae9450
add rank and number of processors to test json filename
mdeshotel Mar 28, 2026
a3dea19
repalce np.empty with np.full
mdeshotel Mar 28, 2026
4cf93f1
add new arg to control the mapping of old variable names to new
mdeshotel Mar 28, 2026
25eaa39
update expected test data
mdeshotel Mar 28, 2026
39d4443
replace remaning np.empty with np.full
mdeshotel Mar 28, 2026
105a756
fix ewts acronym in tests readme.
mdeshotel Mar 30, 2026
e051b17
mention inthe readme that tests should be ran in dev container
mdeshotel Mar 30, 2026
8199b9a
revert geometa naming
mdeshotel Mar 30, 2026
269dee1
fix map_old_to_new_var_names logic
mdeshotel Mar 31, 2026
584c021
update readme to be more verbose about the initial expected data setup.
mdeshotel Mar 31, 2026
026d78b
add type hints
mdeshotel Mar 31, 2026
74e3b34
change config locations to test dir
mdeshotel Mar 31, 2026
18c02ce
compare dict items and report misaligned items
mdeshotel Mar 31, 2026
e20bfd0
update configs and expected results
mdeshotel Mar 31, 2026
9adf42f
replace symlink with actual file
mdeshotel Mar 31, 2026
7db68e5
fix typo
mdeshotel Apr 3, 2026
6818ea6
update gpkg
mdeshotel Apr 3, 2026
5c880fd
updated expected test results
mdeshotel Apr 3, 2026
f1ea03f
fix boolean from pytest arg issue
mdeshotel Apr 3, 2026
cd91046
fix boolean for map_old_to_new_var_names 2.0
mdeshotel Apr 3, 2026
ab1a6ed
add dtype to np.full calls
mdeshotel Apr 5, 2026
da48fa2
add copy_and_stringify_functions
mdeshotel Apr 6, 2026
7aa1322
add class_to_dict function
mdeshotel Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
940 changes: 940 additions & 0 deletions NextGen_Forcings_Engine_BMI/NextGen_Forcings_Engine/core/consts.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1101,33 +1101,35 @@ def init_dict(
# temporary temperature arrays that are un-downscaled.
if input_dict[force_key].q2dDownscaleOpt > 0:
if config_options.grid_type == "gridded":
input_dict[force_key].t2dTmp = np.empty(
input_dict[force_key].t2dTmp = np.full(
[geo_meta_wrf_hydro.ny_local, geo_meta_wrf_hydro.nx_local],
np.float32,
np.nan,
dtype=np.float32,
)
input_dict[force_key].psfcTmp = np.empty(
input_dict[force_key].psfcTmp = np.full(
[geo_meta_wrf_hydro.ny_local, geo_meta_wrf_hydro.nx_local],
np.float32,
np.nan,
dtype=np.float32,
)
elif config_options.grid_type == "unstructured":
input_dict[force_key].t2dTmp = np.empty(
[geo_meta_wrf_hydro.ny_local], np.float32
input_dict[force_key].t2dTmp = np.full(
[geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float32
)
input_dict[force_key].psfcTmp = np.empty(
[geo_meta_wrf_hydro.ny_local], np.float32
input_dict[force_key].psfcTmp = np.full(
[geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float32
)
input_dict[force_key].t2dTmp_elem = np.empty(
[geo_meta_wrf_hydro.ny_local_elem], np.float32
input_dict[force_key].t2dTmp_elem = np.full(
[geo_meta_wrf_hydro.ny_local_elem], np.nan, dtype=np.float32
)
input_dict[force_key].psfcTmp_elem = np.empty(
[geo_meta_wrf_hydro.ny_local_elem], np.float32
input_dict[force_key].psfcTmp_elem = np.full(
[geo_meta_wrf_hydro.ny_local_elem], np.nan, dtype=np.float32
)
elif config_options.grid_type == "hydrofabric":
input_dict[force_key].t2dTmp = np.empty(
[geo_meta_wrf_hydro.ny_local], np.float32
input_dict[force_key].t2dTmp = np.full(
[geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float32
)
input_dict[force_key].psfcTmp = np.empty(
[geo_meta_wrf_hydro.ny_local], np.float32
input_dict[force_key].psfcTmp = np.full(
[geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float32
)
# Initialize the local final grid of values. This is represntative
# of the local grid for this forcing, for a specific output timesetp.
Expand All @@ -1139,56 +1141,65 @@ def init_dict(
input_dict[force_key].grib_vars = input_dict[force_key].grib_vars[:-1]

if config_options.grid_type == "gridded":
input_dict[force_key].final_forcings = np.empty(
input_dict[force_key].final_forcings = np.full(
[force_count, geo_meta_wrf_hydro.ny_local, geo_meta_wrf_hydro.nx_local],
np.float64,
np.nan,
dtype=np.float64,
)
input_dict[force_key].height = np.empty(
[geo_meta_wrf_hydro.ny_local, geo_meta_wrf_hydro.nx_local], np.float32
input_dict[force_key].height = np.full(
[geo_meta_wrf_hydro.ny_local, geo_meta_wrf_hydro.nx_local],
np.nan,
dtype=np.float32,
)
input_dict[force_key].regridded_mask = np.empty(
[geo_meta_wrf_hydro.ny_local, geo_meta_wrf_hydro.nx_local], np.float32
input_dict[force_key].regridded_mask = np.full(
[geo_meta_wrf_hydro.ny_local, geo_meta_wrf_hydro.nx_local],
np.nan,
dtype=np.float32,
)
input_dict[force_key].regridded_mask_AORC = np.empty(
[geo_meta_wrf_hydro.ny_local, geo_meta_wrf_hydro.nx_local], np.float32
input_dict[force_key].regridded_mask_AORC = np.full(
[geo_meta_wrf_hydro.ny_local, geo_meta_wrf_hydro.nx_local],
np.nan,
dtype=np.float32,
)
elif config_options.grid_type == "unstructured":
input_dict[force_key].final_forcings = np.empty(
[force_count, geo_meta_wrf_hydro.ny_local], np.float64
input_dict[force_key].final_forcings = np.full(
[force_count, geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float64
)
input_dict[force_key].height = np.empty(
[geo_meta_wrf_hydro.ny_local], np.float32
input_dict[force_key].height = np.full(
[geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float32
)
input_dict[force_key].regridded_mask = np.empty(
[geo_meta_wrf_hydro.ny_local], np.float32
input_dict[force_key].regridded_mask = np.full(
[geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float32
)
input_dict[force_key].regridded_mask_AORC = np.empty(
[geo_meta_wrf_hydro.ny_local], np.float32
input_dict[force_key].regridded_mask_AORC = np.full(
[geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float32
)
input_dict[force_key].final_forcings_elem = np.empty(
[force_count, geo_meta_wrf_hydro.ny_local_elem], np.float64
input_dict[force_key].final_forcings_elem = np.full(
[force_count, geo_meta_wrf_hydro.ny_local_elem],
np.nan,
dtype=np.float64,
)
input_dict[force_key].height_elem = np.empty(
[geo_meta_wrf_hydro.ny_local_elem], np.float32
input_dict[force_key].height_elem = np.full(
[geo_meta_wrf_hydro.ny_local_elem], np.nan, dtype=np.float32
)
input_dict[force_key].regridded_mask_elem = np.empty(
[geo_meta_wrf_hydro.ny_local_elem], np.float32
input_dict[force_key].regridded_mask_elem = np.full(
[geo_meta_wrf_hydro.ny_local_elem], np.nan, dtype=np.float32
)
input_dict[force_key].regridded_mask_elem_AORC = np.empty(
[geo_meta_wrf_hydro.ny_local_elem], np.float32
input_dict[force_key].regridded_mask_elem_AORC = np.full(
[geo_meta_wrf_hydro.ny_local_elem], np.nan, dtype=np.float32
)
elif config_options.grid_type == "hydrofabric":
input_dict[force_key].final_forcings = np.empty(
[force_count, geo_meta_wrf_hydro.ny_local], np.float64
input_dict[force_key].final_forcings = np.full(
[force_count, geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float64
)
input_dict[force_key].height = np.empty(
[geo_meta_wrf_hydro.ny_local], np.float32
input_dict[force_key].height = np.full(
[geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float32
)
input_dict[force_key].regridded_mask = np.empty(
[geo_meta_wrf_hydro.ny_local], np.float32
input_dict[force_key].regridded_mask = np.full(
[geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float32
)
input_dict[force_key].regridded_mask_AORC = np.empty(
[geo_meta_wrf_hydro.ny_local], np.float32
input_dict[force_key].regridded_mask_AORC = np.full(
[geo_meta_wrf_hydro.ny_local], np.nan, dtype=np.float32
)
# Obtain custom input cycle frequencies
if force_key == 10 or force_key == 11:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import logging
import typing
from collections import OrderedDict

import numpy as np

Expand Down Expand Up @@ -73,6 +74,7 @@ def assert_equal_with_tol(
keys_to_check: tuple | None = None,
absolute_tolerance: float = 1e-6,
relative_tolerance: float = 1e-10,
new_keys_in_actual_ok: bool = False,
):
"""Assert that the key,value pairs in `expect` have matching key,value pairs in `actual`, with numerical tolerance.
It is okay if actual has extra keys that are not present in expect.
Expand All @@ -93,21 +95,26 @@ def assert_equal_with_tol(
errors.append(KeyError(f"Missing keys from actual: {keys_missing_from_actual}"))

keys_missing_from_expected = set(keys_to_check) - set(expect)
if keys_missing_from_expected:
errors.append(
KeyError(f"Missing keys from expected: {keys_missing_from_expected}")
)
if not new_keys_in_actual_ok:
if keys_missing_from_expected:
errors.append(
KeyError(f"Missing keys from expected: {keys_missing_from_expected}")
)

for k in keys_to_check:
### Check key existence
try:
v_expect = expect[k]
if isinstance(v_expect, dict):
v_expect = OrderedDict(sorted(list(v_expect.items())))
except KeyError:
errors.append(KeyError(f"Key {k} is missing from expected"))
continue

try:
v_actual = actual[k]
if isinstance(v_actual, dict):
v_actual = OrderedDict(sorted(list(v_actual.items())))
except KeyError:
msg = f"Key {k} is missing from actual"
errors.append(KeyError(msg))
Expand Down Expand Up @@ -155,14 +162,32 @@ def assert_equal_with_tol(
except np.exceptions.DTypePromotionError:
errors.append(
ValueError(
f"Expected not equal to actual, and could not apply np.allclose. expect={expect}, actual={actual}."
f"Expected not equal to actual, and could not apply np.allclose. key={k}, expect={expect}, actual={actual}."
)
)
continue

except TypeError:
if isinstance(v_expect, (dict, OrderedDict)) and isinstance(
v_actual, (dict, OrderedDict)
):
keys_not_in_v_actual = set(dict(v_expect)) - set(dict(v_actual))
keys_in_v_expect_and_v_actual = set(dict(v_expect)) & set(
dict(v_actual)
)
keys_with_vals_not_matching = [
key
for key in keys_in_v_expect_and_v_actual
if v_expect[key] != v_actual[key]
]
errors.append(
ValueError(
f"Expected not equal to actual for key: {k}. Keys not in actual for {k}: {keys_not_in_v_actual} | keys in actual with values not matching expected for {k}: {keys_with_vals_not_matching}"
)
)
continue
errors.append(
ValueError(
f"Objects not equal, and numerical tolerances (atol={absolute_tolerance} rtol={relative_tolerance}) exceeded for at least one element. {v_expect} vs {v_actual}."
f"Objects not equal, and numerical tolerances (atol={absolute_tolerance} rtol={relative_tolerance}) exceeded for {k}. {v_expect} vs {v_actual}."
)
)

Expand Down
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ log_cli_level = INFO
addopts = --full-trace -vv
testpaths =
tests/esmf_regrid
tests/geomod
tests/input_forcing
155 changes: 155 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Tests README

This directory contains tests for the NextGen Forcing BMI Engine.

## Initial test data

Tests data is included in the `test_data` directory and includes configs, gpkgs, esmf_meshes, expected results and actual results. While the configs, gpkgs, esmf_meshes and expectd results are included in the repo and can be used as is, the following steps can be taken to re-create these test inputs.

---
The initial test data was generated using `nwm-rte` to create a calibration realization
for gage 01123000, starting at time 2013-07-01 00:00:00, and running for 3 timesteps,
using `nwm-rte's` run_suite.sh. See RETRO_FORCING_CONFIG_FILE__AORC_CONUS.

More specifically the initial expected test data was developed with these specific configurations in `config.bashrc`.
```
REPO_TAG_FCST_MGR="856fc0e1201076df909e56c7cd384f58e82965a2"
REPO_TAG_MSW_MGR="693c206a22b5e9ffcca3103166c0ca59e2b11b25"
REPO_TAG_CAL_MGR="7e56bf01477ea77e72dfb25a166ac26ff6090ecb"
REPO_TAG_NGEN_FORCING="LOCAL"
NGEN_SOURCE_MODE="ghcr"
NGEN_BASE__REMOTE_GHCR_TAG="844c5f6"
```

And these two commands in `nwm-rte's` `run_suite.sh`:
```bash
docker_run python "/ngen-app/bin/bin_mounted/run_calibration.py" -n 2 -fsrc "aorc" -start "2013-07-01 00:00:00" -dur 3

docker_run python "/ngen-app/bin/bin_mounted/run_forecast.py" -fconfig "short_range" -dt "2025-07-10 04:00:00" -rname "fcst_run1_short_range"
```
## Test Structure

The test suite is organized into the following modules:

- **`esmf_regrid/`** - Tests for ESMF regridding functionality
- **`geomod/`** - Tests for geomod components
- **`input_forcing/`** - Tests for input forcing data processing
- **`nextgen_forcings_ewts/`** - Tests for EWTS (Error, Warning, and Trapping System) forcings
- **`test_utils.py`** - Shared test utilities and fixtures
- **`conftest.py`** - Pytest configuration and shared fixtures

## Prerequisites
### Setup requirements:
1. Create the forcing config.yml files using RTE.
2. Enter the RTE devcontainer.

### Required Dependencies

The test suite requires Python 3.11 or higher. Install the package with test dependencies inside of the `dev container`:

```bash
# From the repository root directory
pip install -e ".[develop]"
```

Or install pytest directly inside of the `dev container`:

```bash
pip install pytest
```

### Additional Requirements

Ensure all main package dependencies are installed inside of the `dev container` (this typically should happen when the `dev container` is built):

```bash
pip install -e .
```

## Running Tests

### Run All Tests From the Dev Container

```bash
Single processor: (cd src/ngen-forcing && pytest )
Multiple processors: ( cd src/ngen-forcing && mpirun -n 2 pytest )
```
### Run Specific Test Modules From the Dev Container

Run tests for a specific module:

```bash
# ESMF regridding tests
Single processor: ( cd src/ngen-forcing && pytest tests/esmf_regrid)
Multiple processors: ( cd src/ngen-forcing && mpirun -n 2 pytest tests/esmf_regrid)

# GeoMod tests
Single processor: ( cd src/ngen-forcing && pytest tests/geomod)
Multiple processors: ( cd src/ngen-forcing && mpirun -n 2 pytest tests/geomod)

# Input forcing tests
Single processor: ( cd src/ngen-forcing && pytest tests/input_forcing)
Multiple processors: ( cd src/ngen-forcing && mpirun -n 2 pytest tests/input_forcing)
```

Create new test output data (creates expected outputs for subsequent tests)
```bash
# ESMF regridding tests
Single processor: ( cd src/ngen-forcing && FORCING_PYTEST_WRITE_TEST_EXPECTED_DATA=true pytest tests/esmf_regrid)
Multiple processors: ( cd src/ngen-forcing && FORCING_PYTEST_WRITE_TEST_EXPECTED_DATA=true mpirun -n 2 pytest tests/esmf_regrid)

# GeoMod tests
Single processor: ( cd src/ngen-forcing && FORCING_PYTEST_WRITE_TEST_EXPECTED_DATA=true pytest tests/geomod)
Multiple processors: ( cd src/ngen-forcing && FORCING_PYTEST_WRITE_TEST_EXPECTED_DATA=true mpirun -n 2 pytest tests/geomod)

# Input forcing tests
Single processor: ( cd src/ngen-forcing && FORCING_PYTEST_WRITE_TEST_EXPECTED_DATA=true pytest tests/input_forcing)
Multiple processors: ( cd src/ngen-forcing && FORCING_PYTEST_WRITE_TEST_EXPECTED_DATA=true mpirun -n 2 pytest tests/input_forcing)
```

In the rare case where you want to create new `expected` data and run the tests using `old` variable names use the following for `Input Forcing Tests`:
```bash
# Input forcing tests
Single processor: ( cd src/ngen-forcing && FORCING_PYTEST_WRITE_TEST_EXPECTED_DATA=true pytest tests/input_forcing --map_old_to_new_var_names False)
Multiple processors: ( cd src/ngen-forcing && FORCING_PYTEST_WRITE_TEST_EXPECTED_DATA=true mpirun -n 2 pytest tests/input_forcing --map_old_to_new_var_names False)
```
## Test Configuration

The test suite is configured via `pytest.ini` at the repository root:

- **Python path**: Set to repository root (`.`)
- **Logging**: Enabled with INFO level (DEBUG available by uncommenting)
- **Verbosity**: Full trace with verbose output (`-vv`)
- **Test paths**: Pre-configured to discover tests in `esmf_regrid`, `geomod`, and `input_forcing`


## Test Data

Test data is stored in the `test_data/` directory. Tests may reference files from this location for input data and expected results validation.

## Writing New Tests

When adding new tests:

1. Place test files in the appropriate subdirectory
2. Name test files with the `test_*.py` prefix
3. Name test functions with the `test_*` prefix
4. Use fixtures from `conftest.py` for common setup
5. Place test data files in `test_data/` with descriptive names

Example test structure:

```python
import pytest

def test_my_feature():
"""Test description."""
# Arrange
input_data = ...

# Act
result = function_under_test(input_data)

# Assert
assert result == expected_output
```
Loading