diff --git a/src/temporal/t.rast.climatologies/Makefile b/src/temporal/t.rast.climatologies/Makefile
new file mode 100644
index 0000000000..1f9cb18c1f
--- /dev/null
+++ b/src/temporal/t.rast.climatologies/Makefile
@@ -0,0 +1,7 @@
+MODULE_TOPDIR = ../..
+
+PGM = t.rast.climatologies
+
+include $(MODULE_TOPDIR)/include/Make/Script.make
+
+default: script
diff --git a/src/temporal/t.rast.climatologies/t.rast.climatologies.html b/src/temporal/t.rast.climatologies/t.rast.climatologies.html
new file mode 100644
index 0000000000..990adc6630
--- /dev/null
+++ b/src/temporal/t.rast.climatologies/t.rast.climatologies.html
@@ -0,0 +1,40 @@
+
DESCRIPTION
+
+t.rast.climatologies is a module to calculate climatologies, i.e., long term stats, for single days
+or months in a time series. If the s flag is used, the module outputs a new space time
+raster dataset of relative temporal type with the aggregated maps.
+
+EXAMPLE
+
+Daily climatologies
+
+Starting from a space time raster dataset of daily granularity (or granularity lower than one day),
+the module will create a new space time raster dataset of relative temporal type containing the
+long term average for each day along years.
+
+
+
+ t.rast.climatologies input=myinput output=dailyoutput
+
+
+
+Monthly climatologies
+
+Starting from a space time raster dataset of monthly granularity (or lower than one month),
+the module will create two new space time raster datasets containing the long term minimum
+and maximum for each month along years.
+
+
+ t.rast.climatologies input=myinput granularity=month method=min,max output=monthlyoutputmin,monthlyoutputmax
+
+
+
+SEE ALSO
+
+r.series,
+t.rast.series
+
+
+AUTHOR
+
+Luca Delucchi, Fondazione Edmund Mach
diff --git a/src/temporal/t.rast.climatologies/t.rast.climatologies.py b/src/temporal/t.rast.climatologies/t.rast.climatologies.py
new file mode 100644
index 0000000000..34a21369a3
--- /dev/null
+++ b/src/temporal/t.rast.climatologies/t.rast.climatologies.py
@@ -0,0 +1,244 @@
+#!/usr/bin/env python
+
+################################################
+#
+# MODULE: t.rast.climatologies
+# AUTHOR(S): Luca Delucchi, Fondazione Edmund Mach
+# PURPOSE: t.rast.climatologies calculates climatologies for space time raster datasets
+#
+# COPYRIGHT: (C) 2023 by Luca Delucchi
+#
+# This program is free software under the GNU General Public
+# License (>=v2). Read the file COPYING that comes with GRASS
+# for details.
+#
+################################################
+
+# %module
+# % description: Calculates climatologies from a space time raster dataset of absolute temporal type
+# % keyword: temporal
+# % keyword: raster
+# % keyword: aggregation
+# % keyword: series
+# % keyword: time
+# %end
+
+# %option G_OPT_STRDS_INPUT
+# %end
+
+# %option G_OPT_STRDS_OUTPUT
+# % required: no
+# %end
+
+# %option
+# % key: basename
+# % type: string
+# % label: Basename of the newly generated output maps
+# % description: Either a numerical suffix or the start time (s-flag) separated by an underscore will be attached to create a unique identifier
+# % required: yes
+# % multiple: no
+# % gisprompt:
+# %end
+
+# %option
+# % key: method
+# % type: string
+# % description: Aggregate operation to be performed on the raster maps
+# % required: yes
+# % multiple: yes
+# % options: average,count,median,mode,minimum,min_raster,maximum,max_raster,stddev,range,sum,variance,diversity,slope,offset,detcoeff,quart1,quart3,perc90,quantile,skewness,kurtosis
+# % answer: average
+# %end
+
+# %option
+# % key: quantile
+# % type: double
+# % description: Quantile to calculate for method=quantile
+# % required: no
+# % multiple: yes
+# % options: 0.0-1.0
+# %end
+
+# %option
+# % key: granularity
+# % type: string
+# % label: Aggregate by day or month
+# % required: yes
+# % multiple: no
+# % options: day, month
+# % answer: day
+# %end
+
+# %option
+# % key: nprocs
+# % type: integer
+# % description: Number of processes to run in parallel
+# % required: no
+# % multiple: no
+# % answer: 1
+# %end
+
+# %flag
+# % key: s
+# % description: Do not create a space time raster dataset as output
+# %end
+
+import copy
+from datetime import datetime
+
+
+def main():
+ import grass.pygrass.modules as pymod
+ import grass.script as gscript
+ import grass.temporal as tgis
+
+ options, flags = gscript.parser()
+ strds = options["input"]
+ output = options["output"]
+ method = options["method"]
+ gran = options["granularity"]
+ basename = options["basename"]
+ nprocs = options["nprocs"]
+ quantile = options["quantile"]
+ space_time = flags["s"]
+
+ # check if quantile value is used correctly
+ if "quantile" in method and not quantile:
+ gscript.fatal(_("Number requested methods and output maps do not match."))
+ elif quantile and "quantile" not in method:
+ gscript.warning(
+ _("Quantile option set but quantile not selected in method option")
+ )
+
+ # Check if number of methods and output maps matches
+ if "quantile" in method:
+ len_method = len(method.split(",")) - 1
+ else:
+ len_method = len(method.split(","))
+
+ if not space_time:
+ if (len(list(filter(None, quantile.split(",")))) + len_method) != len(
+ output.split(",")
+ ):
+ gscript.fatal(_("Number requested methods and output maps do not match."))
+
+ tgis.init()
+ # We need a database interface
+ dbif = tgis.SQLDatabaseInterfaceConnection()
+ dbif.connect()
+
+ insp = tgis.open_old_stds(strds, "strds", dbif)
+ temporal_type, semantic_type, title, description = insp.get_initial_values()
+ if temporal_type != "absolute":
+ gscript.fatal(
+ _(
+ "Space time raster dataset temporal type is not absolute, this module requires absolute time"
+ )
+ )
+ maps = insp.get_registered_maps_as_objects(None, "start_time", dbif)
+ if maps is None:
+ gscript.fatal(
+ _(
+ "No maps selected in the space time raster dataset {};"
+ " it might be empty or the where option returns no data".format(strds)
+ )
+ )
+ # start the r.series module to be used in a ParallelModuleQueue
+ mod = pymod.Module("r.series")
+ mod.inputs.method = method
+ mod.flags.quiet = True
+ if quantile:
+ mod.inputs.quantile = quantile
+ process_queue = pymod.ParallelModuleQueue(int(nprocs))
+ mapset = tgis.core.get_current_mapset()
+ # depending on granularity it calculates daily or monthly climatologies
+ outmaps = []
+ if gran == "day":
+ outunit = "days"
+ # for each day
+ for doy in range(1, 367):
+ doystr = datetime.strptime(f"2000 {doy}", "%Y %j").strftime("%m_%d")
+ thiswhere = f"strftime('%m_%d', start_time) == '{doystr}'"
+ selemaps = insp.get_registered_maps_as_objects(
+ thiswhere, "start_time", dbif
+ )
+ maps_name = [sam.get_id() for sam in selemaps]
+ # check if there are maps for that day
+ if len(maps_name) > 0:
+ outname = f"{basename}_{doystr}"
+ runmod = copy.deepcopy(mod)
+ runmod.inputs.input = ",".join(maps_name)
+ runmod.outputs.output = outname
+ process_queue.put(runmod)
+ map_layer = tgis.space_time_datasets.RasterDataset(
+ f"{outname}@{mapset}"
+ )
+ extent = tgis.RelativeTemporalExtent(
+ start_time=doy - 1,
+ end_time=doy,
+ unit=outunit,
+ )
+ map_layer.set_temporal_extent(extent=extent)
+ outmaps.append(map_layer)
+
+ if doy % 10 == 0:
+ gscript.percent(doy, 366, 1)
+ else:
+ outunit = "months"
+ for month in range(1, 13):
+ monthstr = "{:02d}".format(month)
+ thiswhere = f"strftime('%m', start_time) == '{monthstr}'"
+ selemaps = insp.get_registered_maps_as_objects(
+ thiswhere, "start_time", None
+ )
+ maps_name = [sam.get_id() for sam in selemaps]
+ if len(maps_name) > 0:
+ outname = f"{basename}_{monthstr}"
+ runmod = copy.deepcopy(mod)
+ runmod.inputs.input = ",".join(maps_name)
+ runmod.outputs.output = outname
+ process_queue.put(runmod)
+ map_layer = tgis.space_time_datasets.RasterDataset(
+ f"{outname}@{mapset}"
+ )
+ extent = tgis.RelativeTemporalExtent(
+ start_time=month - 1,
+ end_time=month,
+ unit=outunit,
+ )
+ map_layer.set_temporal_extent(extent=extent)
+ outmaps.append(map_layer)
+ gscript.percent(month, 12, 1)
+
+ if not space_time:
+ # create new space time raster dataset
+ output_strds = tgis.open_new_stds(
+ output,
+ "strds",
+ "relative",
+ f"{gran} {method} climatologies",
+ f"Climatologies created from {strds}, {gran} {method} maps",
+ semantic_type,
+ dbif,
+ gscript.overwrite(),
+ )
+ register_null = False
+ # register maps into space time raster dataset
+ tgis.register_map_object_list(
+ "rast",
+ outmaps,
+ output_strds,
+ register_null,
+ outunit,
+ dbif,
+ )
+
+ # Update the raster metadata table entries
+ output_strds.metadata.update(dbif)
+
+ dbif.close()
+ return True
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/temporal/t.rast.climatologies/testsuite/test_climatologies_daily.py b/src/temporal/t.rast.climatologies/testsuite/test_climatologies_daily.py
new file mode 100644
index 0000000000..6c56c6d0be
--- /dev/null
+++ b/src/temporal/t.rast.climatologies/testsuite/test_climatologies_daily.py
@@ -0,0 +1,132 @@
+"""Test t.rast.climatologies
+(C) 2023 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS
+for details.
+@author Luca Delucchi
+"""
+
+import grass.temporal as tgis
+from grass.gunittest.case import TestCase
+from grass.gunittest.main import test
+
+
+class TestClimatologies(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ """Initiate the temporal GIS and set the region"""
+ tgis.init(True) # Raise on error instead of exit(1)
+ cls.use_temp_region()
+ cls.runModule("g.region", s=0, n=80, w=0, e=120, b=0, t=50, res=10, res3=10)
+
+ cls.runModule("r.mapcalc", expression="a_1 = 5", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_2 = 10", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_3 = 15", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_4 = 20", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_5 = 25", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_6 = 30", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_7 = 35", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_8 = 40", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_9 = 45", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_1 = 5", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_2 = 10", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_3 = 15", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_4 = 20", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_5 = 25", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_6 = 30", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_7 = 35", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_8 = 40", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_9 = 45", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_1 = 5", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_2 = 10", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_3 = 15", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_4 = 20", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_5 = 25", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_6 = 30", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_7 = 35", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_8 = 40", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_9 = 45", overwrite=True)
+
+ cls.runModule(
+ "t.create",
+ type="strds",
+ temporaltype="absolute",
+ output="daily",
+ title="Daily test",
+ description="Daily test",
+ overwrite=True,
+ )
+ cls.runModule(
+ "t.register",
+ flags="i",
+ type="raster",
+ input="daily",
+ maps="a_1,a_2,a_3,a_4,a_5,a_6,a_7,a_8,a_9",
+ start="2001-01-01",
+ increment="1 day",
+ overwrite=True,
+ )
+ cls.runModule(
+ "t.register",
+ flags="i",
+ type="raster",
+ input="daily",
+ maps="b_1,b_2,b_3,b_4,b_5,b_6,b_7,b_8,b_9",
+ start="2002-01-01",
+ increment="1 day",
+ overwrite=True,
+ )
+ cls.runModule(
+ "t.register",
+ flags="i",
+ type="raster",
+ input="daily",
+ maps="c_1,c_2,c_3,c_4,c_5,c_6,c_7,c_8,c_9",
+ start="2003-01-01",
+ increment="1 day",
+ overwrite=True,
+ )
+
+ @classmethod
+ def tearDownClass(cls):
+ """Remove the time series"""
+ cls.runModule("t.remove", flags="fd", type="strds", inputs="daily")
+
+ def test_1(self):
+ """Test average data"""
+ self.assertModule(
+ "t.rast.climatologies",
+ input="daily",
+ output="daily_clima",
+ method="average",
+ basename="daily_avg",
+ granularity="day",
+ overwrite=True,
+ verbose=True,
+ )
+ out = tgis.open_old_stds("daily_clima", type="strds")
+ self.assertEqual(out.metadata.get_number_of_maps(), 9)
+ self.assertEqual(out.metadata.get_min_min(), 5)
+ self.assertEqual(out.metadata.get_max_max(), 45)
+ self.runModule("t.remove", flags="df", type="strds", inputs="daily_clima")
+
+ def test_noout(self):
+ """Don't create output strds"""
+ self.assertModule(
+ "t.rast.climatologies",
+ input="daily",
+ flags=["s"],
+ method="average",
+ basename="daily_avg",
+ granularity="day",
+ overwrite=True,
+ verbose=True,
+ )
+ self.assertRasterExists("daily_avg_01_01")
+ self.assertRasterExists("daily_avg_01_09")
+ self.assertRasterDoesNotExist("daily_avg_01_10")
+ self.runModule("g.remove", flags="f", type="raster", pattern="daily_avg_*")
+
+
+if __name__ == "__main__":
+ test()
diff --git a/src/temporal/t.rast.climatologies/testsuite/test_climatologies_monthly.py b/src/temporal/t.rast.climatologies/testsuite/test_climatologies_monthly.py
new file mode 100644
index 0000000000..848994022f
--- /dev/null
+++ b/src/temporal/t.rast.climatologies/testsuite/test_climatologies_monthly.py
@@ -0,0 +1,151 @@
+"""Test t.rast.climatologies
+(C) 2023 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS
+for details.
+@author Luca Delucchi
+"""
+
+import grass.temporal as tgis
+from grass.gunittest.case import TestCase
+from grass.gunittest.main import test
+
+
+class TestClimatologies(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ """Initiate the temporal GIS and set the region"""
+ tgis.init(True) # Raise on error instead of exit(1)
+ cls.use_temp_region()
+ cls.runModule("g.region", s=0, n=80, w=0, e=120, b=0, t=50, res=10, res3=10)
+
+ cls.runModule("r.mapcalc", expression="a_1 = 5", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_2 = 10", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_3 = 15", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_4 = 20", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_5 = 25", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_6 = 30", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_7 = 35", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_8 = 40", overwrite=True)
+ cls.runModule("r.mapcalc", expression="a_9 = 45", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_1 = 5", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_2 = 10", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_3 = 15", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_4 = 20", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_5 = 25", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_6 = 30", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_7 = 35", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_8 = 40", overwrite=True)
+ cls.runModule("r.mapcalc", expression="b_9 = 45", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_1 = 5", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_2 = 10", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_3 = 15", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_4 = 20", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_5 = 25", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_6 = 30", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_7 = 35", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_8 = 40", overwrite=True)
+ cls.runModule("r.mapcalc", expression="c_9 = 45", overwrite=True)
+ cls.runModule("r.mapcalc", expression="d_1 = 5", overwrite=True)
+ cls.runModule("r.mapcalc", expression="d_2 = 10", overwrite=True)
+ cls.runModule("r.mapcalc", expression="d_3 = 15", overwrite=True)
+ cls.runModule("r.mapcalc", expression="d_4 = 20", overwrite=True)
+ cls.runModule("r.mapcalc", expression="d_5 = 25", overwrite=True)
+ cls.runModule("r.mapcalc", expression="d_6 = 30", overwrite=True)
+ cls.runModule("r.mapcalc", expression="d_7 = 35", overwrite=True)
+ cls.runModule("r.mapcalc", expression="d_8 = 40", overwrite=True)
+ cls.runModule("r.mapcalc", expression="d_9 = 45", overwrite=True)
+
+ cls.runModule(
+ "t.create",
+ type="strds",
+ temporaltype="absolute",
+ output="daily",
+ title="Daily test",
+ description="Daily test",
+ overwrite=True,
+ )
+ cls.runModule(
+ "t.register",
+ flags="i",
+ type="raster",
+ input="daily",
+ maps="a_1,a_2,a_3,a_4,a_5,a_6,a_7,a_8,a_9",
+ start="2001-01-01",
+ increment="1 day",
+ overwrite=True,
+ )
+ cls.runModule(
+ "t.register",
+ flags="i",
+ type="raster",
+ input="daily",
+ maps="b_1,b_2,b_3,b_4,b_5,b_6,b_7,b_8,b_9",
+ start="2002-01-01",
+ increment="1 day",
+ overwrite=True,
+ )
+ cls.runModule(
+ "t.register",
+ flags="i",
+ type="raster",
+ input="daily",
+ maps="c_1,c_2,c_3,c_4,c_5,c_6,c_7,c_8,c_9",
+ start="2002-02-01",
+ increment="1 day",
+ overwrite=True,
+ )
+ cls.runModule(
+ "t.register",
+ flags="i",
+ type="raster",
+ input="daily",
+ maps="d_1,d_2,d_3,d_4,d_5,d_6,d_7,d_8,d_9",
+ start="2002-02-01",
+ increment="1 day",
+ overwrite=True,
+ )
+
+ @classmethod
+ def tearDownClass(cls):
+ """Remove the time series"""
+ cls.runModule("t.remove", flags="df", type="strds", inputs="daily")
+
+ def test_1(self):
+ """Test average data"""
+ self.assertModule(
+ "t.rast.climatologies",
+ input="daily",
+ output="monthly_clima",
+ method="average",
+ basename="monthly_avg",
+ granularity="month",
+ overwrite=True,
+ verbose=True,
+ )
+ out = tgis.open_old_stds("monthly_clima", type="strds")
+ self.assertEqual(out.metadata.get_number_of_maps(), 2)
+ self.assertEqual(out.metadata.get_min_min(), 25.0)
+ self.assertEqual(out.metadata.get_max_max(), 25.0)
+ self.runModule("t.remove", flags="df", type="strds", inputs="monthly_clima")
+
+ def test_noout(self):
+ """Don't create output strds"""
+ self.assertModule(
+ "t.rast.climatologies",
+ input="daily",
+ flags=["s"],
+ method="average",
+ basename="monthly_avg",
+ granularity="month",
+ overwrite=True,
+ verbose=True,
+ )
+ self.assertRasterExists("monthly_avg_01")
+ self.assertRasterExists("monthly_avg_02")
+ self.assertRasterDoesNotExist("monthly_avg_03")
+ self.runModule("g.remove", flags="f", type="raster", pattern="monthly_avg_*")
+
+
+if __name__ == "__main__":
+ test()