Skip to content

Commit 83556de

Browse files
authored
Replace basic itzi GRASS-based tests by in-memory tests (#218)
* add memory test for number of outputs * add memory test for flow symmetry * add in-memory tests for max_values, stats, boundaries and wse * remove obsolete grass-based tests * move fixtures to conftest. Disable boundary test because of flakiness * split boundary test in two because corners are flaky * split test_5by5 into separate files * move core tests in their own dir. Tag tests needing forking * only fork essential functions in TestBMI * remove forced --forked from CI tests. This is handled by in-file tag for each test needing it. * CI: set correct subdir for post-wheel tests. Run all fast, non-grass tests * update the way tests are run * CI build wheel: install extra dependencies before tests * test_csv_vector: replace deprecated positional indexing on Pandas Series objects. * use windows-compatible paths * CSV vector: use Path internally and get posix path to obstore for Windows compat. * mark tests depending using cloud dependencies. Do not run them in wheel CI. * skip tests if import fail * csv vector output: fix relative path
1 parent 320260f commit 83556de

38 files changed

+1576
-900
lines changed

.github/workflows/build_wheels.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ jobs:
3131
CIBW_ENVIRONMENT: "ITZI_BDIST_WHEEL=1 NO_STABLE_ABI=1" # Trigger generic compiler flags
3232
CIBW_ENVIRONMENT_MACOS: >
3333
MACOSX_DEPLOYMENT_TARGET=14.0
34-
ITZI_BDIST_WHEEL=1
34+
ITZI_BDIST_WHEEL=1
3535
NO_STABLE_ABI=1
36-
CIBW_BEFORE_TEST: "pip install pytest pytest-benchmark pandas"
36+
CIBW_BEFORE_TEST: "pip install pytest pytest-benchmark pandas scipy"
3737
CIBW_TEST_SOURCES: tests
3838
CIBW_TEST_COMMAND: > # Fast tests which do not require GRASS
39-
pytest tests/test_flow.py tests/test_analytic.py tests/test_rastermetrics.py
39+
pytest -m "not slow and not cloud" tests/core/
4040
4141
- uses: actions/upload-artifact@v4
4242
with:

.github/workflows/tests.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ jobs:
2929
3030
- name: Pytest
3131
run: |
32-
$HOME/.local/bin/uv run --all-extras --python ${{ matrix.python-version }} pytest --cov=src --forked --junitxml=junit/test-results.xml --cov-report=xml --cov-report=html tests/
32+
$HOME/.local/bin/uv run --all-extras --python ${{ matrix.python-version }} pytest --cov=src --junitxml=junit/test-core-results.xml --cov-report=xml --cov-report=html tests/core
33+
$HOME/.local/bin/uv run --all-extras --python ${{ matrix.python-version }} pytest --cov=src --junitxml=junit/test-itzi-results.xml --cov-report=xml --cov-report=html tests/grass/test_itzi.py
34+
$HOME/.local/bin/uv run --all-extras --python ${{ matrix.python-version }} pytest --cov=src --junitxml=junit/test-bmi-results.xml --cov-report=xml --cov-report=html tests/grass/test_bmi.py
35+
$HOME/.local/bin/uv run --all-extras --python ${{ matrix.python-version }} pytest --cov=src --junitxml=junit/test-tutorial-results.xml --cov-report=xml --cov-report=html tests/grass/test_tutorial.py
3336
3437
- name: Upload coverage
3538
uses: actions/upload-artifact@v4

docs/prog_manual.rst

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Testing
5555
-------
5656

5757
Testing is done with pytest.
58-
Due to global variables in GRASS (`see issue <https://github.com/OSGeo/grass/issues/629>`__),
58+
Due to mapset switching issues in GRASS (`see issue <https://github.com/OSGeo/grass/issues/629>`__),
5959
the tests must be run in separate processes using *pytest-forked*.
6060

6161
.. code:: sh
@@ -68,6 +68,18 @@ To estimate the test coverage:
6868
6969
uv run pytest --cov=itzi --forked -v tests/
7070
71+
The GRASS-specific tests could be sped up a bit by running them separately:
72+
73+
.. code:: sh
74+
uv run pytest tests/grass/test_itzi.py && uv run pytest tests/grass/test_bmi.py && uv run pytest tests/grass/test_tutorial.py
75+
76+
77+
The tests not relying on GRASS can be run directly:
78+
79+
.. code:: sh
80+
uv run pytest tests/core
81+
82+
7183
Select the python version to test against with the *--python* option.
7284
For example *uv run --python 3.12 pytest tests/* for python 3.12.
7385
This will automatically install the correct python version.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ pythonpath = ["src", "."]
8282
addopts = ["--import-mode=importlib", "--benchmark-min-rounds=7"]
8383
markers = [
8484
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
85+
"cloud: marks tests using cloud functions (deselect with '-m \"not cloud\"')",
8586
]
8687

8788
[tool.ruff]

src/itzi/massbalance.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Copyright (C) 2016-2025 Laurent Courty
2+
Copyright (C) 2016-2026 Laurent Courty
33
44
This program is free software; you can redistribute it and/or
55
modify it under the terms of the GNU General Public License
@@ -34,7 +34,7 @@ def __init__(
3434
def _set_file_name(self, file_name: str) -> str:
3535
"""Generate output file name"""
3636
if not file_name:
37-
file_name = "{}_stats.csv".format(str(datetime.now().strftime("%Y-%m-%dT%H:%M:%S")))
37+
file_name = "{}_stats.csv".format(str(datetime.now().strftime("%Y-%m-%dT%H-%M-%S")))
3838
return file_name
3939

4040
def _create_file(self) -> None:

src/itzi/providers/csv_output.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Copyright (C) 2025 Laurent Courty
2+
Copyright (C) 2025-2026 Laurent Courty
33
44
This program is free software; you can redistribute it and/or
55
modify it under the terms of the GNU General Public License
@@ -17,6 +17,7 @@
1717
from datetime import datetime, timedelta
1818
from typing import TypedDict, TYPE_CHECKING, Tuple, List
1919
from io import StringIO
20+
from pathlib import Path
2021
import csv
2122

2223
import pandas as pd
@@ -61,7 +62,13 @@ def __init__(self, config: CSVVectorOutputConfig) -> None:
6162
except AttributeError:
6263
self.srid = 0
6364
self.store = config["store"]
64-
results_prefix = config["results_prefix"]
65+
# Normalize path for obstore (requires forward slashes)
66+
# Use resolve() to handle relative paths like "./out", but skip for empty strings
67+
prefix_str = config["results_prefix"]
68+
if prefix_str:
69+
results_prefix = Path(prefix_str).resolve().as_posix()
70+
else:
71+
results_prefix = ""
6572

6673
self.existing_ids = {"link": None, "node": None} # Objects ids already in the file
6774
self.existing_max_time = {"link": None, "node": None} # Max of sim_time in existing_file
@@ -77,7 +84,7 @@ def __init__(self, config: CSVVectorOutputConfig) -> None:
7784
self.headers[geom_type] = ["sim_time"] + base_headers + ["srid", "geometry"]
7885

7986
results_name = f"{config['drainage_results_name']}_{geom_type}s.csv"
80-
self.file_paths[geom_type] = results_prefix + "/" + results_name
87+
self.file_paths[geom_type] = f"{results_prefix}/{results_name}"
8188
# No need to check if we overwrite
8289
if not config["overwrite"]:
8390
self._check_existing_csv(geom_type)

tests/conftest.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import pytest
66
import numpy as np
77

8+
from itzi.array_definitions import ARRAY_DEFINITIONS, ArrayCategory
9+
810

911
class Helpers:
1012
@staticmethod
@@ -50,6 +52,32 @@ def md5(file_path):
5052
hash_md5.update(chunk)
5153
return hash_md5.hexdigest()
5254

55+
@staticmethod
56+
def make_input_map_names(**overrides) -> dict[str, str | None]:
57+
"""Generate default input_map_names dict from ARRAY_DEFINITIONS.
58+
59+
Keys set to None are inactive; keys set to a truthy string activate features.
60+
"""
61+
names = {ad.key: None for ad in ARRAY_DEFINITIONS if ArrayCategory.INPUT in ad.category}
62+
names.update(overrides)
63+
return names
64+
65+
@staticmethod
66+
def make_output_map_names(prefix: str, keys: list[str]) -> dict[str, str | None]:
67+
"""Generate output_map_names dict from ARRAY_DEFINITIONS.
68+
69+
Args:
70+
prefix: Prefix for output map names (e.g., "out_5by5")
71+
keys: List of output keys to activate
72+
73+
Returns:
74+
Dict with all OUTPUT-category keys, activated ones set to "prefix_keyname"
75+
"""
76+
names = {ad.key: None for ad in ARRAY_DEFINITIONS if ArrayCategory.OUTPUT in ad.category}
77+
for k in keys:
78+
names[k] = f"{prefix}_{k}"
79+
return names
80+
5381

5482
@pytest.fixture(scope="session")
5583
def helpers():

tests/core/conftest.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from collections import namedtuple
2+
3+
4+
import pytest
5+
import numpy as np
6+
7+
from itzi.providers.domain_data import DomainData
8+
9+
10+
Domain5by5Data = namedtuple(
11+
"Domain5by5Data",
12+
[
13+
"domain_data", # DomainData instance
14+
"arr_dem_flat", # DEM with z=0
15+
"arr_dem_high", # DEM with z=132
16+
"arr_n", # Manning's n = 0.05
17+
"arr_start_h", # Initial depth: 0.2 at center [2,2]
18+
"arr_start_wse", # Initial WSE: 132.2 at center [2,2]
19+
"arr_mask", # All False (no mask)
20+
"arr_bctype", # Boundary condition type for open boundaries
21+
"arr_rain", # Rainfall in m/s
22+
"arr_inf", # Infiltration in m/s
23+
"arr_loss", # Losses in m/s
24+
"arr_inflow", # Inflow in m/s
25+
],
26+
)
27+
28+
29+
@pytest.fixture(scope="module")
30+
def domain_5by5() -> Domain5by5Data:
31+
"""Create a 5x5 domain with all base arrays.
32+
33+
This fixture provides the foundational data for all 5x5 tests:
34+
- 5x5 grid at 10m resolution
35+
- Domain extends: north=50, south=0, east=50, west=0
36+
- Total area: 2500 m²
37+
"""
38+
# Domain dimensions
39+
rows, cols = 5, 5
40+
north, south, east, west = 50.0, 0.0, 50.0, 0.0
41+
42+
# Create DomainData
43+
domain_data = DomainData(
44+
north=north, south=south, east=east, west=west, rows=rows, cols=cols, crs_wkt=""
45+
)
46+
47+
# DEM arrays
48+
arr_dem_flat = np.zeros(domain_data.shape, dtype=np.float32)
49+
arr_dem_high = np.full(domain_data.shape, 132.0, dtype=np.float32)
50+
51+
# Manning's n
52+
arr_n = np.full(domain_data.shape, 0.05, dtype=np.float32)
53+
54+
# Initial water depth: 0.2m at center cell [2, 2], 0 elsewhere
55+
arr_start_h = np.zeros(domain_data.shape, dtype=np.float32)
56+
arr_start_h[2, 2] = 0.2
57+
58+
# Initial water surface elevation: 132.2m at center cell [2, 2]
59+
# (high DEM + 0.2m depth)
60+
arr_start_wse = np.zeros(domain_data.shape, dtype=np.float32)
61+
arr_start_wse[2, 2] = 132.2
62+
63+
# No mask - whole domain active
64+
arr_mask = np.full(domain_data.shape, False, dtype=np.bool_)
65+
66+
# Boundary condition type: 2 (open) at all 16 edge cells
67+
arr_bctype = np.zeros(domain_data.shape, dtype=np.float32)
68+
# Top and bottom rows
69+
arr_bctype[0, :] = 2
70+
arr_bctype[4, :] = 2
71+
# Left and right columns (excluding corners already set)
72+
arr_bctype[:, 0] = 2
73+
arr_bctype[:, 4] = 2
74+
75+
# Rate arrays in m/s
76+
# Rainfall: 10 mm/h = 10/(1000*3600) m/s
77+
arr_rain = np.full(domain_data.shape, 10.0 / (1000 * 3600), dtype=np.float32)
78+
# Infiltration: 2 mm/h = 2/(1000*3600) m/s
79+
arr_inf = np.full(domain_data.shape, 2.0 / (1000 * 3600), dtype=np.float32)
80+
# Losses: 1.5 mm/h = 1.5/(1000*3600) m/s
81+
arr_loss = np.full(domain_data.shape, 1.5 / (1000 * 3600), dtype=np.float32)
82+
# Inflow: 0.1 m/s (already in m/s)
83+
arr_inflow = np.full(domain_data.shape, 0.1, dtype=np.float32)
84+
85+
return Domain5by5Data(
86+
domain_data=domain_data,
87+
arr_dem_flat=arr_dem_flat,
88+
arr_dem_high=arr_dem_high,
89+
arr_n=arr_n,
90+
arr_start_h=arr_start_h,
91+
arr_start_wse=arr_start_wse,
92+
arr_mask=arr_mask,
93+
arr_bctype=arr_bctype,
94+
arr_rain=arr_rain,
95+
arr_inf=arr_inf,
96+
arr_loss=arr_loss,
97+
arr_inflow=arr_inflow,
98+
)

0 commit comments

Comments
 (0)