From a2d116c7a6ffa38a90f32cd217eb7caf4f464727 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 20 Feb 2026 15:20:45 -0500 Subject: [PATCH 01/29] Add basic small-data tests --- raster/r.sim/r.sim.water/tests/conftest.py | 8 + raster/r.sim/r.sim.water/tests/simwe_test.py | 216 +++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 raster/r.sim/r.sim.water/tests/conftest.py create mode 100644 raster/r.sim/r.sim.water/tests/simwe_test.py diff --git a/raster/r.sim/r.sim.water/tests/conftest.py b/raster/r.sim/r.sim.water/tests/conftest.py new file mode 100644 index 00000000000..298f4a470fe --- /dev/null +++ b/raster/r.sim/r.sim.water/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +@pytest.fixture +def session(tmp_path): + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + yield session \ No newline at end of file diff --git a/raster/r.sim/r.sim.water/tests/simwe_test.py b/raster/r.sim/r.sim.water/tests/simwe_test.py new file mode 100644 index 00000000000..cf29cbcdd54 --- /dev/null +++ b/raster/r.sim/r.sim.water/tests/simwe_test.py @@ -0,0 +1,216 @@ +import os + +import numpy as np +import pytest + +import grass.script as gs +from grass.tools import Tools + + +def test_slope(tmp_path, session): + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=3, s=0, n=2, res=1) + elevation = np.array([[0, 0, 0], [0, 0, 0]]) + dx, dy = tools.r_slope_aspect(elevation=elevation, dx=np.array, dy=np.array) + depth = tools.r_sim_water( + elevation=elevation, dx=dx, dy=dy, depth=np.array, random_seed=42, nprocs=1 + ) + assert np.all(depth == [[0, 0, 0], [0, 0, 0]]) + + +def test_slope2(tmp_path): + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=3, s=0, n=2, res=1) + elevation = np.array([[3, 2, 1], [3, 2, 1]]) + dx, dy = tools.r_slope_aspect(elevation=elevation, dx=np.array, dy=np.array) + depth = tools.r_sim_water( + elevation=elevation, + dx=dx, + dy=dy, + depth=np.array, + nwalk=10000, + rain_value=100000, + niter=5, + random_seed=42, + nprocs=1, + ) + assert np.all(depth == [[0, 0, 0], [0, 0, 0]]) + + +@pytest.mark.parametrize("rows", range(3, 5)) +@pytest.mark.parametrize("columns", range(3, 5)) +def test_minimal_size_workflow(tmp_path, rows, columns): + """Test whole workflow from elevation with smallest possible raster size""" + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=columns, s=0, n=rows, res=1) + tools.r_mapcalc(expression="elevation = col()") + tools.r_slope_aspect(elevation="elevation", dx="dx", dy="dy") + # FIXME: check slope and aspect separately + depth = tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth=np.array, + rain_value=10, + niter=5, + random_seed=42, + nprocs=1, + ) + assert np.any(np.array(depth) > 0) + +def test_minimal_size_workflow3x3(tmp_path): + """Test whole workflow from elevation with smallest possible raster size""" + rows = columns = 3 + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=columns, s=0, n=rows, res=1) + tools.r_mapcalc(expression="elevation = col()") + tools.r_slope_aspect(elevation="elevation", dx="dx", dy="dy") + # FIXME: check slope and aspect separately + depth = tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth=np.array, + rain_value=10, + niter=5, + random_seed=42, + nprocs=1, + ) + assert np.any(np.array(depth) > 0) + # TODO: THis should not all be zero. + assert np.any( + np.array(depth) + == pytest.approx( + np.array([[0, 0, 0], [0, 0, 0], [0, 0, 0]]), + abs=1e-3, + ) + ) + + +def test_depth(tmp_path): + """Test small workflow for expected values + + Regression test with values obtained from the tool itself. + """ + rows = 10 + columns = 5 + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=columns, s=0, n=rows, res=1) + elevation = tools.r_mapcalc_simple( + expression=f"elevation = {columns} - col() + 1", output=np.array + ) + assert np.all(np.array(elevation[2]) == [5, 4, 3, 2, 1]) + dx, dy = tools.r_slope_aspect(elevation=elevation, dx=np.array, dy=np.array) + assert np.all(np.array(dx[4]) == [0, 1, 1, 1, 0]) + result = Tools(session=session, consistent_return_value=True).r_sim_water( + elevation=elevation, + dx=dx, + dy=dy, + depth=np.array, + rain_value=10, + niter=5, + random_seed=42, + man_value=0.5, + nprocs=1, + ) + print(result.stderr) + #assert not result.stderr + + depth = result.arrays.depth + assert np.any(np.array(depth) > 0) + assert np.any( + np.array(depth[4]) + == pytest.approx( + np.array([0.0002977, 0.00032908, 0.00022698, 0.00067456, 0.00143326]), + abs=1e-6, + ) + ) + + +def test_depth_direct(tmp_path): + """Manually crafted inputs""" + rows = 1 + columns = 5 + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=columns, s=0, n=rows, res=1) + elevation = np.array([5, 4, 3, 2, 1]) + dx = np.array([1, 1, 1, 1, 1]) + dy = np.array([0, 0, 0, 0, 0]) + depth = tools.r_sim_water( + elevation=elevation, + dx=dx, + dy=dy, + depth=np.array, + rain_value=10, + niter=5, + random_seed=42, + man_value=0.5, + nprocs=1, + ) + assert np.any(np.array(depth) > 0) + assert np.any( + np.array(depth) + == pytest.approx( + np.array( + [[0.00016991, 0.00019165, 0.00026603, 0.00035686, 0.00038382]] + ), + abs=1e-6, + ) + ) + + +def test_depth_same_rain_and_infiltration(tmp_path): + """Manually crafted inputs""" + rows = 1 + columns = 5 + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=columns, s=0, n=rows, res=1) + elevation = np.array([5, 4, 3, 2, 1]) + dx = np.array([1, 1, 1, 1, 1]) + dy = np.array([0, 0, 0, 0, 0]) + depth = tools.r_sim_water( + elevation=elevation, + dx=dx, + dy=dy, + depth=np.array, + rain_value=1000, + infil_value=10, + niter=5, + random_seed=42, + man_value=0.5, + nprocs=1, + ) + assert np.any(np.array(depth) > 0) + assert np.any( + np.array(depth) + == pytest.approx( + np.array( + [[0.00250920, 0.00165546, 0.00485075, 0.00211141, 0.00165546]] + ), + abs=1e-6, + ) + ) + + +# FIXME: using depth array directly causes "'array' object has no attribute 'typecode'" From 38830e0562be2da136c4fffde77087a9ddc43baa Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 20 Feb 2026 16:42:50 -0500 Subject: [PATCH 02/29] r.sim.water: add analytical small-data tests --- raster/r.sim/r.sim.water/tests/conftest.py | 24 +- raster/r.sim/r.sim.water/tests/simwe_test.py | 382 ++++++++++--------- 2 files changed, 216 insertions(+), 190 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/conftest.py b/raster/r.sim/r.sim.water/tests/conftest.py index 298f4a470fe..78b9997a09b 100644 --- a/raster/r.sim/r.sim.water/tests/conftest.py +++ b/raster/r.sim/r.sim.water/tests/conftest.py @@ -1,8 +1,28 @@ +import os + import pytest +import grass.script as gs +from grass.tools import Tools + + @pytest.fixture -def session(tmp_path): +def east_slope_session(tmp_path): + """Session with a 1-row x 5-column eastward slope, ready for r.sim.water. + + Terrain (GRASS col() is 1-indexed from west): + elevation = 6 - col() -> [5, 4, 3, 2, 1] west to east + dx = 1.0 (unit slope vector pointing east) + dy = 0.0 (no north-south component) + + Water flows east and accumulates toward the eastern boundary. + """ project = tmp_path / "simwe" gs.create_project(project) with gs.setup.init(project, env=os.environ.copy()) as session: - yield session \ No newline at end of file + tools = Tools(session=session) + tools.g_region(w=0, e=5, s=0, n=1, res=1) + tools.r_mapcalc(expression="elevation = 6 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + yield session diff --git a/raster/r.sim/r.sim.water/tests/simwe_test.py b/raster/r.sim/r.sim.water/tests/simwe_test.py index cf29cbcdd54..2556f452db9 100644 --- a/raster/r.sim/r.sim.water/tests/simwe_test.py +++ b/raster/r.sim/r.sim.water/tests/simwe_test.py @@ -1,216 +1,222 @@ +"""Minimal analytical tests for r.sim.water (SIMWE). + +These tests use small, hand-crafted domains where the expected output can be +reasoned about from first principles rather than from pre-computed reference +values. Regression tests against realistic data (nc_spm dataset) live in +testsuite/test_r_sim_water.py. + +The two physical principles underlying each test are: + +Mass conservation: + No rainfall input -> no runoff output. Depth must be identically zero. + +Kinematic wave approximation with Manning's equation (steady state): + On a uniform slope S with rainfall rate R and Manning's roughness n: + + discharge per unit width: q(x) = R * x + water depth: h(x) = (n * q(x) / S^0.5)^(3/5) + + This gives the following directional relationships that each test + verifies for the aggregate (summed) depth over the domain: + + more rainfall R -> greater depth (h proportional to R^(3/5)) + higher Manning n -> greater depth (h proportional to n^(3/5)) + steeper slope S -> shallower depth (h proportional to S^(-3/10)) + more infiltration -> shallower depth (net R reduced) + further downstream -> greater depth (q, and thus h, grows with x) + +All tests use: + - a 1-row strip so flow is strictly one-dimensional and easy to reason about + - nprocs=1 for determinism + - a fixed random_seed for reproducibility + - explicit nwalkers well above the default (2 x cells) to keep Monte Carlo + noise low enough for the aggregate comparisons to be reliable +""" + import os import numpy as np -import pytest import grass.script as gs from grass.tools import Tools +# Simulation constants shared across tests. Chosen so that: +# - results are deterministic and reproducible +# - statistical noise is negligible for the aggregate comparisons used +# - individual tests finish in well under a second each +SEED = 42 +NWALKERS = 10000 # 2000/cell for a 1x5 domain; default would be 10 +NITERATIONS = 10 # minutes; default, long enough for near-steady state +NPROCS = 1 # single thread, fully deterministic +RAIN = 100 # mm/hr; generous signal-to-noise on a small domain -def test_slope(tmp_path, session): - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=3, s=0, n=2, res=1) - elevation = np.array([[0, 0, 0], [0, 0, 0]]) - dx, dy = tools.r_slope_aspect(elevation=elevation, dx=np.array, dy=np.array) - depth = tools.r_sim_water( - elevation=elevation, dx=dx, dy=dy, depth=np.array, random_seed=42, nprocs=1 - ) - assert np.all(depth == [[0, 0, 0], [0, 0, 0]]) +def _run_sim(session, *, rain_value=RAIN, infil_value=0, man_value=0.1): + """Run r.sim.water on the session's terrain; return depth as ndarray. -def test_slope2(tmp_path): - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=3, s=0, n=2, res=1) - elevation = np.array([[3, 2, 1], [3, 2, 1]]) - dx, dy = tools.r_slope_aspect(elevation=elevation, dx=np.array, dy=np.array) - depth = tools.r_sim_water( - elevation=elevation, - dx=dx, - dy=dy, - depth=np.array, - nwalk=10000, - rain_value=100000, - niter=5, - random_seed=42, - nprocs=1, - ) - assert np.all(depth == [[0, 0, 0], [0, 0, 0]]) + Assumes the session already contains rasters named elevation, dx, and dy + (as created by the east_slope_session fixture or inline setup). + """ + tools = Tools(session=session) + result = tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth=np.array, + rain_value=rain_value, + infil_value=infil_value, + man_value=man_value, + nwalkers=NWALKERS, + niterations=NITERATIONS, + random_seed=SEED, + nprocs=NPROCS, + ) + return np.asarray(result) + + +def test_no_rain_produces_no_depth(east_slope_session): + """Zero rainfall must yield zero water depth everywhere. + + With no water input there can be no runoff. This holds exactly + regardless of seed or number of walkers: no particles are created. + """ + depth = _run_sim(east_slope_session, rain_value=0) + assert np.all(depth == 0), f"Expected all-zero depth with zero rain:\n{depth}" -@pytest.mark.parametrize("rows", range(3, 5)) -@pytest.mark.parametrize("columns", range(3, 5)) -def test_minimal_size_workflow(tmp_path, rows, columns): - """Test whole workflow from elevation with smallest possible raster size""" - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=columns, s=0, n=rows, res=1) - tools.r_mapcalc(expression="elevation = col()") - tools.r_slope_aspect(elevation="elevation", dx="dx", dy="dy") - # FIXME: check slope and aspect separately - depth = tools.r_sim_water( - elevation="elevation", - dx="dx", - dy="dy", - depth=np.array, - rain_value=10, - niter=5, - random_seed=42, - nprocs=1, - ) - assert np.any(np.array(depth) > 0) +def test_rain_produces_positive_depth(east_slope_session): + """Rainfall on a slope must create positive water depth somewhere.""" + depth = _run_sim(east_slope_session) + assert np.sum(depth) > 0, "Expected positive total depth with rainfall on a slope" -def test_minimal_size_workflow3x3(tmp_path): - """Test whole workflow from elevation with smallest possible raster size""" - rows = columns = 3 - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=columns, s=0, n=rows, res=1) - tools.r_mapcalc(expression="elevation = col()") - tools.r_slope_aspect(elevation="elevation", dx="dx", dy="dy") - # FIXME: check slope and aspect separately - depth = tools.r_sim_water( - elevation="elevation", - dx="dx", - dy="dy", - depth=np.array, - rain_value=10, - niter=5, - random_seed=42, - nprocs=1, - ) - assert np.any(np.array(depth) > 0) - # TODO: THis should not all be zero. - assert np.any( - np.array(depth) - == pytest.approx( - np.array([[0, 0, 0], [0, 0, 0], [0, 0, 0]]), - abs=1e-3, - ) - ) + +def test_more_rain_gives_more_depth(east_slope_session): + """Doubling rainfall must increase total water depth. + + From Manning's kinematic wave: h proportional to R^(3/5), so doubling R + increases depth by approximately 52 percent. Only the direction (greater) + is checked. + """ + depth_low = _run_sim(east_slope_session, rain_value=50) + depth_high = _run_sim(east_slope_session, rain_value=100) + assert np.sum(depth_high) > np.sum(depth_low), ( + f"Total depth should increase with rainfall rate: " + f"sum(rain=50)={np.sum(depth_low):.3e}, " + f"sum(rain=100)={np.sum(depth_high):.3e}" + ) -def test_depth(tmp_path): - """Test small workflow for expected values +def test_higher_manning_gives_more_depth(east_slope_session): + """Rougher surface (higher Manning's n) must increase water depth. - Regression test with values obtained from the tool itself. + From Manning's kinematic wave: h proportional to n^(3/5). A 10x increase + in n raises depth by approximately 4x. Only the direction is checked. """ - rows = 10 - columns = 5 - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=columns, s=0, n=rows, res=1) - elevation = tools.r_mapcalc_simple( - expression=f"elevation = {columns} - col() + 1", output=np.array - ) - assert np.all(np.array(elevation[2]) == [5, 4, 3, 2, 1]) - dx, dy = tools.r_slope_aspect(elevation=elevation, dx=np.array, dy=np.array) - assert np.all(np.array(dx[4]) == [0, 1, 1, 1, 0]) - result = Tools(session=session, consistent_return_value=True).r_sim_water( - elevation=elevation, - dx=dx, - dy=dy, - depth=np.array, - rain_value=10, - niter=5, - random_seed=42, - man_value=0.5, - nprocs=1, - ) - print(result.stderr) - #assert not result.stderr - - depth = result.arrays.depth - assert np.any(np.array(depth) > 0) - assert np.any( - np.array(depth[4]) - == pytest.approx( - np.array([0.0002977, 0.00032908, 0.00022698, 0.00067456, 0.00143326]), - abs=1e-6, - ) - ) + depth_smooth = _run_sim(east_slope_session, man_value=0.05) + depth_rough = _run_sim(east_slope_session, man_value=0.5) + assert np.sum(depth_rough) > np.sum(depth_smooth), ( + f"Total depth should increase with Manning's n: " + f"sum(n=0.05)={np.sum(depth_smooth):.3e}, " + f"sum(n=0.5)={np.sum(depth_rough):.3e}" + ) -def test_depth_direct(tmp_path): - """Manually crafted inputs""" - rows = 1 - columns = 5 +def test_infiltration_reduces_depth(east_slope_session): + """Adding overland-flow infiltration must reduce water depth. + + Infiltration removes water from the flowing sheet, decreasing depth + and discharge. + """ + depth_no_infil = _run_sim(east_slope_session, rain_value=RAIN, infil_value=0) + depth_with_infil = _run_sim( + east_slope_session, rain_value=RAIN, infil_value=RAIN // 2 + ) + assert np.sum(depth_no_infil) > np.sum(depth_with_infil), ( + f"Total depth should decrease when infiltration is added: " + f"sum(no infil)={np.sum(depth_no_infil):.3e}, " + f"sum(with infil)={np.sum(depth_with_infil):.3e}" + ) + + +def test_depth_increases_downstream(tmp_path): + """Water must be deeper in the downslope half of a uniform slope. + + Continuity: q(x) = R * x grows linearly with distance x from the + divide. Manning: h(x) = (n * q / S^0.5)^(3/5) proportional to x^(3/5), + also increases downstream. + + Domain: 1 row x 6 columns, split into upslope cols 0-2 and + downslope cols 3-5. Total depth in the downslope half must exceed + that in the upslope half. + """ project = tmp_path / "simwe" gs.create_project(project) with gs.setup.init(project, env=os.environ.copy()) as session: tools = Tools(session=session) - tools.g_region(w=0, e=columns, s=0, n=rows, res=1) - elevation = np.array([5, 4, 3, 2, 1]) - dx = np.array([1, 1, 1, 1, 1]) - dy = np.array([0, 0, 0, 0, 0]) - depth = tools.r_sim_water( - elevation=elevation, - dx=dx, - dy=dy, - depth=np.array, - rain_value=10, - niter=5, - random_seed=42, - man_value=0.5, - nprocs=1, - ) - assert np.any(np.array(depth) > 0) - assert np.any( - np.array(depth) - == pytest.approx( - np.array( - [[0.00016991, 0.00019165, 0.00026603, 0.00035686, 0.00038382]] - ), - abs=1e-6, - ) + tools.g_region(w=0, e=6, s=0, n=1, res=1) + # elevation = 7 - col() gives [6, 5, 4, 3, 2, 1] west to east + tools.r_mapcalc(expression="elevation = 7 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + + depth = _run_sim(session) + flat = depth.flatten() # shape (6,) for a 1-row raster + + upslope_sum = float(np.sum(flat[:3])) + downslope_sum = float(np.sum(flat[3:])) + assert downslope_sum > upslope_sum, ( + f"Downslope total depth ({downslope_sum:.3e}) should exceed " + f"upslope total depth ({upslope_sum:.3e})" ) -def test_depth_same_rain_and_infiltration(tmp_path): - """Manually crafted inputs""" - rows = 1 - columns = 5 - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=columns, s=0, n=rows, res=1) - elevation = np.array([5, 4, 3, 2, 1]) - dx = np.array([1, 1, 1, 1, 1]) - dy = np.array([0, 0, 0, 0, 0]) - depth = tools.r_sim_water( - elevation=elevation, - dx=dx, - dy=dy, - depth=np.array, - rain_value=1000, - infil_value=10, - niter=5, - random_seed=42, - man_value=0.5, - nprocs=1, - ) - assert np.any(np.array(depth) > 0) - assert np.any( - np.array(depth) - == pytest.approx( - np.array( - [[0.00250920, 0.00165546, 0.00485075, 0.00211141, 0.00165546]] - ), - abs=1e-6, - ) - ) +def test_steeper_slope_gives_less_depth(tmp_path): + """A steeper slope must produce shallower water depth for the same rainfall. + From Manning's kinematic wave: h proportional to S^(-3/10). Doubling the + slope magnitude S reduces depth by approximately 19 percent. Only the + direction is checked. + + Two sessions are created with matching elevation rasters so that the + dx values are physically consistent with the terrain: + gentle: elevation = 6 - col() (drops 1 m per cell), dx = 1 + steep: elevation = 11 - 2*col() (drops 2 m per cell), dx = 2 + """ -# FIXME: using depth array directly causes "'array' object has no attribute 'typecode'" + def setup_and_run(sub_path, ele_expr, dx_value): + project = sub_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=5, s=0, n=1, res=1) + tools.r_mapcalc(expression=f"elevation = {ele_expr}") + tools.r_mapcalc(expression=f"dx = {dx_value}") + tools.r_mapcalc(expression="dy = 0.0") + return np.asarray(_run_sim(session)) + + depth_gentle = setup_and_run(tmp_path / "gentle", "6 - col()", 1) + depth_steep = setup_and_run(tmp_path / "steep", "11 - 2 * col()", 2) + + assert np.sum(depth_gentle) > np.sum(depth_steep), ( + f"Gentler slope depth ({np.sum(depth_gentle):.3e}) should exceed " + f"steeper slope depth ({np.sum(depth_steep):.3e})" + ) + + +def test_discharge_positive_with_rain(east_slope_session): + """Rainfall on a slope must produce positive water discharge.""" + tools = Tools(session=east_slope_session) + discharge = np.asarray( + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + discharge=np.array, + rain_value=RAIN, + nwalkers=NWALKERS, + niterations=NITERATIONS, + random_seed=SEED, + nprocs=NPROCS, + ) + ) + assert np.sum(discharge) > 0, "Expected positive discharge with rainfall on a slope" From 05229b86c7557e703629875d25eaa6afc7d0e2b3 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Wed, 25 Feb 2026 16:33:08 -0500 Subject: [PATCH 03/29] r.sim.water: Add tests for most of the other parameters --- raster/r.sim/r.sim.water/tests/simwe_test.py | 542 +++++++++++++++---- 1 file changed, 445 insertions(+), 97 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/simwe_test.py b/raster/r.sim/r.sim.water/tests/simwe_test.py index 2556f452db9..726336fe7ab 100644 --- a/raster/r.sim/r.sim.water/tests/simwe_test.py +++ b/raster/r.sim/r.sim.water/tests/simwe_test.py @@ -1,75 +1,58 @@ """Minimal analytical tests for r.sim.water (SIMWE). -These tests use small, hand-crafted domains where the expected output can be -reasoned about from first principles rather than from pre-computed reference -values. Regression tests against realistic data (nc_spm dataset) live in -testsuite/test_r_sim_water.py. - -The two physical principles underlying each test are: - -Mass conservation: - No rainfall input -> no runoff output. Depth must be identically zero. - -Kinematic wave approximation with Manning's equation (steady state): - On a uniform slope S with rainfall rate R and Manning's roughness n: - - discharge per unit width: q(x) = R * x - water depth: h(x) = (n * q(x) / S^0.5)^(3/5) - - This gives the following directional relationships that each test - verifies for the aggregate (summed) depth over the domain: - - more rainfall R -> greater depth (h proportional to R^(3/5)) - higher Manning n -> greater depth (h proportional to n^(3/5)) - steeper slope S -> shallower depth (h proportional to S^(-3/10)) - more infiltration -> shallower depth (net R reduced) - further downstream -> greater depth (q, and thus h, grows with x) - -All tests use: - - a 1-row strip so flow is strictly one-dimensional and easy to reason about - - nprocs=1 for determinism - - a fixed random_seed for reproducibility - - explicit nwalkers well above the default (2 x cells) to keep Monte Carlo - noise low enough for the aggregate comparisons to be reliable +Tests use small, hand-crafted domains where expected output can be reasoned +about from first principles rather than from pre-computed reference values. """ import os import numpy as np +import pytest import grass.script as gs from grass.tools import Tools -# Simulation constants shared across tests. Chosen so that: -# - results are deterministic and reproducible -# - statistical noise is negligible for the aggregate comparisons used -# - individual tests finish in well under a second each +# Fixed seed and single thread make results fully deterministic. SEED = 42 -NWALKERS = 10000 # 2000/cell for a 1x5 domain; default would be 10 -NITERATIONS = 10 # minutes; default, long enough for near-steady state -NPROCS = 1 # single thread, fully deterministic -RAIN = 100 # mm/hr; generous signal-to-noise on a small domain +NPROCS = 1 + +# 10000 walkers on a 1-row x 5-column domain gives 2000 walkers/cell. +# The default (2 x cells) would be 10, which is too noisy for aggregate +# comparisons. Unless otherwise noted, tests use this domain and walker count. +NWALKERS = 10000 +NITERATIONS = 10 # minutes; long enough for near-steady state on a small domain +RAIN = 100 # mm/hr; generous signal-to-noise ratio on a small domain -def _run_sim(session, *, rain_value=RAIN, infil_value=0, man_value=0.1): + +def run_sim(session, *, random_seed=SEED, **kwargs): """Run r.sim.water on the session's terrain; return depth as ndarray. Assumes the session already contains rasters named elevation, dx, and dy - (as created by the east_slope_session fixture or inline setup). + (as created by a fixture or inline setup). Additional keyword arguments + are passed directly to r.sim.water (e.g., rain_value, man_value, rain, man, + nwalkers). """ + defaults = { + "rain_value": RAIN, + "infil_value": 0, + "man_value": 0.1, + "nwalkers": NWALKERS, + } + defaults.update(kwargs) + # On the command line, an unwanted parameter is simply omitted, so we + # simulate that. + defaults = {k: v for k, v in defaults.items() if v is not None} tools = Tools(session=session) result = tools.r_sim_water( elevation="elevation", dx="dx", dy="dy", depth=np.array, - rain_value=rain_value, - infil_value=infil_value, - man_value=man_value, - nwalkers=NWALKERS, niterations=NITERATIONS, - random_seed=SEED, + random_seed=random_seed, nprocs=NPROCS, + **defaults, ) return np.asarray(result) @@ -77,77 +60,72 @@ def _run_sim(session, *, rain_value=RAIN, infil_value=0, man_value=0.1): def test_no_rain_produces_no_depth(east_slope_session): """Zero rainfall must yield zero water depth everywhere. - With no water input there can be no runoff. This holds exactly + With no water input there can be no runoff. This holds exactly regardless of seed or number of walkers: no particles are created. """ - depth = _run_sim(east_slope_session, rain_value=0) + depth = run_sim(east_slope_session, rain_value=0) assert np.all(depth == 0), f"Expected all-zero depth with zero rain:\n{depth}" def test_rain_produces_positive_depth(east_slope_session): """Rainfall on a slope must create positive water depth somewhere.""" - depth = _run_sim(east_slope_session) + depth = run_sim(east_slope_session) assert np.sum(depth) > 0, "Expected positive total depth with rainfall on a slope" def test_more_rain_gives_more_depth(east_slope_session): """Doubling rainfall must increase total water depth. - From Manning's kinematic wave: h proportional to R^(3/5), so doubling R - increases depth by approximately 52 percent. Only the direction (greater) - is checked. + Manning's kinematic wave gives h proportional to R^(3/5), so doubling R + increases depth by a factor of 2^(3/5). """ - depth_low = _run_sim(east_slope_session, rain_value=50) - depth_high = _run_sim(east_slope_session, rain_value=100) - assert np.sum(depth_high) > np.sum(depth_low), ( + sum_low = float(np.sum(run_sim(east_slope_session, rain_value=50))) + sum_high = float(np.sum(run_sim(east_slope_session, rain_value=100))) + assert sum_high > sum_low, ( f"Total depth should increase with rainfall rate: " - f"sum(rain=50)={np.sum(depth_low):.3e}, " - f"sum(rain=100)={np.sum(depth_high):.3e}" + f"sum(rain=50)={sum_low:.3e}, sum(rain=100)={sum_high:.3e}" ) + assert sum_high / sum_low == pytest.approx(2 ** (3 / 5), rel=0.3) def test_higher_manning_gives_more_depth(east_slope_session): - """Rougher surface (higher Manning's n) must increase water depth. - - From Manning's kinematic wave: h proportional to n^(3/5). A 10x increase - in n raises depth by approximately 4x. Only the direction is checked. - """ - depth_smooth = _run_sim(east_slope_session, man_value=0.05) - depth_rough = _run_sim(east_slope_session, man_value=0.5) - assert np.sum(depth_rough) > np.sum(depth_smooth), ( + """Rougher surface (higher Manning's n) must increase water depth.""" + sum_smooth = float(np.sum(run_sim(east_slope_session, man_value=0.05))) + sum_rough = float(np.sum(run_sim(east_slope_session, man_value=0.5))) + assert sum_rough > sum_smooth, ( f"Total depth should increase with Manning's n: " - f"sum(n=0.05)={np.sum(depth_smooth):.3e}, " - f"sum(n=0.5)={np.sum(depth_rough):.3e}" + f"sum(n=0.05)={sum_smooth:.3e}, sum(n=0.5)={sum_rough:.3e}" ) def test_infiltration_reduces_depth(east_slope_session): """Adding overland-flow infiltration must reduce water depth. - Infiltration removes water from the flowing sheet, decreasing depth - and discharge. + Infiltration removes water from the flowing sheet, decreasing both + depth and discharge. """ - depth_no_infil = _run_sim(east_slope_session, rain_value=RAIN, infil_value=0) - depth_with_infil = _run_sim( - east_slope_session, rain_value=RAIN, infil_value=RAIN // 2 + sum_no_infil = float( + np.sum(run_sim(east_slope_session, rain_value=RAIN, infil_value=0)) + ) + sum_with_infil = float( + np.sum(run_sim(east_slope_session, rain_value=RAIN, infil_value=RAIN // 2)) ) - assert np.sum(depth_no_infil) > np.sum(depth_with_infil), ( + assert sum_no_infil > sum_with_infil, ( f"Total depth should decrease when infiltration is added: " - f"sum(no infil)={np.sum(depth_no_infil):.3e}, " - f"sum(with infil)={np.sum(depth_with_infil):.3e}" + f"sum(no infil)={sum_no_infil:.3e}, sum(with infil)={sum_with_infil:.3e}" ) def test_depth_increases_downstream(tmp_path): """Water must be deeper in the downslope half of a uniform slope. - Continuity: q(x) = R * x grows linearly with distance x from the - divide. Manning: h(x) = (n * q / S^0.5)^(3/5) proportional to x^(3/5), - also increases downstream. + By continuity, discharge q(x) = R * x grows linearly with distance x + from the divide. Manning's kinematic wave then gives h(x) proportional + to x^(3/5), also increasing downstream. - Domain: 1 row x 6 columns, split into upslope cols 0-2 and - downslope cols 3-5. Total depth in the downslope half must exceed - that in the upslope half. + Uses a 1-row x 6-column domain (instead of the standard 5-column one) + so the strip splits cleanly into an upslope half (cols 0-2) and a + downslope half (cols 3-5). """ project = tmp_path / "simwe" gs.create_project(project) @@ -159,9 +137,7 @@ def test_depth_increases_downstream(tmp_path): tools.r_mapcalc(expression="dx = 1.0") tools.r_mapcalc(expression="dy = 0.0") - depth = _run_sim(session) - flat = depth.flatten() # shape (6,) for a 1-row raster - + flat = run_sim(session).flatten() # shape (6,) for a 1-row raster upslope_sum = float(np.sum(flat[:3])) downslope_sum = float(np.sum(flat[3:])) assert downslope_sum > upslope_sum, ( @@ -173,14 +149,12 @@ def test_depth_increases_downstream(tmp_path): def test_steeper_slope_gives_less_depth(tmp_path): """A steeper slope must produce shallower water depth for the same rainfall. - From Manning's kinematic wave: h proportional to S^(-3/10). Doubling the - slope magnitude S reduces depth by approximately 19 percent. Only the - direction is checked. + Manning's kinematic wave gives h proportional to S^(-3/10), so doubling + the slope magnitude S reduces depth by a factor of 2^(-3/10). - Two sessions are created with matching elevation rasters so that the - dx values are physically consistent with the terrain: - gentle: elevation = 6 - col() (drops 1 m per cell), dx = 1 - steep: elevation = 11 - 2*col() (drops 2 m per cell), dx = 2 + Elevation and dx are kept consistent: a 1 m/cell drop uses dx=1, and a + 2 m/cell drop uses dx=2, matching the slope magnitude S in Manning's + equation. """ def setup_and_run(sub_path, ele_expr, dx_value): @@ -192,15 +166,18 @@ def setup_and_run(sub_path, ele_expr, dx_value): tools.r_mapcalc(expression=f"elevation = {ele_expr}") tools.r_mapcalc(expression=f"dx = {dx_value}") tools.r_mapcalc(expression="dy = 0.0") - return np.asarray(_run_sim(session)) + return np.asarray(run_sim(session)) - depth_gentle = setup_and_run(tmp_path / "gentle", "6 - col()", 1) - depth_steep = setup_and_run(tmp_path / "steep", "11 - 2 * col()", 2) + # gentle: drops 1 m per cell (elevation 5,4,3,2,1), dx = 1 + sum_gentle = float(np.sum(setup_and_run(tmp_path / "gentle", "6 - col()", 1))) + # steep: drops 2 m per cell (elevation 9,7,5,3,1), dx = 2 + sum_steep = float(np.sum(setup_and_run(tmp_path / "steep", "11 - 2 * col()", 2))) - assert np.sum(depth_gentle) > np.sum(depth_steep), ( - f"Gentler slope depth ({np.sum(depth_gentle):.3e}) should exceed " - f"steeper slope depth ({np.sum(depth_steep):.3e})" + assert sum_gentle > sum_steep, ( + f"Gentler slope depth ({sum_gentle:.3e}) should exceed " + f"steeper slope depth ({sum_steep:.3e})" ) + assert sum_gentle / sum_steep == pytest.approx(2 ** (3 / 10), rel=0.3) def test_discharge_positive_with_rain(east_slope_session): @@ -220,3 +197,374 @@ def test_discharge_positive_with_rain(east_slope_session): ) ) assert np.sum(discharge) > 0, "Expected positive discharge with rainfall on a slope" + + +def test_results_consistent_across_seeds(east_slope_session): + """Different random seeds must produce similar total depth. + + The Monte Carlo result converges as nwalkers grows. With 10000 walkers + the total depth should be stable across seeds within a few percent. + """ + seeds = [1, 7, 42, 99, 123] + sums = [float(np.sum(run_sim(east_slope_session, random_seed=s))) for s in seeds] + mean_sum = np.mean(sums) + for seed, total in zip(seeds, sums, strict=True): + assert total == pytest.approx(mean_sum, rel=0.1), ( + f"Seed {seed} total depth {total:.3e} deviates from mean {mean_sum:.3e}" + ) + + +def test_rain_raster_matches_scalar(east_slope_session): + """A uniform rain raster must produce the same depth as the equivalent scalar.""" + tools = Tools(session=east_slope_session) + tools.r_mapcalc(expression=f"rain_map = {RAIN}") + sum_scalar = float(np.sum(run_sim(east_slope_session, rain_value=RAIN))) + sum_raster = float( + np.sum(run_sim(east_slope_session, rain="rain_map", rain_value=None)) + ) + assert sum_raster == pytest.approx(sum_scalar, rel=1e-6) + + +def test_random_seed_flag(east_slope_session): + """The -s flag must generate a random seed and produce valid, varying output. + + Two runs with -s should both produce positive depth but differ from each + other because each run gets a different auto-generated seed. + """ + tools = Tools(session=east_slope_session) + common = { + "elevation": "elevation", + "dx": "dx", + "dy": "dy", + "depth": np.array, + "rain_value": RAIN, + "man_value": 0.1, + "nwalkers": NWALKERS, + "niterations": NITERATIONS, + "nprocs": NPROCS, + "flags": "s", + } + depth_a = np.asarray(tools.r_sim_water(**common)) + depth_b = np.asarray(tools.r_sim_water(**common)) + assert np.sum(depth_a) > 0, "Expected positive depth with -s flag (run a)" + assert np.sum(depth_b) > 0, "Expected positive depth with -s flag (run b)" + assert not np.array_equal(depth_a, depth_b), ( + "Two runs with -s should produce different results" + ) + + +def run_sim_error(session, *, nwalkers=NWALKERS, **kwargs): + """Run r.sim.water and return the error output as ndarray.""" + defaults = {"rain_value": RAIN, "infil_value": 0, "man_value": 0.1} + defaults.update(kwargs) + defaults = {k: v for k, v in defaults.items() if v is not None} + tools = Tools(session=session) + return np.asarray( + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + error=np.array, + nwalkers=nwalkers, + niterations=NITERATIONS, + random_seed=SEED, + nprocs=NPROCS, + **defaults, + ) + ) + + +def test_error_output(east_slope_session): + """The error output must be non-negative.""" + error = run_sim_error(east_slope_session) + assert np.all(error >= 0), f"Error output should be non-negative:\n{error}" + + +def test_no_rain_produces_no_error(east_slope_session): + """Zero rainfall must yield zero error everywhere.""" + error = run_sim_error(east_slope_session, rain_value=0) + assert np.all(error == 0), f"Expected all-zero error with zero rain:\n{error}" + + +def test_more_walkers_reduces_error(east_slope_session): + """More walkers must reduce the Monte Carlo sampling error. + + The error scales as 1/sqrt(N), so quadrupling walkers should + roughly halve the total error. + """ + error_few = float(np.sum(run_sim_error(east_slope_session, nwalkers=NWALKERS))) + error_many = float(np.sum(run_sim_error(east_slope_session, nwalkers=4 * NWALKERS))) + assert error_many < error_few, ( + f"Total error should decrease with more walkers: " + f"sum({NWALKERS})={error_few:.3e}, sum({4 * NWALKERS})={error_many:.3e}" + ) + + +def test_mintimestep(east_slope_session): + """A larger minimum time step must still produce valid positive depth. + + Increasing mintimestep reduces accuracy but speeds up the simulation. + The result should still be positive and in the same order of magnitude. + """ + sum_default = float(np.sum(run_sim(east_slope_session))) + sum_large_step = float(np.sum(run_sim(east_slope_session, mintimestep=1.0))) + assert sum_large_step > 0, "Expected positive depth with large mintimestep" + # With 1 m cells, a 1-second minimum step lets walkers overshoot multiple + # cells, roughly doubling the total depth. On 10 m cells the same step has + # negligible effect (~2%). We only check same order of magnitude here. + assert sum_large_step == pytest.approx(sum_default, rel=2.0), ( + f"Large mintimestep result ({sum_large_step:.3e}) deviates too far " + f"from default ({sum_default:.3e})" + ) + + +def test_higher_diffusion_coeff_reduces_depth(east_slope_session): + """Higher diffusion coefficient must reduce total water depth. + + The diffusion term spreads walkers away from flow concentration zones. + More diffusion means walkers disperse faster, reducing depth accumulation. + """ + sum_low = float(np.sum(run_sim(east_slope_session, diffusion_coeff=0.2))) + sum_high = float(np.sum(run_sim(east_slope_session, diffusion_coeff=2.0))) + assert sum_low > sum_high, ( + f"Total depth should decrease with higher diffusion: " + f"sum(dc=0.2)={sum_low:.3e}, sum(dc=2.0)={sum_high:.3e}" + ) + + +def test_lower_hmax_increases_diffusion(tmp_path): + """A lower hmax threshold must increase diffusion, reducing total depth. + + When water depth exceeds hmax, diffusion is amplified by (halpha + 1). + Lowering hmax causes this amplification to kick in sooner. + + Uses a 200-cell domain with high rain and roughness so that depths + exceed the hmax threshold. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=200, s=0, n=1, res=1) + tools.r_mapcalc(expression="elevation = 201 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + + nw = 400000 + sum_low_hmax = float( + np.sum( + run_sim( + session, rain_value=1000, man_value=0.5, hmax=0.001, nwalkers=nw + ) + ) + ) + sum_default_hmax = float( + np.sum( + run_sim(session, rain_value=1000, man_value=0.5, hmax=0.3, nwalkers=nw) + ) + ) + assert sum_low_hmax < sum_default_hmax, ( + f"Lower hmax should increase diffusion and reduce depth: " + f"sum(hmax=0.001)={sum_low_hmax:.3e}, sum(hmax=0.3)={sum_default_hmax:.3e}" + ) + + +def test_higher_halpha_reduces_depth(tmp_path): + """A higher halpha must increase the diffusion boost above hmax. + + halpha controls how much extra diffusion is applied when depth exceeds + hmax: diffusion is multiplied by (halpha + 1). A low hmax is needed + so that depths actually exceed the threshold. + + Uses a 200-cell domain with high rain and roughness. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=200, s=0, n=1, res=1) + tools.r_mapcalc(expression="elevation = 201 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + + nw = 400000 + sum_low = float( + np.sum( + run_sim( + session, + rain_value=1000, + man_value=0.5, + hmax=0.001, + halpha=0.5, + nwalkers=nw, + ) + ) + ) + sum_high = float( + np.sum( + run_sim( + session, + rain_value=1000, + man_value=0.5, + hmax=0.001, + halpha=50.0, + nwalkers=nw, + ) + ) + ) + assert sum_high < sum_low, ( + f"Higher halpha should increase diffusion and reduce depth: " + f"sum(halpha=0.5)={sum_low:.3e}, sum(halpha=50)={sum_high:.3e}" + ) + + +def test_hbeta_changes_result(tmp_path): + """Changing hbeta must produce a different result when depth exceeds hmax. + + hbeta weights the running average of walker velocity above the hmax + threshold. The effect is small on a 1D domain, so only inequality + (not direction) is checked. + + Uses a 200-cell domain with high rain, roughness, and low hmax. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=200, s=0, n=1, res=1) + tools.r_mapcalc(expression="elevation = 201 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + + nw = 400000 + depth_default = run_sim( + session, rain_value=1000, man_value=0.5, hmax=0.001, hbeta=0.5, nwalkers=nw + ) + depth_high = run_sim( + session, rain_value=1000, man_value=0.5, hmax=0.001, hbeta=10.0, nwalkers=nw + ) + assert not np.array_equal(depth_default, depth_high), ( + "Changing hbeta should produce a different depth result" + ) + + +def test_man_raster_matches_scalar(east_slope_session): + """A uniform Manning's n raster must produce the same depth as the equivalent scalar.""" + man_n = 0.1 + tools = Tools(session=east_slope_session) + tools.r_mapcalc(expression=f"man_map = {man_n}") + sum_scalar = float(np.sum(run_sim(east_slope_session, man_value=man_n))) + sum_raster = float( + np.sum(run_sim(east_slope_session, man="man_map", man_value=None)) + ) + assert sum_raster == pytest.approx(sum_scalar, rel=1e-6) + + +def test_infil_raster_matches_scalar(east_slope_session): + """A uniform infiltration raster must produce the same depth as the equivalent scalar.""" + tools = Tools(session=east_slope_session) + infil_rate = RAIN // 2 + tools.r_mapcalc(expression=f"infil_map = {infil_rate}") + sum_scalar = float(np.sum(run_sim(east_slope_session, infil_value=infil_rate))) + sum_raster = float( + np.sum(run_sim(east_slope_session, infil="infil_map", infil_value=None)) + ) + assert sum_raster == pytest.approx(sum_scalar, rel=1e-6) + + +def test_dx_dy_optional(tmp_path): + """Omitting dx and dy must produce valid positive depth. + + The documentation states that dx and dy are optional; when omitted, + partial derivatives are computed internally from the elevation. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=5, s=0, n=1, res=1) + tools.r_mapcalc(expression="elevation = 6 - col()") + depth = np.asarray( + tools.r_sim_water( + elevation="elevation", + depth=np.array, + rain_value=RAIN, + man_value=0.1, + nwalkers=NWALKERS, + niterations=NITERATIONS, + random_seed=SEED, + nprocs=NPROCS, + ) + ) + assert np.sum(depth) > 0, "Expected positive depth when dx/dy are omitted" + + +def test_flow_control_increases_depth(east_slope_session): + """A uniform flow control map must increase total water depth. + + The flow_control raster defines a per-cell trapping probability (0-1). + When trapped, a walker's velocity is reversed and scaled to 10%, + slowing it down. Slower walkers linger longer, increasing depth. + Higher trapping probability means more accumulation. + """ + tools = Tools(session=east_slope_session) + sum_no_control = float(np.sum(run_sim(east_slope_session))) + tools.r_mapcalc(expression="flow_ctrl_low = 0.3") + tools.r_mapcalc(expression="flow_ctrl_high = 0.8") + sum_low_trap = float( + np.sum(run_sim(east_slope_session, flow_control="flow_ctrl_low")) + ) + sum_high_trap = float( + np.sum(run_sim(east_slope_session, flow_control="flow_ctrl_high")) + ) + assert sum_low_trap > sum_no_control, ( + f"Trapping should increase depth: " + f"sum(no trap)={sum_no_control:.3e}, sum(trap=0.3)={sum_low_trap:.3e}" + ) + assert sum_high_trap > sum_low_trap, ( + f"More trapping should increase depth further: " + f"sum(trap=0.3)={sum_low_trap:.3e}, sum(trap=0.8)={sum_high_trap:.3e}" + ) + + +def test_time_series_output(tmp_path): + """The -t flag with output_step must produce intermediate depth maps. + + With niterations=10 and output_step=5, the tool should create depth + maps at minutes 5 and 10. The later time step should have greater or + equal total depth as water accumulates over time. + + Uses a 200-cell domain at 10 m resolution with high roughness so that + walkers survive the full simulation. On small or steep domains walkers + leave before the first output step, producing no time-series maps. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=2000, s=0, n=10, res=10) + tools.r_mapcalc(expression="elevation = 201 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth="ts_depth", + rain_value=RAIN, + man_value=0.3, + nwalkers=NWALKERS, + niterations=NITERATIONS, + output_step=5, + random_seed=SEED, + nprocs=NPROCS, + flags="t", + ) + + sum_05 = tools.r_univar(map="ts_depth.05", format="json")["sum"] + sum_10 = tools.r_univar(map="ts_depth.10", format="json")["sum"] + assert sum_05 > 0, "Expected positive depth at t=5" + assert sum_10 >= sum_05, ( + f"Depth at t=10 ({sum_10:.3e}) should be >= depth at t=5 ({sum_05:.3e})" + ) From 7f227b5200c108852f24179d4c72d1a93885ec4d Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Wed, 18 Mar 2026 15:33:24 -0400 Subject: [PATCH 04/29] Add niterations tests, improve assertions, and validate walker requirements --- .../{simwe_test.py => r_sim_water_test.py} | 258 +++++++++++++++++- 1 file changed, 256 insertions(+), 2 deletions(-) rename raster/r.sim/r.sim.water/tests/{simwe_test.py => r_sim_water_test.py} (70%) diff --git a/raster/r.sim/r.sim.water/tests/simwe_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py similarity index 70% rename from raster/r.sim/r.sim.water/tests/simwe_test.py rename to raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 726336fe7ab..9691f7fb5ae 100644 --- a/raster/r.sim/r.sim.water/tests/simwe_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -4,7 +4,9 @@ about from first principles rather than from pre-computed reference values. """ +import io import os +import pathlib import numpy as np import pytest @@ -38,6 +40,8 @@ def run_sim(session, *, random_seed=SEED, **kwargs): "infil_value": 0, "man_value": 0.1, "nwalkers": NWALKERS, + "nprocs": NPROCS, + "niterations": NITERATIONS, } defaults.update(kwargs) # On the command line, an unwanted parameter is simply omitted, so we @@ -49,9 +53,7 @@ def run_sim(session, *, random_seed=SEED, **kwargs): dx="dx", dy="dy", depth=np.array, - niterations=NITERATIONS, random_seed=random_seed, - nprocs=NPROCS, **defaults, ) return np.asarray(result) @@ -318,6 +320,108 @@ def test_mintimestep(east_slope_session): ) +def test_longer_simulation_larger_domain(tmp_path): + """More iterations must increase total water depth on a larger domain. + + On a larger domain with slower drainage, longer simulations accumulate + more water. Uses a 200-cell domain at 10 m resolution so that walkers + remain within the domain for the full duration. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=2000, s=0, n=10, res=10) + tools.r_mapcalc(expression="elevation = 201 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + + sum_short = float( + np.sum(run_sim(session, rain_value=RAIN, man_value=0.3, niterations=5)) + ) + sum_long = float( + np.sum(run_sim(session, rain_value=RAIN, man_value=0.3, niterations=20)) + ) + assert sum_long > sum_short, ( + f"Longer simulation should produce more depth: " + f"sum(5 min)={sum_short:.3e}, sum(20 min)={sum_long:.3e}" + ) + ratio = sum_long / sum_short + assert ratio >= 1.05, ( + f"20-min simulation should produce at least 5% more depth than 5-min " + f"(ratio={ratio:.2f})" + ) + + +def test_niterations_affects_time_series_progression(tmp_path): + """More iterations must create more time-series output maps. + + With output_step=5, niterations=10 produces maps at t=5,10 while + niterations=20 produces maps at t=5,10,15. More time-series maps + indicate longer simulation duration. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=2000, s=0, n=10, res=10) + tools.r_mapcalc(expression="elevation = 201 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + + # niterations=10 with output_step=5 produces maps at t=5,10 + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth="depth_10min", + rain_value=RAIN, + man_value=0.3, + nwalkers=NWALKERS, + niterations=10, + output_step=5, + random_seed=SEED, + nprocs=NPROCS, + flags="t", + ) + + # niterations=20 with output_step=5 produces maps at t=5,10,15 + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth="depth_20min", + rain_value=RAIN, + man_value=0.3, + nwalkers=NWALKERS, + niterations=20, + output_step=5, + random_seed=SEED, + nprocs=NPROCS, + flags="t", + ) + + # 10-min run should produce 2 time-series maps (t=5, t=10) + maps_10 = list( + tools.g_list(type="raster", pattern="depth_10min*", format="json") + ) + assert len(maps_10) == 2, ( + f"10-min simulation with output_step=5 should produce 2 maps, got {len(maps_10)}" + ) + + # 20-min run should produce 3 time-series maps (t=5, t=10, t=15) + maps_20 = list( + tools.g_list(type="raster", pattern="depth_20min*", format="json") + ) + assert len(maps_20) == 3, ( + f"20-min simulation with output_step=5 should produce 3 maps, got {len(maps_20)}" + ) + assert len(maps_20) > len(maps_10), ( + f"Longer simulation should produce more time-series maps: " + f"20-min has {len(maps_20)} maps, 10-min has {len(maps_10)} maps" + ) + + def test_higher_diffusion_coeff_reduces_depth(east_slope_session): """Higher diffusion coefficient must reduce total water depth. @@ -568,3 +672,153 @@ def test_time_series_output(tmp_path): assert sum_10 >= sum_05, ( f"Depth at t=10 ({sum_10:.3e}) should be >= depth at t=5 ({sum_05:.3e})" ) + + +def test_observation_logfile(east_slope_session, tmp_path): + """Observation points must log water depth at each time step. + + Three observation points are placed on the east_slope_session domain + at upslope, midslope, and downslope positions. The logfile must contain + a header with category numbers and data lines with depth values. + Depth should increase from upslope to downslope. + """ + tools = Tools(session=east_slope_session) + + points_data = io.StringIO("0.5|0.5|1\n2.5|0.5|2\n4.5|0.5|3\n") + tools.v_in_ascii(input=points_data, output="points", cat=3) + + logfile = str(tmp_path / "obs_log.txt") + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth=np.array, + rain_value=RAIN, + man_value=0.1, + nwalkers=NWALKERS, + niterations=NITERATIONS, + random_seed=SEED, + nprocs=NPROCS, + observation="points", + logfile=logfile, + ) + + lines = pathlib.Path(logfile).read_text(encoding="utf-8").strip().split("\n") + + # Header: "STEP CAT0001 CAT0002 CAT0003" + header = lines[0].split() + assert header[0] == "STEP" + assert "CAT0001" in header + assert "CAT0002" in header + assert "CAT0003" in header + + # Must have at least one data line after the header. + assert len(lines) > 1, "Logfile should contain data lines after the header" + + # Data line: "000028 0.0000 0.0001 0.0001" (step, then one depth per point) + last_vals = [float(v) for v in lines[-1].split()[1:]] + upslope = last_vals[0] + midslope = last_vals[1] + downslope = last_vals[2] + assert downslope >= midslope >= upslope, ( + f"Depth should increase downstream: " + f"upslope={upslope:.4f}, midslope={midslope:.4f}, downslope={downslope:.4f}" + ) + + +def test_walkers_output(tmp_path): + """The walkers_output parameter must produce a vector point map. + + Each surviving walker position is written as a 3D point. The number + of points should not exceed nwalkers. + + Uses a large domain (200 cells) to give walkers sufficient residence + time before exiting. High roughness (0.3) further increases walker + retention; small domains produce no walker output regardless of roughness. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=2000, s=0, n=10, res=10) + tools.r_mapcalc(expression="elevation = 201 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth=np.array, + rain_value=RAIN, + man_value=0.3, + nwalkers=NWALKERS, + niterations=NITERATIONS, + random_seed=SEED, + nprocs=NPROCS, + walkers_output="walkers", + ) + + info = tools.v_info(map="walkers", flags="t", format="json") + npoints = int(info["points"]) + assert npoints > 0, "Expected at least one walker point" + assert npoints <= NWALKERS, ( + f"Number of walker points ({npoints}) should not exceed " + f"nwalkers ({NWALKERS})" + ) + + +def test_walkers_output_time_series(tmp_path): + """With -t, walkers_output must produce per-step vector maps. + + The maps use an underscore-separated time suffix (e.g., walkers_05) + unlike raster outputs which use a dot (e.g., depth.05). + + Uses a large domain (200 cells) with high roughness (0.3) so walkers + survive long enough to generate multiple time-series outputs. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=2000, s=0, n=10, res=10) + tools.r_mapcalc(expression="elevation = 201 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth="ts_depth", + rain_value=RAIN, + man_value=0.3, + nwalkers=NWALKERS, + niterations=NITERATIONS, + output_step=5, + random_seed=SEED, + nprocs=NPROCS, + walkers_output="walkers", + flags="t", + ) + + info_05 = tools.v_info(map="walkers_05", flags="t", format="json") + info_10 = tools.v_info(map="walkers_10", flags="t", format="json") + assert int(info_05["points"]) > 0, "Expected walker points at t=5" + assert int(info_10["points"]) > 0, "Expected walker points at t=10" + + +def test_nprocs_gives_same_result(east_slope_session): + """Multiple threads produce a result close to a single thread. + + Thread-level partitioning changes walker ordering and random draws, + so results are not bitwise identical, but total depth should agree + within a few percent. + """ + sum_single = float(np.sum(run_sim(east_slope_session))) + sum_multi = float(np.sum(run_sim(east_slope_session, nprocs=4))) + tolerance = 0.05 + assert sum_multi == pytest.approx(sum_single, rel=tolerance), ( + f"nprocs=4 result ({sum_multi:.3e}) should match " + f"nprocs=1 result ({sum_single:.3e}) within {tolerance:.0%}" + ) From a431ff6d7135e298b4763a69aefd2611a1bd05d6 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Wed, 18 Mar 2026 16:23:22 -0400 Subject: [PATCH 05/29] r.sim.water: Use module-scoped session to reuse data (from 12.27s to 10.03s) --- raster/r.sim/r.sim.water/tests/conftest.py | 40 +++++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/conftest.py b/raster/r.sim/r.sim.water/tests/conftest.py index 78b9997a09b..d539c7da40a 100644 --- a/raster/r.sim/r.sim.water/tests/conftest.py +++ b/raster/r.sim/r.sim.water/tests/conftest.py @@ -3,13 +3,42 @@ import pytest import grass.script as gs +from grass.experimental.mapset import TemporaryMapsetSession from grass.tools import Tools +@pytest.fixture(scope="module") +def simwe_session(tmp_path_factory): + """Module-scoped GRASS session for SIMWE tests with shared base terrain. + + Creates a persistent project and initializes the GRASS runtime environment + in the PERMANENT mapset, where the base terrain (elevation, dx, dy) is set up. + This session persists for the entire test module. All function-scoped fixtures + create temporary mapsets within this session and automatically see the base + terrain from PERMANENT. + """ + tmp_path = tmp_path_factory.mktemp("simwe_module") + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + # Set up base 1x5 eastward slope terrain in PERMANENT mapset. + # Use -s flag to save region as default (inherited by temporary mapsets). + tools.g_region(w=0, e=5, s=0, n=1, res=1, flags="s") + tools.r_mapcalc(expression="elevation = 6 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + yield session + + @pytest.fixture -def east_slope_session(tmp_path): +def east_slope_session(simwe_session): """Session with a 1-row x 5-column eastward slope, ready for r.sim.water. + Uses the module-scoped simwe_session and creates a new temporary mapset + for each test. The base terrain (elevation, dx, dy) and computational region + are inherited from the PERMANENT mapset created in the module scope. + Terrain (GRASS col() is 1-indexed from west): elevation = 6 - col() -> [5, 4, 3, 2, 1] west to east dx = 1.0 (unit slope vector pointing east) @@ -17,12 +46,5 @@ def east_slope_session(tmp_path): Water flows east and accumulates toward the eastern boundary. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=5, s=0, n=1, res=1) - tools.r_mapcalc(expression="elevation = 6 - col()") - tools.r_mapcalc(expression="dx = 1.0") - tools.r_mapcalc(expression="dy = 0.0") + with TemporaryMapsetSession(env=simwe_session.env) as session: yield session From 28bc62289b47290f0a56f0c1750d20894279273b Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Thu, 19 Mar 2026 12:47:34 -0400 Subject: [PATCH 06/29] Add north-south 1D tests --- .../r.sim.water/tests/r_sim_water_test.py | 128 +++++++++++++++++- 1 file changed, 123 insertions(+), 5 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 9691f7fb5ae..971e1844d21 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -70,7 +70,10 @@ def test_no_rain_produces_no_depth(east_slope_session): def test_rain_produces_positive_depth(east_slope_session): - """Rainfall on a slope must create positive water depth somewhere.""" + """Rainfall on a slope must create positive water depth somewhere. + + See test_north_slope_rain_produces_positive_depth for the north-south variant. + """ depth = run_sim(east_slope_session) assert np.sum(depth) > 0, "Expected positive total depth with rainfall on a slope" @@ -125,9 +128,9 @@ def test_depth_increases_downstream(tmp_path): from the divide. Manning's kinematic wave then gives h(x) proportional to x^(3/5), also increasing downstream. - Uses a 1-row x 6-column domain (instead of the standard 5-column one) - so the strip splits cleanly into an upslope half (cols 0-2) and a - downslope half (cols 3-5). + Uses a 1-row x 6-column domain to split cleanly into an upslope half + (cols 0-2) and a downslope half (cols 3-5). See + test_north_slope_depth_increases_downstream for the north-south variant. """ project = tmp_path / "simwe" gs.create_project(project) @@ -680,7 +683,8 @@ def test_observation_logfile(east_slope_session, tmp_path): Three observation points are placed on the east_slope_session domain at upslope, midslope, and downslope positions. The logfile must contain a header with category numbers and data lines with depth values. - Depth should increase from upslope to downslope. + Depth should increase from upslope to downslope. See + test_north_slope_observation_logfile for the north-south variant. """ tools = Tools(session=east_slope_session) @@ -822,3 +826,117 @@ def test_nprocs_gives_same_result(east_slope_session): f"nprocs=4 result ({sum_multi:.3e}) should match " f"nprocs=1 result ({sum_single:.3e}) within {tolerance:.0%}" ) + + +def test_north_slope_rain_produces_positive_depth(tmp_path): + """Rainfall on a north-south slope must create positive water depth. + + Mirrors test_rain_produces_positive_depth with dy instead of dx, verifying + flow direction when dy != 0 and dx = 0. + """ + project = tmp_path / "simwe_north" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=1, s=0, n=5, res=1) + tools.r_mapcalc(expression="elevation = row()") + tools.r_mapcalc(expression="dx = 0.0") + tools.r_mapcalc(expression="dy = 1.0") + + depth = run_sim(session) + assert np.sum(depth) > 0, ( + "Expected positive total depth with rainfall on north-south slope" + ) + + +def test_north_slope_depth_increases_downstream(tmp_path): + """Water must be deeper in the downslope (northward) half of a north-south slope. + + By continuity, discharge q(y) = R * y grows linearly with distance y + from the divide. Manning's kinematic wave then gives h(y) proportional + to y^(3/5), increasing toward the northern boundary. + + Uses a 6-row x 1-column domain to split cleanly into a downslope half + (rows 0-2, north) and an upslope half (rows 3-5, south). Mirrors + test_depth_increases_downstream with dy instead of dx. + + Note: dy = 1.0 because elevation increases southward (with increasing row). + """ + project = tmp_path / "simwe_north_grad" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=1, s=0, n=6, res=1) + # elevation = row() gives [1, 2, 3, 4, 5, 6] north to south + # North (low row index) has low elevation, water flows north + tools.r_mapcalc(expression="elevation = row()") + tools.r_mapcalc(expression="dx = 0.0") + tools.r_mapcalc(expression="dy = 1.0") + + depth = run_sim(session).flatten() # shape (6,) for a 1-column raster + upslope_sum = float(np.sum(depth[3:])) # rows 3-5 (south, high elevation) + downslope_sum = float(np.sum(depth[:3])) # rows 0-2 (north, low elevation) + assert downslope_sum > upslope_sum, ( + f"Downslope total depth ({downslope_sum:.3e}) should exceed " + f"upslope total depth ({upslope_sum:.3e})" + ) + + +def test_north_slope_observation_logfile(tmp_path): + """Observation parameter works with north-south (dy != 0) flow. + + Mirrors test_observation_logfile with dy instead of dx. Water flows north + (downhill), so observation points should show increasing depth from south + (cat0001) to north (cat0003). Uses a 10-row x 1-column domain for adequate + water flow to observation points. + """ + project = tmp_path / "simwe_north_obs" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=1, s=0, n=10, res=1) + tools.r_mapcalc(expression="elevation = row()") + tools.r_mapcalc(expression="dx = 0.0") + tools.r_mapcalc(expression="dy = 1.0") + + # Three observation points along the north-south axis. + points_data = io.StringIO("0.5|1.5|1\n0.5|5.5|2\n0.5|9.5|3\n") + tools.v_in_ascii(input=points_data, output="points", cat=3) + + logfile = str(tmp_path / "obs_log_north.txt") + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth=np.array, + rain_value=RAIN, + man_value=0.1, + nwalkers=NWALKERS, + niterations=NITERATIONS, + random_seed=SEED, + nprocs=NPROCS, + observation="points", + logfile=logfile, + ) + + lines = pathlib.Path(logfile).read_text(encoding="utf-8").strip().split("\n") + + # Header: "STEP CAT0001 CAT0002 CAT0003" + header = lines[0].split() + assert header[0] == "STEP" + assert "CAT0001" in header + assert "CAT0002" in header + assert "CAT0003" in header + + # Must have at least one data line after the header. + assert len(lines) > 1, "Logfile should contain data lines after the header" + + # Data line: depth increases from south (cat0001) to north (cat0003) + last_vals = [float(v) for v in lines[-1].split()[1:]] + south = last_vals[0] + mid = last_vals[1] + north = last_vals[2] + assert north >= mid >= south, ( + f"Depth should increase downstream (northward): " + f"south={south:.4f}, mid={mid:.4f}, north={north:.4f}" + ) From 643c3f5c717fb1329d34e0fb3177199423de1163 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 20 Mar 2026 16:32:51 -0400 Subject: [PATCH 07/29] Decrease the number of walkers and simulation time in tests which allow for that to cut the runtime slightly (by 0.5s on a good day) --- .../r.sim.water/tests/r_sim_water_test.py | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 971e1844d21..2b429736894 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -18,12 +18,10 @@ SEED = 42 NPROCS = 1 -# 10000 walkers on a 1-row x 5-column domain gives 2000 walkers/cell. -# The default (2 x cells) would be 10, which is too noisy for aggregate -# comparisons. Unless otherwise noted, tests use this domain and walker count. -NWALKERS = 10000 - -NITERATIONS = 10 # minutes; long enough for near-steady state on a small domain +# With default walkers and niterations=2, small domains reach +# near-steady state in ~0.2 s per run while keeping Monte Carlo noise low +# enough for structural assertions. +NITERATIONS = 2 # minutes; sufficient for near-steady state on a small domain RAIN = 100 # mm/hr; generous signal-to-noise ratio on a small domain @@ -39,7 +37,6 @@ def run_sim(session, *, random_seed=SEED, **kwargs): "rain_value": RAIN, "infil_value": 0, "man_value": 0.1, - "nwalkers": NWALKERS, "nprocs": NPROCS, "niterations": NITERATIONS, } @@ -195,7 +192,6 @@ def test_discharge_positive_with_rain(east_slope_session): dy="dy", discharge=np.array, rain_value=RAIN, - nwalkers=NWALKERS, niterations=NITERATIONS, random_seed=SEED, nprocs=NPROCS, @@ -207,11 +203,14 @@ def test_discharge_positive_with_rain(east_slope_session): def test_results_consistent_across_seeds(east_slope_session): """Different random seeds must produce similar total depth. - The Monte Carlo result converges as nwalkers grows. With 10000 walkers + The Monte Carlo result converges as nwalkers grows. With enough walkers the total depth should be stable across seeds within a few percent. """ seeds = [1, 7, 42, 99, 123] - sums = [float(np.sum(run_sim(east_slope_session, random_seed=s))) for s in seeds] + sums = [ + float(np.sum(run_sim(east_slope_session, nwalkers=1000, random_seed=s))) + for s in seeds + ] mean_sum = np.mean(sums) for seed, total in zip(seeds, sums, strict=True): assert total == pytest.approx(mean_sum, rel=0.1), ( @@ -244,7 +243,6 @@ def test_random_seed_flag(east_slope_session): "depth": np.array, "rain_value": RAIN, "man_value": 0.1, - "nwalkers": NWALKERS, "niterations": NITERATIONS, "nprocs": NPROCS, "flags": "s", @@ -258,9 +256,14 @@ def test_random_seed_flag(east_slope_session): ) -def run_sim_error(session, *, nwalkers=NWALKERS, **kwargs): +def run_sim_error(session, **kwargs): """Run r.sim.water and return the error output as ndarray.""" - defaults = {"rain_value": RAIN, "infil_value": 0, "man_value": 0.1} + defaults = { + "rain_value": RAIN, + "infil_value": 0, + "man_value": 0.1, + "niterations": NITERATIONS, + } defaults.update(kwargs) defaults = {k: v for k, v in defaults.items() if v is not None} tools = Tools(session=session) @@ -270,8 +273,6 @@ def run_sim_error(session, *, nwalkers=NWALKERS, **kwargs): dx="dx", dy="dy", error=np.array, - nwalkers=nwalkers, - niterations=NITERATIONS, random_seed=SEED, nprocs=NPROCS, **defaults, @@ -297,11 +298,13 @@ def test_more_walkers_reduces_error(east_slope_session): The error scales as 1/sqrt(N), so quadrupling walkers should roughly halve the total error. """ - error_few = float(np.sum(run_sim_error(east_slope_session, nwalkers=NWALKERS))) - error_many = float(np.sum(run_sim_error(east_slope_session, nwalkers=4 * NWALKERS))) + few = 10000 + many = 40000 + error_few = float(np.sum(run_sim_error(east_slope_session, nwalkers=few))) + error_many = float(np.sum(run_sim_error(east_slope_session, nwalkers=many))) assert error_many < error_few, ( f"Total error should decrease with more walkers: " - f"sum({NWALKERS})={error_few:.3e}, sum({4 * NWALKERS})={error_many:.3e}" + f"sum({few})={error_few:.3e}, sum({many})={error_many:.3e}" ) @@ -380,7 +383,7 @@ def test_niterations_affects_time_series_progression(tmp_path): depth="depth_10min", rain_value=RAIN, man_value=0.3, - nwalkers=NWALKERS, + nwalkers=10000, niterations=10, output_step=5, random_seed=SEED, @@ -396,7 +399,7 @@ def test_niterations_affects_time_series_progression(tmp_path): depth="depth_20min", rain_value=RAIN, man_value=0.3, - nwalkers=NWALKERS, + nwalkers=10000, niterations=20, output_step=5, random_seed=SEED, @@ -597,7 +600,6 @@ def test_dx_dy_optional(tmp_path): depth=np.array, rain_value=RAIN, man_value=0.1, - nwalkers=NWALKERS, niterations=NITERATIONS, random_seed=SEED, nprocs=NPROCS, @@ -661,8 +663,7 @@ def test_time_series_output(tmp_path): depth="ts_depth", rain_value=RAIN, man_value=0.3, - nwalkers=NWALKERS, - niterations=NITERATIONS, + niterations=10, output_step=5, random_seed=SEED, nprocs=NPROCS, @@ -699,7 +700,7 @@ def test_observation_logfile(east_slope_session, tmp_path): depth=np.array, rain_value=RAIN, man_value=0.1, - nwalkers=NWALKERS, + nwalkers=1000, niterations=NITERATIONS, random_seed=SEED, nprocs=NPROCS, @@ -749,6 +750,7 @@ def test_walkers_output(tmp_path): tools.r_mapcalc(expression="dx = 1.0") tools.r_mapcalc(expression="dy = 0.0") + nwalkers = 500 tools.r_sim_water( elevation="elevation", dx="dx", @@ -756,7 +758,7 @@ def test_walkers_output(tmp_path): depth=np.array, rain_value=RAIN, man_value=0.3, - nwalkers=NWALKERS, + nwalkers=nwalkers, niterations=NITERATIONS, random_seed=SEED, nprocs=NPROCS, @@ -766,9 +768,9 @@ def test_walkers_output(tmp_path): info = tools.v_info(map="walkers", flags="t", format="json") npoints = int(info["points"]) assert npoints > 0, "Expected at least one walker point" - assert npoints <= NWALKERS, ( + assert npoints <= nwalkers, ( f"Number of walker points ({npoints}) should not exceed " - f"nwalkers ({NWALKERS})" + f"nwalkers ({nwalkers})" ) @@ -797,8 +799,7 @@ def test_walkers_output_time_series(tmp_path): depth="ts_depth", rain_value=RAIN, man_value=0.3, - nwalkers=NWALKERS, - niterations=NITERATIONS, + niterations=10, output_step=5, random_seed=SEED, nprocs=NPROCS, @@ -819,8 +820,8 @@ def test_nprocs_gives_same_result(east_slope_session): so results are not bitwise identical, but total depth should agree within a few percent. """ - sum_single = float(np.sum(run_sim(east_slope_session))) - sum_multi = float(np.sum(run_sim(east_slope_session, nprocs=4))) + sum_single = float(np.sum(run_sim(east_slope_session, nwalkers=1000))) + sum_multi = float(np.sum(run_sim(east_slope_session, nwalkers=1000, nprocs=4))) tolerance = 0.05 assert sum_multi == pytest.approx(sum_single, rel=tolerance), ( f"nprocs=4 result ({sum_multi:.3e}) should match " @@ -911,7 +912,7 @@ def test_north_slope_observation_logfile(tmp_path): depth=np.array, rain_value=RAIN, man_value=0.1, - nwalkers=NWALKERS, + nwalkers=1000, niterations=NITERATIONS, random_seed=SEED, nprocs=NPROCS, From cbb333d7b017560da32c8277c8f5a33226f149df Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 20 Mar 2026 17:03:41 -0400 Subject: [PATCH 08/29] r.sim.water: Add 2D landform tests for flow on synthetic landscapes Add 11 tests using 2D synthetic landscapes (pit, peak, ridge, valley, saddle, hollow vs spur, terraced slope) with theoretical assertions about spatial depth patterns and full-grid regression checks. Rotation tests verify directional isotropy. All tests run in ~3 s. Tests were generated with the help of AI (Claude). --- .../tests/r_sim_water_landforms_test.py | 850 ++++++++++++++++++ 1 file changed, 850 insertions(+) create mode 100644 raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py new file mode 100644 index 00000000000..978be4dc64e --- /dev/null +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py @@ -0,0 +1,850 @@ +"""Landscape-configuration tests for r.sim.water (SIMWE). + +Tests use 2D synthetic landscapes with small spatial extent and +hand-crafted elevation surfaces where expected output can be reasoned +about from the landscape geometry. +""" + +import os + +import numpy as np +import pytest + +import grass.script as gs +from grass.tools import Tools + +# Fixed seed and single thread make results fully deterministic. +SEED = 42 +NPROCS = 1 + +NITERATIONS = 2 # minutes; enough for near-steady state on small domains +RAIN = 100 # mm/hr + + +def run_sim(session, *, random_seed=SEED, **kwargs): + """Run r.sim.water on a 2D domain; return depth as 2D ndarray. + + Omits dx/dy so partial derivatives are computed internally. + """ + defaults = { + "rain_value": RAIN, + "infil_value": 0, + "man_value": 0.1, + "nprocs": NPROCS, + "niterations": NITERATIONS, + } + defaults.update(kwargs) + defaults = {k: v for k, v in defaults.items() if v is not None} + tools = Tools(session=session) + return tools.r_sim_water( + elevation="elevation", + depth=np.array, + random_seed=random_seed, + **defaults, + ) + + +def test_pit_collects_water(tmp_path): + """A bowl-shaped pit must collect most water in its center. + + A 5x5 grid with elevation equal to the Chebyshev distance from the + center creates a closed depression. Water flows inward from all + directions and accumulates at the lowest point. + + Elevation: + 3 3 3 3 3 + 3 2 2 2 3 + 3 2 1 2 3 + 3 2 2 2 3 + 3 3 3 3 3 + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=5, s=0, n=5, res=1) + # row() and col() are 1-indexed, so center of 5x5 is at (3, 3). + tools.r_mapcalc( + expression="elevation = max(abs(row() - 3), abs(col() - 3)) + 1" + ) + + depth = run_sim(session) + + # The center cell is at NumPy index [2, 2]. + center_depth = float(depth[2, 2]) + total_depth = float(np.sum(depth)) + + # The pit center must have the maximum depth. + assert center_depth == np.max(depth), ( + f"Pit center depth ({center_depth:.4f}) should be the domain maximum " + f"({np.max(depth):.4f})" + ) + + # Most water should be trapped in the center. The 30% threshold + # holds across resolutions from 0.1 m to 30 m. + assert center_depth > 0.3 * total_depth, ( + f"Center should hold >30% of total water: " + f"center={center_depth:.4f}, total={total_depth:.4f}" + ) + + # Radial symmetry: the 4 cardinal neighbors should have similar depth. + neighbors = [depth[1, 2], depth[3, 2], depth[2, 1], depth[2, 3]] + mean_neighbor = float(np.mean(neighbors)) + for i, nd in enumerate(neighbors): + assert nd == pytest.approx(mean_neighbor, rel=0.3), ( + f"Cardinal neighbor {i} depth ({nd:.4f}) deviates from " + f"mean neighbor depth ({mean_neighbor:.4f})" + ) + + # Cardinal neighbors must have less depth than center. + assert all(n < center_depth for n in neighbors), ( + f"All neighbors should have less depth than center: " + f"neighbors={[f'{n:.4f}' for n in neighbors]}, " + f"center={center_depth:.4f}" + ) + + # Radial symmetry for the 4 diagonal neighbors. + diagonals = [depth[1, 1], depth[1, 3], depth[3, 1], depth[3, 3]] + mean_diagonal = float(np.mean(diagonals)) + for i, dd in enumerate(diagonals): + assert dd == pytest.approx(mean_diagonal, rel=0.3), ( + f"Diagonal neighbor {i} depth ({dd:.4f}) deviates from " + f"mean diagonal depth ({mean_diagonal:.4f})" + ) + + # Depth must decrease monotonically from center outward: + # center > cardinal ring > edge midpoints. + edge_midpoints = [depth[0, 2], depth[4, 2], depth[2, 0], depth[2, 4]] + assert mean_neighbor > float(np.mean(edge_midpoints)), ( + f"Ring 1 mean depth ({mean_neighbor:.4f}) should exceed " + f"ring 2 mean depth ({float(np.mean(edge_midpoints)):.4f})" + ) + + # Full grid regression check for this seed and parameters. + expected = np.array( + [ + [0.000503, 0.000395, 0.000451, 0.000642, 0.000395], + [0.000451, 0.002898, 0.014304, 0.002434, 0.000800], + [0.000451, 0.014336, 0.087167, 0.014282, 0.000451], + [0.000503, 0.002556, 0.014759, 0.002851, 0.000684], + [0.000642, 0.000503, 0.000260, 0.000800, 0.000503], + ] + ) + np.testing.assert_allclose(depth, expected, atol=0.002) + + +def test_peak_sheds_water(tmp_path): + """A conical peak must have minimum depth at the summit. + + A 5x5 grid with elevation decreasing outward from the center creates + a peak. Water flows away from the summit in all directions, so the + center should have the least water and edges should accumulate more. + + Elevation: + 2 2 2 2 2 + 2 3 3 3 2 + 2 3 4 3 2 + 2 3 3 3 2 + 2 2 2 2 2 + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=5, s=0, n=5, res=1) + tools.r_mapcalc( + expression="elevation = 4 - max(abs(row() - 3), abs(col() - 3))" + ) + + depth = run_sim(session) + + # Cardinal neighbors should have similar depth (radial symmetry). + neighbors = [depth[1, 2], depth[3, 2], depth[2, 1], depth[2, 3]] + mean_neighbor = float(np.mean(neighbors)) + for i, nd in enumerate(neighbors): + assert nd == pytest.approx(mean_neighbor, rel=0.4), ( + f"Cardinal neighbor {i} depth ({nd:.6f}) deviates from " + f"mean neighbor depth ({mean_neighbor:.6f})" + ) + + # Diagonal neighbors should have similar depth. + diagonals = [depth[1, 1], depth[1, 3], depth[3, 1], depth[3, 3]] + mean_diagonal = float(np.mean(diagonals)) + for i, dd in enumerate(diagonals): + assert dd == pytest.approx(mean_diagonal, rel=0.4), ( + f"Diagonal neighbor {i} depth ({dd:.6f}) deviates from " + f"mean diagonal depth ({mean_diagonal:.6f})" + ) + + # Outer ring (edges) should have more depth than inner ring. + edge_midpoints = [depth[0, 2], depth[4, 2], depth[2, 0], depth[2, 4]] + mean_edge = float(np.mean(edge_midpoints)) + assert mean_edge > mean_neighbor, ( + f"Edge depth ({mean_edge:.6f}) should exceed " + f"neighbor depth ({mean_neighbor:.6f})" + ) + + # Full grid regression check. + expected = np.array( + [ + [0.000973, 0.000872, 0.000800, 0.000451, 0.000800], + [0.000684, 0.000598, 0.000552, 0.000503, 0.000260], + [0.000552, 0.000332, 0.000598, 0.000503, 0.000724], + [0.000503, 0.000503, 0.000332, 0.000395, 0.000260], + [0.000907, 0.000642, 0.000642, 0.000763, 0.000642], + ] + ) + np.testing.assert_allclose(depth, expected, atol=0.0002) + + +def test_ridge_sheds_water_to_flanks(tmp_path): + """A ridge must shed water to both flanks, leaving the crest drier. + + A 5x7 grid with a N-S ridge along the center column. The crest + is the highest point and water flows away toward the edges. + + Elevation (each row): + 0 1 2 3 2 1 0 + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=7, s=0, n=5, res=1) + tools.r_mapcalc(expression="elevation = 3 - abs(col() - 4)") + + depth = run_sim(session) + + # Edge columns (0 and 6) should accumulate more water than + # the crest column (3) because water flows downhill. + crest_sum = float(np.sum(depth[:, 3])) + left_edge_sum = float(np.sum(depth[:, 0])) + right_edge_sum = float(np.sum(depth[:, 6])) + assert left_edge_sum > crest_sum, ( + f"Left edge ({left_edge_sum:.6f}) should exceed crest ({crest_sum:.6f})" + ) + assert right_edge_sum > crest_sum, ( + f"Right edge ({right_edge_sum:.6f}) should exceed crest ({crest_sum:.6f})" + ) + + # Left-right symmetry: columns equidistant from crest should + # have similar total depth. + for offset in range(1, 4): + left_sum = float(np.sum(depth[:, 3 - offset])) + right_sum = float(np.sum(depth[:, 3 + offset])) + assert left_sum == pytest.approx(right_sum, rel=0.4), ( + f"Offset {offset}: left sum ({left_sum:.6f}) vs " + f"right sum ({right_sum:.6f})" + ) + + # Full grid regression check. + expected = np.array( + [ + [0.000946, 0.000586, 0.000303, 0.000303, 0.000360, 0.000459, 0.000546], + [0.001390, 0.000796, 0.000661, 0.001455, 0.000412, 0.000827, 0.001182], + [0.001206, 0.001055, 0.000796, 0.001081, 0.000586, 0.000624, 0.001497], + [0.001254, 0.000730, 0.000586, 0.001158, 0.000412, 0.000827, 0.001055], + [0.000888, 0.000546, 0.000459, 0.000730, 0.000238, 0.000546, 0.000661], + ] + ) + np.testing.assert_allclose(depth, expected, atol=0.0005) + + +def test_ridge_rotation_invariance(tmp_path): + """A N-S ridge and E-W ridge must produce rotationally equivalent depth. + + The E-W ridge is a 90-degree rotation of the N-S ridge. After rotating + the E-W result back, total depth and cross-ridge profiles should match. + """ + # N-S ridge: 5 rows x 7 cols, ridge along center column. + project_ns = tmp_path / "ns" + gs.create_project(project_ns) + with gs.setup.init(project_ns, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=7, s=0, n=5, res=1) + tools.r_mapcalc(expression="elevation = 3 - abs(col() - 4)") + depth_ns = run_sim(session) + + # E-W ridge: 7 rows x 5 cols, ridge along center row. + project_ew = tmp_path / "ew" + gs.create_project(project_ew) + with gs.setup.init(project_ew, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=5, s=0, n=7, res=1) + tools.r_mapcalc(expression="elevation = 3 - abs(row() - 4)") + depth_ew = run_sim(session) + + # Total depth should be comparable. The ridge has only a 3 m + # elevation range on a small grid, so boundary effects and Monte + # Carlo noise cause ~10% variation between orientations. + total_ns = float(np.sum(depth_ns)) + total_ew = float(np.sum(depth_ew)) + assert total_ns == pytest.approx(total_ew, rel=0.1), ( + f"Total depth N-S ({total_ns:.6f}) vs E-W ({total_ew:.6f})" + ) + + # Rotating the E-W result 90 degrees counter-clockwise should + # approximate the N-S result. + depth_ew_rotated = np.rot90(depth_ew) + assert depth_ew_rotated.shape == depth_ns.shape + + # Cross-ridge profiles (summed along the ridge axis) should match. + # Monte Carlo noise on small grids requires generous tolerance. + profile_ns = np.sum(depth_ns, axis=0) + profile_ew = np.sum(depth_ew_rotated, axis=0) + np.testing.assert_allclose(profile_ns, profile_ew, rtol=0.35) + + +def test_valley_concentrates_flow_downstream(tmp_path): + """A V-shaped valley must concentrate water along its floor. + + A 10x7 grid with a V-shaped cross-section draining southward. + The valley floor (center column) should have much more water than + the hillslopes, and discharge should increase downstream. + + Cross-section (each row, west to east): + 3 2 1 0 1 2 3 + Downstream gradient adds 0.5 per row, so the top row is 4.5 m + higher than the bottom row. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=7, s=0, n=10, res=1) + tools.r_mapcalc( + expression="elevation = abs(col() - 4) + (nrows() - row()) * 0.5" + ) + + depth = run_sim(session) + + # Valley floor (col index 3) should have much higher per-cell + # depth than the hillslope average. + floor_mean = float(np.mean(depth[:, 3])) + hillslope_cols = np.concatenate([depth[:, :3], depth[:, 4:]], axis=1) + hillslope_mean = float(np.mean(hillslope_cols)) + assert floor_mean > 3 * hillslope_mean, ( + f"Valley floor mean depth ({floor_mean:.6f}) should be >3x " + f"hillslope mean ({hillslope_mean:.6f})" + ) + + # Discharge (depth) should increase downstream (toward higher row + # indices in the array). Compare upper and lower halves. + upper_floor = float(np.sum(depth[:5, 3])) + lower_floor = float(np.sum(depth[5:, 3])) + assert lower_floor > upper_floor, ( + f"Lower valley floor ({lower_floor:.6f}) should exceed " + f"upper valley floor ({upper_floor:.6f})" + ) + + # Cross-valley symmetry: columns equidistant from floor should + # have similar total depth. + for offset in range(1, 4): + left = float(np.sum(depth[:, 3 - offset])) + right = float(np.sum(depth[:, 3 + offset])) + assert left == pytest.approx(right, rel=0.4), ( + f"Offset {offset}: left ({left:.6f}) vs right ({right:.6f})" + ) + + # Full grid regression check. + expected = np.array( + [ + [0.000276, 0.000404, 0.000579, 0.001990, 0.000684, 0.000704, 0.000276], + [0.000556, 0.000834, 0.000951, 0.001532, 0.000724, 0.000194, 0.000458], + [0.000276, 0.000533, 0.000919, 0.002308, 0.000886, 0.000601, 0.000643], + [0.000533, 0.000431, 0.001119, 0.003021, 0.001014, 0.000684, 0.000484], + [0.000404, 0.000643, 0.000798, 0.003759, 0.001271, 0.000724, 0.000431], + [0.000276, 0.000622, 0.001060, 0.004491, 0.001323, 0.000664, 0.000404], + [0.000509, 0.000601, 0.001189, 0.004852, 0.001323, 0.000556, 0.000484], + [0.000431, 0.000643, 0.001244, 0.005432, 0.001555, 0.000509, 0.000311], + [0.000374, 0.000724, 0.001203, 0.005944, 0.001361, 0.000643, 0.000664], + [0.000622, 0.000851, 0.001323, 0.006594, 0.001635, 0.000684, 0.000431], + ] + ) + np.testing.assert_allclose(depth, expected, atol=0.002) + + +def test_valley_rotation_invariance(tmp_path): + """A N-S valley and E-W valley must produce rotationally equivalent depth.""" + # N-S valley: 10 rows x 7 cols. + project_ns = tmp_path / "ns" + gs.create_project(project_ns) + with gs.setup.init(project_ns, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=7, s=0, n=10, res=1) + tools.r_mapcalc( + expression="elevation = abs(col() - 4) + (nrows() - row()) * 0.5" + ) + depth_ns = run_sim(session) + + # E-W valley: 7 rows x 10 cols. + project_ew = tmp_path / "ew" + gs.create_project(project_ew) + with gs.setup.init(project_ew, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=10, s=0, n=7, res=1) + tools.r_mapcalc( + expression="elevation = abs(row() - 4) + (ncols() - col()) * 0.5" + ) + depth_ew = run_sim(session) + + total_ns = float(np.sum(depth_ns)) + total_ew = float(np.sum(depth_ew)) + assert total_ns == pytest.approx(total_ew, rel=0.02), ( + f"Total depth N-S ({total_ns:.6f}) vs E-W ({total_ew:.6f})" + ) + + # Cross-valley profiles should match after rotation. + profile_ns = np.sum(depth_ns, axis=0) + depth_ew_rotated = np.rot90(depth_ew) + profile_ew = np.sum(depth_ew_rotated, axis=0) + np.testing.assert_allclose(profile_ns, profile_ew, rtol=0.15) + + +def test_saddle_bifurcates_flow(tmp_path): + """A saddle must split flow into two low quadrants. + + A 7x7 grid shaped as a hyperbolic paraboloid (saddle surface). + The surface rises along the N-S axis and drops along the E-W + axis, creating two high zones (top and bottom) and two low zones + (left and right). Water should flow preferentially into the low + zones. + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=7, s=0, n=7, res=1) + tools.r_mapcalc( + expression=( + "elevation = (row() - 4)*(row() - 4) - (col() - 4)*(col() - 4) + 10" + ) + ) + + depth = run_sim(session) + + # Low quadrants: where (col-4)^2 > (row-4)^2, i.e., columns + # far from center. These are the left and right sides. + # High quadrants: rows far from center (top and bottom). + low_mask = np.zeros_like(depth, dtype=bool) + high_mask = np.zeros_like(depth, dtype=bool) + for r in range(7): + for c in range(7): + row_dist = abs(r - 3) + col_dist = abs(c - 3) + if col_dist > row_dist: + low_mask[r, c] = True + elif row_dist > col_dist: + high_mask[r, c] = True + + low_sum = float(np.sum(depth[low_mask])) + high_sum = float(np.sum(depth[high_mask])) + assert low_sum > high_sum, ( + f"Low quadrant depth ({low_sum:.6f}) should exceed " + f"high quadrant depth ({high_sum:.6f})" + ) + + # Saddle point (center) should have more depth than the high + # quadrant mean because flow converges through the saddle. + high_mean = high_sum / np.sum(high_mask) + saddle_depth = float(depth[3, 3]) + assert saddle_depth > high_mean, ( + f"Saddle depth ({saddle_depth:.6f}) should exceed " + f"high quadrant mean ({high_mean:.6f})" + ) + + # Full grid regression check. + expected = np.array( + [ + [0.000264, 0.000282, 0.000155, 0.000203, 0.000244, 0.000155, 0.000264], + [0.000406, 0.000299, 0.000282, 0.000392, 0.000224, 0.000509, 0.000485], + [0.000721, 0.000509, 0.000460, 0.000812, 0.000332, 0.000556, 0.000847], + [0.001300, 0.000830, 0.000662, 0.003083, 0.001075, 0.000946, 0.001388], + [0.000777, 0.000545, 0.000299, 0.000777, 0.000473, 0.000611, 0.000839], + [0.000434, 0.000348, 0.000299, 0.000406, 0.000282, 0.000434, 0.000447], + [0.000299, 0.000203, 0.000264, 0.000203, 0.000244, 0.000282, 0.000244], + ] + ) + np.testing.assert_allclose(depth, expected, atol=0.001) + + +def test_saddle_rotation_invariance(tmp_path): + """Rotating the saddle 90 degrees must swap peak/valley axes. + + The original saddle has valleys along columns (left-right). + A 90-degree rotation swaps row and column terms, putting valleys + along rows (top-bottom). + """ + project_a = tmp_path / "a" + gs.create_project(project_a) + with gs.setup.init(project_a, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=7, s=0, n=7, res=1) + tools.r_mapcalc( + expression=( + "elevation = (row() - 4)*(row() - 4) - (col() - 4)*(col() - 4) + 10" + ) + ) + depth_a = run_sim(session) + + project_b = tmp_path / "b" + gs.create_project(project_b) + with gs.setup.init(project_b, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=7, s=0, n=7, res=1) + tools.r_mapcalc( + expression=( + "elevation = (col() - 4)*(col() - 4) - (row() - 4)*(row() - 4) + 10" + ) + ) + depth_b = run_sim(session) + + # Both are square so total depth should be comparable. + total_a = float(np.sum(depth_a)) + total_b = float(np.sum(depth_b)) + assert total_a == pytest.approx(total_b, rel=0.1), ( + f"Total depth original ({total_a:.6f}) vs rotated ({total_b:.6f})" + ) + + # After rotation, the outer ring (excluding the noisy center row/col) + # should have comparable depth distribution. Compare edge sums + # excluding the center row/col (index 3) where the saddle singularity + # amplifies Monte Carlo noise. + edge_cols_a = float(np.sum(depth_a[:, :3]) + np.sum(depth_a[:, 4:])) + edge_rows_b = float(np.sum(depth_b[:3, :]) + np.sum(depth_b[4:, :])) + assert edge_cols_a == pytest.approx(edge_rows_b, rel=0.2), ( + f"Edge depth original ({edge_cols_a:.6f}) vs rotated ({edge_rows_b:.6f})" + ) + + +def test_hollow_concentrates_more_than_spur(tmp_path): + """A hollow (convergent) must concentrate more water than a spur (divergent). + + Both have the same grid size and total rainfall. The hollow's concave + shape funnels water together while the spur's convex shape spreads + it apart. + """ + # Hollow: convergent flow, cross-section narrows downstream. + project_hollow = tmp_path / "hollow" + gs.create_project(project_hollow) + with gs.setup.init(project_hollow, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=7, s=0, n=10, res=1) + tools.r_mapcalc( + expression=( + "elevation = (nrows() - row()) * 0.5" + " + (col() - 4)*(col() - 4) * (1.0 - 0.08 * row())" + ) + ) + depth_hollow = run_sim(session) + + # Spur: divergent flow, cross-section widens downstream. + project_spur = tmp_path / "spur" + gs.create_project(project_spur) + with gs.setup.init(project_spur, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=7, s=0, n=10, res=1) + tools.r_mapcalc( + expression=( + "elevation = (nrows() - row()) * 0.5" + " + (col() - 4)*(col() - 4) * (0.2 + 0.08 * row())" + ) + ) + depth_spur = run_sim(session) + + # Peak depth in the hollow should exceed peak depth in the spur + # because convergent flow concentrates water. + max_hollow = float(np.max(depth_hollow)) + max_spur = float(np.max(depth_spur)) + assert max_hollow > max_spur, ( + f"Hollow peak depth ({max_hollow:.6f}) should exceed " + f"spur peak depth ({max_spur:.6f})" + ) + + # Full grid regression check for hollow. + expected_hollow = np.array( + [ + [0.000201, 0.000365, 0.000545, 0.001885, 0.000645, 0.000365, 0.000201], + [0.000291, 0.000527, 0.000871, 0.001734, 0.000612, 0.000317, 0.000291], + [0.000201, 0.000342, 0.000660, 0.002747, 0.000791, 0.000508, 0.000342], + [0.000388, 0.000365, 0.000983, 0.003093, 0.001007, 0.000409, 0.000291], + [0.000291, 0.000490, 0.000845, 0.003805, 0.001041, 0.000527, 0.000317], + [0.000164, 0.000490, 0.001064, 0.004477, 0.001109, 0.000490, 0.000317], + [0.000365, 0.000451, 0.001226, 0.004669, 0.001266, 0.000430, 0.000342], + [0.000342, 0.000562, 0.001184, 0.004930, 0.001626, 0.000365, 0.000263], + [0.000291, 0.000645, 0.001467, 0.005532, 0.001402, 0.000579, 0.000579], + [0.000612, 0.000764, 0.001651, 0.006953, 0.002035, 0.000858, 0.000342], + ] + ) + np.testing.assert_allclose(depth_hollow, expected_hollow, atol=0.002) + + # Full grid regression check for spur. + expected_spur = np.array( + [ + [0.000314, 0.000670, 0.000927, 0.001595, 0.000700, 0.000504, 0.000261], + [0.000384, 0.000447, 0.001089, 0.001892, 0.000825, 0.000574, 0.000427], + [0.000339, 0.000362, 0.000812, 0.002922, 0.001174, 0.000574, 0.000427], + [0.000314, 0.000591, 0.001100, 0.003638, 0.000998, 0.000522, 0.000314], + [0.000288, 0.000406, 0.000963, 0.004235, 0.001195, 0.000447, 0.000231], + [0.000163, 0.000467, 0.001021, 0.004930, 0.001185, 0.000486, 0.000314], + [0.000339, 0.000447, 0.001010, 0.005427, 0.000914, 0.000362, 0.000261], + [0.000199, 0.000339, 0.000998, 0.005920, 0.001089, 0.000362, 0.000231], + [0.000288, 0.000540, 0.001121, 0.006517, 0.001185, 0.000540, 0.000427], + [0.000288, 0.000384, 0.001215, 0.006864, 0.001143, 0.000261, 0.000231], + ] + ) + np.testing.assert_allclose(depth_spur, expected_spur, atol=0.002) + + +def test_terrace_accumulates_on_steps(tmp_path): + """Terraced slopes must accumulate water on flat steps, not steep drops. + + A 10x12 grid with three flat steps separated by steep drops. + Downstream steps should accumulate more water. + + Elevation (west to east): + 9 9 9 9 7 6 6 6 4 3 3 3 + Pattern: step - drop - step - drop - step + """ + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=12, s=0, n=10, res=1) + tools.r_mapcalc( + expression=( + "elevation = if(col() <= 4, 9," + " if(col() == 5, 7," + " if(col() <= 8, 6," + " if(col() == 9, 4, 3))))" + ) + ) + + depth = run_sim(session) + + # Column indices (0-based) for each feature. + step1_cols = slice(0, 4) + drop1_col = 4 + step2_cols = slice(5, 8) + drop2_col = 8 + step3_cols = slice(9, 12) + + step1_mean = float(np.mean(depth[:, step1_cols])) + drop1_mean = float(np.mean(depth[:, drop1_col])) + step2_mean = float(np.mean(depth[:, step2_cols])) + drop2_mean = float(np.mean(depth[:, drop2_col])) + step3_mean = float(np.mean(depth[:, step3_cols])) + + # Flat steps should have more depth than steep drops. + assert step2_mean > drop1_mean, ( + f"Step 2 mean ({step2_mean:.6f}) should exceed " + f"drop 1 mean ({drop1_mean:.6f})" + ) + assert step3_mean > drop2_mean, ( + f"Step 3 mean ({step3_mean:.6f}) should exceed " + f"drop 2 mean ({drop2_mean:.6f})" + ) + + # Downstream steps should accumulate more water. + assert step2_mean > step1_mean, ( + f"Step 2 ({step2_mean:.6f}) should exceed step 1 ({step1_mean:.6f})" + ) + assert step3_mean > step2_mean, ( + f"Step 3 ({step3_mean:.6f}) should exceed step 2 ({step2_mean:.6f})" + ) + + # Full grid regression check. + expected = np.array( + [ + [ + 0.000827, + 0.000923, + 0.002275, + 0.000315, + 0.000477, + 0.001096, + 0.004063, + 0.000776, + 0.001012, + 0.001928, + 0.008246, + 0.004348, + ], + [ + 0.001783, + 0.002471, + 0.002726, + 0.000968, + 0.001055, + 0.001956, + 0.006184, + 0.001566, + 0.001327, + 0.003007, + 0.012262, + 0.007194, + ], + [ + 0.001984, + 0.002965, + 0.003505, + 0.000776, + 0.000668, + 0.001433, + 0.004731, + 0.001433, + 0.001327, + 0.003371, + 0.013642, + 0.008678, + ], + [ + 0.003561, + 0.005511, + 0.003312, + 0.000827, + 0.000876, + 0.001783, + 0.006326, + 0.001363, + 0.001433, + 0.003959, + 0.015099, + 0.009384, + ], + [ + 0.003818, + 0.005705, + 0.003636, + 0.000968, + 0.000968, + 0.002447, + 0.007334, + 0.001928, + 0.001433, + 0.003959, + 0.017127, + 0.010672, + ], + [ + 0.004348, + 0.006223, + 0.003580, + 0.000723, + 0.000827, + 0.002172, + 0.005732, + 0.001215, + 0.001398, + 0.004029, + 0.018605, + 0.011421, + ], + [ + 0.003764, + 0.005412, + 0.003580, + 0.000827, + 0.000668, + 0.001723, + 0.005005, + 0.001327, + 0.001215, + 0.003505, + 0.016332, + 0.010490, + ], + [ + 0.003524, + 0.003636, + 0.003172, + 0.000668, + 0.000723, + 0.001783, + 0.005988, + 0.001096, + 0.001055, + 0.002965, + 0.012781, + 0.009848, + ], + [ + 0.003351, + 0.003371, + 0.002770, + 0.000546, + 0.000668, + 0.001984, + 0.005483, + 0.001467, + 0.001501, + 0.002814, + 0.010435, + 0.008395, + ], + [ + 0.002495, + 0.001501, + 0.002275, + 0.000546, + 0.000477, + 0.001215, + 0.003543, + 0.001177, + 0.001137, + 0.002172, + 0.007910, + 0.004131, + ], + ] + ) + np.testing.assert_allclose(depth, expected, atol=0.003) + + +def test_terrace_rotation_invariance(tmp_path): + """E-W terraces and N-S terraces must produce rotationally equivalent depth.""" + # E-W terraces: 10 rows x 12 cols. + project_ew = tmp_path / "ew" + gs.create_project(project_ew) + with gs.setup.init(project_ew, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=12, s=0, n=10, res=1) + tools.r_mapcalc( + expression=( + "elevation = if(col() <= 4, 9," + " if(col() == 5, 7," + " if(col() <= 8, 6," + " if(col() == 9, 4, 3))))" + ) + ) + depth_ew = run_sim(session) + + # N-S terraces: 12 rows x 10 cols. + project_ns = tmp_path / "ns" + gs.create_project(project_ns) + with gs.setup.init(project_ns, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=10, s=0, n=12, res=1) + tools.r_mapcalc( + expression=( + "elevation = if(row() <= 4, 9," + " if(row() == 5, 7," + " if(row() <= 8, 6," + " if(row() == 9, 4, 3))))" + ) + ) + depth_ns = run_sim(session) + + # Total depth should be comparable. + total_ew = float(np.sum(depth_ew)) + total_ns = float(np.sum(depth_ns)) + assert total_ew == pytest.approx(total_ns, rel=0.02), ( + f"Total depth E-W ({total_ew:.6f}) vs N-S ({total_ns:.6f})" + ) + + # Cross-terrace profiles should match after rotation. + # Monte Carlo noise on small grids requires generous tolerance. + profile_ew = np.sum(depth_ew, axis=0) + depth_ns_rotated = np.rot90(depth_ns) + profile_ns = np.sum(depth_ns_rotated, axis=0) + np.testing.assert_allclose(profile_ew, profile_ns, rtol=0.3) From 1b78e6b49dbaa2068ead4a0cc4822c9924cab5ba Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Thu, 11 Jun 2026 16:43:58 -0400 Subject: [PATCH 09/29] Replace niter by duration --- .../tests/r_sim_water_landforms_test.py | 4 +- .../r.sim.water/tests/r_sim_water_test.py | 44 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py index 978be4dc64e..08b90fad8f3 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py @@ -17,7 +17,7 @@ SEED = 42 NPROCS = 1 -NITERATIONS = 2 # minutes; enough for near-steady state on small domains +DURATION = 2 # minutes; enough for near-steady state on small domains RAIN = 100 # mm/hr @@ -31,7 +31,7 @@ def run_sim(session, *, random_seed=SEED, **kwargs): "infil_value": 0, "man_value": 0.1, "nprocs": NPROCS, - "niterations": NITERATIONS, + "duration": DURATION, } defaults.update(kwargs) defaults = {k: v for k, v in defaults.items() if v is not None} diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 2b429736894..c77a802679c 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -18,10 +18,10 @@ SEED = 42 NPROCS = 1 -# With default walkers and niterations=2, small domains reach +# With default walkers and duration=2, small domains reach # near-steady state in ~0.2 s per run while keeping Monte Carlo noise low # enough for structural assertions. -NITERATIONS = 2 # minutes; sufficient for near-steady state on a small domain +DURATION = 2 # minutes; sufficient for near-steady state on a small domain RAIN = 100 # mm/hr; generous signal-to-noise ratio on a small domain @@ -38,7 +38,7 @@ def run_sim(session, *, random_seed=SEED, **kwargs): "infil_value": 0, "man_value": 0.1, "nprocs": NPROCS, - "niterations": NITERATIONS, + "duration": DURATION, } defaults.update(kwargs) # On the command line, an unwanted parameter is simply omitted, so we @@ -192,7 +192,7 @@ def test_discharge_positive_with_rain(east_slope_session): dy="dy", discharge=np.array, rain_value=RAIN, - niterations=NITERATIONS, + duration=DURATION, random_seed=SEED, nprocs=NPROCS, ) @@ -243,7 +243,7 @@ def test_random_seed_flag(east_slope_session): "depth": np.array, "rain_value": RAIN, "man_value": 0.1, - "niterations": NITERATIONS, + "duration": DURATION, "nprocs": NPROCS, "flags": "s", } @@ -262,7 +262,7 @@ def run_sim_error(session, **kwargs): "rain_value": RAIN, "infil_value": 0, "man_value": 0.1, - "niterations": NITERATIONS, + "duration": DURATION, } defaults.update(kwargs) defaults = {k: v for k, v in defaults.items() if v is not None} @@ -343,10 +343,10 @@ def test_longer_simulation_larger_domain(tmp_path): tools.r_mapcalc(expression="dy = 0.0") sum_short = float( - np.sum(run_sim(session, rain_value=RAIN, man_value=0.3, niterations=5)) + np.sum(run_sim(session, rain_value=RAIN, man_value=0.3, duration=5)) ) sum_long = float( - np.sum(run_sim(session, rain_value=RAIN, man_value=0.3, niterations=20)) + np.sum(run_sim(session, rain_value=RAIN, man_value=0.3, duration=20)) ) assert sum_long > sum_short, ( f"Longer simulation should produce more depth: " @@ -359,11 +359,11 @@ def test_longer_simulation_larger_domain(tmp_path): ) -def test_niterations_affects_time_series_progression(tmp_path): +def test_duration_affects_time_series_progression(tmp_path): """More iterations must create more time-series output maps. - With output_step=5, niterations=10 produces maps at t=5,10 while - niterations=20 produces maps at t=5,10,15. More time-series maps + With output_step=5, duration=10 produces maps at t=5,10 while + duration=20 produces maps at t=5,10,15. More time-series maps indicate longer simulation duration. """ project = tmp_path / "simwe" @@ -375,7 +375,7 @@ def test_niterations_affects_time_series_progression(tmp_path): tools.r_mapcalc(expression="dx = 1.0") tools.r_mapcalc(expression="dy = 0.0") - # niterations=10 with output_step=5 produces maps at t=5,10 + # duration=10 with output_step=5 produces maps at t=5,10 tools.r_sim_water( elevation="elevation", dx="dx", @@ -384,14 +384,14 @@ def test_niterations_affects_time_series_progression(tmp_path): rain_value=RAIN, man_value=0.3, nwalkers=10000, - niterations=10, + duration=10, output_step=5, random_seed=SEED, nprocs=NPROCS, flags="t", ) - # niterations=20 with output_step=5 produces maps at t=5,10,15 + # duration=20 with output_step=5 produces maps at t=5,10,15 tools.r_sim_water( elevation="elevation", dx="dx", @@ -400,7 +400,7 @@ def test_niterations_affects_time_series_progression(tmp_path): rain_value=RAIN, man_value=0.3, nwalkers=10000, - niterations=20, + duration=20, output_step=5, random_seed=SEED, nprocs=NPROCS, @@ -600,7 +600,7 @@ def test_dx_dy_optional(tmp_path): depth=np.array, rain_value=RAIN, man_value=0.1, - niterations=NITERATIONS, + duration=DURATION, random_seed=SEED, nprocs=NPROCS, ) @@ -639,7 +639,7 @@ def test_flow_control_increases_depth(east_slope_session): def test_time_series_output(tmp_path): """The -t flag with output_step must produce intermediate depth maps. - With niterations=10 and output_step=5, the tool should create depth + With duration=10 and output_step=5, the tool should create depth maps at minutes 5 and 10. The later time step should have greater or equal total depth as water accumulates over time. @@ -663,7 +663,7 @@ def test_time_series_output(tmp_path): depth="ts_depth", rain_value=RAIN, man_value=0.3, - niterations=10, + duration=10, output_step=5, random_seed=SEED, nprocs=NPROCS, @@ -701,7 +701,7 @@ def test_observation_logfile(east_slope_session, tmp_path): rain_value=RAIN, man_value=0.1, nwalkers=1000, - niterations=NITERATIONS, + duration=DURATION, random_seed=SEED, nprocs=NPROCS, observation="points", @@ -759,7 +759,7 @@ def test_walkers_output(tmp_path): rain_value=RAIN, man_value=0.3, nwalkers=nwalkers, - niterations=NITERATIONS, + duration=DURATION, random_seed=SEED, nprocs=NPROCS, walkers_output="walkers", @@ -799,7 +799,7 @@ def test_walkers_output_time_series(tmp_path): depth="ts_depth", rain_value=RAIN, man_value=0.3, - niterations=10, + duration=10, output_step=5, random_seed=SEED, nprocs=NPROCS, @@ -913,7 +913,7 @@ def test_north_slope_observation_logfile(tmp_path): rain_value=RAIN, man_value=0.1, nwalkers=1000, - niterations=NITERATIONS, + duration=DURATION, random_seed=SEED, nprocs=NPROCS, observation="points", From 9a1a4b4f0aef0f09b5ed379fe147483b972a6183 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Thu, 11 Jun 2026 16:46:27 -0400 Subject: [PATCH 10/29] error output is now always zero --- .../r.sim.water/tests/r_sim_water_test.py | 29 +++---------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index c77a802679c..17c423ecebf 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -280,32 +280,11 @@ def run_sim_error(session, **kwargs): ) -def test_error_output(east_slope_session): - """The error output must be non-negative.""" +def test_error_output_is_zero(east_slope_session): + """The error output is all zeros while its computation is disabled.""" error = run_sim_error(east_slope_session) - assert np.all(error >= 0), f"Error output should be non-negative:\n{error}" - - -def test_no_rain_produces_no_error(east_slope_session): - """Zero rainfall must yield zero error everywhere.""" - error = run_sim_error(east_slope_session, rain_value=0) - assert np.all(error == 0), f"Expected all-zero error with zero rain:\n{error}" - - -def test_more_walkers_reduces_error(east_slope_session): - """More walkers must reduce the Monte Carlo sampling error. - - The error scales as 1/sqrt(N), so quadrupling walkers should - roughly halve the total error. - """ - few = 10000 - many = 40000 - error_few = float(np.sum(run_sim_error(east_slope_session, nwalkers=few))) - error_many = float(np.sum(run_sim_error(east_slope_session, nwalkers=many))) - assert error_many < error_few, ( - f"Total error should decrease with more walkers: " - f"sum({few})={error_few:.3e}, sum({many})={error_many:.3e}" - ) + assert error.shape == (1, 5), f"Expected one error value per cell:\n{error}" + assert np.all(error == 0), f"Expected all-zero error map:\n{error}" def test_mintimestep(east_slope_session): From 784115340d6b109687852ebaec9ca665f357f2e7 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Thu, 11 Jun 2026 17:01:50 -0400 Subject: [PATCH 11/29] The RNG is shared, possibly racing, so outputs vary, increasing tolerance for diff between single and multi-thread to difference between seeds (rather than assuming it should fit within a tighter tolerance with the given seed and thread combo). --- raster/r.sim/r.sim.water/tests/r_sim_water_test.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 17c423ecebf..5432991eacb 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -795,13 +795,14 @@ def test_walkers_output_time_series(tmp_path): def test_nprocs_gives_same_result(east_slope_session): """Multiple threads produce a result close to a single thread. - Thread-level partitioning changes walker ordering and random draws, - so results are not bitwise identical, but total depth should agree - within a few percent. + Threads share the random number generator state, so multi-threaded + results vary between runs even with a fixed seed. Total depth should + still agree with the single-threaded result within Monte Carlo noise, + using the same tolerance as test_results_consistent_across_seeds. """ sum_single = float(np.sum(run_sim(east_slope_session, nwalkers=1000))) sum_multi = float(np.sum(run_sim(east_slope_session, nwalkers=1000, nprocs=4))) - tolerance = 0.05 + tolerance = 0.1 assert sum_multi == pytest.approx(sum_single, rel=tolerance), ( f"nprocs=4 result ({sum_multi:.3e}) should match " f"nprocs=1 result ({sum_single:.3e}) within {tolerance:.0%}" From 344839c3c2bbd08e183c91f8752bba86e511ef18 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 30 Jun 2026 13:03:49 -0400 Subject: [PATCH 12/29] r.sim.water: Share fixtures and deduplicate test setup Introduce module-scoped session fixtures for the shared long-slope and diffusion domains and read-only landform fixtures, so each base landform and the diffusion reference run is computed once and reused by its shape and rotation tests. Fold run_sim_error into a direct call, replace the hand-rolled approx loops with numpy.testing.assert_allclose, and drop the redundant np.asarray wrapping. Developed with substantial assistance from an AI agent (Claude). --- .../tests/r_sim_water_landforms_test.py | 719 +++++++++--------- .../r.sim.water/tests/r_sim_water_test.py | 547 +++++++------ 2 files changed, 628 insertions(+), 638 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py index 08b90fad8f3..099f1e33f2d 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py @@ -44,6 +44,69 @@ def run_sim(session, *, random_seed=SEED, **kwargs): ) +def _landform_depth(tmp_path_factory, name, region, elevation): + """Build a one-off landform project and return its depth. + + Backs the module-scoped landform fixtures so each base landform is + simulated once and shared between its shape test and its rotation test + (which would otherwise recompute the identical surface). The returned + array is read-only so neither consumer can mutate the shared result. + """ + project = tmp_path_factory.mktemp(name) / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(**region) + tools.r_mapcalc(expression=f"elevation = {elevation}") + depth = run_sim(session) + depth.flags.writeable = False + return depth + + +@pytest.fixture(scope="module") +def ridge_ns_depth(tmp_path_factory): + """N-S ridge depth (5 rows x 7 cols); ridge along the center column.""" + return _landform_depth( + tmp_path_factory, + "ridge_ns", + {"w": 0, "e": 7, "s": 0, "n": 5, "res": 1}, + "3 - abs(col() - 4)", + ) + + +@pytest.fixture(scope="module") +def valley_ns_depth(tmp_path_factory): + """N-S valley depth (10 rows x 7 cols); V-section draining southward.""" + return _landform_depth( + tmp_path_factory, + "valley_ns", + {"w": 0, "e": 7, "s": 0, "n": 10, "res": 1}, + "abs(col() - 4) + (nrows() - row()) * 0.5", + ) + + +@pytest.fixture(scope="module") +def saddle_depth(tmp_path_factory): + """Saddle depth (7x7); hyperbolic paraboloid with E-W low axes.""" + return _landform_depth( + tmp_path_factory, + "saddle", + {"w": 0, "e": 7, "s": 0, "n": 7, "res": 1}, + "(row() - 4)*(row() - 4) - (col() - 4)*(col() - 4) + 10", + ) + + +@pytest.fixture(scope="module") +def terrace_ew_depth(tmp_path_factory): + """E-W terrace depth (10 rows x 12 cols); three flat steps and drops.""" + return _landform_depth( + tmp_path_factory, + "terrace_ew", + {"w": 0, "e": 12, "s": 0, "n": 10, "res": 1}, + "if(col() <= 4, 9, if(col() == 5, 7, if(col() <= 8, 6, if(col() == 9, 4, 3))))", + ) + + def test_pit_collects_water(tmp_path): """A bowl-shaped pit must collect most water in its center. @@ -90,11 +153,7 @@ def test_pit_collects_water(tmp_path): # Radial symmetry: the 4 cardinal neighbors should have similar depth. neighbors = [depth[1, 2], depth[3, 2], depth[2, 1], depth[2, 3]] mean_neighbor = float(np.mean(neighbors)) - for i, nd in enumerate(neighbors): - assert nd == pytest.approx(mean_neighbor, rel=0.3), ( - f"Cardinal neighbor {i} depth ({nd:.4f}) deviates from " - f"mean neighbor depth ({mean_neighbor:.4f})" - ) + np.testing.assert_allclose(neighbors, mean_neighbor, rtol=0.3) # Cardinal neighbors must have less depth than center. assert all(n < center_depth for n in neighbors), ( @@ -106,11 +165,7 @@ def test_pit_collects_water(tmp_path): # Radial symmetry for the 4 diagonal neighbors. diagonals = [depth[1, 1], depth[1, 3], depth[3, 1], depth[3, 3]] mean_diagonal = float(np.mean(diagonals)) - for i, dd in enumerate(diagonals): - assert dd == pytest.approx(mean_diagonal, rel=0.3), ( - f"Diagonal neighbor {i} depth ({dd:.4f}) deviates from " - f"mean diagonal depth ({mean_diagonal:.4f})" - ) + np.testing.assert_allclose(diagonals, mean_diagonal, rtol=0.3) # Depth must decrease monotonically from center outward: # center > cardinal ring > edge midpoints. @@ -161,20 +216,12 @@ def test_peak_sheds_water(tmp_path): # Cardinal neighbors should have similar depth (radial symmetry). neighbors = [depth[1, 2], depth[3, 2], depth[2, 1], depth[2, 3]] mean_neighbor = float(np.mean(neighbors)) - for i, nd in enumerate(neighbors): - assert nd == pytest.approx(mean_neighbor, rel=0.4), ( - f"Cardinal neighbor {i} depth ({nd:.6f}) deviates from " - f"mean neighbor depth ({mean_neighbor:.6f})" - ) + np.testing.assert_allclose(neighbors, mean_neighbor, rtol=0.4) # Diagonal neighbors should have similar depth. diagonals = [depth[1, 1], depth[1, 3], depth[3, 1], depth[3, 3]] mean_diagonal = float(np.mean(diagonals)) - for i, dd in enumerate(diagonals): - assert dd == pytest.approx(mean_diagonal, rel=0.4), ( - f"Diagonal neighbor {i} depth ({dd:.6f}) deviates from " - f"mean diagonal depth ({mean_diagonal:.6f})" - ) + np.testing.assert_allclose(diagonals, mean_diagonal, rtol=0.4) # Outer ring (edges) should have more depth than inner ring. edge_midpoints = [depth[0, 2], depth[4, 2], depth[2, 0], depth[2, 4]] @@ -197,7 +244,7 @@ def test_peak_sheds_water(tmp_path): np.testing.assert_allclose(depth, expected, atol=0.0002) -def test_ridge_sheds_water_to_flanks(tmp_path): +def test_ridge_sheds_water_to_flanks(ridge_ns_depth): """A ridge must shed water to both flanks, leaving the crest drier. A 5x7 grid with a N-S ridge along the center column. The crest @@ -206,48 +253,40 @@ def test_ridge_sheds_water_to_flanks(tmp_path): Elevation (each row): 0 1 2 3 2 1 0 """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=7, s=0, n=5, res=1) - tools.r_mapcalc(expression="elevation = 3 - abs(col() - 4)") - - depth = run_sim(session) + depth = ridge_ns_depth + + # Edge columns (0 and 6) should accumulate more water than + # the crest column (3) because water flows downhill. + crest_sum = float(np.sum(depth[:, 3])) + left_edge_sum = float(np.sum(depth[:, 0])) + right_edge_sum = float(np.sum(depth[:, 6])) + assert left_edge_sum > crest_sum, ( + f"Left edge ({left_edge_sum:.6f}) should exceed crest ({crest_sum:.6f})" + ) + assert right_edge_sum > crest_sum, ( + f"Right edge ({right_edge_sum:.6f}) should exceed crest ({crest_sum:.6f})" + ) - # Edge columns (0 and 6) should accumulate more water than - # the crest column (3) because water flows downhill. - crest_sum = float(np.sum(depth[:, 3])) - left_edge_sum = float(np.sum(depth[:, 0])) - right_edge_sum = float(np.sum(depth[:, 6])) - assert left_edge_sum > crest_sum, ( - f"Left edge ({left_edge_sum:.6f}) should exceed crest ({crest_sum:.6f})" - ) - assert right_edge_sum > crest_sum, ( - f"Right edge ({right_edge_sum:.6f}) should exceed crest ({crest_sum:.6f})" + # Left-right symmetry: columns equidistant from crest should + # have similar total depth. + for offset in range(1, 4): + left_sum = float(np.sum(depth[:, 3 - offset])) + right_sum = float(np.sum(depth[:, 3 + offset])) + assert left_sum == pytest.approx(right_sum, rel=0.4), ( + f"Offset {offset}: left sum ({left_sum:.6f}) vs right sum ({right_sum:.6f})" ) - # Left-right symmetry: columns equidistant from crest should - # have similar total depth. - for offset in range(1, 4): - left_sum = float(np.sum(depth[:, 3 - offset])) - right_sum = float(np.sum(depth[:, 3 + offset])) - assert left_sum == pytest.approx(right_sum, rel=0.4), ( - f"Offset {offset}: left sum ({left_sum:.6f}) vs " - f"right sum ({right_sum:.6f})" - ) - - # Full grid regression check. - expected = np.array( - [ - [0.000946, 0.000586, 0.000303, 0.000303, 0.000360, 0.000459, 0.000546], - [0.001390, 0.000796, 0.000661, 0.001455, 0.000412, 0.000827, 0.001182], - [0.001206, 0.001055, 0.000796, 0.001081, 0.000586, 0.000624, 0.001497], - [0.001254, 0.000730, 0.000586, 0.001158, 0.000412, 0.000827, 0.001055], - [0.000888, 0.000546, 0.000459, 0.000730, 0.000238, 0.000546, 0.000661], - ] - ) - np.testing.assert_allclose(depth, expected, atol=0.0005) + # Full grid regression check. + expected = np.array( + [ + [0.000946, 0.000586, 0.000303, 0.000303, 0.000360, 0.000459, 0.000546], + [0.001390, 0.000796, 0.000661, 0.001455, 0.000412, 0.000827, 0.001182], + [0.001206, 0.001055, 0.000796, 0.001081, 0.000586, 0.000624, 0.001497], + [0.001254, 0.000730, 0.000586, 0.001158, 0.000412, 0.000827, 0.001055], + [0.000888, 0.000546, 0.000459, 0.000730, 0.000238, 0.000546, 0.000661], + ] + ) + np.testing.assert_allclose(depth, expected, atol=0.0005) def test_ridge_rotation_invariance(tmp_path): @@ -295,7 +334,7 @@ def test_ridge_rotation_invariance(tmp_path): np.testing.assert_allclose(profile_ns, profile_ew, rtol=0.35) -def test_valley_concentrates_flow_downstream(tmp_path): +def test_valley_concentrates_flow_downstream(valley_ns_depth): """A V-shaped valley must concentrate water along its floor. A 10x7 grid with a V-shaped cross-section draining southward. @@ -307,61 +346,52 @@ def test_valley_concentrates_flow_downstream(tmp_path): Downstream gradient adds 0.5 per row, so the top row is 4.5 m higher than the bottom row. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=7, s=0, n=10, res=1) - tools.r_mapcalc( - expression="elevation = abs(col() - 4) + (nrows() - row()) * 0.5" - ) - - depth = run_sim(session) + depth = valley_ns_depth + + # Valley floor (col index 3) should have much higher per-cell + # depth than the hillslope average. + floor_mean = float(np.mean(depth[:, 3])) + hillslope_cols = np.concatenate([depth[:, :3], depth[:, 4:]], axis=1) + hillslope_mean = float(np.mean(hillslope_cols)) + assert floor_mean > 3 * hillslope_mean, ( + f"Valley floor mean depth ({floor_mean:.6f}) should be >3x " + f"hillslope mean ({hillslope_mean:.6f})" + ) - # Valley floor (col index 3) should have much higher per-cell - # depth than the hillslope average. - floor_mean = float(np.mean(depth[:, 3])) - hillslope_cols = np.concatenate([depth[:, :3], depth[:, 4:]], axis=1) - hillslope_mean = float(np.mean(hillslope_cols)) - assert floor_mean > 3 * hillslope_mean, ( - f"Valley floor mean depth ({floor_mean:.6f}) should be >3x " - f"hillslope mean ({hillslope_mean:.6f})" - ) + # Discharge (depth) should increase downstream (toward higher row + # indices in the array). Compare upper and lower halves. + upper_floor = float(np.sum(depth[:5, 3])) + lower_floor = float(np.sum(depth[5:, 3])) + assert lower_floor > upper_floor, ( + f"Lower valley floor ({lower_floor:.6f}) should exceed " + f"upper valley floor ({upper_floor:.6f})" + ) - # Discharge (depth) should increase downstream (toward higher row - # indices in the array). Compare upper and lower halves. - upper_floor = float(np.sum(depth[:5, 3])) - lower_floor = float(np.sum(depth[5:, 3])) - assert lower_floor > upper_floor, ( - f"Lower valley floor ({lower_floor:.6f}) should exceed " - f"upper valley floor ({upper_floor:.6f})" + # Cross-valley symmetry: columns equidistant from floor should + # have similar total depth. + for offset in range(1, 4): + left = float(np.sum(depth[:, 3 - offset])) + right = float(np.sum(depth[:, 3 + offset])) + assert left == pytest.approx(right, rel=0.4), ( + f"Offset {offset}: left ({left:.6f}) vs right ({right:.6f})" ) - # Cross-valley symmetry: columns equidistant from floor should - # have similar total depth. - for offset in range(1, 4): - left = float(np.sum(depth[:, 3 - offset])) - right = float(np.sum(depth[:, 3 + offset])) - assert left == pytest.approx(right, rel=0.4), ( - f"Offset {offset}: left ({left:.6f}) vs right ({right:.6f})" - ) - - # Full grid regression check. - expected = np.array( - [ - [0.000276, 0.000404, 0.000579, 0.001990, 0.000684, 0.000704, 0.000276], - [0.000556, 0.000834, 0.000951, 0.001532, 0.000724, 0.000194, 0.000458], - [0.000276, 0.000533, 0.000919, 0.002308, 0.000886, 0.000601, 0.000643], - [0.000533, 0.000431, 0.001119, 0.003021, 0.001014, 0.000684, 0.000484], - [0.000404, 0.000643, 0.000798, 0.003759, 0.001271, 0.000724, 0.000431], - [0.000276, 0.000622, 0.001060, 0.004491, 0.001323, 0.000664, 0.000404], - [0.000509, 0.000601, 0.001189, 0.004852, 0.001323, 0.000556, 0.000484], - [0.000431, 0.000643, 0.001244, 0.005432, 0.001555, 0.000509, 0.000311], - [0.000374, 0.000724, 0.001203, 0.005944, 0.001361, 0.000643, 0.000664], - [0.000622, 0.000851, 0.001323, 0.006594, 0.001635, 0.000684, 0.000431], - ] - ) - np.testing.assert_allclose(depth, expected, atol=0.002) + # Full grid regression check. + expected = np.array( + [ + [0.000276, 0.000404, 0.000579, 0.001990, 0.000684, 0.000704, 0.000276], + [0.000556, 0.000834, 0.000951, 0.001532, 0.000724, 0.000194, 0.000458], + [0.000276, 0.000533, 0.000919, 0.002308, 0.000886, 0.000601, 0.000643], + [0.000533, 0.000431, 0.001119, 0.003021, 0.001014, 0.000684, 0.000484], + [0.000404, 0.000643, 0.000798, 0.003759, 0.001271, 0.000724, 0.000431], + [0.000276, 0.000622, 0.001060, 0.004491, 0.001323, 0.000664, 0.000404], + [0.000509, 0.000601, 0.001189, 0.004852, 0.001323, 0.000556, 0.000484], + [0.000431, 0.000643, 0.001244, 0.005432, 0.001555, 0.000509, 0.000311], + [0.000374, 0.000724, 0.001203, 0.005944, 0.001361, 0.000643, 0.000664], + [0.000622, 0.000851, 0.001323, 0.006594, 0.001635, 0.000684, 0.000431], + ] + ) + np.testing.assert_allclose(depth, expected, atol=0.002) def test_valley_rotation_invariance(tmp_path): @@ -401,7 +431,7 @@ def test_valley_rotation_invariance(tmp_path): np.testing.assert_allclose(profile_ns, profile_ew, rtol=0.15) -def test_saddle_bifurcates_flow(tmp_path): +def test_saddle_bifurcates_flow(saddle_depth): """A saddle must split flow into two low quadrants. A 7x7 grid shaped as a hyperbolic paraboloid (saddle surface). @@ -410,62 +440,51 @@ def test_saddle_bifurcates_flow(tmp_path): (left and right). Water should flow preferentially into the low zones. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=7, s=0, n=7, res=1) - tools.r_mapcalc( - expression=( - "elevation = (row() - 4)*(row() - 4) - (col() - 4)*(col() - 4) + 10" - ) - ) - - depth = run_sim(session) - - # Low quadrants: where (col-4)^2 > (row-4)^2, i.e., columns - # far from center. These are the left and right sides. - # High quadrants: rows far from center (top and bottom). - low_mask = np.zeros_like(depth, dtype=bool) - high_mask = np.zeros_like(depth, dtype=bool) - for r in range(7): - for c in range(7): - row_dist = abs(r - 3) - col_dist = abs(c - 3) - if col_dist > row_dist: - low_mask[r, c] = True - elif row_dist > col_dist: - high_mask[r, c] = True - - low_sum = float(np.sum(depth[low_mask])) - high_sum = float(np.sum(depth[high_mask])) - assert low_sum > high_sum, ( - f"Low quadrant depth ({low_sum:.6f}) should exceed " - f"high quadrant depth ({high_sum:.6f})" - ) + depth = saddle_depth + + # Low quadrants: where (col-4)^2 > (row-4)^2, i.e., columns + # far from center. These are the left and right sides. + # High quadrants: rows far from center (top and bottom). + low_mask = np.zeros_like(depth, dtype=bool) + high_mask = np.zeros_like(depth, dtype=bool) + for r in range(7): + for c in range(7): + row_dist = abs(r - 3) + col_dist = abs(c - 3) + if col_dist > row_dist: + low_mask[r, c] = True + elif row_dist > col_dist: + high_mask[r, c] = True + + low_sum = float(np.sum(depth[low_mask])) + high_sum = float(np.sum(depth[high_mask])) + assert low_sum > high_sum, ( + f"Low quadrant depth ({low_sum:.6f}) should exceed " + f"high quadrant depth ({high_sum:.6f})" + ) - # Saddle point (center) should have more depth than the high - # quadrant mean because flow converges through the saddle. - high_mean = high_sum / np.sum(high_mask) - saddle_depth = float(depth[3, 3]) - assert saddle_depth > high_mean, ( - f"Saddle depth ({saddle_depth:.6f}) should exceed " - f"high quadrant mean ({high_mean:.6f})" - ) + # Saddle point (center) should have more depth than the high + # quadrant mean because flow converges through the saddle. + high_mean = high_sum / np.sum(high_mask) + saddle_point_depth = float(depth[3, 3]) + assert saddle_point_depth > high_mean, ( + f"Saddle depth ({saddle_point_depth:.6f}) should exceed " + f"high quadrant mean ({high_mean:.6f})" + ) - # Full grid regression check. - expected = np.array( - [ - [0.000264, 0.000282, 0.000155, 0.000203, 0.000244, 0.000155, 0.000264], - [0.000406, 0.000299, 0.000282, 0.000392, 0.000224, 0.000509, 0.000485], - [0.000721, 0.000509, 0.000460, 0.000812, 0.000332, 0.000556, 0.000847], - [0.001300, 0.000830, 0.000662, 0.003083, 0.001075, 0.000946, 0.001388], - [0.000777, 0.000545, 0.000299, 0.000777, 0.000473, 0.000611, 0.000839], - [0.000434, 0.000348, 0.000299, 0.000406, 0.000282, 0.000434, 0.000447], - [0.000299, 0.000203, 0.000264, 0.000203, 0.000244, 0.000282, 0.000244], - ] - ) - np.testing.assert_allclose(depth, expected, atol=0.001) + # Full grid regression check. + expected = np.array( + [ + [0.000264, 0.000282, 0.000155, 0.000203, 0.000244, 0.000155, 0.000264], + [0.000406, 0.000299, 0.000282, 0.000392, 0.000224, 0.000509, 0.000485], + [0.000721, 0.000509, 0.000460, 0.000812, 0.000332, 0.000556, 0.000847], + [0.001300, 0.000830, 0.000662, 0.003083, 0.001075, 0.000946, 0.001388], + [0.000777, 0.000545, 0.000299, 0.000777, 0.000473, 0.000611, 0.000839], + [0.000434, 0.000348, 0.000299, 0.000406, 0.000282, 0.000434, 0.000447], + [0.000299, 0.000203, 0.000264, 0.000203, 0.000244, 0.000282, 0.000244], + ] + ) + np.testing.assert_allclose(depth, expected, atol=0.001) def test_saddle_rotation_invariance(tmp_path): @@ -596,7 +615,7 @@ def test_hollow_concentrates_more_than_spur(tmp_path): np.testing.assert_allclose(depth_spur, expected_spur, atol=0.002) -def test_terrace_accumulates_on_steps(tmp_path): +def test_terrace_accumulates_on_steps(terrace_ew_depth): """Terraced slopes must accumulate water on flat steps, not steep drops. A 10x12 grid with three flat steps separated by steep drops. @@ -606,199 +625,183 @@ def test_terrace_accumulates_on_steps(tmp_path): 9 9 9 9 7 6 6 6 4 3 3 3 Pattern: step - drop - step - drop - step """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=12, s=0, n=10, res=1) - tools.r_mapcalc( - expression=( - "elevation = if(col() <= 4, 9," - " if(col() == 5, 7," - " if(col() <= 8, 6," - " if(col() == 9, 4, 3))))" - ) - ) - - depth = run_sim(session) - - # Column indices (0-based) for each feature. - step1_cols = slice(0, 4) - drop1_col = 4 - step2_cols = slice(5, 8) - drop2_col = 8 - step3_cols = slice(9, 12) - - step1_mean = float(np.mean(depth[:, step1_cols])) - drop1_mean = float(np.mean(depth[:, drop1_col])) - step2_mean = float(np.mean(depth[:, step2_cols])) - drop2_mean = float(np.mean(depth[:, drop2_col])) - step3_mean = float(np.mean(depth[:, step3_cols])) - - # Flat steps should have more depth than steep drops. - assert step2_mean > drop1_mean, ( - f"Step 2 mean ({step2_mean:.6f}) should exceed " - f"drop 1 mean ({drop1_mean:.6f})" - ) - assert step3_mean > drop2_mean, ( - f"Step 3 mean ({step3_mean:.6f}) should exceed " - f"drop 2 mean ({drop2_mean:.6f})" - ) + depth = terrace_ew_depth + + # Column indices (0-based) for each feature. + step1_cols = slice(0, 4) + drop1_col = 4 + step2_cols = slice(5, 8) + drop2_col = 8 + step3_cols = slice(9, 12) + + step1_mean = float(np.mean(depth[:, step1_cols])) + drop1_mean = float(np.mean(depth[:, drop1_col])) + step2_mean = float(np.mean(depth[:, step2_cols])) + drop2_mean = float(np.mean(depth[:, drop2_col])) + step3_mean = float(np.mean(depth[:, step3_cols])) + + # Flat steps should have more depth than steep drops. + assert step2_mean > drop1_mean, ( + f"Step 2 mean ({step2_mean:.6f}) should exceed drop 1 mean ({drop1_mean:.6f})" + ) + assert step3_mean > drop2_mean, ( + f"Step 3 mean ({step3_mean:.6f}) should exceed drop 2 mean ({drop2_mean:.6f})" + ) - # Downstream steps should accumulate more water. - assert step2_mean > step1_mean, ( - f"Step 2 ({step2_mean:.6f}) should exceed step 1 ({step1_mean:.6f})" - ) - assert step3_mean > step2_mean, ( - f"Step 3 ({step3_mean:.6f}) should exceed step 2 ({step2_mean:.6f})" - ) + # Downstream steps should accumulate more water. + assert step2_mean > step1_mean, ( + f"Step 2 ({step2_mean:.6f}) should exceed step 1 ({step1_mean:.6f})" + ) + assert step3_mean > step2_mean, ( + f"Step 3 ({step3_mean:.6f}) should exceed step 2 ({step2_mean:.6f})" + ) - # Full grid regression check. - expected = np.array( + # Full grid regression check. + expected = np.array( + [ [ - [ - 0.000827, - 0.000923, - 0.002275, - 0.000315, - 0.000477, - 0.001096, - 0.004063, - 0.000776, - 0.001012, - 0.001928, - 0.008246, - 0.004348, - ], - [ - 0.001783, - 0.002471, - 0.002726, - 0.000968, - 0.001055, - 0.001956, - 0.006184, - 0.001566, - 0.001327, - 0.003007, - 0.012262, - 0.007194, - ], - [ - 0.001984, - 0.002965, - 0.003505, - 0.000776, - 0.000668, - 0.001433, - 0.004731, - 0.001433, - 0.001327, - 0.003371, - 0.013642, - 0.008678, - ], - [ - 0.003561, - 0.005511, - 0.003312, - 0.000827, - 0.000876, - 0.001783, - 0.006326, - 0.001363, - 0.001433, - 0.003959, - 0.015099, - 0.009384, - ], - [ - 0.003818, - 0.005705, - 0.003636, - 0.000968, - 0.000968, - 0.002447, - 0.007334, - 0.001928, - 0.001433, - 0.003959, - 0.017127, - 0.010672, - ], - [ - 0.004348, - 0.006223, - 0.003580, - 0.000723, - 0.000827, - 0.002172, - 0.005732, - 0.001215, - 0.001398, - 0.004029, - 0.018605, - 0.011421, - ], - [ - 0.003764, - 0.005412, - 0.003580, - 0.000827, - 0.000668, - 0.001723, - 0.005005, - 0.001327, - 0.001215, - 0.003505, - 0.016332, - 0.010490, - ], - [ - 0.003524, - 0.003636, - 0.003172, - 0.000668, - 0.000723, - 0.001783, - 0.005988, - 0.001096, - 0.001055, - 0.002965, - 0.012781, - 0.009848, - ], - [ - 0.003351, - 0.003371, - 0.002770, - 0.000546, - 0.000668, - 0.001984, - 0.005483, - 0.001467, - 0.001501, - 0.002814, - 0.010435, - 0.008395, - ], - [ - 0.002495, - 0.001501, - 0.002275, - 0.000546, - 0.000477, - 0.001215, - 0.003543, - 0.001177, - 0.001137, - 0.002172, - 0.007910, - 0.004131, - ], - ] - ) - np.testing.assert_allclose(depth, expected, atol=0.003) + 0.000827, + 0.000923, + 0.002275, + 0.000315, + 0.000477, + 0.001096, + 0.004063, + 0.000776, + 0.001012, + 0.001928, + 0.008246, + 0.004348, + ], + [ + 0.001783, + 0.002471, + 0.002726, + 0.000968, + 0.001055, + 0.001956, + 0.006184, + 0.001566, + 0.001327, + 0.003007, + 0.012262, + 0.007194, + ], + [ + 0.001984, + 0.002965, + 0.003505, + 0.000776, + 0.000668, + 0.001433, + 0.004731, + 0.001433, + 0.001327, + 0.003371, + 0.013642, + 0.008678, + ], + [ + 0.003561, + 0.005511, + 0.003312, + 0.000827, + 0.000876, + 0.001783, + 0.006326, + 0.001363, + 0.001433, + 0.003959, + 0.015099, + 0.009384, + ], + [ + 0.003818, + 0.005705, + 0.003636, + 0.000968, + 0.000968, + 0.002447, + 0.007334, + 0.001928, + 0.001433, + 0.003959, + 0.017127, + 0.010672, + ], + [ + 0.004348, + 0.006223, + 0.003580, + 0.000723, + 0.000827, + 0.002172, + 0.005732, + 0.001215, + 0.001398, + 0.004029, + 0.018605, + 0.011421, + ], + [ + 0.003764, + 0.005412, + 0.003580, + 0.000827, + 0.000668, + 0.001723, + 0.005005, + 0.001327, + 0.001215, + 0.003505, + 0.016332, + 0.010490, + ], + [ + 0.003524, + 0.003636, + 0.003172, + 0.000668, + 0.000723, + 0.001783, + 0.005988, + 0.001096, + 0.001055, + 0.002965, + 0.012781, + 0.009848, + ], + [ + 0.003351, + 0.003371, + 0.002770, + 0.000546, + 0.000668, + 0.001984, + 0.005483, + 0.001467, + 0.001501, + 0.002814, + 0.010435, + 0.008395, + ], + [ + 0.002495, + 0.001501, + 0.002275, + 0.000546, + 0.000477, + 0.001215, + 0.003543, + 0.001177, + 0.001137, + 0.002172, + 0.007910, + 0.004131, + ], + ] + ) + np.testing.assert_allclose(depth, expected, atol=0.003) def test_terrace_rotation_invariance(tmp_path): diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 5432991eacb..c29b74ed395 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -12,6 +12,7 @@ import pytest import grass.script as gs +from grass.experimental.mapset import TemporaryMapsetSession from grass.tools import Tools # Fixed seed and single thread make results fully deterministic. @@ -45,7 +46,7 @@ def run_sim(session, *, random_seed=SEED, **kwargs): # simulate that. defaults = {k: v for k, v in defaults.items() if v is not None} tools = Tools(session=session) - result = tools.r_sim_water( + return tools.r_sim_water( elevation="elevation", dx="dx", dy="dy", @@ -53,7 +54,74 @@ def run_sim(session, *, random_seed=SEED, **kwargs): random_seed=random_seed, **defaults, ) - return np.asarray(result) + + +@pytest.fixture(scope="module") +def long_slope_project(tmp_path_factory): + """Module-scoped project with a 200-cell, 10 m-resolution eastward slope. + + Several time-series and walker-output tests need a domain large enough + that walkers stay inside it for the full simulation. The terrain lives + in PERMANENT; each test gets its own temporary mapset via + long_slope_session so named outputs do not collide. + """ + project = tmp_path_factory.mktemp("simwe_long") / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=2000, s=0, n=10, res=10, flags="s") + tools.r_mapcalc(expression="elevation = 201 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + yield session + + +@pytest.fixture +def long_slope_session(long_slope_project): + with TemporaryMapsetSession(env=long_slope_project.env) as session: + yield session + + +@pytest.fixture(scope="module") +def diffusion_project(tmp_path_factory): + """Module-scoped project with a 200-cell, 1 m-resolution eastward slope. + + Shared by the hmax/halpha/hbeta diffusion tests, which need high rain + and roughness on a long 1D domain so depths exceed the hmax threshold. + """ + project = tmp_path_factory.mktemp("simwe_diffusion") / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=200, s=0, n=1, res=1, flags="s") + tools.r_mapcalc(expression="elevation = 201 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + yield session + + +@pytest.fixture +def diffusion_session(diffusion_project): + with TemporaryMapsetSession(env=diffusion_project.env) as session: + yield session + + +@pytest.fixture(scope="module") +def diffusion_low_hmax_depth(diffusion_project): + """Depth for the low-hmax (high-diffusion) reference run. + + The hmax=0.001 run with 400000 walkers is the most expensive simulation + in the suite and is the shared reference for both the hmax and hbeta + tests (hbeta=0.5 is the tool default, so test_hbeta's baseline run is + identical to this one). Computed once and returned read-only so + consumers cannot mutate the shared array. + """ + with TemporaryMapsetSession(env=diffusion_project.env) as session: + depth = run_sim( + session, rain_value=1000, man_value=0.5, hmax=0.001, nwalkers=400000 + ) + depth.flags.writeable = False + return depth def test_no_rain_produces_no_depth(east_slope_session): @@ -256,33 +324,20 @@ def test_random_seed_flag(east_slope_session): ) -def run_sim_error(session, **kwargs): - """Run r.sim.water and return the error output as ndarray.""" - defaults = { - "rain_value": RAIN, - "infil_value": 0, - "man_value": 0.1, - "duration": DURATION, - } - defaults.update(kwargs) - defaults = {k: v for k, v in defaults.items() if v is not None} - tools = Tools(session=session) - return np.asarray( - tools.r_sim_water( - elevation="elevation", - dx="dx", - dy="dy", - error=np.array, - random_seed=SEED, - nprocs=NPROCS, - **defaults, - ) - ) - - def test_error_output_is_zero(east_slope_session): """The error output is all zeros while its computation is disabled.""" - error = run_sim_error(east_slope_session) + tools = Tools(session=east_slope_session) + error = tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + error=np.array, + rain_value=RAIN, + man_value=0.1, + duration=DURATION, + random_seed=SEED, + nprocs=NPROCS, + ) assert error.shape == (1, 5), f"Expected one error value per cell:\n{error}" assert np.all(error == 0), f"Expected all-zero error map:\n{error}" @@ -305,106 +360,86 @@ def test_mintimestep(east_slope_session): ) -def test_longer_simulation_larger_domain(tmp_path): +def test_longer_simulation_larger_domain(long_slope_session): """More iterations must increase total water depth on a larger domain. On a larger domain with slower drainage, longer simulations accumulate more water. Uses a 200-cell domain at 10 m resolution so that walkers remain within the domain for the full duration. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=2000, s=0, n=10, res=10) - tools.r_mapcalc(expression="elevation = 201 - col()") - tools.r_mapcalc(expression="dx = 1.0") - tools.r_mapcalc(expression="dy = 0.0") - - sum_short = float( - np.sum(run_sim(session, rain_value=RAIN, man_value=0.3, duration=5)) - ) - sum_long = float( - np.sum(run_sim(session, rain_value=RAIN, man_value=0.3, duration=20)) - ) - assert sum_long > sum_short, ( - f"Longer simulation should produce more depth: " - f"sum(5 min)={sum_short:.3e}, sum(20 min)={sum_long:.3e}" - ) - ratio = sum_long / sum_short - assert ratio >= 1.05, ( - f"20-min simulation should produce at least 5% more depth than 5-min " - f"(ratio={ratio:.2f})" - ) + sum_short = float( + np.sum(run_sim(long_slope_session, rain_value=RAIN, man_value=0.3, duration=5)) + ) + sum_long = float( + np.sum(run_sim(long_slope_session, rain_value=RAIN, man_value=0.3, duration=20)) + ) + assert sum_long > sum_short, ( + f"Longer simulation should produce more depth: " + f"sum(5 min)={sum_short:.3e}, sum(20 min)={sum_long:.3e}" + ) + ratio = sum_long / sum_short + assert ratio >= 1.05, ( + f"20-min simulation should produce at least 5% more depth than 5-min " + f"(ratio={ratio:.2f})" + ) -def test_duration_affects_time_series_progression(tmp_path): +def test_duration_affects_time_series_progression(long_slope_session): """More iterations must create more time-series output maps. With output_step=5, duration=10 produces maps at t=5,10 while duration=20 produces maps at t=5,10,15. More time-series maps indicate longer simulation duration. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=2000, s=0, n=10, res=10) - tools.r_mapcalc(expression="elevation = 201 - col()") - tools.r_mapcalc(expression="dx = 1.0") - tools.r_mapcalc(expression="dy = 0.0") + tools = Tools(session=long_slope_session) - # duration=10 with output_step=5 produces maps at t=5,10 - tools.r_sim_water( - elevation="elevation", - dx="dx", - dy="dy", - depth="depth_10min", - rain_value=RAIN, - man_value=0.3, - nwalkers=10000, - duration=10, - output_step=5, - random_seed=SEED, - nprocs=NPROCS, - flags="t", - ) + # duration=10 with output_step=5 produces maps at t=5,10 + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth="depth_10min", + rain_value=RAIN, + man_value=0.3, + nwalkers=10000, + duration=10, + output_step=5, + random_seed=SEED, + nprocs=NPROCS, + flags="t", + ) - # duration=20 with output_step=5 produces maps at t=5,10,15 - tools.r_sim_water( - elevation="elevation", - dx="dx", - dy="dy", - depth="depth_20min", - rain_value=RAIN, - man_value=0.3, - nwalkers=10000, - duration=20, - output_step=5, - random_seed=SEED, - nprocs=NPROCS, - flags="t", - ) + # duration=20 with output_step=5 produces maps at t=5,10,15 + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth="depth_20min", + rain_value=RAIN, + man_value=0.3, + nwalkers=10000, + duration=20, + output_step=5, + random_seed=SEED, + nprocs=NPROCS, + flags="t", + ) - # 10-min run should produce 2 time-series maps (t=5, t=10) - maps_10 = list( - tools.g_list(type="raster", pattern="depth_10min*", format="json") - ) - assert len(maps_10) == 2, ( - f"10-min simulation with output_step=5 should produce 2 maps, got {len(maps_10)}" - ) + # 10-min run should produce 2 time-series maps (t=5, t=10) + maps_10 = list(tools.g_list(type="raster", pattern="depth_10min*", format="json")) + assert len(maps_10) == 2, ( + f"10-min simulation with output_step=5 should produce 2 maps, got {len(maps_10)}" + ) - # 20-min run should produce 3 time-series maps (t=5, t=10, t=15) - maps_20 = list( - tools.g_list(type="raster", pattern="depth_20min*", format="json") - ) - assert len(maps_20) == 3, ( - f"20-min simulation with output_step=5 should produce 3 maps, got {len(maps_20)}" - ) - assert len(maps_20) > len(maps_10), ( - f"Longer simulation should produce more time-series maps: " - f"20-min has {len(maps_20)} maps, 10-min has {len(maps_10)} maps" - ) + # 20-min run should produce 3 time-series maps (t=5, t=10, t=15) + maps_20 = list(tools.g_list(type="raster", pattern="depth_20min*", format="json")) + assert len(maps_20) == 3, ( + f"20-min simulation with output_step=5 should produce 3 maps, got {len(maps_20)}" + ) + assert len(maps_20) > len(maps_10), ( + f"Longer simulation should produce more time-series maps: " + f"20-min has {len(maps_20)} maps, 10-min has {len(maps_10)} maps" + ) def test_higher_diffusion_coeff_reduces_depth(east_slope_session): @@ -421,120 +456,97 @@ def test_higher_diffusion_coeff_reduces_depth(east_slope_session): ) -def test_lower_hmax_increases_diffusion(tmp_path): +def test_lower_hmax_increases_diffusion(diffusion_session, diffusion_low_hmax_depth): """A lower hmax threshold must increase diffusion, reducing total depth. When water depth exceeds hmax, diffusion is amplified by (halpha + 1). Lowering hmax causes this amplification to kick in sooner. Uses a 200-cell domain with high rain and roughness so that depths - exceed the hmax threshold. + exceed the hmax threshold. The low-hmax run is the shared + diffusion_low_hmax_depth reference. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=200, s=0, n=1, res=1) - tools.r_mapcalc(expression="elevation = 201 - col()") - tools.r_mapcalc(expression="dx = 1.0") - tools.r_mapcalc(expression="dy = 0.0") - - nw = 400000 - sum_low_hmax = float( - np.sum( - run_sim( - session, rain_value=1000, man_value=0.5, hmax=0.001, nwalkers=nw - ) - ) - ) - sum_default_hmax = float( - np.sum( - run_sim(session, rain_value=1000, man_value=0.5, hmax=0.3, nwalkers=nw) + sum_low_hmax = float(np.sum(diffusion_low_hmax_depth)) + sum_default_hmax = float( + np.sum( + run_sim( + diffusion_session, + rain_value=1000, + man_value=0.5, + hmax=0.3, + nwalkers=400000, ) ) - assert sum_low_hmax < sum_default_hmax, ( - f"Lower hmax should increase diffusion and reduce depth: " - f"sum(hmax=0.001)={sum_low_hmax:.3e}, sum(hmax=0.3)={sum_default_hmax:.3e}" - ) + ) + assert sum_low_hmax < sum_default_hmax, ( + f"Lower hmax should increase diffusion and reduce depth: " + f"sum(hmax=0.001)={sum_low_hmax:.3e}, sum(hmax=0.3)={sum_default_hmax:.3e}" + ) -def test_higher_halpha_reduces_depth(tmp_path): +def test_higher_halpha_reduces_depth(diffusion_session): """A higher halpha must increase the diffusion boost above hmax. halpha controls how much extra diffusion is applied when depth exceeds hmax: diffusion is multiplied by (halpha + 1). A low hmax is needed so that depths actually exceed the threshold. - Uses a 200-cell domain with high rain and roughness. + Uses a 200-cell domain with high rain and roughness. Both runs use a + non-default halpha, so neither matches the shared low-hmax reference. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=200, s=0, n=1, res=1) - tools.r_mapcalc(expression="elevation = 201 - col()") - tools.r_mapcalc(expression="dx = 1.0") - tools.r_mapcalc(expression="dy = 0.0") - - nw = 400000 - sum_low = float( - np.sum( - run_sim( - session, - rain_value=1000, - man_value=0.5, - hmax=0.001, - halpha=0.5, - nwalkers=nw, - ) + nw = 400000 + sum_low = float( + np.sum( + run_sim( + diffusion_session, + rain_value=1000, + man_value=0.5, + hmax=0.001, + halpha=0.5, + nwalkers=nw, ) ) - sum_high = float( - np.sum( - run_sim( - session, - rain_value=1000, - man_value=0.5, - hmax=0.001, - halpha=50.0, - nwalkers=nw, - ) + ) + sum_high = float( + np.sum( + run_sim( + diffusion_session, + rain_value=1000, + man_value=0.5, + hmax=0.001, + halpha=50.0, + nwalkers=nw, ) ) - assert sum_high < sum_low, ( - f"Higher halpha should increase diffusion and reduce depth: " - f"sum(halpha=0.5)={sum_low:.3e}, sum(halpha=50)={sum_high:.3e}" - ) + ) + assert sum_high < sum_low, ( + f"Higher halpha should increase diffusion and reduce depth: " + f"sum(halpha=0.5)={sum_low:.3e}, sum(halpha=50)={sum_high:.3e}" + ) -def test_hbeta_changes_result(tmp_path): +def test_hbeta_changes_result(diffusion_session, diffusion_low_hmax_depth): """Changing hbeta must produce a different result when depth exceeds hmax. hbeta weights the running average of walker velocity above the hmax threshold. The effect is small on a 1D domain, so only inequality (not direction) is checked. - Uses a 200-cell domain with high rain, roughness, and low hmax. + Uses a 200-cell domain with high rain, roughness, and low hmax. The + default-hbeta run is the shared diffusion_low_hmax_depth reference + (hbeta=0.5 is the tool default). """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=200, s=0, n=1, res=1) - tools.r_mapcalc(expression="elevation = 201 - col()") - tools.r_mapcalc(expression="dx = 1.0") - tools.r_mapcalc(expression="dy = 0.0") - - nw = 400000 - depth_default = run_sim( - session, rain_value=1000, man_value=0.5, hmax=0.001, hbeta=0.5, nwalkers=nw - ) - depth_high = run_sim( - session, rain_value=1000, man_value=0.5, hmax=0.001, hbeta=10.0, nwalkers=nw - ) - assert not np.array_equal(depth_default, depth_high), ( - "Changing hbeta should produce a different depth result" - ) + depth_high = run_sim( + diffusion_session, + rain_value=1000, + man_value=0.5, + hmax=0.001, + hbeta=10.0, + nwalkers=400000, + ) + assert not np.array_equal(diffusion_low_hmax_depth, depth_high), ( + "Changing hbeta should produce a different depth result" + ) def test_man_raster_matches_scalar(east_slope_session): @@ -615,7 +627,7 @@ def test_flow_control_increases_depth(east_slope_session): ) -def test_time_series_output(tmp_path): +def test_time_series_output(long_slope_session): """The -t flag with output_step must produce intermediate depth maps. With duration=10 and output_step=5, the tool should create depth @@ -626,35 +638,27 @@ def test_time_series_output(tmp_path): walkers survive the full simulation. On small or steep domains walkers leave before the first output step, producing no time-series maps. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=2000, s=0, n=10, res=10) - tools.r_mapcalc(expression="elevation = 201 - col()") - tools.r_mapcalc(expression="dx = 1.0") - tools.r_mapcalc(expression="dy = 0.0") - - tools.r_sim_water( - elevation="elevation", - dx="dx", - dy="dy", - depth="ts_depth", - rain_value=RAIN, - man_value=0.3, - duration=10, - output_step=5, - random_seed=SEED, - nprocs=NPROCS, - flags="t", - ) + tools = Tools(session=long_slope_session) + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth="ts_depth", + rain_value=RAIN, + man_value=0.3, + duration=10, + output_step=5, + random_seed=SEED, + nprocs=NPROCS, + flags="t", + ) - sum_05 = tools.r_univar(map="ts_depth.05", format="json")["sum"] - sum_10 = tools.r_univar(map="ts_depth.10", format="json")["sum"] - assert sum_05 > 0, "Expected positive depth at t=5" - assert sum_10 >= sum_05, ( - f"Depth at t=10 ({sum_10:.3e}) should be >= depth at t=5 ({sum_05:.3e})" - ) + sum_05 = tools.r_univar(map="ts_depth.05", format="json")["sum"] + sum_10 = tools.r_univar(map="ts_depth.10", format="json")["sum"] + assert sum_05 > 0, "Expected positive depth at t=5" + assert sum_10 >= sum_05, ( + f"Depth at t=10 ({sum_10:.3e}) should be >= depth at t=5 ({sum_05:.3e})" + ) def test_observation_logfile(east_slope_session, tmp_path): @@ -710,7 +714,7 @@ def test_observation_logfile(east_slope_session, tmp_path): ) -def test_walkers_output(tmp_path): +def test_walkers_output(long_slope_session): """The walkers_output parameter must produce a vector point map. Each surviving walker position is written as a 3D point. The number @@ -720,40 +724,31 @@ def test_walkers_output(tmp_path): time before exiting. High roughness (0.3) further increases walker retention; small domains produce no walker output regardless of roughness. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=2000, s=0, n=10, res=10) - tools.r_mapcalc(expression="elevation = 201 - col()") - tools.r_mapcalc(expression="dx = 1.0") - tools.r_mapcalc(expression="dy = 0.0") - - nwalkers = 500 - tools.r_sim_water( - elevation="elevation", - dx="dx", - dy="dy", - depth=np.array, - rain_value=RAIN, - man_value=0.3, - nwalkers=nwalkers, - duration=DURATION, - random_seed=SEED, - nprocs=NPROCS, - walkers_output="walkers", - ) + tools = Tools(session=long_slope_session) + nwalkers = 500 + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth=np.array, + rain_value=RAIN, + man_value=0.3, + nwalkers=nwalkers, + duration=DURATION, + random_seed=SEED, + nprocs=NPROCS, + walkers_output="walkers", + ) - info = tools.v_info(map="walkers", flags="t", format="json") - npoints = int(info["points"]) - assert npoints > 0, "Expected at least one walker point" - assert npoints <= nwalkers, ( - f"Number of walker points ({npoints}) should not exceed " - f"nwalkers ({nwalkers})" - ) + info = tools.v_info(map="walkers", flags="t", format="json") + npoints = int(info["points"]) + assert npoints > 0, "Expected at least one walker point" + assert npoints <= nwalkers, ( + f"Number of walker points ({npoints}) should not exceed nwalkers ({nwalkers})" + ) -def test_walkers_output_time_series(tmp_path): +def test_walkers_output_time_series(long_slope_session): """With -t, walkers_output must produce per-step vector maps. The maps use an underscore-separated time suffix (e.g., walkers_05) @@ -762,34 +757,26 @@ def test_walkers_output_time_series(tmp_path): Uses a large domain (200 cells) with high roughness (0.3) so walkers survive long enough to generate multiple time-series outputs. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=2000, s=0, n=10, res=10) - tools.r_mapcalc(expression="elevation = 201 - col()") - tools.r_mapcalc(expression="dx = 1.0") - tools.r_mapcalc(expression="dy = 0.0") - - tools.r_sim_water( - elevation="elevation", - dx="dx", - dy="dy", - depth="ts_depth", - rain_value=RAIN, - man_value=0.3, - duration=10, - output_step=5, - random_seed=SEED, - nprocs=NPROCS, - walkers_output="walkers", - flags="t", - ) + tools = Tools(session=long_slope_session) + tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + depth="ts_depth", + rain_value=RAIN, + man_value=0.3, + duration=10, + output_step=5, + random_seed=SEED, + nprocs=NPROCS, + walkers_output="walkers", + flags="t", + ) - info_05 = tools.v_info(map="walkers_05", flags="t", format="json") - info_10 = tools.v_info(map="walkers_10", flags="t", format="json") - assert int(info_05["points"]) > 0, "Expected walker points at t=5" - assert int(info_10["points"]) > 0, "Expected walker points at t=10" + info_05 = tools.v_info(map="walkers_05", flags="t", format="json") + info_10 = tools.v_info(map="walkers_10", flags="t", format="json") + assert int(info_05["points"]) > 0, "Expected walker points at t=5" + assert int(info_10["points"]) > 0, "Expected walker points at t=10" def test_nprocs_gives_same_result(east_slope_session): From 3a334fa457e33d4c79f62e6e2517b855ebb2e9c9 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 30 Jun 2026 13:04:39 -0400 Subject: [PATCH 13/29] r.sim.water: Strengthen discharge, mintimestep, and time-series assertions Replace the discharge smoke check (sum > 0) with an order-of-magnitude mass-balance bracket, give the mintimestep test a two-sided ratio bound instead of the unfalsifiable rel=2.0 (which accepted any positive value), and relax the time-series map count to a robust lower bound that does not depend on the stochastic last-walker exit time. Developed with assistance from an AI agent (Claude). --- .../r.sim.water/tests/r_sim_water_test.py | 75 ++++++++++++++----- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index c29b74ed395..882eea5082a 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -251,7 +251,27 @@ def setup_and_run(sub_path, ele_expr, dx_value): def test_discharge_positive_with_rain(east_slope_session): - """Rainfall on a slope must produce positive water discharge.""" + """Rainfall on a slope must produce discharge consistent with mass balance. + + Discharge is output in m3/s. By steady-state continuity, the total + discharge summed over the domain cannot exceed the rainfall caught by the + contributing area: sum(q) <= R * total_contributing_area. On the + east_slope_session domain (1 row x 5 cols, res=1, elevation = 6 - col()), + the cell at column x (x = 0,...,4) sits (x + 0.5) m downslope of the divide + and is 1 m wide, so its contributing area is (x + 0.5) m^2 and the analytic + steady-state total is analytic_sum = R * sum(x + 0.5). + + This is an order-of-magnitude mass-balance bound, not the exact q = R * x + relation: on this tiny domain boundary and diffusion effects make the + simulated total only a fraction (empirically ~0.4-0.7 across seeds) of the + analytic steady-state total. We therefore bracket the simulated sum between + analytic_sum/3 and analytic_sum. The lower bound already implies that + discharge is positive, while the bracket also catches gross unit errors + (e.g. a mm/hr or l/s confusion would be off by ~1000x). + """ + rain_rate = RAIN / 1000.0 / 3600.0 # mm/hr -> m/s + analytic_sum = rain_rate * sum(x + 0.5 for x in range(5)) + tools = Tools(session=east_slope_session) discharge = np.asarray( tools.r_sim_water( @@ -265,7 +285,11 @@ def test_discharge_positive_with_rain(east_slope_session): nprocs=NPROCS, ) ) - assert np.sum(discharge) > 0, "Expected positive discharge with rainfall on a slope" + total = float(np.sum(discharge)) + assert analytic_sum / 3 < total < analytic_sum, ( + f"Total discharge {total:.3e} m3/s should fall within the mass-balance " + f"bracket ({analytic_sum / 3:.3e}, {analytic_sum:.3e}) m3/s" + ) def test_results_consistent_across_seeds(east_slope_session): @@ -343,20 +367,29 @@ def test_error_output_is_zero(east_slope_session): def test_mintimestep(east_slope_session): - """A larger minimum time step must still produce valid positive depth. - - Increasing mintimestep reduces accuracy but speeds up the simulation. - The result should still be positive and in the same order of magnitude. + """A larger minimum time step inflates depth by a bounded, measurable amount. + + mintimestep sets a floor on the integration time step: a larger value + speeds up the simulation at the cost of accuracy. On these 1 m cells a + 1 s floor lets walkers overshoot roughly one cell per step, so total depth + increases rather than staying the same. Measured across 12 seeds the ratio + sum_large_step / sum_default stayed in 1.88-2.51 (mean ~2.1, "roughly + doubling"); the large-step sum was effectively seed-independent while the + default sum carried the Monte Carlo variation. The same 1 s floor on 10 m + cells would have a negligible effect. + + The bound is two-sided: the lower bound (1.3) enforces the documented + upward direction (and subsumes positivity), while the upper bound (3.0) + caps the inflation. Both lie well outside the observed range, so the test + passes for the real ~2x effect but fails if depth were off by an order of + magnitude in either direction (e.g. a vanishing or a 10x result). """ sum_default = float(np.sum(run_sim(east_slope_session))) sum_large_step = float(np.sum(run_sim(east_slope_session, mintimestep=1.0))) - assert sum_large_step > 0, "Expected positive depth with large mintimestep" - # With 1 m cells, a 1-second minimum step lets walkers overshoot multiple - # cells, roughly doubling the total depth. On 10 m cells the same step has - # negligible effect (~2%). We only check same order of magnitude here. - assert sum_large_step == pytest.approx(sum_default, rel=2.0), ( - f"Large mintimestep result ({sum_large_step:.3e}) deviates too far " - f"from default ({sum_default:.3e})" + ratio = sum_large_step / sum_default + assert 1.3 < ratio < 3.0, ( + f"Large mintimestep depth ({sum_large_step:.3e}) relative to default " + f"({sum_default:.3e}) gives ratio {ratio:.3f}, outside expected (1.3, 3.0)" ) @@ -388,8 +421,12 @@ def test_duration_affects_time_series_progression(long_slope_session): """More iterations must create more time-series output maps. With output_step=5, duration=10 produces maps at t=5,10 while - duration=20 produces maps at t=5,10,15. More time-series maps - indicate longer simulation duration. + duration=20 produces maps at t=5,10,15 and, if walkers survive to the + end, also t=20. The exact count of the longer run is not pinned: whether + the final t=20 map appears depends on whether the last walkers leave the + domain before the final step, which varies with seed and platform. We + assert the robust lower bound (at least three maps) and that the longer + simulation yields strictly more maps than the shorter one. """ tools = Tools(session=long_slope_session) @@ -431,10 +468,12 @@ def test_duration_affects_time_series_progression(long_slope_session): f"10-min simulation with output_step=5 should produce 2 maps, got {len(maps_10)}" ) - # 20-min run should produce 3 time-series maps (t=5, t=10, t=15) + # 20-min run should produce at least 3 time-series maps (t=5, t=10, t=15; + # t=20 as well when the last walkers survive to the final step). maps_20 = list(tools.g_list(type="raster", pattern="depth_20min*", format="json")) - assert len(maps_20) == 3, ( - f"20-min simulation with output_step=5 should produce 3 maps, got {len(maps_20)}" + assert len(maps_20) >= 3, ( + f"20-min simulation with output_step=5 should produce at least 3 maps, " + f"got {len(maps_20)}" ) assert len(maps_20) > len(maps_10), ( f"Longer simulation should produce more time-series maps: " From 20b163f5f8a75de28be7538bdf3c0d0992ce0e3c Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 30 Jun 2026 13:07:25 -0400 Subject: [PATCH 14/29] r.sim.water: Tune rotation and diffusion tolerances and walker counts Run the rotation-invariance tests with 10000 walkers (instead of the ~70-240 tool default on these small grids) so the per-column profile comparison is meaningful, and set the rotation tolerances from a multi-seed orientation-spread measurement. Reduce the diffusion tests from 400000 to 40000 walkers: their assertions are structural inequalities whose relative margins are walker-count independent (held across 20 seeds from 2000 to 400000). Developed with assistance from an AI agent (Claude). --- .../tests/r_sim_water_landforms_test.py | 123 +++++++++++++----- .../r.sim.water/tests/r_sim_water_test.py | 19 ++- 2 files changed, 104 insertions(+), 38 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py index 099f1e33f2d..7ca1be6d0d7 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py @@ -20,6 +20,37 @@ DURATION = 2 # minutes; enough for near-steady state on small domains RAIN = 100 # mm/hr +# Walker count for the rotation-invariance tests. r.sim.water defaults to +# about twice the cell count, which is only ~70-240 walkers on these small +# grids; at that count a single column total is dominated by Monte Carlo +# noise. The rotation tests therefore run their own higher-walker +# simulations for both orientations (the default-walker shape tests and +# their pinned regression arrays are left unchanged). An empirical sweep +# (10 seeds per landform at 2000, 5000, 10000, 20000 walkers) showed the +# per-column profile spread leveling off around 10000: worst-case spread +# fell from ~9-11% at 2000 to ~5% at 10000, with little further gain at +# 20000 (~4%) for roughly double the runtime. 10000 is the chosen knee. +ROTATION_NWALKERS = 10000 + +# Shared tolerances for the rotation-invariance tests, derived from the +# sweep at ROTATION_NWALKERS. r.sim.water is a stochastic Monte Carlo +# walker simulation, so a landform and its 90-degree rotation produce +# equivalent but not identical depth. One value is used per distinct +# quantity because each comparison measures the same physical property +# (rotational equivalence under walker noise). +# +# Worst-case orientation-to-orientation spread across 10 seeds at 10000 +# walkers: total depth <=1.3% (ridge, terrace), per-column profile <=5.0% +# (valley), saddle edge-sum <=0.3%. TOTAL_REL and PROFILE_RTOL cover those +# maxima with roughly 4x and 2x margin for variation across platforms and +# compilers. The saddle's edge-sum aggregates whole row/column blocks (not +# single columns), so it is far smoother than the per-column profiles and +# uses its own tighter SADDLE_EDGE_REL (about 7x margin) rather than the +# noisier PROFILE_RTOL. +TOTAL_REL = 0.05 +PROFILE_RTOL = 0.1 +SADDLE_EDGE_REL = 0.02 + def run_sim(session, *, random_seed=SEED, **kwargs): """Run r.sim.water on a 2D domain; return depth as 2D ndarray. @@ -294,6 +325,10 @@ def test_ridge_rotation_invariance(tmp_path): The E-W ridge is a 90-degree rotation of the N-S ridge. After rotating the E-W result back, total depth and cross-ridge profiles should match. + + Both orientations are simulated here at ROTATION_NWALKERS rather than + reusing the default-walker shape fixture, so the per-column profile + comparison is meaningful (see ROTATION_NWALKERS for the rationale). """ # N-S ridge: 5 rows x 7 cols, ridge along center column. project_ns = tmp_path / "ns" @@ -302,7 +337,7 @@ def test_ridge_rotation_invariance(tmp_path): tools = Tools(session=session) tools.g_region(w=0, e=7, s=0, n=5, res=1) tools.r_mapcalc(expression="elevation = 3 - abs(col() - 4)") - depth_ns = run_sim(session) + depth_ns = run_sim(session, nwalkers=ROTATION_NWALKERS) # E-W ridge: 7 rows x 5 cols, ridge along center row. project_ew = tmp_path / "ew" @@ -311,14 +346,14 @@ def test_ridge_rotation_invariance(tmp_path): tools = Tools(session=session) tools.g_region(w=0, e=5, s=0, n=7, res=1) tools.r_mapcalc(expression="elevation = 3 - abs(row() - 4)") - depth_ew = run_sim(session) + depth_ew = run_sim(session, nwalkers=ROTATION_NWALKERS) - # Total depth should be comparable. The ridge has only a 3 m - # elevation range on a small grid, so boundary effects and Monte - # Carlo noise cause ~10% variation between orientations. + # Total depth should be comparable between the two orientations. + # TOTAL_REL is shared by all four rotation tests; see its definition + # for the empirical basis (ridge total spread <=1.3% at 10000 walkers). total_ns = float(np.sum(depth_ns)) total_ew = float(np.sum(depth_ew)) - assert total_ns == pytest.approx(total_ew, rel=0.1), ( + assert total_ns == pytest.approx(total_ew, rel=TOTAL_REL), ( f"Total depth N-S ({total_ns:.6f}) vs E-W ({total_ew:.6f})" ) @@ -327,11 +362,12 @@ def test_ridge_rotation_invariance(tmp_path): depth_ew_rotated = np.rot90(depth_ew) assert depth_ew_rotated.shape == depth_ns.shape - # Cross-ridge profiles (summed along the ridge axis) should match. - # Monte Carlo noise on small grids requires generous tolerance. + # Cross-ridge profiles (per-column sums) should match within + # PROFILE_RTOL; at 10000 walkers the worst-case per-column spread was + # ~2.8% for the ridge across 10 seeds (see PROFILE_RTOL definition). profile_ns = np.sum(depth_ns, axis=0) profile_ew = np.sum(depth_ew_rotated, axis=0) - np.testing.assert_allclose(profile_ns, profile_ew, rtol=0.35) + np.testing.assert_allclose(profile_ns, profile_ew, rtol=PROFILE_RTOL) def test_valley_concentrates_flow_downstream(valley_ns_depth): @@ -395,8 +431,12 @@ def test_valley_concentrates_flow_downstream(valley_ns_depth): def test_valley_rotation_invariance(tmp_path): - """A N-S valley and E-W valley must produce rotationally equivalent depth.""" - # N-S valley: 10 rows x 7 cols. + """A N-S valley and E-W valley must produce rotationally equivalent depth. + + Both orientations are simulated here at ROTATION_NWALKERS so the + per-column profile comparison is meaningful (see ROTATION_NWALKERS). + """ + # N-S valley: 10 rows x 7 cols, V-section draining southward. project_ns = tmp_path / "ns" gs.create_project(project_ns) with gs.setup.init(project_ns, env=os.environ.copy()) as session: @@ -405,7 +445,7 @@ def test_valley_rotation_invariance(tmp_path): tools.r_mapcalc( expression="elevation = abs(col() - 4) + (nrows() - row()) * 0.5" ) - depth_ns = run_sim(session) + depth_ns = run_sim(session, nwalkers=ROTATION_NWALKERS) # E-W valley: 7 rows x 10 cols. project_ew = tmp_path / "ew" @@ -416,19 +456,24 @@ def test_valley_rotation_invariance(tmp_path): tools.r_mapcalc( expression="elevation = abs(row() - 4) + (ncols() - col()) * 0.5" ) - depth_ew = run_sim(session) + depth_ew = run_sim(session, nwalkers=ROTATION_NWALKERS) + # Total depth should be comparable between orientations; TOTAL_REL is + # shared across the rotation tests (see its definition). The valley + # total spread was <=0.5% at 10000 walkers across 10 seeds. total_ns = float(np.sum(depth_ns)) total_ew = float(np.sum(depth_ew)) - assert total_ns == pytest.approx(total_ew, rel=0.02), ( + assert total_ns == pytest.approx(total_ew, rel=TOTAL_REL), ( f"Total depth N-S ({total_ns:.6f}) vs E-W ({total_ew:.6f})" ) - # Cross-valley profiles should match after rotation. + # Per-column cross-valley profiles should match within PROFILE_RTOL; + # the valley had the largest per-column spread of the three profile + # landforms (~5.0% at 10000 walkers), which PROFILE_RTOL covers ~2x. profile_ns = np.sum(depth_ns, axis=0) depth_ew_rotated = np.rot90(depth_ew) profile_ew = np.sum(depth_ew_rotated, axis=0) - np.testing.assert_allclose(profile_ns, profile_ew, rtol=0.15) + np.testing.assert_allclose(profile_ns, profile_ew, rtol=PROFILE_RTOL) def test_saddle_bifurcates_flow(saddle_depth): @@ -492,8 +537,10 @@ def test_saddle_rotation_invariance(tmp_path): The original saddle has valleys along columns (left-right). A 90-degree rotation swaps row and column terms, putting valleys - along rows (top-bottom). + along rows (top-bottom). Both orientations are simulated here at + ROTATION_NWALKERS (see ROTATION_NWALKERS for the rationale). """ + # Original saddle (7x7); hyperbolic paraboloid with E-W low axes. project_a = tmp_path / "a" gs.create_project(project_a) with gs.setup.init(project_a, env=os.environ.copy()) as session: @@ -504,7 +551,7 @@ def test_saddle_rotation_invariance(tmp_path): "elevation = (row() - 4)*(row() - 4) - (col() - 4)*(col() - 4) + 10" ) ) - depth_a = run_sim(session) + depth_a = run_sim(session, nwalkers=ROTATION_NWALKERS) project_b = tmp_path / "b" gs.create_project(project_b) @@ -516,22 +563,27 @@ def test_saddle_rotation_invariance(tmp_path): "elevation = (col() - 4)*(col() - 4) - (row() - 4)*(row() - 4) + 10" ) ) - depth_b = run_sim(session) + depth_b = run_sim(session, nwalkers=ROTATION_NWALKERS) - # Both are square so total depth should be comparable. + # Both are square so total depth should be comparable; TOTAL_REL is + # shared across the rotation tests (see its definition). The saddle + # total spread was <=0.4% at 10000 walkers across 10 seeds. total_a = float(np.sum(depth_a)) total_b = float(np.sum(depth_b)) - assert total_a == pytest.approx(total_b, rel=0.1), ( + assert total_a == pytest.approx(total_b, rel=TOTAL_REL), ( f"Total depth original ({total_a:.6f}) vs rotated ({total_b:.6f})" ) # After rotation, the outer ring (excluding the noisy center row/col) # should have comparable depth distribution. Compare edge sums # excluding the center row/col (index 3) where the saddle singularity - # amplifies Monte Carlo noise. + # amplifies Monte Carlo noise. This aggregates whole row/column blocks, + # so it is far smoother than the per-column profiles of the other + # rotation tests (worst-case spread <=0.3% at 10000 walkers across 10 + # seeds); it therefore uses the tighter SADDLE_EDGE_REL. edge_cols_a = float(np.sum(depth_a[:, :3]) + np.sum(depth_a[:, 4:])) edge_rows_b = float(np.sum(depth_b[:3, :]) + np.sum(depth_b[4:, :])) - assert edge_cols_a == pytest.approx(edge_rows_b, rel=0.2), ( + assert edge_cols_a == pytest.approx(edge_rows_b, rel=SADDLE_EDGE_REL), ( f"Edge depth original ({edge_cols_a:.6f}) vs rotated ({edge_rows_b:.6f})" ) @@ -805,8 +857,12 @@ def test_terrace_accumulates_on_steps(terrace_ew_depth): def test_terrace_rotation_invariance(tmp_path): - """E-W terraces and N-S terraces must produce rotationally equivalent depth.""" - # E-W terraces: 10 rows x 12 cols. + """E-W terraces and N-S terraces must produce rotationally equivalent depth. + + Both orientations are simulated here at ROTATION_NWALKERS so the + per-column profile comparison is meaningful (see ROTATION_NWALKERS). + """ + # E-W terraces: 10 rows x 12 cols, three flat steps and drops. project_ew = tmp_path / "ew" gs.create_project(project_ew) with gs.setup.init(project_ew, env=os.environ.copy()) as session: @@ -820,7 +876,7 @@ def test_terrace_rotation_invariance(tmp_path): " if(col() == 9, 4, 3))))" ) ) - depth_ew = run_sim(session) + depth_ew = run_sim(session, nwalkers=ROTATION_NWALKERS) # N-S terraces: 12 rows x 10 cols. project_ns = tmp_path / "ns" @@ -836,18 +892,21 @@ def test_terrace_rotation_invariance(tmp_path): " if(row() == 9, 4, 3))))" ) ) - depth_ns = run_sim(session) + depth_ns = run_sim(session, nwalkers=ROTATION_NWALKERS) - # Total depth should be comparable. + # Total depth should be comparable; TOTAL_REL is shared across the + # rotation tests (see its definition). The terrace total spread was + # <=1.3% at 10000 walkers across 10 seeds. total_ew = float(np.sum(depth_ew)) total_ns = float(np.sum(depth_ns)) - assert total_ew == pytest.approx(total_ns, rel=0.02), ( + assert total_ew == pytest.approx(total_ns, rel=TOTAL_REL), ( f"Total depth E-W ({total_ew:.6f}) vs N-S ({total_ns:.6f})" ) - # Cross-terrace profiles should match after rotation. - # Monte Carlo noise on small grids requires generous tolerance. + # Per-column cross-terrace profiles should match within PROFILE_RTOL; + # the terrace per-column spread was ~4.7% at 10000 walkers across 10 + # seeds, which PROFILE_RTOL covers ~2x. profile_ew = np.sum(depth_ew, axis=0) depth_ns_rotated = np.rot90(depth_ns) profile_ns = np.sum(depth_ns_rotated, axis=0) - np.testing.assert_allclose(profile_ew, profile_ns, rtol=0.3) + np.testing.assert_allclose(profile_ew, profile_ns, rtol=PROFILE_RTOL) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 882eea5082a..c8e75a32346 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -110,15 +110,22 @@ def diffusion_session(diffusion_project): def diffusion_low_hmax_depth(diffusion_project): """Depth for the low-hmax (high-diffusion) reference run. - The hmax=0.001 run with 400000 walkers is the most expensive simulation - in the suite and is the shared reference for both the hmax and hbeta + The hmax=0.001 run is the shared reference for both the hmax and hbeta tests (hbeta=0.5 is the tool default, so test_hbeta's baseline run is identical to this one). Computed once and returned read-only so consumers cannot mutate the shared array. + + The diffusion inequalities are structural (lowering hmax or raising + halpha roughly halves total depth), not Monte Carlo convergence limited: + across 20 seeds the relative margins held at ~0.52 (hmax) and ~0.46 + (halpha) with zero failures at every walker count from 2000 to 400000. + nwalkers=40000 keeps an order-of-magnitude safety factor above where + the margins are noise-driven while running far faster than the original + 400000. """ with TemporaryMapsetSession(env=diffusion_project.env) as session: depth = run_sim( - session, rain_value=1000, man_value=0.5, hmax=0.001, nwalkers=400000 + session, rain_value=1000, man_value=0.5, hmax=0.001, nwalkers=40000 ) depth.flags.writeable = False return depth @@ -513,7 +520,7 @@ def test_lower_hmax_increases_diffusion(diffusion_session, diffusion_low_hmax_de rain_value=1000, man_value=0.5, hmax=0.3, - nwalkers=400000, + nwalkers=40000, ) ) ) @@ -533,7 +540,7 @@ def test_higher_halpha_reduces_depth(diffusion_session): Uses a 200-cell domain with high rain and roughness. Both runs use a non-default halpha, so neither matches the shared low-hmax reference. """ - nw = 400000 + nw = 40000 sum_low = float( np.sum( run_sim( @@ -581,7 +588,7 @@ def test_hbeta_changes_result(diffusion_session, diffusion_low_hmax_depth): man_value=0.5, hmax=0.001, hbeta=10.0, - nwalkers=400000, + nwalkers=40000, ) assert not np.array_equal(diffusion_low_hmax_depth, depth_high), ( "Changing hbeta should produce a different depth result" From f0235aaa9bf6673677e28d044d7b0138114a9323 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 25 Sep 2026 12:04:36 -0400 Subject: [PATCH 15/29] r.sim.water: Ignore seed environment variables in the -s flag test test_random_seed_flag asserts that two runs with -s differ, but G_srand48_auto() takes a fixed seed from GRASS_RANDOM_SEED or SOURCE_DATE_EPOCH when either is set, for example in reproducible builds, which makes the runs identical. Remove the two variables from the environment passed to the tool so the test exercises the time and PID based seeding it is meant to cover. --- raster/r.sim/r.sim.water/tests/r_sim_water_test.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index c8e75a32346..d479d928073 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -333,8 +333,18 @@ def test_random_seed_flag(east_slope_session): Two runs with -s should both produce positive depth but differ from each other because each run gets a different auto-generated seed. + + The auto-generated seed is taken from GRASS_RANDOM_SEED or + SOURCE_DATE_EPOCH when either is set (e.g., in reproducible builds), which + would make the two runs identical. They are removed from the environment + so that the test exercises the time- and PID-based seeding. """ - tools = Tools(session=east_slope_session) + env = { + key: value + for key, value in east_slope_session.env.items() + if key not in {"GRASS_RANDOM_SEED", "SOURCE_DATE_EPOCH"} + } + tools = Tools(env=env) common = { "elevation": "elevation", "dx": "dx", From c1367419713c4a5f607737730cef92276f91acfe Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 25 Sep 2026 12:04:37 -0400 Subject: [PATCH 16/29] r.sim.water: Make landform tests seed-robust and pin results in millimeters The peak and ridge assertions held for seed 42 by luck at the default walker count: across 13 seeds, ring symmetry on the peak failed for about half of them, and the edge-versus-crest comparison on the ridge rested on a false assumption, the crest being the second wettest column. Run both tests at 10000 walkers with tolerances derived from seed sweeps, assert interior properties only (accumulation down the flanks and outward from the summit) to stay clear of edge effects, and grow the peak to 7x7 so a second interior ring exists. The hollow-versus-spur test compared two bowls; replace the surfaces with a parabolic cross-section of equal curvature and opposite sign, on which the hollow holds at least 3.6 times the spur's axis depth in every seed. Pin every landform's full grid in millimeters with two decimals against a 0.01 mm tolerance, the coarsest at which a change in the random number generator still moves at least one cell (0.04 mm) on every landform, instead of loose tolerances that left most cells unconstrained. Give the elevation surfaces as arrays passed straight to the tool, which reproduces the r.mapcalc result bitwise, so the shape is visible in the code and no docstring table can drift from an expression. This removes the per-test project setup and four one-consumer fixtures, and collapses three rotation tests into one parametrized test that derives the rotated surface with np.rot90. The seed sweeps, the hollow and spur redesign, and the tolerance analysis were done with substantial AI assistance (Claude Code). --- .../tests/r_sim_water_landforms_test.py | 1175 ++++++----------- 1 file changed, 404 insertions(+), 771 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py index 7ca1be6d0d7..f864b06075e 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py @@ -1,8 +1,11 @@ -"""Landscape-configuration tests for r.sim.water (SIMWE). - -Tests use 2D synthetic landscapes with small spatial extent and -hand-crafted elevation surfaces where expected output can be reasoned -about from the landscape geometry. +"""Landform tests for r.sim.water (SIMWE). + +Each test simulates a small synthetic elevation surface, given as an array +so that the shape is visible in the code, and checks two things: what the +model must do on that landform for any random seed (physical assertions, +with tolerances from seed sweeps), and the result for SEED (a pinned array +that fails whenever the simulated result changes, including a change in the +random number generator). """ import os @@ -20,372 +23,263 @@ DURATION = 2 # minutes; enough for near-steady state on small domains RAIN = 100 # mm/hr -# Walker count for the rotation-invariance tests. r.sim.water defaults to -# about twice the cell count, which is only ~70-240 walkers on these small -# grids; at that count a single column total is dominated by Monte Carlo -# noise. The rotation tests therefore run their own higher-walker -# simulations for both orientations (the default-walker shape tests and -# their pinned regression arrays are left unchanged). An empirical sweep -# (10 seeds per landform at 2000, 5000, 10000, 20000 walkers) showed the -# per-column profile spread leveling off around 10000: worst-case spread -# fell from ~9-11% at 2000 to ~5% at 10000, with little further gain at -# 20000 (~4%) for roughly double the runtime. 10000 is the chosen knee. -ROTATION_NWALKERS = 10000 - -# Shared tolerances for the rotation-invariance tests, derived from the -# sweep at ROTATION_NWALKERS. r.sim.water is a stochastic Monte Carlo -# walker simulation, so a landform and its 90-degree rotation produce -# equivalent but not identical depth. One value is used per distinct -# quantity because each comparison measures the same physical property -# (rotational equivalence under walker noise). -# -# Worst-case orientation-to-orientation spread across 10 seeds at 10000 -# walkers: total depth <=1.3% (ridge, terrace), per-column profile <=5.0% -# (valley), saddle edge-sum <=0.3%. TOTAL_REL and PROFILE_RTOL cover those -# maxima with roughly 4x and 2x margin for variation across platforms and -# compilers. The saddle's edge-sum aggregates whole row/column blocks (not -# single columns), so it is far smoother than the per-column profiles and -# uses its own tighter SADDLE_EDGE_REL (about 7x margin) rather than the -# noisier PROFILE_RTOL. -TOTAL_REL = 0.05 -PROFILE_RTOL = 0.1 -SADDLE_EDGE_REL = 0.02 - - -def run_sim(session, *, random_seed=SEED, **kwargs): - """Run r.sim.water on a 2D domain; return depth as 2D ndarray. - - Omits dx/dy so partial derivatives are computed internally. - """ - defaults = { - "rain_value": RAIN, - "infil_value": 0, - "man_value": 0.1, - "nprocs": NPROCS, - "duration": DURATION, - } - defaults.update(kwargs) - defaults = {k: v for k, v in defaults.items() if v is not None} - tools = Tools(session=session) - return tools.r_sim_water( - elevation="elevation", - depth=np.array, - random_seed=random_seed, - **defaults, - ) - - -def _landform_depth(tmp_path_factory, name, region, elevation): - """Build a one-off landform project and return its depth. - - Backs the module-scoped landform fixtures so each base landform is - simulated once and shared between its shape test and its rotation test - (which would otherwise recompute the identical surface). The returned - array is read-only so neither consumer can mutate the shared result. +# With the default walker count (two per cell) the depth of a single cell or +# column varies too much between seeds to compare two simulations. In a +# sweep from 2000 to 20000 walkers the spread of per-column sums leveled off +# at 10000. +HIGH_NWALKERS = 10000 + +# Relative tolerances for comparing two simulations at HIGH_NWALKERS (rtol +# in numpy.testing.assert_allclose, rel in pytest.approx). Each is at least +# twice the largest difference seen across seeds. +TOTAL_DEPTH_RTOL = 0.05 # Depth summed over the whole grid. +COLUMN_PROFILE_RTOL = 0.1 # Depth summed per column, compared column by column. + +# The pinned arrays are the float32 result for SEED in millimeters with two +# decimals, so rounding alone moves a value by up to 0.005 mm and the +# tolerance allows twice that. A change in the random number generator moved +# at least one cell by 0.04 mm in every landform at every walker count +# tested, so it still fails the pin. +PIN_ATOL_MM = 0.01 + +# Elevation surfaces, one cell per element at unit resolution. Rows run north +# to south and columns west to east, matching the depth arrays. +PIT = np.array( + [ + [3, 3, 3, 3, 3], + [3, 2, 2, 2, 3], + [3, 2, 1, 2, 3], + [3, 2, 2, 2, 3], + [3, 3, 3, 3, 3], + ] +) +PEAK = np.array( + [ + [3, 3, 3, 3, 3, 3, 3], + [3, 4, 4, 4, 4, 4, 3], + [3, 4, 5, 5, 5, 4, 3], + [3, 4, 5, 6, 5, 4, 3], + [3, 4, 5, 5, 5, 4, 3], + [3, 4, 4, 4, 4, 4, 3], + [3, 3, 3, 3, 3, 3, 3], + ] +) +RIDGE = np.array([[0, 1, 2, 3, 2, 1, 0]] * 5) +SADDLE = np.array( + [ + [10, 15, 18, 19, 18, 15, 10], + [5, 10, 13, 14, 13, 10, 5], + [2, 7, 10, 11, 10, 7, 2], + [1, 6, 9, 10, 9, 6, 1], + [2, 7, 10, 11, 10, 7, 2], + [5, 10, 13, 14, 13, 10, 5], + [10, 15, 18, 19, 18, 15, 10], + ] +) +TERRACE = np.array([[9, 9, 9, 9, 7, 6, 6, 6, 4, 3, 3, 3]] * 10) + + +def surface(row_values, column_values): + """Elevation grid in which each cell is its row value plus its column value.""" + return np.array(row_values)[:, np.newaxis] + np.array(column_values) + + +# Ten rows falling southward by 0.5 per row. +DOWNSLOPE = [4.5, 4.0, 3.5, 3.0, 2.5, 2.0, 1.5, 1.0, 0.5, 0.0] +# A V-shaped, a concave parabolic, and a convex parabolic cross-section on +# that slope. +VALLEY = surface(DOWNSLOPE, [3, 2, 1, 0, 1, 2, 3]) +HOLLOW = surface(DOWNSLOPE, [1.8, 0.8, 0.2, 0.0, 0.2, 0.8, 1.8]) +SPUR = surface(DOWNSLOPE, [-1.8, -0.8, -0.2, 0.0, -0.2, -0.8, -1.8]) + + +def landform_depth(project, elevation, *, random_seed=SEED, **kwargs): + """Simulate r.sim.water on an elevation array in a new project; return depth. + + The region is one cell per array element at unit resolution. Only the + elevation is given, so the tool computes the slope components itself. """ - project = tmp_path_factory.mktemp(name) / "simwe" + nrows, ncols = elevation.shape gs.create_project(project) with gs.setup.init(project, env=os.environ.copy()) as session: tools = Tools(session=session) - tools.g_region(**region) - tools.r_mapcalc(expression=f"elevation = {elevation}") - depth = run_sim(session) - depth.flags.writeable = False - return depth + tools.g_region(w=0, e=ncols, s=0, n=nrows, res=1) + return tools.r_sim_water( + elevation=elevation, + depth=np.array, + random_seed=random_seed, + rain_value=RAIN, + infil_value=0, + man_value=0.1, + nprocs=NPROCS, + duration=DURATION, + **kwargs, + ) -@pytest.fixture(scope="module") -def ridge_ns_depth(tmp_path_factory): - """N-S ridge depth (5 rows x 7 cols); ridge along the center column.""" - return _landform_depth( - tmp_path_factory, - "ridge_ns", - {"w": 0, "e": 7, "s": 0, "n": 5, "res": 1}, - "3 - abs(col() - 4)", +def assert_depth_pinned(depth, expected_mm): + """Compare simulated depth in meters with the pinned values in millimeters.""" + np.testing.assert_allclose( + depth.astype(np.float64) * 1000, expected_mm, rtol=0, atol=PIN_ATOL_MM ) -@pytest.fixture(scope="module") -def valley_ns_depth(tmp_path_factory): - """N-S valley depth (10 rows x 7 cols); V-section draining southward.""" - return _landform_depth( - tmp_path_factory, - "valley_ns", - {"w": 0, "e": 7, "s": 0, "n": 10, "res": 1}, - "abs(col() - 4) + (nrows() - row()) * 0.5", - ) +def test_pit_collects_water(tmp_path): + """A square bowl must collect most water in its center cell, symmetrically.""" + depth = landform_depth(tmp_path / "pit", PIT) + center_depth = float(depth[2, 2]) + total_depth = float(np.sum(depth)) -@pytest.fixture(scope="module") -def saddle_depth(tmp_path_factory): - """Saddle depth (7x7); hyperbolic paraboloid with E-W low axes.""" - return _landform_depth( - tmp_path_factory, - "saddle", - {"w": 0, "e": 7, "s": 0, "n": 7, "res": 1}, - "(row() - 4)*(row() - 4) - (col() - 4)*(col() - 4) + 10", + assert center_depth == np.max(depth), ( + f"Pit center depth ({center_depth:.4f}) should be the domain maximum " + f"({np.max(depth):.4f})" ) - -@pytest.fixture(scope="module") -def terrace_ew_depth(tmp_path_factory): - """E-W terrace depth (10 rows x 12 cols); three flat steps and drops.""" - return _landform_depth( - tmp_path_factory, - "terrace_ew", - {"w": 0, "e": 12, "s": 0, "n": 10, "res": 1}, - "if(col() <= 4, 9, if(col() == 5, 7, if(col() <= 8, 6, if(col() == 9, 4, 3))))", + # The 30% threshold holds across resolutions from 0.1 m to 30 m. + assert center_depth > 0.3 * total_depth, ( + f"Center should hold >30% of total water: " + f"center={center_depth:.4f}, total={total_depth:.4f}" ) + # Radial symmetry: the 4 cardinal neighbors should have similar depth. + neighbors = [depth[1, 2], depth[3, 2], depth[2, 1], depth[2, 3]] + mean_neighbor = float(np.mean(neighbors)) + np.testing.assert_allclose(neighbors, mean_neighbor, rtol=0.3) -def test_pit_collects_water(tmp_path): - """A bowl-shaped pit must collect most water in its center. - - A 5x5 grid with elevation equal to the Chebyshev distance from the - center creates a closed depression. Water flows inward from all - directions and accumulates at the lowest point. - - Elevation: - 3 3 3 3 3 - 3 2 2 2 3 - 3 2 1 2 3 - 3 2 2 2 3 - 3 3 3 3 3 - """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=5, s=0, n=5, res=1) - # row() and col() are 1-indexed, so center of 5x5 is at (3, 3). - tools.r_mapcalc( - expression="elevation = max(abs(row() - 3), abs(col() - 3)) + 1" - ) - - depth = run_sim(session) - - # The center cell is at NumPy index [2, 2]. - center_depth = float(depth[2, 2]) - total_depth = float(np.sum(depth)) - - # The pit center must have the maximum depth. - assert center_depth == np.max(depth), ( - f"Pit center depth ({center_depth:.4f}) should be the domain maximum " - f"({np.max(depth):.4f})" - ) - - # Most water should be trapped in the center. The 30% threshold - # holds across resolutions from 0.1 m to 30 m. - assert center_depth > 0.3 * total_depth, ( - f"Center should hold >30% of total water: " - f"center={center_depth:.4f}, total={total_depth:.4f}" - ) - - # Radial symmetry: the 4 cardinal neighbors should have similar depth. - neighbors = [depth[1, 2], depth[3, 2], depth[2, 1], depth[2, 3]] - mean_neighbor = float(np.mean(neighbors)) - np.testing.assert_allclose(neighbors, mean_neighbor, rtol=0.3) - - # Cardinal neighbors must have less depth than center. - assert all(n < center_depth for n in neighbors), ( - f"All neighbors should have less depth than center: " - f"neighbors={[f'{n:.4f}' for n in neighbors]}, " - f"center={center_depth:.4f}" - ) + assert all(n < center_depth for n in neighbors), ( + f"All neighbors should have less depth than center: " + f"neighbors={[f'{n:.4f}' for n in neighbors]}, " + f"center={center_depth:.4f}" + ) - # Radial symmetry for the 4 diagonal neighbors. - diagonals = [depth[1, 1], depth[1, 3], depth[3, 1], depth[3, 3]] - mean_diagonal = float(np.mean(diagonals)) - np.testing.assert_allclose(diagonals, mean_diagonal, rtol=0.3) - - # Depth must decrease monotonically from center outward: - # center > cardinal ring > edge midpoints. - edge_midpoints = [depth[0, 2], depth[4, 2], depth[2, 0], depth[2, 4]] - assert mean_neighbor > float(np.mean(edge_midpoints)), ( - f"Ring 1 mean depth ({mean_neighbor:.4f}) should exceed " - f"ring 2 mean depth ({float(np.mean(edge_midpoints)):.4f})" - ) + # Radial symmetry for the 4 diagonal neighbors. + diagonals = [depth[1, 1], depth[1, 3], depth[3, 1], depth[3, 3]] + mean_diagonal = float(np.mean(diagonals)) + np.testing.assert_allclose(diagonals, mean_diagonal, rtol=0.3) + + # Depth decreases from the center outward: center, then the cardinal + # ring, then the edge midpoints. + edge_midpoints = [depth[0, 2], depth[4, 2], depth[2, 0], depth[2, 4]] + assert mean_neighbor > float(np.mean(edge_midpoints)), ( + f"Ring 1 mean depth ({mean_neighbor:.4f}) should exceed " + f"ring 2 mean depth ({float(np.mean(edge_midpoints)):.4f})" + ) - # Full grid regression check for this seed and parameters. - expected = np.array( - [ - [0.000503, 0.000395, 0.000451, 0.000642, 0.000395], - [0.000451, 0.002898, 0.014304, 0.002434, 0.000800], - [0.000451, 0.014336, 0.087167, 0.014282, 0.000451], - [0.000503, 0.002556, 0.014759, 0.002851, 0.000684], - [0.000642, 0.000503, 0.000260, 0.000800, 0.000503], - ] - ) - np.testing.assert_allclose(depth, expected, atol=0.002) + assert_depth_pinned( + depth, + [ + [0.50, 0.39, 0.45, 0.64, 0.39], + [0.45, 2.90, 14.30, 2.43, 0.80], + [0.45, 14.34, 87.17, 14.28, 0.45], + [0.50, 2.56, 14.76, 2.85, 0.68], + [0.64, 0.50, 0.26, 0.80, 0.50], + ], + ) def test_peak_sheds_water(tmp_path): - """A conical peak must have minimum depth at the summit. - - A 5x5 grid with elevation decreasing outward from the center creates - a peak. Water flows away from the summit in all directions, so the - center should have the least water and edges should accumulate more. - - Elevation: - 2 2 2 2 2 - 2 3 3 3 2 - 2 3 4 3 2 - 2 3 3 3 2 - 2 2 2 2 2 + """A conical peak must shed water outward, symmetrically in all directions. + + Water shed from the summit accumulates outward, so the second square ring + around the summit holds more per cell than the first, and cells at the + same distance from the summit hold similar depth. The summit itself holds + more than the ring around it: the slope computed from its equal-height + neighbors is zero, so its water leaves only by diffusion. The outer ring + is the domain edge and is left out to avoid edge effects. """ - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=5, s=0, n=5, res=1) - tools.r_mapcalc( - expression="elevation = 4 - max(abs(row() - 3), abs(col() - 3))" - ) + depth = landform_depth(tmp_path / "peak", PEAK, nwalkers=HIGH_NWALKERS) + + # The summit is at NumPy index [3, 3]. + summit = float(depth[3, 3]) + neighbors = [depth[2, 3], depth[4, 3], depth[3, 2], depth[3, 4]] + diagonals = [depth[2, 2], depth[2, 4], depth[4, 2], depth[4, 4]] + ring2 = [ + depth[i, j] + for i in range(1, 6) + for j in range(1, 6) + if max(abs(i - 3), abs(j - 3)) == 2 + ] + + # Relative tolerance for single cells; the largest deviation from the + # ring mean seen across seeds was about 7%. + cell_rtol = 0.15 + np.testing.assert_allclose(neighbors, np.mean(neighbors), rtol=cell_rtol) + np.testing.assert_allclose(diagonals, np.mean(diagonals), rtol=cell_rtol) + + mean_ring1 = float(np.mean(neighbors + diagonals)) + mean_ring2 = float(np.mean(ring2)) + assert mean_ring2 > mean_ring1, ( + f"Ring 2 mean depth ({mean_ring2:.6f}) should exceed " + f"ring 1 mean depth ({mean_ring1:.6f})" + ) + assert summit > mean_ring1, ( + f"Summit depth ({summit:.6f}) should exceed " + f"ring 1 mean depth ({mean_ring1:.6f})" + ) - depth = run_sim(session) + assert_depth_pinned( + depth, + [ + [0.89, 0.68, 0.81, 0.89, 0.82, 0.70, 0.90], + [0.69, 0.52, 0.48, 0.57, 0.49, 0.52, 0.74], + [0.79, 0.49, 0.38, 0.42, 0.39, 0.52, 0.79], + [0.92, 0.58, 0.40, 0.69, 0.42, 0.58, 0.90], + [0.81, 0.50, 0.41, 0.42, 0.37, 0.49, 0.80], + [0.71, 0.50, 0.48, 0.60, 0.51, 0.51, 0.69], + [0.86, 0.67, 0.80, 0.92, 0.80, 0.74, 0.87], + ], + ) - # Cardinal neighbors should have similar depth (radial symmetry). - neighbors = [depth[1, 2], depth[3, 2], depth[2, 1], depth[2, 3]] - mean_neighbor = float(np.mean(neighbors)) - np.testing.assert_allclose(neighbors, mean_neighbor, rtol=0.4) - # Diagonal neighbors should have similar depth. - diagonals = [depth[1, 1], depth[1, 3], depth[3, 1], depth[3, 3]] - mean_diagonal = float(np.mean(diagonals)) - np.testing.assert_allclose(diagonals, mean_diagonal, rtol=0.4) +def test_ridge_sheds_water_to_flanks(tmp_path): + """A ridge must shed water down both flanks, symmetrically. - # Outer ring (edges) should have more depth than inner ring. - edge_midpoints = [depth[0, 2], depth[4, 2], depth[2, 0], depth[2, 4]] - mean_edge = float(np.mean(edge_midpoints)) - assert mean_edge > mean_neighbor, ( - f"Edge depth ({mean_edge:.6f}) should exceed " - f"neighbor depth ({mean_neighbor:.6f})" + Water shed from the crest accumulates down each flank, so the lower flank + column holds more than the shoulder column next to the crest, and the two + sides mirror each other. The crest itself holds about twice the depth of + its shoulders: the slope computed from its equal-height neighbors is + zero, so its water leaves only by diffusion. The edge columns are left + out to avoid edge effects. + """ + depth = landform_depth(tmp_path / "ridge", RIDGE, nwalkers=HIGH_NWALKERS) + profile = np.sum(depth, axis=0) + + # Columns 2 and 4 are the shoulders next to the crest (3); columns 1 and + # 5 are the lower flanks. + for shoulder, flank in ((2, 1), (4, 5)): + assert profile[flank] > profile[shoulder], ( + f"Lower flank column {flank} ({profile[flank]:.6f}) should " + f"exceed shoulder column {shoulder} ({profile[shoulder]:.6f})" ) - - # Full grid regression check. - expected = np.array( - [ - [0.000973, 0.000872, 0.000800, 0.000451, 0.000800], - [0.000684, 0.000598, 0.000552, 0.000503, 0.000260], - [0.000552, 0.000332, 0.000598, 0.000503, 0.000724], - [0.000503, 0.000503, 0.000332, 0.000395, 0.000260], - [0.000907, 0.000642, 0.000642, 0.000763, 0.000642], - ] + assert profile[3] > profile[shoulder], ( + f"Crest ({profile[3]:.6f}) should exceed shoulder column " + f"{shoulder} ({profile[shoulder]:.6f})" ) - np.testing.assert_allclose(depth, expected, atol=0.0002) - -def test_ridge_sheds_water_to_flanks(ridge_ns_depth): - """A ridge must shed water to both flanks, leaving the crest drier. - - A 5x7 grid with a N-S ridge along the center column. The crest - is the highest point and water flows away toward the edges. - - Elevation (each row): - 0 1 2 3 2 1 0 - """ - depth = ridge_ns_depth + # The per-column profile mirrors across the crest. + np.testing.assert_allclose(profile[:3], profile[::-1][:3], rtol=COLUMN_PROFILE_RTOL) - # Edge columns (0 and 6) should accumulate more water than - # the crest column (3) because water flows downhill. - crest_sum = float(np.sum(depth[:, 3])) - left_edge_sum = float(np.sum(depth[:, 0])) - right_edge_sum = float(np.sum(depth[:, 6])) - assert left_edge_sum > crest_sum, ( - f"Left edge ({left_edge_sum:.6f}) should exceed crest ({crest_sum:.6f})" - ) - assert right_edge_sum > crest_sum, ( - f"Right edge ({right_edge_sum:.6f}) should exceed crest ({crest_sum:.6f})" - ) - - # Left-right symmetry: columns equidistant from crest should - # have similar total depth. - for offset in range(1, 4): - left_sum = float(np.sum(depth[:, 3 - offset])) - right_sum = float(np.sum(depth[:, 3 + offset])) - assert left_sum == pytest.approx(right_sum, rel=0.4), ( - f"Offset {offset}: left sum ({left_sum:.6f}) vs right sum ({right_sum:.6f})" - ) - - # Full grid regression check. - expected = np.array( + assert_depth_pinned( + depth, [ - [0.000946, 0.000586, 0.000303, 0.000303, 0.000360, 0.000459, 0.000546], - [0.001390, 0.000796, 0.000661, 0.001455, 0.000412, 0.000827, 0.001182], - [0.001206, 0.001055, 0.000796, 0.001081, 0.000586, 0.000624, 0.001497], - [0.001254, 0.000730, 0.000586, 0.001158, 0.000412, 0.000827, 0.001055], - [0.000888, 0.000546, 0.000459, 0.000730, 0.000238, 0.000546, 0.000661], - ] + [1.00, 0.66, 0.40, 0.57, 0.42, 0.68, 1.02], + [1.09, 0.76, 0.58, 1.37, 0.56, 0.77, 1.11], + [1.11, 0.73, 0.52, 1.12, 0.50, 0.72, 1.09], + [1.13, 0.78, 0.58, 1.45, 0.58, 0.75, 1.11], + [1.00, 0.64, 0.40, 0.59, 0.40, 0.66, 1.00], + ], ) - np.testing.assert_allclose(depth, expected, atol=0.0005) - -def test_ridge_rotation_invariance(tmp_path): - """A N-S ridge and E-W ridge must produce rotationally equivalent depth. - The E-W ridge is a 90-degree rotation of the N-S ridge. After rotating - the E-W result back, total depth and cross-ridge profiles should match. +def test_valley_concentrates_flow_downstream(tmp_path): + """A V-shaped valley draining southward must concentrate water on its floor. - Both orientations are simulated here at ROTATION_NWALKERS rather than - reusing the default-walker shape fixture, so the per-column profile - comparison is meaningful (see ROTATION_NWALKERS for the rationale). + The floor holds far more per cell than the hillslopes, depth on the floor + increases downstream, and the two sides mirror each other. """ - # N-S ridge: 5 rows x 7 cols, ridge along center column. - project_ns = tmp_path / "ns" - gs.create_project(project_ns) - with gs.setup.init(project_ns, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=7, s=0, n=5, res=1) - tools.r_mapcalc(expression="elevation = 3 - abs(col() - 4)") - depth_ns = run_sim(session, nwalkers=ROTATION_NWALKERS) - - # E-W ridge: 7 rows x 5 cols, ridge along center row. - project_ew = tmp_path / "ew" - gs.create_project(project_ew) - with gs.setup.init(project_ew, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=5, s=0, n=7, res=1) - tools.r_mapcalc(expression="elevation = 3 - abs(row() - 4)") - depth_ew = run_sim(session, nwalkers=ROTATION_NWALKERS) - - # Total depth should be comparable between the two orientations. - # TOTAL_REL is shared by all four rotation tests; see its definition - # for the empirical basis (ridge total spread <=1.3% at 10000 walkers). - total_ns = float(np.sum(depth_ns)) - total_ew = float(np.sum(depth_ew)) - assert total_ns == pytest.approx(total_ew, rel=TOTAL_REL), ( - f"Total depth N-S ({total_ns:.6f}) vs E-W ({total_ew:.6f})" - ) - - # Rotating the E-W result 90 degrees counter-clockwise should - # approximate the N-S result. - depth_ew_rotated = np.rot90(depth_ew) - assert depth_ew_rotated.shape == depth_ns.shape - - # Cross-ridge profiles (per-column sums) should match within - # PROFILE_RTOL; at 10000 walkers the worst-case per-column spread was - # ~2.8% for the ridge across 10 seeds (see PROFILE_RTOL definition). - profile_ns = np.sum(depth_ns, axis=0) - profile_ew = np.sum(depth_ew_rotated, axis=0) - np.testing.assert_allclose(profile_ns, profile_ew, rtol=PROFILE_RTOL) - - -def test_valley_concentrates_flow_downstream(valley_ns_depth): - """A V-shaped valley must concentrate water along its floor. - - A 10x7 grid with a V-shaped cross-section draining southward. - The valley floor (center column) should have much more water than - the hillslopes, and discharge should increase downstream. - - Cross-section (each row, west to east): - 3 2 1 0 1 2 3 - Downstream gradient adds 0.5 per row, so the top row is 4.5 m - higher than the bottom row. - """ - depth = valley_ns_depth + depth = landform_depth(tmp_path / "valley", VALLEY) - # Valley floor (col index 3) should have much higher per-cell - # depth than the hillslope average. floor_mean = float(np.mean(depth[:, 3])) hillslope_cols = np.concatenate([depth[:, :3], depth[:, 4:]], axis=1) hillslope_mean = float(np.mean(hillslope_cols)) @@ -394,8 +288,7 @@ def test_valley_concentrates_flow_downstream(valley_ns_depth): f"hillslope mean ({hillslope_mean:.6f})" ) - # Discharge (depth) should increase downstream (toward higher row - # indices in the array). Compare upper and lower halves. + # Downstream is toward higher row indices; compare the two halves. upper_floor = float(np.sum(depth[:5, 3])) lower_floor = float(np.sum(depth[5:, 3])) assert lower_floor > upper_floor, ( @@ -403,8 +296,6 @@ def test_valley_concentrates_flow_downstream(valley_ns_depth): f"upper valley floor ({upper_floor:.6f})" ) - # Cross-valley symmetry: columns equidistant from floor should - # have similar total depth. for offset in range(1, 4): left = float(np.sum(depth[:, 3 - offset])) right = float(np.sum(depth[:, 3 + offset])) @@ -412,94 +303,39 @@ def test_valley_concentrates_flow_downstream(valley_ns_depth): f"Offset {offset}: left ({left:.6f}) vs right ({right:.6f})" ) - # Full grid regression check. - expected = np.array( + assert_depth_pinned( + depth, [ - [0.000276, 0.000404, 0.000579, 0.001990, 0.000684, 0.000704, 0.000276], - [0.000556, 0.000834, 0.000951, 0.001532, 0.000724, 0.000194, 0.000458], - [0.000276, 0.000533, 0.000919, 0.002308, 0.000886, 0.000601, 0.000643], - [0.000533, 0.000431, 0.001119, 0.003021, 0.001014, 0.000684, 0.000484], - [0.000404, 0.000643, 0.000798, 0.003759, 0.001271, 0.000724, 0.000431], - [0.000276, 0.000622, 0.001060, 0.004491, 0.001323, 0.000664, 0.000404], - [0.000509, 0.000601, 0.001189, 0.004852, 0.001323, 0.000556, 0.000484], - [0.000431, 0.000643, 0.001244, 0.005432, 0.001555, 0.000509, 0.000311], - [0.000374, 0.000724, 0.001203, 0.005944, 0.001361, 0.000643, 0.000664], - [0.000622, 0.000851, 0.001323, 0.006594, 0.001635, 0.000684, 0.000431], - ] - ) - np.testing.assert_allclose(depth, expected, atol=0.002) - - -def test_valley_rotation_invariance(tmp_path): - """A N-S valley and E-W valley must produce rotationally equivalent depth. - - Both orientations are simulated here at ROTATION_NWALKERS so the - per-column profile comparison is meaningful (see ROTATION_NWALKERS). - """ - # N-S valley: 10 rows x 7 cols, V-section draining southward. - project_ns = tmp_path / "ns" - gs.create_project(project_ns) - with gs.setup.init(project_ns, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=7, s=0, n=10, res=1) - tools.r_mapcalc( - expression="elevation = abs(col() - 4) + (nrows() - row()) * 0.5" - ) - depth_ns = run_sim(session, nwalkers=ROTATION_NWALKERS) + [0.28, 0.40, 0.58, 1.99, 0.68, 0.70, 0.28], + [0.56, 0.83, 0.95, 1.53, 0.72, 0.19, 0.46], + [0.28, 0.53, 0.92, 2.31, 0.89, 0.60, 0.64], + [0.53, 0.43, 1.12, 3.02, 1.01, 0.68, 0.48], + [0.40, 0.64, 0.80, 3.76, 1.27, 0.72, 0.43], + [0.28, 0.62, 1.06, 4.49, 1.32, 0.66, 0.40], + [0.51, 0.60, 1.19, 4.85, 1.32, 0.56, 0.48], + [0.43, 0.64, 1.24, 5.43, 1.55, 0.51, 0.31], + [0.37, 0.72, 1.20, 5.94, 1.36, 0.64, 0.66], + [0.62, 0.85, 1.32, 6.59, 1.63, 0.68, 0.43], + ], + ) - # E-W valley: 7 rows x 10 cols. - project_ew = tmp_path / "ew" - gs.create_project(project_ew) - with gs.setup.init(project_ew, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=10, s=0, n=7, res=1) - tools.r_mapcalc( - expression="elevation = abs(row() - 4) + (ncols() - col()) * 0.5" - ) - depth_ew = run_sim(session, nwalkers=ROTATION_NWALKERS) - - # Total depth should be comparable between orientations; TOTAL_REL is - # shared across the rotation tests (see its definition). The valley - # total spread was <=0.5% at 10000 walkers across 10 seeds. - total_ns = float(np.sum(depth_ns)) - total_ew = float(np.sum(depth_ew)) - assert total_ns == pytest.approx(total_ew, rel=TOTAL_REL), ( - f"Total depth N-S ({total_ns:.6f}) vs E-W ({total_ew:.6f})" - ) - - # Per-column cross-valley profiles should match within PROFILE_RTOL; - # the valley had the largest per-column spread of the three profile - # landforms (~5.0% at 10000 walkers), which PROFILE_RTOL covers ~2x. - profile_ns = np.sum(depth_ns, axis=0) - depth_ew_rotated = np.rot90(depth_ew) - profile_ew = np.sum(depth_ew_rotated, axis=0) - np.testing.assert_allclose(profile_ns, profile_ew, rtol=PROFILE_RTOL) - - -def test_saddle_bifurcates_flow(saddle_depth): - """A saddle must split flow into two low quadrants. - - A 7x7 grid shaped as a hyperbolic paraboloid (saddle surface). - The surface rises along the N-S axis and drops along the E-W - axis, creating two high zones (top and bottom) and two low zones - (left and right). Water should flow preferentially into the low - zones. + +def test_saddle_bifurcates_flow(tmp_path): + """A saddle must send more water into its two low quadrants than its high ones. + + The surface rises away from the center along the north-south axis and + falls along the east-west axis, so the east and west quadrants are low + and the north and south quadrants are high. """ - depth = saddle_depth - - # Low quadrants: where (col-4)^2 > (row-4)^2, i.e., columns - # far from center. These are the left and right sides. - # High quadrants: rows far from center (top and bottom). - low_mask = np.zeros_like(depth, dtype=bool) - high_mask = np.zeros_like(depth, dtype=bool) - for r in range(7): - for c in range(7): - row_dist = abs(r - 3) - col_dist = abs(c - 3) - if col_dist > row_dist: - low_mask[r, c] = True - elif row_dist > col_dist: - high_mask[r, c] = True + depth = landform_depth(tmp_path / "saddle", SADDLE) + + # A cell is in a low quadrant when it is farther from the center column + # than from the center row, and in a high quadrant in the opposite case. + rows, cols = np.indices(depth.shape) + row_dist = np.abs(rows - 3) + col_dist = np.abs(cols - 3) + low_mask = col_dist > row_dist + high_mask = row_dist > col_dist low_sum = float(np.sum(depth[low_mask])) high_sum = float(np.sum(depth[high_mask])) @@ -508,8 +344,8 @@ def test_saddle_bifurcates_flow(saddle_depth): f"high quadrant depth ({high_sum:.6f})" ) - # Saddle point (center) should have more depth than the high - # quadrant mean because flow converges through the saddle. + # Flow converges through the saddle point on its way to the low + # quadrants, so the saddle point is wetter than the high quadrants. high_mean = high_sum / np.sum(high_mask) saddle_point_depth = float(depth[3, 3]) assert saddle_point_depth > high_mean, ( @@ -517,169 +353,101 @@ def test_saddle_bifurcates_flow(saddle_depth): f"high quadrant mean ({high_mean:.6f})" ) - # Full grid regression check. - expected = np.array( + assert_depth_pinned( + depth, [ - [0.000264, 0.000282, 0.000155, 0.000203, 0.000244, 0.000155, 0.000264], - [0.000406, 0.000299, 0.000282, 0.000392, 0.000224, 0.000509, 0.000485], - [0.000721, 0.000509, 0.000460, 0.000812, 0.000332, 0.000556, 0.000847], - [0.001300, 0.000830, 0.000662, 0.003083, 0.001075, 0.000946, 0.001388], - [0.000777, 0.000545, 0.000299, 0.000777, 0.000473, 0.000611, 0.000839], - [0.000434, 0.000348, 0.000299, 0.000406, 0.000282, 0.000434, 0.000447], - [0.000299, 0.000203, 0.000264, 0.000203, 0.000244, 0.000282, 0.000244], - ] + [0.26, 0.28, 0.15, 0.20, 0.24, 0.15, 0.26], + [0.41, 0.30, 0.28, 0.39, 0.22, 0.51, 0.49], + [0.72, 0.51, 0.46, 0.81, 0.33, 0.56, 0.85], + [1.30, 0.83, 0.66, 3.08, 1.07, 0.95, 1.39], + [0.78, 0.54, 0.30, 0.78, 0.47, 0.61, 0.84], + [0.43, 0.35, 0.30, 0.41, 0.28, 0.43, 0.45], + [0.30, 0.20, 0.26, 0.20, 0.24, 0.28, 0.24], + ], ) - np.testing.assert_allclose(depth, expected, atol=0.001) - -def test_saddle_rotation_invariance(tmp_path): - """Rotating the saddle 90 degrees must swap peak/valley axes. - The original saddle has valleys along columns (left-right). - A 90-degree rotation swaps row and column terms, putting valleys - along rows (top-bottom). Both orientations are simulated here at - ROTATION_NWALKERS (see ROTATION_NWALKERS for the rationale). +def test_hollow_concentrates_more_than_spur(tmp_path): + """A hollow must concentrate more water on its axis than a spur. + + Both surfaces fall southward by 0.5 per row and have the same parabolic + cross-section, concave for the hollow and convex for the spur. On the + center column the slope is the same for both, so the depth difference + there comes from convergence alone. The spur's crest is not its driest + column (the columns beside it are), so the test compares the two + landforms rather than columns within the spur. """ - # Original saddle (7x7); hyperbolic paraboloid with E-W low axes. - project_a = tmp_path / "a" - gs.create_project(project_a) - with gs.setup.init(project_a, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=7, s=0, n=7, res=1) - tools.r_mapcalc( - expression=( - "elevation = (row() - 4)*(row() - 4) - (col() - 4)*(col() - 4) + 10" - ) - ) - depth_a = run_sim(session, nwalkers=ROTATION_NWALKERS) - - project_b = tmp_path / "b" - gs.create_project(project_b) - with gs.setup.init(project_b, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=7, s=0, n=7, res=1) - tools.r_mapcalc( - expression=( - "elevation = (col() - 4)*(col() - 4) - (row() - 4)*(row() - 4) + 10" - ) - ) - depth_b = run_sim(session, nwalkers=ROTATION_NWALKERS) - - # Both are square so total depth should be comparable; TOTAL_REL is - # shared across the rotation tests (see its definition). The saddle - # total spread was <=0.4% at 10000 walkers across 10 seeds. - total_a = float(np.sum(depth_a)) - total_b = float(np.sum(depth_b)) - assert total_a == pytest.approx(total_b, rel=TOTAL_REL), ( - f"Total depth original ({total_a:.6f}) vs rotated ({total_b:.6f})" + depth_hollow = landform_depth(tmp_path / "hollow", HOLLOW) + depth_spur = landform_depth(tmp_path / "spur", SPUR) + + # The factor 2 is a regression threshold: across 13 seeds at the default + # walker count the measured ratio was never below 3.6. + axis_hollow = float(np.sum(depth_hollow[:, 3])) + axis_spur = float(np.sum(depth_spur[:, 3])) + assert axis_hollow > 2 * axis_spur, ( + f"Hollow axis depth ({axis_hollow:.6f}) should exceed twice the " + f"spur axis depth ({axis_spur:.6f})" ) - # After rotation, the outer ring (excluding the noisy center row/col) - # should have comparable depth distribution. Compare edge sums - # excluding the center row/col (index 3) where the saddle singularity - # amplifies Monte Carlo noise. This aggregates whole row/column blocks, - # so it is far smoother than the per-column profiles of the other - # rotation tests (worst-case spread <=0.3% at 10000 walkers across 10 - # seeds); it therefore uses the tighter SADDLE_EDGE_REL. - edge_cols_a = float(np.sum(depth_a[:, :3]) + np.sum(depth_a[:, 4:])) - edge_rows_b = float(np.sum(depth_b[:3, :]) + np.sum(depth_b[4:, :])) - assert edge_cols_a == pytest.approx(edge_rows_b, rel=SADDLE_EDGE_REL), ( - f"Edge depth original ({edge_cols_a:.6f}) vs rotated ({edge_rows_b:.6f})" + # The hollow's axis is its wettest column. + column_sums = np.sum(depth_hollow, axis=0) + assert int(np.argmax(column_sums)) == 3, ( + f"Hollow axis should be the wettest column, column sums: " + f"{[f'{c:.6f}' for c in column_sums]}" ) - -def test_hollow_concentrates_more_than_spur(tmp_path): - """A hollow (convergent) must concentrate more water than a spur (divergent). - - Both have the same grid size and total rainfall. The hollow's concave - shape funnels water together while the spur's convex shape spreads - it apart. - """ - # Hollow: convergent flow, cross-section narrows downstream. - project_hollow = tmp_path / "hollow" - gs.create_project(project_hollow) - with gs.setup.init(project_hollow, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=7, s=0, n=10, res=1) - tools.r_mapcalc( - expression=( - "elevation = (nrows() - row()) * 0.5" - " + (col() - 4)*(col() - 4) * (1.0 - 0.08 * row())" - ) - ) - depth_hollow = run_sim(session) - - # Spur: divergent flow, cross-section widens downstream. - project_spur = tmp_path / "spur" - gs.create_project(project_spur) - with gs.setup.init(project_spur, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=7, s=0, n=10, res=1) - tools.r_mapcalc( - expression=( - "elevation = (nrows() - row()) * 0.5" - " + (col() - 4)*(col() - 4) * (0.2 + 0.08 * row())" - ) - ) - depth_spur = run_sim(session) - - # Peak depth in the hollow should exceed peak depth in the spur - # because convergent flow concentrates water. + # The maximum depth, at the outlet end of the axis, follows the same + # order with the same threshold (measured ratio never below 3.7). max_hollow = float(np.max(depth_hollow)) max_spur = float(np.max(depth_spur)) - assert max_hollow > max_spur, ( + assert max_hollow > 2 * max_spur, ( f"Hollow peak depth ({max_hollow:.6f}) should exceed " f"spur peak depth ({max_spur:.6f})" ) - # Full grid regression check for hollow. - expected_hollow = np.array( + assert_depth_pinned( + depth_hollow, [ - [0.000201, 0.000365, 0.000545, 0.001885, 0.000645, 0.000365, 0.000201], - [0.000291, 0.000527, 0.000871, 0.001734, 0.000612, 0.000317, 0.000291], - [0.000201, 0.000342, 0.000660, 0.002747, 0.000791, 0.000508, 0.000342], - [0.000388, 0.000365, 0.000983, 0.003093, 0.001007, 0.000409, 0.000291], - [0.000291, 0.000490, 0.000845, 0.003805, 0.001041, 0.000527, 0.000317], - [0.000164, 0.000490, 0.001064, 0.004477, 0.001109, 0.000490, 0.000317], - [0.000365, 0.000451, 0.001226, 0.004669, 0.001266, 0.000430, 0.000342], - [0.000342, 0.000562, 0.001184, 0.004930, 0.001626, 0.000365, 0.000263], - [0.000291, 0.000645, 0.001467, 0.005532, 0.001402, 0.000579, 0.000579], - [0.000612, 0.000764, 0.001651, 0.006953, 0.002035, 0.000858, 0.000342], - ] - ) - np.testing.assert_allclose(depth_hollow, expected_hollow, atol=0.002) - - # Full grid regression check for spur. - expected_spur = np.array( + [0.29, 0.57, 0.64, 1.02, 0.85, 0.57, 0.29], + [0.43, 0.71, 0.96, 1.46, 0.91, 0.37, 0.37], + [0.29, 0.54, 1.21, 2.06, 1.08, 0.77, 0.49], + [0.52, 0.62, 1.30, 2.79, 1.52, 0.69, 0.57], + [0.46, 0.71, 1.36, 3.21, 1.65, 0.81, 0.37], + [0.25, 0.69, 1.78, 3.92, 1.69, 0.64, 0.40], + [0.54, 0.62, 1.51, 4.50, 1.70, 0.54, 0.57], + [0.25, 0.87, 1.60, 4.90, 1.74, 0.59, 0.37], + [0.37, 0.69, 1.97, 5.54, 1.99, 0.83, 0.62], + [0.57, 0.87, 2.14, 6.92, 2.76, 0.69, 0.33], + ], + ) + + assert_depth_pinned( + depth_spur, [ - [0.000314, 0.000670, 0.000927, 0.001595, 0.000700, 0.000504, 0.000261], - [0.000384, 0.000447, 0.001089, 0.001892, 0.000825, 0.000574, 0.000427], - [0.000339, 0.000362, 0.000812, 0.002922, 0.001174, 0.000574, 0.000427], - [0.000314, 0.000591, 0.001100, 0.003638, 0.000998, 0.000522, 0.000314], - [0.000288, 0.000406, 0.000963, 0.004235, 0.001195, 0.000447, 0.000231], - [0.000163, 0.000467, 0.001021, 0.004930, 0.001185, 0.000486, 0.000314], - [0.000339, 0.000447, 0.001010, 0.005427, 0.000914, 0.000362, 0.000261], - [0.000199, 0.000339, 0.000998, 0.005920, 0.001089, 0.000362, 0.000231], - [0.000288, 0.000540, 0.001121, 0.006517, 0.001185, 0.000540, 0.000427], - [0.000288, 0.000384, 0.001215, 0.006864, 0.001143, 0.000261, 0.000231], - ] - ) - np.testing.assert_allclose(depth_spur, expected_spur, atol=0.002) - - -def test_terrace_accumulates_on_steps(terrace_ew_depth): - """Terraced slopes must accumulate water on flat steps, not steep drops. - - A 10x12 grid with three flat steps separated by steep drops. - Downstream steps should accumulate more water. - - Elevation (west to east): - 9 9 9 9 7 6 6 6 4 3 3 3 - Pattern: step - drop - step - drop - step + [0.75, 0.43, 0.59, 0.43, 0.43, 0.46, 0.71], + [0.73, 0.69, 0.52, 0.77, 0.49, 0.52, 0.54], + [1.10, 0.71, 0.49, 0.83, 0.83, 0.62, 0.81], + [0.96, 0.77, 0.46, 0.69, 0.73, 0.96, 0.98], + [1.02, 0.49, 0.59, 0.75, 0.54, 0.91, 1.26], + [1.03, 1.08, 0.57, 0.98, 0.62, 0.83, 1.32], + [1.20, 0.85, 0.67, 1.10, 0.40, 0.77, 1.13], + [1.20, 0.59, 0.57, 0.83, 0.79, 0.83, 0.96], + [1.30, 0.75, 0.57, 0.81, 0.93, 0.81, 1.15], + [1.24, 0.79, 0.59, 1.15, 0.69, 1.30, 1.61], + ], + ) + + +def test_terrace_accumulates_on_steps(tmp_path): + """Terraced slopes must hold water on the flat steps, not on the steep drops. + + Three flat steps, separated by one-column drops, descend from west to + east. Each step holds more than the drop above it, and each step holds + more than the step above it. """ - depth = terrace_ew_depth + depth = landform_depth(tmp_path / "terrace", TERRACE) - # Column indices (0-based) for each feature. + # Column indices of each feature in TERRACE. step1_cols = slice(0, 4) drop1_col = 4 step2_cols = slice(5, 8) @@ -692,15 +460,12 @@ def test_terrace_accumulates_on_steps(terrace_ew_depth): drop2_mean = float(np.mean(depth[:, drop2_col])) step3_mean = float(np.mean(depth[:, step3_cols])) - # Flat steps should have more depth than steep drops. assert step2_mean > drop1_mean, ( f"Step 2 mean ({step2_mean:.6f}) should exceed drop 1 mean ({drop1_mean:.6f})" ) assert step3_mean > drop2_mean, ( f"Step 3 mean ({step3_mean:.6f}) should exceed drop 2 mean ({drop2_mean:.6f})" ) - - # Downstream steps should accumulate more water. assert step2_mean > step1_mean, ( f"Step 2 ({step2_mean:.6f}) should exceed step 1 ({step1_mean:.6f})" ) @@ -708,205 +473,73 @@ def test_terrace_accumulates_on_steps(terrace_ew_depth): f"Step 3 ({step3_mean:.6f}) should exceed step 2 ({step2_mean:.6f})" ) - # Full grid regression check. - expected = np.array( + assert_depth_pinned( + depth, [ - [ - 0.000827, - 0.000923, - 0.002275, - 0.000315, - 0.000477, - 0.001096, - 0.004063, - 0.000776, - 0.001012, - 0.001928, - 0.008246, - 0.004348, - ], - [ - 0.001783, - 0.002471, - 0.002726, - 0.000968, - 0.001055, - 0.001956, - 0.006184, - 0.001566, - 0.001327, - 0.003007, - 0.012262, - 0.007194, - ], - [ - 0.001984, - 0.002965, - 0.003505, - 0.000776, - 0.000668, - 0.001433, - 0.004731, - 0.001433, - 0.001327, - 0.003371, - 0.013642, - 0.008678, - ], - [ - 0.003561, - 0.005511, - 0.003312, - 0.000827, - 0.000876, - 0.001783, - 0.006326, - 0.001363, - 0.001433, - 0.003959, - 0.015099, - 0.009384, - ], - [ - 0.003818, - 0.005705, - 0.003636, - 0.000968, - 0.000968, - 0.002447, - 0.007334, - 0.001928, - 0.001433, - 0.003959, - 0.017127, - 0.010672, - ], - [ - 0.004348, - 0.006223, - 0.003580, - 0.000723, - 0.000827, - 0.002172, - 0.005732, - 0.001215, - 0.001398, - 0.004029, - 0.018605, - 0.011421, - ], - [ - 0.003764, - 0.005412, - 0.003580, - 0.000827, - 0.000668, - 0.001723, - 0.005005, - 0.001327, - 0.001215, - 0.003505, - 0.016332, - 0.010490, - ], - [ - 0.003524, - 0.003636, - 0.003172, - 0.000668, - 0.000723, - 0.001783, - 0.005988, - 0.001096, - 0.001055, - 0.002965, - 0.012781, - 0.009848, - ], - [ - 0.003351, - 0.003371, - 0.002770, - 0.000546, - 0.000668, - 0.001984, - 0.005483, - 0.001467, - 0.001501, - 0.002814, - 0.010435, - 0.008395, - ], - [ - 0.002495, - 0.001501, - 0.002275, - 0.000546, - 0.000477, - 0.001215, - 0.003543, - 0.001177, - 0.001137, - 0.002172, - 0.007910, - 0.004131, - ], - ] - ) - np.testing.assert_allclose(depth, expected, atol=0.003) - - -def test_terrace_rotation_invariance(tmp_path): - """E-W terraces and N-S terraces must produce rotationally equivalent depth. - - Both orientations are simulated here at ROTATION_NWALKERS so the - per-column profile comparison is meaningful (see ROTATION_NWALKERS). + [0.83, 0.92, 2.27, 0.31, 0.48, 1.10, 4.06, 0.78, 1.01, 1.93, 8.25, 4.35], + [1.78, 2.47, 2.73, 0.97, 1.05, 1.96, 6.18, 1.57, 1.33, 3.01, 12.26, 7.19], + [1.98, 2.97, 3.50, 0.78, 0.67, 1.43, 4.73, 1.43, 1.33, 3.37, 13.64, 8.68], + [3.56, 5.51, 3.31, 0.83, 0.88, 1.78, 6.33, 1.36, 1.43, 3.96, 15.10, 9.38], + [3.82, 5.70, 3.64, 0.97, 0.97, 2.45, 7.33, 1.93, 1.43, 3.96, 17.13, 10.67], + [4.35, 6.22, 3.58, 0.72, 0.83, 2.17, 5.73, 1.22, 1.40, 4.03, 18.60, 11.42], + [3.76, 5.41, 3.58, 0.83, 0.67, 1.72, 5.00, 1.33, 1.22, 3.50, 16.33, 10.49], + [3.52, 3.64, 3.17, 0.67, 0.72, 1.78, 5.99, 1.10, 1.05, 2.97, 12.78, 9.85], + [3.35, 3.37, 2.77, 0.55, 0.67, 1.98, 5.48, 1.47, 1.50, 2.81, 10.44, 8.40], + [2.50, 1.50, 2.27, 0.55, 0.48, 1.22, 3.54, 1.18, 1.14, 2.17, 7.91, 4.13], + ], + ) + + +@pytest.mark.parametrize( + "elevation", [RIDGE, VALLEY, TERRACE], ids=["ridge", "valley", "terrace"] +) +def test_rotation_invariance(tmp_path, elevation): + """A landform and its 90-degree rotation must produce equivalent depth. + + The rotated surface is simulated on its own grid and its result rotated + back, so the two depth grids share one orientation. Total depth and the + per-column sums must then agree within walker noise. """ - # E-W terraces: 10 rows x 12 cols, three flat steps and drops. - project_ew = tmp_path / "ew" - gs.create_project(project_ew) - with gs.setup.init(project_ew, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=12, s=0, n=10, res=1) - tools.r_mapcalc( - expression=( - "elevation = if(col() <= 4, 9," - " if(col() == 5, 7," - " if(col() <= 8, 6," - " if(col() == 9, 4, 3))))" - ) - ) - depth_ew = run_sim(session, nwalkers=ROTATION_NWALKERS) + depth = landform_depth(tmp_path / "a", elevation, nwalkers=HIGH_NWALKERS) + depth_rotated = landform_depth( + tmp_path / "b", np.rot90(elevation, k=-1), nwalkers=HIGH_NWALKERS + ) + depth_back = np.rot90(depth_rotated) - # N-S terraces: 12 rows x 10 cols. - project_ns = tmp_path / "ns" - gs.create_project(project_ns) - with gs.setup.init(project_ns, env=os.environ.copy()) as session: - tools = Tools(session=session) - tools.g_region(w=0, e=10, s=0, n=12, res=1) - tools.r_mapcalc( - expression=( - "elevation = if(row() <= 4, 9," - " if(row() == 5, 7," - " if(row() <= 8, 6," - " if(row() == 9, 4, 3))))" - ) - ) - depth_ns = run_sim(session, nwalkers=ROTATION_NWALKERS) - - # Total depth should be comparable; TOTAL_REL is shared across the - # rotation tests (see its definition). The terrace total spread was - # <=1.3% at 10000 walkers across 10 seeds. - total_ew = float(np.sum(depth_ew)) - total_ns = float(np.sum(depth_ns)) - assert total_ew == pytest.approx(total_ns, rel=TOTAL_REL), ( - f"Total depth E-W ({total_ew:.6f}) vs N-S ({total_ns:.6f})" - ) - - # Per-column cross-terrace profiles should match within PROFILE_RTOL; - # the terrace per-column spread was ~4.7% at 10000 walkers across 10 - # seeds, which PROFILE_RTOL covers ~2x. - profile_ew = np.sum(depth_ew, axis=0) - depth_ns_rotated = np.rot90(depth_ns) - profile_ns = np.sum(depth_ns_rotated, axis=0) - np.testing.assert_allclose(profile_ew, profile_ns, rtol=PROFILE_RTOL) + total = float(np.sum(depth)) + total_back = float(np.sum(depth_back)) + assert total == pytest.approx(total_back, rel=TOTAL_DEPTH_RTOL), ( + f"Total depth ({total:.6f}) vs rotated ({total_back:.6f})" + ) + np.testing.assert_allclose( + np.sum(depth, axis=0), np.sum(depth_back, axis=0), rtol=COLUMN_PROFILE_RTOL + ) + + +def test_saddle_rotation_invariance(tmp_path): + """A saddle and its 90-degree rotation must produce equivalent depth. + + The saddle point amplifies walker noise in the center row and column, so + instead of per-column sums the test compares the blocks on either side + of the center column, which are far smoother. + """ + depth = landform_depth(tmp_path / "a", SADDLE, nwalkers=HIGH_NWALKERS) + depth_rotated = landform_depth( + tmp_path / "b", np.rot90(SADDLE, k=-1), nwalkers=HIGH_NWALKERS + ) + depth_back = np.rot90(depth_rotated) + + total = float(np.sum(depth)) + total_back = float(np.sum(depth_back)) + assert total == pytest.approx(total_back, rel=TOTAL_DEPTH_RTOL), ( + f"Total depth ({total:.6f}) vs rotated ({total_back:.6f})" + ) + + # Relative tolerance for the side blocks; the largest difference seen + # across seeds was 0.3%. + side_rtol = 0.02 + sides = float(np.sum(depth[:, :3]) + np.sum(depth[:, 4:])) + sides_back = float(np.sum(depth_back[:, :3]) + np.sum(depth_back[:, 4:])) + assert sides == pytest.approx(sides_back, rel=side_rtol), ( + f"Side block depth ({sides:.6f}) vs rotated ({sides_back:.6f})" + ) From 26dc4fd9def649c917a5ef5f39409045491b7d17 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 25 Sep 2026 14:39:06 -0400 Subject: [PATCH 17/29] r.sim.water: Align test text with what the tests assert Rename the discharge and nprocs tests after their assertions, say duration where iterations no longer apply, and drop stale comments on the time-series map count and an old walker count. State the measured reason the saddle point is wet: zero computed gradient plus rain converging from the center column. Note that the rotation tests pin nothing. --- .../tests/r_sim_water_landforms_test.py | 19 +++++++++++-------- .../r.sim.water/tests/r_sim_water_test.py | 17 +++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py index f864b06075e..5615af8320f 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py @@ -1,11 +1,12 @@ """Landform tests for r.sim.water (SIMWE). Each test simulates a small synthetic elevation surface, given as an array -so that the shape is visible in the code, and checks two things: what the -model must do on that landform for any random seed (physical assertions, -with tolerances from seed sweeps), and the result for SEED (a pinned array -that fails whenever the simulated result changes, including a change in the -random number generator). +so that the shape is visible in the code, and asserts what the model must +do on that landform for any random seed (physical assertions, with +tolerances from seed sweeps). The shape tests also pin the result for SEED +(an array that fails whenever the simulated result changes, including a +change in the random number generator); the rotation tests compare two +simulations and pin nothing. """ import os @@ -344,8 +345,10 @@ def test_saddle_bifurcates_flow(tmp_path): f"high quadrant depth ({high_sum:.6f})" ) - # Flow converges through the saddle point on its way to the low - # quadrants, so the saddle point is wetter than the high quadrants. + # The saddle point has zero computed gradient, so water reaching it + # leaves only by diffusion, and about four fifths of that water is rain + # from the center column, which slopes toward it from north and south. + # It is therefore wetter than the high quadrants. high_mean = high_sum / np.sum(high_mask) saddle_point_depth = float(depth[3, 3]) assert saddle_point_depth > high_mean, ( @@ -401,7 +404,7 @@ def test_hollow_concentrates_more_than_spur(tmp_path): max_hollow = float(np.max(depth_hollow)) max_spur = float(np.max(depth_spur)) assert max_hollow > 2 * max_spur, ( - f"Hollow peak depth ({max_hollow:.6f}) should exceed " + f"Hollow peak depth ({max_hollow:.6f}) should exceed twice the " f"spur peak depth ({max_spur:.6f})" ) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index d479d928073..87d37169cd3 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -42,8 +42,8 @@ def run_sim(session, *, random_seed=SEED, **kwargs): "duration": DURATION, } defaults.update(kwargs) - # On the command line, an unwanted parameter is simply omitted, so we - # simulate that. + # A value of None removes a default, for example rain_value when a rain + # raster is given. defaults = {k: v for k, v in defaults.items() if v is not None} tools = Tools(session=session) return tools.r_sim_water( @@ -120,8 +120,7 @@ def diffusion_low_hmax_depth(diffusion_project): across 20 seeds the relative margins held at ~0.52 (hmax) and ~0.46 (halpha) with zero failures at every walker count from 2000 to 400000. nwalkers=40000 keeps an order-of-magnitude safety factor above where - the margins are noise-driven while running far faster than the original - 400000. + the margins are noise-driven. """ with TemporaryMapsetSession(env=diffusion_project.env) as session: depth = run_sim( @@ -257,7 +256,7 @@ def setup_and_run(sub_path, ele_expr, dx_value): assert sum_gentle / sum_steep == pytest.approx(2 ** (3 / 10), rel=0.3) -def test_discharge_positive_with_rain(east_slope_session): +def test_discharge_within_mass_balance_bracket(east_slope_session): """Rainfall on a slope must produce discharge consistent with mass balance. Discharge is output in m3/s. By steady-state continuity, the total @@ -411,7 +410,7 @@ def test_mintimestep(east_slope_session): def test_longer_simulation_larger_domain(long_slope_session): - """More iterations must increase total water depth on a larger domain. + """A longer duration must increase total water depth on a larger domain. On a larger domain with slower drainage, longer simulations accumulate more water. Uses a 200-cell domain at 10 m resolution so that walkers @@ -435,7 +434,7 @@ def test_longer_simulation_larger_domain(long_slope_session): def test_duration_affects_time_series_progression(long_slope_session): - """More iterations must create more time-series output maps. + """A longer duration must create more time-series output maps. With output_step=5, duration=10 produces maps at t=5,10 while duration=20 produces maps at t=5,10,15 and, if walkers survive to the @@ -447,7 +446,6 @@ def test_duration_affects_time_series_progression(long_slope_session): """ tools = Tools(session=long_slope_session) - # duration=10 with output_step=5 produces maps at t=5,10 tools.r_sim_water( elevation="elevation", dx="dx", @@ -463,7 +461,6 @@ def test_duration_affects_time_series_progression(long_slope_session): flags="t", ) - # duration=20 with output_step=5 produces maps at t=5,10,15 tools.r_sim_water( elevation="elevation", dx="dx", @@ -835,7 +832,7 @@ def test_walkers_output_time_series(long_slope_session): assert int(info_10["points"]) > 0, "Expected walker points at t=10" -def test_nprocs_gives_same_result(east_slope_session): +def test_nprocs_gives_result_within_noise(east_slope_session): """Multiple threads produce a result close to a single thread. Threads share the random number generator state, so multi-threaded From 2fa778ee665a9f61ae3b363eaee1200c465e87bf Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 25 Sep 2026 14:43:02 -0400 Subject: [PATCH 18/29] r.sim.water: Drop redundant np.asarray wrappers in tests --- .../r.sim.water/tests/r_sim_water_test.py | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 87d37169cd3..6e6f75ce4f9 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -242,7 +242,7 @@ def setup_and_run(sub_path, ele_expr, dx_value): tools.r_mapcalc(expression=f"elevation = {ele_expr}") tools.r_mapcalc(expression=f"dx = {dx_value}") tools.r_mapcalc(expression="dy = 0.0") - return np.asarray(run_sim(session)) + return run_sim(session) # gentle: drops 1 m per cell (elevation 5,4,3,2,1), dx = 1 sum_gentle = float(np.sum(setup_and_run(tmp_path / "gentle", "6 - col()", 1))) @@ -279,17 +279,15 @@ def test_discharge_within_mass_balance_bracket(east_slope_session): analytic_sum = rain_rate * sum(x + 0.5 for x in range(5)) tools = Tools(session=east_slope_session) - discharge = np.asarray( - tools.r_sim_water( - elevation="elevation", - dx="dx", - dy="dy", - discharge=np.array, - rain_value=RAIN, - duration=DURATION, - random_seed=SEED, - nprocs=NPROCS, - ) + discharge = tools.r_sim_water( + elevation="elevation", + dx="dx", + dy="dy", + discharge=np.array, + rain_value=RAIN, + duration=DURATION, + random_seed=SEED, + nprocs=NPROCS, ) total = float(np.sum(discharge)) assert analytic_sum / 3 < total < analytic_sum, ( @@ -355,8 +353,8 @@ def test_random_seed_flag(east_slope_session): "nprocs": NPROCS, "flags": "s", } - depth_a = np.asarray(tools.r_sim_water(**common)) - depth_b = np.asarray(tools.r_sim_water(**common)) + depth_a = tools.r_sim_water(**common) + depth_b = tools.r_sim_water(**common) assert np.sum(depth_a) > 0, "Expected positive depth with -s flag (run a)" assert np.sum(depth_b) > 0, "Expected positive depth with -s flag (run b)" assert not np.array_equal(depth_a, depth_b), ( @@ -638,16 +636,14 @@ def test_dx_dy_optional(tmp_path): tools = Tools(session=session) tools.g_region(w=0, e=5, s=0, n=1, res=1) tools.r_mapcalc(expression="elevation = 6 - col()") - depth = np.asarray( - tools.r_sim_water( - elevation="elevation", - depth=np.array, - rain_value=RAIN, - man_value=0.1, - duration=DURATION, - random_seed=SEED, - nprocs=NPROCS, - ) + depth = tools.r_sim_water( + elevation="elevation", + depth=np.array, + rain_value=RAIN, + man_value=0.1, + duration=DURATION, + random_seed=SEED, + nprocs=NPROCS, ) assert np.sum(depth) > 0, "Expected positive depth when dx/dy are omitted" From 4ca6289d1f0be0fc5111a5c65fb0e7e1e4ff3722 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 25 Sep 2026 14:43:18 -0400 Subject: [PATCH 19/29] r.sim.water: Move slope fixtures from conftest into the test file --- raster/r.sim/r.sim.water/tests/conftest.py | 50 ------------------- .../r.sim.water/tests/r_sim_water_test.py | 43 ++++++++++++++++ 2 files changed, 43 insertions(+), 50 deletions(-) delete mode 100644 raster/r.sim/r.sim.water/tests/conftest.py diff --git a/raster/r.sim/r.sim.water/tests/conftest.py b/raster/r.sim/r.sim.water/tests/conftest.py deleted file mode 100644 index d539c7da40a..00000000000 --- a/raster/r.sim/r.sim.water/tests/conftest.py +++ /dev/null @@ -1,50 +0,0 @@ -import os - -import pytest - -import grass.script as gs -from grass.experimental.mapset import TemporaryMapsetSession -from grass.tools import Tools - - -@pytest.fixture(scope="module") -def simwe_session(tmp_path_factory): - """Module-scoped GRASS session for SIMWE tests with shared base terrain. - - Creates a persistent project and initializes the GRASS runtime environment - in the PERMANENT mapset, where the base terrain (elevation, dx, dy) is set up. - This session persists for the entire test module. All function-scoped fixtures - create temporary mapsets within this session and automatically see the base - terrain from PERMANENT. - """ - tmp_path = tmp_path_factory.mktemp("simwe_module") - project = tmp_path / "simwe" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - tools = Tools(session=session) - # Set up base 1x5 eastward slope terrain in PERMANENT mapset. - # Use -s flag to save region as default (inherited by temporary mapsets). - tools.g_region(w=0, e=5, s=0, n=1, res=1, flags="s") - tools.r_mapcalc(expression="elevation = 6 - col()") - tools.r_mapcalc(expression="dx = 1.0") - tools.r_mapcalc(expression="dy = 0.0") - yield session - - -@pytest.fixture -def east_slope_session(simwe_session): - """Session with a 1-row x 5-column eastward slope, ready for r.sim.water. - - Uses the module-scoped simwe_session and creates a new temporary mapset - for each test. The base terrain (elevation, dx, dy) and computational region - are inherited from the PERMANENT mapset created in the module scope. - - Terrain (GRASS col() is 1-indexed from west): - elevation = 6 - col() -> [5, 4, 3, 2, 1] west to east - dx = 1.0 (unit slope vector pointing east) - dy = 0.0 (no north-south component) - - Water flows east and accumulates toward the eastern boundary. - """ - with TemporaryMapsetSession(env=simwe_session.env) as session: - yield session diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 6e6f75ce4f9..88ddd664a6e 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -56,6 +56,49 @@ def run_sim(session, *, random_seed=SEED, **kwargs): ) +@pytest.fixture(scope="module") +def simwe_session(tmp_path_factory): + """Module-scoped GRASS session for SIMWE tests with shared base terrain. + + Creates a persistent project and initializes the GRASS runtime environment + in the PERMANENT mapset, where the base terrain (elevation, dx, dy) is set up. + This session persists for the entire test module. All function-scoped fixtures + create temporary mapsets within this session and automatically see the base + terrain from PERMANENT. + """ + tmp_path = tmp_path_factory.mktemp("simwe_module") + project = tmp_path / "simwe" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + # Set up base 1x5 eastward slope terrain in PERMANENT mapset. + # Use -s flag to save region as default (inherited by temporary mapsets). + tools.g_region(w=0, e=5, s=0, n=1, res=1, flags="s") + tools.r_mapcalc(expression="elevation = 6 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + yield session + + +@pytest.fixture +def east_slope_session(simwe_session): + """Session with a 1-row x 5-column eastward slope, ready for r.sim.water. + + Uses the module-scoped simwe_session and creates a new temporary mapset + for each test. The base terrain (elevation, dx, dy) and computational region + are inherited from the PERMANENT mapset created in the module scope. + + Terrain (GRASS col() is 1-indexed from west): + elevation = 6 - col() -> [5, 4, 3, 2, 1] west to east + dx = 1.0 (unit slope vector pointing east) + dy = 0.0 (no north-south component) + + Water flows east and accumulates toward the eastern boundary. + """ + with TemporaryMapsetSession(env=simwe_session.env) as session: + yield session + + @pytest.fixture(scope="module") def long_slope_project(tmp_path_factory): """Module-scoped project with a 200-cell, 10 m-resolution eastward slope. From a8878541e59c97d5576502270e503f222fbaa1d5 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 25 Sep 2026 14:47:46 -0400 Subject: [PATCH 20/29] r.sim.water: Base the pit and valley thresholds on measured margins --- .../tests/r_sim_water_landforms_test.py | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py index 5615af8320f..dd841a35b87 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py @@ -25,7 +25,7 @@ RAIN = 100 # mm/hr # With the default walker count (two per cell) the depth of a single cell or -# column varies too much between seeds to compare two simulations. In a +# column varies too much between seeds to compare cells or columns. In a # sweep from 2000 to 20000 walkers the spread of per-column sums leveled off # at 10000. HIGH_NWALKERS = 10000 @@ -137,7 +137,8 @@ def test_pit_collects_water(tmp_path): f"({np.max(depth):.4f})" ) - # The 30% threshold holds across resolutions from 0.1 m to 30 m. + # At unit resolution, across 13 seeds the center held at least 53% of + # the total depth. assert center_depth > 0.3 * total_depth, ( f"Center should hold >30% of total water: " f"center={center_depth:.4f}, total={total_depth:.4f}" @@ -279,8 +280,9 @@ def test_valley_concentrates_flow_downstream(tmp_path): The floor holds far more per cell than the hillslopes, depth on the floor increases downstream, and the two sides mirror each other. """ - depth = landform_depth(tmp_path / "valley", VALLEY) + depth = landform_depth(tmp_path / "valley", VALLEY, nwalkers=HIGH_NWALKERS) + # Across 13 seeds the ratio was never below 5.6. floor_mean = float(np.mean(depth[:, 3])) hillslope_cols = np.concatenate([depth[:, :3], depth[:, 4:]], axis=1) hillslope_mean = float(np.mean(hillslope_cols)) @@ -297,26 +299,23 @@ def test_valley_concentrates_flow_downstream(tmp_path): f"upper valley floor ({upper_floor:.6f})" ) - for offset in range(1, 4): - left = float(np.sum(depth[:, 3 - offset])) - right = float(np.sum(depth[:, 3 + offset])) - assert left == pytest.approx(right, rel=0.4), ( - f"Offset {offset}: left ({left:.6f}) vs right ({right:.6f})" - ) + # The per-column profile mirrors across the floor. + profile = np.sum(depth, axis=0) + np.testing.assert_allclose(profile[:3], profile[::-1][:3], rtol=COLUMN_PROFILE_RTOL) assert_depth_pinned( depth, [ - [0.28, 0.40, 0.58, 1.99, 0.68, 0.70, 0.28], - [0.56, 0.83, 0.95, 1.53, 0.72, 0.19, 0.46], - [0.28, 0.53, 0.92, 2.31, 0.89, 0.60, 0.64], - [0.53, 0.43, 1.12, 3.02, 1.01, 0.68, 0.48], - [0.40, 0.64, 0.80, 3.76, 1.27, 0.72, 0.43], - [0.28, 0.62, 1.06, 4.49, 1.32, 0.66, 0.40], - [0.51, 0.60, 1.19, 4.85, 1.32, 0.56, 0.48], - [0.43, 0.64, 1.24, 5.43, 1.55, 0.51, 0.31], - [0.37, 0.72, 1.20, 5.94, 1.36, 0.64, 0.66], - [0.62, 0.85, 1.32, 6.59, 1.63, 0.68, 0.43], + [0.34, 0.49, 0.77, 2.17, 0.74, 0.47, 0.36], + [0.44, 0.60, 0.77, 1.55, 0.80, 0.59, 0.39], + [0.43, 0.63, 0.96, 2.42, 0.97, 0.59, 0.43], + [0.41, 0.63, 1.03, 3.19, 1.03, 0.64, 0.41], + [0.41, 0.65, 1.11, 3.88, 1.08, 0.62, 0.42], + [0.43, 0.61, 1.16, 4.46, 1.16, 0.64, 0.41], + [0.45, 0.63, 1.21, 5.02, 1.23, 0.64, 0.41], + [0.45, 0.65, 1.32, 5.50, 1.27, 0.61, 0.42], + [0.44, 0.64, 1.37, 6.04, 1.32, 0.63, 0.47], + [0.44, 0.73, 1.54, 6.61, 1.55, 0.77, 0.50], ], ) From f385efc95141302d66ddb15445e0cb8e96af50d3 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 25 Sep 2026 15:18:00 -0400 Subject: [PATCH 21/29] r.sim.water: Test mintimestep as a floor with a predictable accuracy cost --- .../r.sim.water/tests/r_sim_water_test.py | 90 ++++++++++++++----- 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 88ddd664a6e..1ed2c237438 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -25,6 +25,10 @@ DURATION = 2 # minutes; sufficient for near-steady state on a small domain RAIN = 100 # mm/hr; generous signal-to-noise ratio on a small domain +# Near-zero diffusion makes walkers move only downslope, which isolates the +# effect of the time step in the mintimestep tests. +NO_DIFFUSION = {"diffusion_coeff": 0.001} + def run_sim(session, *, random_seed=SEED, **kwargs): """Run r.sim.water on the session's terrain; return depth as ndarray. @@ -423,32 +427,74 @@ def test_error_output_is_zero(east_slope_session): assert np.all(error == 0), f"Expected all-zero error map:\n{error}" -def test_mintimestep(east_slope_session): - """A larger minimum time step inflates depth by a bounded, measurable amount. - - mintimestep sets a floor on the integration time step: a larger value - speeds up the simulation at the cost of accuracy. On these 1 m cells a - 1 s floor lets walkers overshoot roughly one cell per step, so total depth - increases rather than staying the same. Measured across 12 seeds the ratio - sum_large_step / sum_default stayed in 1.88-2.51 (mean ~2.1, "roughly - doubling"); the large-step sum was effectively seed-independent while the - default sum carried the Monte Carlo variation. The same 1 s floor on 10 m - cells would have a negligible effect. +def test_mintimestep_is_a_floor_and_harmless_within_one_cell_per_step( + east_slope_session, tmp_path +): + """mintimestep must only raise the time step, and a small raise is harmless. - The bound is two-sided: the lower bound (1.3) enforces the documented - upward direction (and subsumes positivity), while the upper bound (3.0) - caps the inflation. Both lie well outside the observed range, so the test - passes for the real ~2x effect but fails if depth were off by an order of - magnitude in either direction (e.g. a vanishing or a 10x result). + The tool computes its own step, 0.025 s on this slope. A 0.01 s floor is + below that, so the result must be identical. A 0.1 s floor is above it + but moves walkers only one cell per step, so depth stays the same within + noise, and so it does with a 1 s floor on 10 m cells. """ - sum_default = float(np.sum(run_sim(east_slope_session))) - sum_large_step = float(np.sum(run_sim(east_slope_session, mintimestep=1.0))) - ratio = sum_large_step / sum_default - assert 1.3 < ratio < 3.0, ( - f"Large mintimestep depth ({sum_large_step:.3e}) relative to default " - f"({sum_default:.3e}) gives ratio {ratio:.3f}, outside expected (1.3, 3.0)" + depth_default = run_sim(east_slope_session) + np.testing.assert_array_equal( + run_sim(east_slope_session, mintimestep=0.01), depth_default + ) + + # Across 13 seeds the two ratios below stayed within 0.96 and 1.05. + sum_default = float(np.sum(run_sim(east_slope_session, **NO_DIFFUSION))) + sum_one_cell = float( + np.sum(run_sim(east_slope_session, mintimestep=0.1, **NO_DIFFUSION)) + ) + assert sum_one_cell / sum_default == pytest.approx(1, rel=0.1), ( + f"A 0.1 s floor changed depth by a factor of {sum_one_cell / sum_default:.3f}" ) + project = tmp_path / "coarse" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + tools.g_region(w=0, e=50, s=0, n=10, res=10) + tools.r_mapcalc(expression="elevation = 6 - col()") + tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dy = 0.0") + coarse_default = float(np.sum(run_sim(session, **NO_DIFFUSION))) + coarse_floor = float(np.sum(run_sim(session, mintimestep=1.0, **NO_DIFFUSION))) + assert coarse_floor / coarse_default == pytest.approx(1, rel=0.1), ( + f"A 1 s floor on 10 m cells changed depth by a factor of " + f"{coarse_floor / coarse_default:.3f}" + ) + + +def test_mintimestep_skipping_cells_inflates_depth_predictably(east_slope_session): + """A floor that makes walkers skip cells inflates depth by a known factor. + + With a 1 s floor a walker moves 10 m per step and leaves the 5 m domain + at once. The scheme still credits half of that first step to the cell the + walker started in, so each cell records half the floor as residence + time, far more than the water really spends there, and depth grows by a + factor that follows from the residence times of the default run. + """ + sum_default = float(np.sum(run_sim(east_slope_session, **NO_DIFFUSION))) + + # At the 10 m/s that dx=1 and n=0.1 give, a walker crosses a 1 m cell in + # 0.1 s. Cell c (0 at the top) collects half a crossing from its own rain + # and a full crossing from each of the c cells above it. Depth per cell + # is that residence time to the power 0.6. + residence_default = [0.05 + 0.1 * cell for cell in range(5)] + depth_sum_default = sum(t**0.6 for t in residence_default) + for floor in (1.0, 2.0): + predicted = 5 * (floor / 2) ** 0.6 / depth_sum_default + sum_floor = float( + np.sum(run_sim(east_slope_session, mintimestep=floor, **NO_DIFFUSION)) + ) + # Across 13 seeds the measured ratio stayed within 5% of the prediction. + assert sum_floor / sum_default == pytest.approx(predicted, rel=0.1), ( + f"A {floor} s floor gave {sum_floor / sum_default:.3f} times the " + f"default depth, expected about {predicted:.2f}" + ) + def test_longer_simulation_larger_domain(long_slope_session): """A longer duration must increase total water depth on a larger domain. From f46f2773b78d6ccf2629299a722b63bd5338a6b7 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Mon, 28 Sep 2026 15:18:54 -0400 Subject: [PATCH 22/29] r.sim.water: Assert depth increases cell by cell downstream --- .../r.sim.water/tests/r_sim_water_test.py | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 1ed2c237438..79b1c4e1887 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -240,14 +240,13 @@ def test_infiltration_reduces_depth(east_slope_session): def test_depth_increases_downstream(tmp_path): - """Water must be deeper in the downslope half of a uniform slope. + """Water must get deeper from cell to cell down a uniform slope. By continuity, discharge q(x) = R * x grows linearly with distance x from the divide. Manning's kinematic wave then gives h(x) proportional to x^(3/5), also increasing downstream. - Uses a 1-row x 6-column domain to split cleanly into an upslope half - (cols 0-2) and a downslope half (cols 3-5). See + Uses a 1-row x 6-column domain. See test_north_slope_depth_increases_downstream for the north-south variant. """ project = tmp_path / "simwe" @@ -260,12 +259,12 @@ def test_depth_increases_downstream(tmp_path): tools.r_mapcalc(expression="dx = 1.0") tools.r_mapcalc(expression="dy = 0.0") - flat = run_sim(session).flatten() # shape (6,) for a 1-row raster - upslope_sum = float(np.sum(flat[:3])) - downslope_sum = float(np.sum(flat[3:])) - assert downslope_sum > upslope_sum, ( - f"Downslope total depth ({downslope_sum:.3e}) should exceed " - f"upslope total depth ({upslope_sum:.3e})" + # With the default walkers a single cell can be drier than the one + # above it; across 13 seeds at 2000 walkers every cell exceeded the + # one above it by at least 4%. + flat = run_sim(session, nwalkers=2000).flatten() + assert np.all(np.diff(flat) > 0), ( + f"Depth should increase cell by cell downstream: {flat}" ) @@ -956,14 +955,13 @@ def test_north_slope_rain_produces_positive_depth(tmp_path): def test_north_slope_depth_increases_downstream(tmp_path): - """Water must be deeper in the downslope (northward) half of a north-south slope. + """Water must get deeper from cell to cell down a north-south slope. By continuity, discharge q(y) = R * y grows linearly with distance y from the divide. Manning's kinematic wave then gives h(y) proportional to y^(3/5), increasing toward the northern boundary. - Uses a 6-row x 1-column domain to split cleanly into a downslope half - (rows 0-2, north) and an upslope half (rows 3-5, south). Mirrors + Uses a 6-row x 1-column domain with the outlet in row 0. Mirrors test_depth_increases_downstream with dy instead of dx. Note: dy = 1.0 because elevation increases southward (with increasing row). @@ -979,12 +977,11 @@ def test_north_slope_depth_increases_downstream(tmp_path): tools.r_mapcalc(expression="dx = 0.0") tools.r_mapcalc(expression="dy = 1.0") - depth = run_sim(session).flatten() # shape (6,) for a 1-column raster - upslope_sum = float(np.sum(depth[3:])) # rows 3-5 (south, high elevation) - downslope_sum = float(np.sum(depth[:3])) # rows 0-2 (north, low elevation) - assert downslope_sum > upslope_sum, ( - f"Downslope total depth ({downslope_sum:.3e}) should exceed " - f"upslope total depth ({upslope_sum:.3e})" + # Across 13 seeds at 2000 walkers every cell exceeded the one above + # it (to the south) by at least 3%. + depth = run_sim(session, nwalkers=2000).flatten() + assert np.all(np.diff(depth) < 0), ( + f"Depth should increase cell by cell toward the north: {depth}" ) From 14b44420b9c15cab056c9aff52dcfdd7ebdb347f Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Mon, 28 Sep 2026 15:18:54 -0400 Subject: [PATCH 23/29] r.sim.water: Run the hollow and spur at 1000 walkers --- .../tests/r_sim_water_landforms_test.py | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py index dd841a35b87..7c56680f6f1 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py @@ -379,11 +379,13 @@ def test_hollow_concentrates_more_than_spur(tmp_path): column (the columns beside it are), so the test compares the two landforms rather than columns within the spur. """ - depth_hollow = landform_depth(tmp_path / "hollow", HOLLOW) - depth_spur = landform_depth(tmp_path / "spur", SPUR) + # 1000 walkers keep the depth pattern of both surfaces readable in the + # result at no measurable cost in runtime. + depth_hollow = landform_depth(tmp_path / "hollow", HOLLOW, nwalkers=1000) + depth_spur = landform_depth(tmp_path / "spur", SPUR, nwalkers=1000) - # The factor 2 is a regression threshold: across 13 seeds at the default - # walker count the measured ratio was never below 3.6. + # The factor 2 is a regression threshold: across 13 seeds the measured + # ratio was never below 3.8. axis_hollow = float(np.sum(depth_hollow[:, 3])) axis_spur = float(np.sum(depth_spur[:, 3])) assert axis_hollow > 2 * axis_spur, ( @@ -399,7 +401,7 @@ def test_hollow_concentrates_more_than_spur(tmp_path): ) # The maximum depth, at the outlet end of the axis, follows the same - # order with the same threshold (measured ratio never below 3.7). + # order with the same threshold (measured ratio never below 4.8). max_hollow = float(np.max(depth_hollow)) max_spur = float(np.max(depth_spur)) assert max_hollow > 2 * max_spur, ( @@ -410,32 +412,32 @@ def test_hollow_concentrates_more_than_spur(tmp_path): assert_depth_pinned( depth_hollow, [ - [0.29, 0.57, 0.64, 1.02, 0.85, 0.57, 0.29], - [0.43, 0.71, 0.96, 1.46, 0.91, 0.37, 0.37], - [0.29, 0.54, 1.21, 2.06, 1.08, 0.77, 0.49], - [0.52, 0.62, 1.30, 2.79, 1.52, 0.69, 0.57], - [0.46, 0.71, 1.36, 3.21, 1.65, 0.81, 0.37], - [0.25, 0.69, 1.78, 3.92, 1.69, 0.64, 0.40], - [0.54, 0.62, 1.51, 4.50, 1.70, 0.54, 0.57], - [0.25, 0.87, 1.60, 4.90, 1.74, 0.59, 0.37], - [0.37, 0.69, 1.97, 5.54, 1.99, 0.83, 0.62], - [0.57, 0.87, 2.14, 6.92, 2.76, 0.69, 0.33], + [0.34, 0.54, 0.79, 0.67, 0.67, 0.52, 0.36], + [0.41, 0.57, 0.96, 1.22, 0.90, 0.64, 0.46], + [0.47, 0.71, 1.25, 2.08, 1.20, 0.73, 0.37], + [0.41, 0.60, 1.46, 2.84, 1.33, 0.57, 0.42], + [0.48, 0.65, 1.46, 3.42, 1.43, 0.71, 0.46], + [0.38, 0.73, 1.56, 4.01, 1.56, 0.76, 0.42], + [0.43, 0.68, 1.77, 4.53, 1.72, 0.74, 0.39], + [0.46, 0.65, 1.79, 4.99, 1.68, 0.65, 0.43], + [0.37, 0.71, 1.87, 5.54, 1.87, 0.80, 0.42], + [0.43, 0.79, 2.18, 6.95, 2.21, 0.77, 0.43], ], ) assert_depth_pinned( depth_spur, [ - [0.75, 0.43, 0.59, 0.43, 0.43, 0.46, 0.71], - [0.73, 0.69, 0.52, 0.77, 0.49, 0.52, 0.54], - [1.10, 0.71, 0.49, 0.83, 0.83, 0.62, 0.81], - [0.96, 0.77, 0.46, 0.69, 0.73, 0.96, 0.98], - [1.02, 0.49, 0.59, 0.75, 0.54, 0.91, 1.26], - [1.03, 1.08, 0.57, 0.98, 0.62, 0.83, 1.32], - [1.20, 0.85, 0.67, 1.10, 0.40, 0.77, 1.13], - [1.20, 0.59, 0.57, 0.83, 0.79, 0.83, 0.96], - [1.30, 0.75, 0.57, 0.81, 0.93, 0.81, 1.15], - [1.24, 0.79, 0.59, 1.15, 0.69, 1.30, 1.61], + [0.73, 0.46, 0.29, 0.45, 0.36, 0.51, 0.55], + [0.74, 0.63, 0.56, 0.64, 0.45, 0.56, 0.80], + [0.97, 0.77, 0.64, 0.78, 0.56, 0.72, 1.09], + [1.08, 0.77, 0.61, 0.89, 0.54, 0.68, 1.00], + [1.13, 0.80, 0.64, 0.92, 0.71, 0.66, 0.98], + [1.08, 0.83, 0.69, 0.96, 0.67, 0.80, 1.11], + [1.19, 0.75, 0.77, 1.07, 0.77, 0.76, 1.16], + [1.14, 0.85, 0.73, 1.01, 0.81, 0.75, 1.09], + [1.20, 0.72, 0.81, 1.05, 0.79, 0.91, 1.26], + [1.40, 1.09, 0.93, 1.24, 0.81, 0.88, 1.29], ], ) From 721a2c4b40568ee5da010eebe25f1e3ed7f4596a Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 29 Sep 2026 10:14:22 -0400 Subject: [PATCH 24/29] r.sim.water: Add SPDX license headers to the tests --- raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py | 2 ++ raster/r.sim/r.sim.water/tests/r_sim_water_test.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py index 7c56680f6f1..57f97cea1ca 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_landforms_test.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: GPL-2.0-or-later + """Landform tests for r.sim.water (SIMWE). Each test simulates a small synthetic elevation surface, given as an array diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 79b1c4e1887..6ddb322c8c6 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: GPL-2.0-or-later + """Minimal analytical tests for r.sim.water (SIMWE). Tests use small, hand-crafted domains where expected output can be reasoned From 6984b86a4fc65c5eca2027bde29adfb6018c6a20 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 29 Sep 2026 10:14:23 -0400 Subject: [PATCH 25/29] r.sim.water: Copy the shared diffusion fixture array while its mapset exists --- raster/r.sim/r.sim.water/tests/r_sim_water_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 6ddb322c8c6..550d089db6c 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -172,8 +172,9 @@ def diffusion_low_hmax_depth(diffusion_project): the margins are noise-driven. """ with TemporaryMapsetSession(env=diffusion_project.env) as session: - depth = run_sim( - session, rain_value=1000, man_value=0.5, hmax=0.001, nwalkers=40000 + # Copy the result while the mapset holding its backing file exists. + depth = np.array( + run_sim(session, rain_value=1000, man_value=0.5, hmax=0.001, nwalkers=40000) ) depth.flags.writeable = False return depth From 9fabf7cb3218ccc5b6d25bb9330f85082b94d229 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 29 Sep 2026 10:14:24 -0400 Subject: [PATCH 26/29] r.sim.water: Check the depth and walker time series in one run --- .../r.sim.water/tests/r_sim_water_test.py | 49 +++++-------------- 1 file changed, 13 insertions(+), 36 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 550d089db6c..864f65733fc 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -767,12 +767,14 @@ def test_flow_control_increases_depth(east_slope_session): ) -def test_time_series_output(long_slope_session): - """The -t flag with output_step must produce intermediate depth maps. +def test_time_series_outputs(long_slope_session): + """The -t flag with output_step must write depth and walker maps per step. - With duration=10 and output_step=5, the tool should create depth - maps at minutes 5 and 10. The later time step should have greater or - equal total depth as water accumulates over time. + With duration=10 and output_step=5, the tool writes depth maps at + minutes 5 and 10 (ts_depth.05 and ts_depth.10) and, with walkers_output, + vector point maps with an underscore-separated suffix (walkers_05 and + walkers_10). Total depth at the later step is at least that of the + earlier one, as water accumulates over time. Uses a 200-cell domain at 10 m resolution with high roughness so that walkers survive the full simulation. On small or steep domains walkers @@ -790,6 +792,7 @@ def test_time_series_output(long_slope_session): output_step=5, random_seed=SEED, nprocs=NPROCS, + walkers_output="walkers", flags="t", ) @@ -800,6 +803,11 @@ def test_time_series_output(long_slope_session): f"Depth at t=10 ({sum_10:.3e}) should be >= depth at t=5 ({sum_05:.3e})" ) + info_05 = tools.v_info(map="walkers_05", flags="t", format="json") + info_10 = tools.v_info(map="walkers_10", flags="t", format="json") + assert int(info_05["points"]) > 0, "Expected walker points at t=5" + assert int(info_10["points"]) > 0, "Expected walker points at t=10" + def test_observation_logfile(east_slope_session, tmp_path): """Observation points must log water depth at each time step. @@ -888,37 +896,6 @@ def test_walkers_output(long_slope_session): ) -def test_walkers_output_time_series(long_slope_session): - """With -t, walkers_output must produce per-step vector maps. - - The maps use an underscore-separated time suffix (e.g., walkers_05) - unlike raster outputs which use a dot (e.g., depth.05). - - Uses a large domain (200 cells) with high roughness (0.3) so walkers - survive long enough to generate multiple time-series outputs. - """ - tools = Tools(session=long_slope_session) - tools.r_sim_water( - elevation="elevation", - dx="dx", - dy="dy", - depth="ts_depth", - rain_value=RAIN, - man_value=0.3, - duration=10, - output_step=5, - random_seed=SEED, - nprocs=NPROCS, - walkers_output="walkers", - flags="t", - ) - - info_05 = tools.v_info(map="walkers_05", flags="t", format="json") - info_10 = tools.v_info(map="walkers_10", flags="t", format="json") - assert int(info_05["points"]) > 0, "Expected walker points at t=5" - assert int(info_10["points"]) > 0, "Expected walker points at t=10" - - def test_nprocs_gives_result_within_noise(east_slope_session): """Multiple threads produce a result close to a single thread. From 4d42d96f4be6a7d52bdf808b4ff965dea463b95f Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 29 Sep 2026 10:14:26 -0400 Subject: [PATCH 27/29] r.sim.water: Drop a stale comment on the time-series map count --- raster/r.sim/r.sim.water/tests/r_sim_water_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 864f65733fc..895a3d0616e 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -565,7 +565,6 @@ def test_duration_affects_time_series_progression(long_slope_session): flags="t", ) - # 10-min run should produce 2 time-series maps (t=5, t=10) maps_10 = list(tools.g_list(type="raster", pattern="depth_10min*", format="json")) assert len(maps_10) == 2, ( f"10-min simulation with output_step=5 should produce 2 maps, got {len(maps_10)}" From e33505b4c02970a708781b2a6dec983436620902 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 29 Sep 2026 10:14:27 -0400 Subject: [PATCH 28/29] r.sim.water: Bound walker points by nwalkers plus the number of cells --- raster/r.sim/r.sim.water/tests/r_sim_water_test.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 895a3d0616e..43f2abf398f 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -864,8 +864,11 @@ def test_observation_logfile(east_slope_session, tmp_path): def test_walkers_output(long_slope_session): """The walkers_output parameter must produce a vector point map. - Each surviving walker position is written as a 3D point. The number - of points should not exceed nwalkers. + Each surviving walker position is written as a 3D point. The tool gives + each cell one walker more than its share of nwalkers truncated to an + integer, so the number of points cannot exceed nwalkers plus the number + of cells, although the option describes nwalkers as the number of + walkers. Uses a large domain (200 cells) to give walkers sufficient residence time before exiting. High roughness (0.3) further increases walker @@ -890,8 +893,11 @@ def test_walkers_output(long_slope_session): info = tools.v_info(map="walkers", flags="t", format="json") npoints = int(info["points"]) assert npoints > 0, "Expected at least one walker point" - assert npoints <= nwalkers, ( - f"Number of walker points ({npoints}) should not exceed nwalkers ({nwalkers})" + region = tools.g_region(flags="p", format="json") + cells = int(region["rows"]) * int(region["cols"]) + assert npoints <= nwalkers + cells, ( + f"Number of walker points ({npoints}) should not exceed nwalkers plus " + f"the number of cells ({nwalkers} + {cells})" ) From af597870055229b4f3fb2faccc42d87c510e4b20 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 29 Sep 2026 10:14:28 -0400 Subject: [PATCH 29/29] r.sim.water: Match dx to the 10 m slopes and use a 3 s floor there --- raster/r.sim/r.sim.water/tests/r_sim_water_test.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py index 43f2abf398f..8dcf2f02736 100644 --- a/raster/r.sim/r.sim.water/tests/r_sim_water_test.py +++ b/raster/r.sim/r.sim.water/tests/r_sim_water_test.py @@ -119,8 +119,9 @@ def long_slope_project(tmp_path_factory): with gs.setup.init(project, env=os.environ.copy()) as session: tools = Tools(session=session) tools.g_region(w=0, e=2000, s=0, n=10, res=10, flags="s") + # dx and dy set the slope; 0.1 matches the 1 m drop per 10 m cell. tools.r_mapcalc(expression="elevation = 201 - col()") - tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dx = 0.1") tools.r_mapcalc(expression="dy = 0.0") yield session @@ -437,7 +438,7 @@ def test_mintimestep_is_a_floor_and_harmless_within_one_cell_per_step( The tool computes its own step, 0.025 s on this slope. A 0.01 s floor is below that, so the result must be identical. A 0.1 s floor is above it but moves walkers only one cell per step, so depth stays the same within - noise, and so it does with a 1 s floor on 10 m cells. + noise, and so it does with a 3 s floor on 10 m cells at a slope of 0.1. """ depth_default = run_sim(east_slope_session) np.testing.assert_array_equal( @@ -458,13 +459,15 @@ def test_mintimestep_is_a_floor_and_harmless_within_one_cell_per_step( with gs.setup.init(project, env=os.environ.copy()) as session: tools = Tools(session=session) tools.g_region(w=0, e=50, s=0, n=10, res=10) + # dx and dy set the slope; 0.1 matches the 1 m drop per 10 m cell and + # gives 3.2 m/s, so a 3 s floor moves walkers about one cell per step. tools.r_mapcalc(expression="elevation = 6 - col()") - tools.r_mapcalc(expression="dx = 1.0") + tools.r_mapcalc(expression="dx = 0.1") tools.r_mapcalc(expression="dy = 0.0") coarse_default = float(np.sum(run_sim(session, **NO_DIFFUSION))) - coarse_floor = float(np.sum(run_sim(session, mintimestep=1.0, **NO_DIFFUSION))) + coarse_floor = float(np.sum(run_sim(session, mintimestep=3.0, **NO_DIFFUSION))) assert coarse_floor / coarse_default == pytest.approx(1, rel=0.1), ( - f"A 1 s floor on 10 m cells changed depth by a factor of " + f"A 3 s floor on 10 m cells changed depth by a factor of " f"{coarse_floor / coarse_default:.3f}" )