Skip to content

grass.tools: Add NumPy arrays IO to Tools #5878

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 39 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
d905882
grass.experimental: Add object to access modules as functions
wenzeslaus Apr 18, 2023
aaef183
Support verbosity, overwrite and region freezing
wenzeslaus Apr 21, 2023
54db575
Raise exception instead of calling handle_errors
wenzeslaus Apr 22, 2023
82f5894
Allow to specify stdin and use a new instance of Tools itself to exec…
wenzeslaus Apr 22, 2023
0f1e210
Add ignore errors, r_mapcalc example, draft tests
wenzeslaus Apr 22, 2023
f4e3fed
Add test for exceptions
wenzeslaus Apr 24, 2023
04087e8
Add tests and Makefile
wenzeslaus May 4, 2023
6ab8e40
Convert values to ints and floats in keyval
wenzeslaus May 4, 2023
744cfac
Do not overwrite by default to follow default behavior in GRASS GIS
wenzeslaus May 4, 2023
24c27e6
Add doc, remove old code and todos
wenzeslaus Jun 3, 2023
ff187a6
Add to top Makefile
wenzeslaus Jun 3, 2023
22773c8
Add docs for tests
wenzeslaus Jun 3, 2023
2911065
Allow test to fail because of the missing seed parameter (so results …
wenzeslaus Jun 4, 2023
3ac46c3
Merge branch 'main' into add-session-tools-object
echoix Nov 11, 2024
437d46e
Allow for optional output capture (error handling and printing still …
wenzeslaus Apr 23, 2025
cb8f483
Merge branch 'main' into add-session-tools-object
wenzeslaus Apr 23, 2025
a958142
Merge remote-tracking branch 'upstream/main' into add-session-tools-o…
wenzeslaus Apr 25, 2025
61972d4
Access JSON as dict directly without an attribute using getitem. Sugg…
wenzeslaus Apr 25, 2025
c86d8ff
Fix whitespace and regexp
wenzeslaus Apr 25, 2025
3b995c9
Represent not captured stdout as None, not empty string.
wenzeslaus Apr 25, 2025
d8c354d
Merge remote-tracking branch 'upstream/main' into add-session-tools-o…
wenzeslaus Apr 29, 2025
4cc5a32
Add run subcommand to have a CLI use case for the tools. It runs one …
wenzeslaus Apr 29, 2025
459b2ad
Update function name
wenzeslaus Apr 30, 2025
513c9f8
Add prototype code for numpy support
wenzeslaus Jun 2, 2025
24ef6b9
Merge main branch
wenzeslaus Jun 2, 2025
4a1e374
Make the special features standalone objects used by composition
wenzeslaus Jun 11, 2025
651df11
Merge remote-tracking branch 'upstream/main' into add-session-tools-o…
wenzeslaus Jun 11, 2025
41ad7ae
Remove r.pack IO
wenzeslaus Jun 11, 2025
ebdb16f
Merge remote-tracking branch 'upstream/main' into add-numpy-array-io-…
wenzeslaus Jul 27, 2025
e3797ed
Integrate numpy code into grass.tool and remove the experimental tool…
wenzeslaus Jul 27, 2025
52a1429
Remove tools from experimental Makefile
wenzeslaus Jul 27, 2025
3a4629e
Remove unused fixture and move comment with discussion to PR comment
wenzeslaus Jul 27, 2025
cd5e121
Merge remote-tracking branch 'upstream/main' into add-numpy-array-io-…
wenzeslaus Jul 28, 2025
d3136d6
Add cleanup. Improve benchmark message.
wenzeslaus Jul 29, 2025
13cc850
Add benchmark
wenzeslaus Jul 29, 2025
d6c7af4
Merge remote-tracking branch 'upstream/main' into add-numpy-array-io-…
wenzeslaus Jul 29, 2025
697053f
Add NumPy arrays to docstring
wenzeslaus Jul 29, 2025
79cf950
Document the support class
wenzeslaus Jul 29, 2025
b9cae0e
Merge tool name parameter update
wenzeslaus Jul 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion python/grass/benchmark/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def benchmark_resolutions(module, resolutions, label, repeat=5, nprocs=None):
region = gs.region()
n_cells.append(region["cells"])
print("\u2500" * term_size.columns)
print(f"Benchmark with {resolution} resolution...\n")
print(f"Benchmark with resolution {resolution}...\n")
time_sum = 0
measured_times = []
for _ in range(repeat):
Expand Down
121 changes: 121 additions & 0 deletions python/grass/tools/benchmark/benchmark_grass_tools_numpy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import time
import numpy as np


from grass.tools import Tools
from grass.benchmark import (
num_cells_plot,
benchmark_resolutions,
load_results,
save_results,
)


class TimeMeasurer:
def __init__(self):
self._time = None
self._start = None

@property
def time(self):
return self._time

def start(self):
self._start = time.perf_counter()

def stop(self):
self._time = time.perf_counter() - self._start


class PlainNumPyBenchmark(TimeMeasurer):
def run(self):
tools = Tools()
region = tools.g_region(flags="p", format="json")
a = np.full((region["rows"], region["cols"]), 1)
b = np.full((region["rows"], region["cols"]), 1)

self.start()
c = 2 * np.sqrt(a + b) * np.sqrt(a) + np.sqrt(b) + a / 2
self.stop()

print(c.sum())
print(c.size)

del a
del b
del c


class PlainGRASSBenchmark(TimeMeasurer):
def run(self):
tools = Tools(overwrite=True)
tools.r_mapcalc(expression="a = 1")
tools.r_mapcalc(expression="b = 1")

self.start()
tools.r_mapcalc(expression="c = 2 * sqrt(a + b) * sqrt(a) * sqrt(b) + a / 2")
self.stop()

c_stats = tools.r_univar(map="c", format="json")
print(c_stats["sum"])
print(c_stats["cells"])


class NumPyGRASSBenchmark(TimeMeasurer):
def run(self):
tools = Tools()
region = tools.g_region(flags="p", format="json")
a = np.full((region["rows"], region["cols"]), 1)
b = np.full((region["rows"], region["cols"]), 1)

self.start()
c = tools.r_mapcalc_simple(
expression="2* sqrt(A + B) * sqrt(A) * sqrt(B) + A / 2",
a=a,
b=b,
output=np.array,
)
self.stop()

c_stats = tools.r_univar(map=c, format="json")
print(c_stats["sum"])
print(c_stats["cells"])

del a
del b
del c


def main():
resolutions = [5, 2, 1, 0.5]
repeat = 10
results = [
benchmark_resolutions(
module=PlainNumPyBenchmark(),
label="NumPy",
resolutions=resolutions,
repeat=repeat,
),
benchmark_resolutions(
module=PlainGRASSBenchmark(),
label="GRASS",
resolutions=resolutions,
repeat=repeat,
),
benchmark_resolutions(
module=NumPyGRASSBenchmark(),
label="NumPy GRASS",
resolutions=resolutions,
repeat=repeat,
),
]
print(results)
results = load_results(save_results(results))
print(results)
plot_file = "test_res_plot.png"
num_cells_plot(results.results, filename=plot_file)
print(plot_file)


if __name__ == "__main__":
main()
37 changes: 36 additions & 1 deletion python/grass/tools/session_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ class Tools:
the *ToolResult* object:

>>> tools.g_region(flags="p").text # doctest: +SKIP

Raster input and outputs can be NumPy arrays:

>>> import numpy as np
>>> tools.g_region(rows=2, cols=3)
ToolResult(...)
>>> slope = tools.r_slope_aspect(elevation=np.ones((2, 3)), slope=np.ndarray)

When multiple outputs are returned, they are returned as a tuple:

>>> (slope, aspect) = tools.r_slope_aspect(
... elevation=np.ones((2, 3)), slope=np.array, aspect=np.array
... )
"""

def __init__(
Expand Down Expand Up @@ -167,13 +180,35 @@ def run(self, tool_name_: str, /, **kwargs):
# Get a fixed env parameter at at the beginning of each execution,
# but repeat it every time in case the referenced environment is modified.
args, popen_options = gs.popen_args_command(tool_name_, **kwargs)

# Compute the environment for subprocesses and store it for later use.
if "env" not in popen_options:
popen_options["env"] = self._modified_env_if_needed()

object_parameter_handler.translate_objects_to_data(
kwargs, env=popen_options["env"]
)

# We approximate original kwargs with the possibly-modified kwargs.
return self.run_cmd(
result = self.run_cmd(
args,
tool_kwargs=kwargs,
input=object_parameter_handler.stdin,
**popen_options,
)
use_objects = object_parameter_handler.translate_data_to_objects(
kwargs, env=popen_options["env"]
)
if use_objects:
result = object_parameter_handler.result
if object_parameter_handler.temporary_rasters:
self.call(
"g.remove",
type="raster",
name=object_parameter_handler.temporary_rasters,
flags="f",
)
return result

def run_cmd(
self,
Expand Down
63 changes: 61 additions & 2 deletions python/grass/tools/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,81 @@
import shutil
from io import StringIO

import numpy as np

import grass.script as gs
import grass.script.array as garray


class ParameterConverter:
"""Converts parameter values to strings and facilitates flow of the data."""

def __init__(self):
self._numpy_inputs = {}
self._numpy_outputs = {}
self._numpy_outputs = []
self._numpy_inputs_ordered = []
self.stdin = None
self.result = None
self.temporary_rasters = []

def process_parameters(self, kwargs):
"""Converts high level parameter values to strings.

Converts io.StringIO to dash and stores the string in the *stdin* attribute.
Replaces NumPy arrays by temporary raster names and stores the arrays.
Replaces NumPy array types by temporary raster names.

Temporary names are accessible in the *temporary_rasters* attribute and need
to be cleaned.
The functions *translate_objects_to_data* and *translate_data_to_objects*
need to be called before and after the computation to do the translations
from NumPy arrays to GRASS data and from GRASS data to NumPy arrays.

Simple type conversions from numbers and iterables to strings are expected to
be done by lower level code.
"""
for key, value in kwargs.items():
if isinstance(value, StringIO):
if isinstance(value, np.ndarray):
name = gs.append_uuid("tmp_serialized_input_array")
kwargs[key] = name
self._numpy_inputs[key] = (name, value)
elif value in (np.ndarray, np.array, garray.array):
# We test for class or the function.
name = gs.append_uuid("tmp_serialized_output_array")
kwargs[key] = name
self._numpy_outputs.append((name, value))
elif isinstance(value, StringIO):
kwargs[key] = "-"
self.stdin = value.getvalue()

def translate_objects_to_data(self, kwargs, env):
"""Convert NumPy arrays to GRASS data"""
for name, value in self._numpy_inputs.values():
map2d = garray.array(env=env)
map2d[:] = value
map2d.write(name)
self.temporary_rasters.append(name)

def translate_data_to_objects(self, kwargs, env):
"""Convert GRASS data to NumPy arrays

Returns True if there is one or more output arrays, False otherwise.
The arrays are stored in the *result* attribute.
"""
output_arrays = []
for name, value in self._numpy_outputs:
output_array = garray.array(name, env=env)
output_arrays.append(output_array)
self.temporary_rasters.append(name)
if len(output_arrays) == 1:
self.result = output_arrays[0]
return True
if len(output_arrays) > 1:
self.result = tuple(output_arrays)
return True
self.result = None
return False


class ToolFunctionResolver:
def __init__(self, *, run_function, env):
Expand Down
85 changes: 85 additions & 0 deletions python/grass/tools/tests/grass_tools_session_tools_numpy_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import numpy as np

import grass.script as gs
from grass.tools import Tools


def test_numpy_one_input(xy_dataset_session):
"""Check that global overwrite is not used when separate env is used"""
tools = Tools(session=xy_dataset_session)
tools.r_slope_aspect(elevation=np.ones((1, 1)), slope="slope")
assert tools.r_info(map="slope", format="json")["datatype"] == "FCELL"


def test_numpy_one_input_one_output(xy_dataset_session):
"""Check that a NumPy array works as input and for signaling output

It tests that the np.ndarray class is supported to signal output.
Return type is not strictly defined, so we are not testing for it explicitly
(only by actually using it as an NumPy array).
"""
tools = Tools(session=xy_dataset_session)
tools.g_region(rows=2, cols=3)
slope = tools.r_slope_aspect(elevation=np.ones((2, 3)), slope=np.ndarray)
assert slope.shape == (2, 3)
assert np.all(slope == np.full((2, 3), 0))


def test_numpy_with_name_and_parameter(xy_dataset_session):
"""Check that a NumPy array works as input and for signaling output

It tests that the np.ndarray class is supported to signal output.
Return type is not strictly defined, so we are not testing for it explicitly
(only by actually using it as an NumPy array).
"""
tools = Tools(session=xy_dataset_session)
tools.g_region(rows=2, cols=3)
slope = tools.run("r.slope.aspect", elevation=np.ones((2, 3)), slope=np.ndarray)
assert slope.shape == (2, 3)
assert np.all(slope == np.full((2, 3), 0))


def test_numpy_one_input_multiple_outputs(xy_dataset_session):
"""Check that a NumPy array function works for signaling multiple outputs

Besides multiple outputs it tests that np.array is supported to signal output.
"""
tools = Tools(session=xy_dataset_session)
tools.g_region(rows=2, cols=3)
(slope, aspect) = tools.r_slope_aspect(
elevation=np.ones((2, 3)), slope=np.array, aspect=np.array
)
assert slope.shape == (2, 3)
assert np.all(slope == np.full((2, 3), 0))
assert aspect.shape == (2, 3)
assert np.all(aspect == np.full((2, 3), 0))


def test_numpy_multiple_inputs_one_output(xy_dataset_session):
"""Check that a NumPy array works for multiple inputs"""
tools = Tools(session=xy_dataset_session)
tools.g_region(rows=2, cols=3)
result = tools.r_mapcalc_simple(
expression="A + B", a=np.full((2, 3), 2), b=np.full((2, 3), 5), output=np.array
)
assert result.shape == (2, 3)
assert np.all(result == np.full((2, 3), 7))


def test_numpy_grass_array_input_output(xy_dataset_session):
"""Check that global overwrite is not used when separate env is used

When grass array output is requested, we explicitly test the return value type.
"""
tools = Tools(session=xy_dataset_session)
rows = 2
cols = 3
tools.g_region(rows=rows, cols=cols)
tools.r_mapcalc_simple(expression="5", output="const_5")
const_5 = gs.array.array("const_5", env=xy_dataset_session.env)
result = tools.r_mapcalc_simple(
expression="2 * A", a=const_5, output=gs.array.array
)
assert result.shape == (rows, cols)
assert np.all(result == np.full((rows, cols), 10))
assert isinstance(result, gs.array.array)
Loading