From a9a8df555c252d9f2a076db8cac6879975e6c164 Mon Sep 17 00:00:00 2001 From: selma-Bentaiba Date: Tue, 31 Mar 2026 18:16:53 +0100 Subject: [PATCH 1/2] temporal: add granularity and start options to t.rast.univar #3042 --- python/grass/temporal/univar_statistics.py | 88 ++++++++++++++++-- temporal/t.rast.univar/t.rast.univar.py | 19 +++- .../testsuite/test_t_rast_univar.py | 90 +++++++++++++++++++ 3 files changed, 188 insertions(+), 9 deletions(-) diff --git a/python/grass/temporal/univar_statistics.py b/python/grass/temporal/univar_statistics.py index 54448d078f0..3551f0d2506 100755 --- a/python/grass/temporal/univar_statistics.py +++ b/python/grass/temporal/univar_statistics.py @@ -8,7 +8,20 @@ import grass.temporal as tgis tgis.print_gridded_dataset_univar_statistics( - type, input, output, where, extended, no_header, fs, rast_region + "strds", + input, + output, + where, + extended, + percentile=percentile, + no_header=no_header, + fs=separator, + zones=zones, + rast_region=rast_region, + region_relation=region_relation, + nprocs=nprocs, + granularity=options["granularity"] or None, + start=options["start"] or None, ) .. @@ -21,6 +34,7 @@ :authors: Soeren Gebbert """ +from .datetime_math import adjust_datetime_to_granularity, increment_datetime_by_string from multiprocessing import Pool from subprocess import PIPE @@ -125,6 +139,8 @@ def print_gridded_dataset_univar_statistics( zones=None, percentile=None, nprocs: int = 1, + granularity: str | None = None, + start: str | None = None, ) -> None: """Print univariate statistics for a space time raster or raster3d dataset. @@ -175,6 +191,46 @@ def print_gridded_dataset_univar_statistics( spatial_relation=region_relation, ) + # When the user asks for granularity, we group maps into time windows + # and later feed each group as a comma-joined list to r.univar. + # No new rasters written to disk — that's the whole point of this feature. + granule_groups = None + if granularity and rows: + # Snap the start of the first window to the requested granularity + # unless the user gave an explicit start time + if start: + from datetime import datetime as _dt + + # try dateutil first, fall back to a basic format + try: + from dateutil import parser as _dp + + win_start = _dp.parse(start) + except ImportError: + win_start = _dt.strptime(start, "%Y-%m-%d %H:%M:%S") + else: + win_start = adjust_datetime_to_granularity( + rows[0]["start_time"], granularity + ) + + # last map's end_time can be None for point-in-time maps — fall back to start + last = rows[-1] + win_end_global = last["end_time"] or last["start_time"] + + # walk through time and collect which maps fall inside each window + granule_groups = [] + t = win_start + while t < win_end_global: + t_end = increment_datetime_by_string(t, granularity) + group = [ + row + for row in rows + if row["start_time"] >= t and row["start_time"] < t_end + ] + if group: + granule_groups.append((t, t_end, group)) + t = t_end + if not rows and rows != [""]: dbif.close() gs.verbose( @@ -250,14 +306,30 @@ def print_gridded_dataset_univar_statistics( ) nprocs = max(nprocs, 1) - if nprocs == 1: + if granule_groups is not None: + # granularity mode: each group of maps gets merged into one r.univar call. + # r.univar handles multiple maps natively — no temp files, no aggregate step. + # Output row uses the granule window as start/end, not individual map ids. + strings = [] + for win_start, win_end, group in granule_groups: + map_ids = ",".join(row["id"] for row in group) + + # fake a row dict that compute_univar_stats can work with — + # id becomes the map list, timestamps become the granule bounds + synthetic_row = { + "id": map_ids, + "start_time": win_start, + "end_time": win_end, + # granule rows carry no single semantic label; leave it blank + "semantic_label": None, + } + result = compute_univar_stats(synthetic_row, univar_module, fs, rast_region) + if result: + strings.append(result) + + elif nprocs == 1: strings = [ - compute_univar_stats( - row, - univar_module, - fs, - ) - for row in rows + compute_univar_stats(row, univar_module, fs, rast_region) for row in rows ] else: with Pool(min(nprocs, len(rows))) as pool: diff --git a/temporal/t.rast.univar/t.rast.univar.py b/temporal/t.rast.univar/t.rast.univar.py index dbc2a31ab4d..2044153ff51 100755 --- a/temporal/t.rast.univar/t.rast.univar.py +++ b/temporal/t.rast.univar/t.rast.univar.py @@ -59,6 +59,22 @@ # % guisection: Selection # %end +# %option +# % key: granularity +# % type: string +# % description: Aggregation granularity, format absolute time "x years, x months, x weeks, x days, x hours, x minutes, x seconds" or an integer value for relative time +# % required: no +# % multiple: no +# %end + +# %option +# % key: start +# % type: string +# % description: Start time for granule alignment (format: "YYYY-MM-DD HH:MM:SS") +# % required: no +# % multiple: no +# %end + # %option # % key: region_relation # % description: Process only maps with this spatial relation to the current computational region @@ -101,7 +117,6 @@ def main(): # Get the options and flags options, flags = gs.parser() - # lazy imports import grass.temporal as tgis @@ -150,6 +165,8 @@ def main(): rast_region=rast_region, region_relation=region_relation, nprocs=nprocs, + granularity=options["granularity"] or None, + start=options["start"] or None, ) diff --git a/temporal/t.rast.univar/testsuite/test_t_rast_univar.py b/temporal/t.rast.univar/testsuite/test_t_rast_univar.py index c36fb4a9b9a..966f93f43c7 100644 --- a/temporal/t.rast.univar/testsuite/test_t_rast_univar.py +++ b/temporal/t.rast.univar/testsuite/test_t_rast_univar.py @@ -550,6 +550,96 @@ def test_with_spatial_filter_is_contained(self): self.assertLooksLike(ref_line, res_line) +class TestRasterUnivarGranularity(TestCase): + """Tests for the granularity aggregation option added in #3042. + Four maps registered 3 months apart (Jan, Apr, Jul, Oct 2001), + values 100/200/300/400 - chosen so the expected stats are dead simple + to verify by hand.""" + + @classmethod + def setUpClass(cls): + cls.use_temp_region() + cls.runModule("g.region", s=0, n=80, w=0, e=120, res=1) + for i, val in enumerate([100, 200, 300, 400], start=1): + cls.runModule( + "r.mapcalc", + expression=f"gran_test_{i} = {val}", + overwrite=True, + ) + cls.runModule( + "t.create", + type="strds", + temporaltype="absolute", + output="gran_A", + title="granularity test", + description="granularity test", + overwrite=True, + ) + cls.runModule( + "t.register", + flags="i", + type="raster", + input="gran_A", + maps="gran_test_1,gran_test_2,gran_test_3,gran_test_4", + start="2001-01-01", + increment="3 months", + overwrite=True, + ) + + @classmethod + def tearDownClass(cls): + cls.runModule("t.remove", flags="df", type="strds", inputs="gran_A") + cls.del_temp_region() + + def test_granularity_6months(self): + """6-month windows should collapse 4 maps into 2 rows""" + t_rast_univar = SimpleModule( + "t.rast.univar", + input="gran_A", + granularity="6 months", + verbose=True, + ) + self.assertModule(t_rast_univar) + lines = [l for l in t_rast_univar.outputs.stdout.strip().split("\n") if l] + # header + 2 data rows + self.assertEqual(len(lines), 3) + # first granule: Jan-Jul, mean of 100+200 = 150 + self.assertIn("2001-01-01 00:00:00", lines[1]) + self.assertIn("2001-07-01 00:00:00", lines[1]) + self.assertIn("150", lines[1]) + # second granule: Jul-Jan, mean of 300+400 = 350 + self.assertIn("2001-07-01 00:00:00", lines[2]) + self.assertIn("2002-01-01 00:00:00", lines[2]) + self.assertIn("350", lines[2]) + + def test_granularity_1year(self): + """1-year window should collapse all 4 maps into a single row""" + t_rast_univar = SimpleModule( + "t.rast.univar", + input="gran_A", + granularity="1 year", + verbose=True, + ) + self.assertModule(t_rast_univar) + lines = [l for l in t_rast_univar.outputs.stdout.strip().split("\n") if l] + # header + 1 data row + self.assertEqual(len(lines), 2) + self.assertIn("2001-01-01 00:00:00", lines[1]) + self.assertIn("2002-01-01 00:00:00", lines[1]) + self.assertIn("250", lines[1]) + + def test_no_granularity_unchanged(self): + """Without granularity the output should still be one row per map""" + t_rast_univar = SimpleModule( + "t.rast.univar", + input="gran_A", + verbose=True, + ) + self.assertModule(t_rast_univar) + lines = [l for l in t_rast_univar.outputs.stdout.strip().split("\n") if l] + self.assertEqual(len(lines), 5) # header + 4 maps + + if __name__ == "__main__": from grass.gunittest.main import test From 5e395a1a3e57f005641b30d8afd69aa74056faaf Mon Sep 17 00:00:00 2001 From: selma-Bentaiba Date: Sun, 5 Apr 2026 13:37:35 +0100 Subject: [PATCH 2/2] temporal: remove start option from t.rast.univar, infer from first map --- python/grass/temporal/univar_statistics.py | 24 +--------------------- temporal/t.rast.univar/t.rast.univar.py | 11 +--------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/python/grass/temporal/univar_statistics.py b/python/grass/temporal/univar_statistics.py index 3551f0d2506..e6491ca987b 100755 --- a/python/grass/temporal/univar_statistics.py +++ b/python/grass/temporal/univar_statistics.py @@ -21,7 +21,6 @@ region_relation=region_relation, nprocs=nprocs, granularity=options["granularity"] or None, - start=options["start"] or None, ) .. @@ -140,7 +139,6 @@ def print_gridded_dataset_univar_statistics( percentile=None, nprocs: int = 1, granularity: str | None = None, - start: str | None = None, ) -> None: """Print univariate statistics for a space time raster or raster3d dataset. @@ -190,29 +188,9 @@ def print_gridded_dataset_univar_statistics( spatial_extent=spatial_extent, spatial_relation=region_relation, ) - - # When the user asks for granularity, we group maps into time windows - # and later feed each group as a comma-joined list to r.univar. - # No new rasters written to disk — that's the whole point of this feature. granule_groups = None if granularity and rows: - # Snap the start of the first window to the requested granularity - # unless the user gave an explicit start time - if start: - from datetime import datetime as _dt - - # try dateutil first, fall back to a basic format - try: - from dateutil import parser as _dp - - win_start = _dp.parse(start) - except ImportError: - win_start = _dt.strptime(start, "%Y-%m-%d %H:%M:%S") - else: - win_start = adjust_datetime_to_granularity( - rows[0]["start_time"], granularity - ) - + win_start = adjust_datetime_to_granularity(rows[0]["start_time"], granularity) # last map's end_time can be None for point-in-time maps — fall back to start last = rows[-1] win_end_global = last["end_time"] or last["start_time"] diff --git a/temporal/t.rast.univar/t.rast.univar.py b/temporal/t.rast.univar/t.rast.univar.py index 2044153ff51..ad0675c6444 100755 --- a/temporal/t.rast.univar/t.rast.univar.py +++ b/temporal/t.rast.univar/t.rast.univar.py @@ -5,7 +5,7 @@ # MODULE: t.rast.univar # AUTHOR(S): Soeren Gebbert # -# PURPOSE: Calculates univariate statistics from the non-null cells for each registered raster map of a space time raster dataset +# PURPOSE: Calculates statistics univariate from the non-null cells for each registered raster map of a space time raster dataset # COPYRIGHT: (C) 2011-2017, Soeren Gebbert and the GRASS Development Team # # This program is free software; you can redistribute it and/or modify @@ -67,14 +67,6 @@ # % multiple: no # %end -# %option -# % key: start -# % type: string -# % description: Start time for granule alignment (format: "YYYY-MM-DD HH:MM:SS") -# % required: no -# % multiple: no -# %end - # %option # % key: region_relation # % description: Process only maps with this spatial relation to the current computational region @@ -166,7 +158,6 @@ def main(): region_relation=region_relation, nprocs=nprocs, granularity=options["granularity"] or None, - start=options["start"] or None, )