diff --git a/src/imagery/i.sentinel.aggregate.metadata/Makefile b/src/imagery/i.sentinel.aggregate.metadata/Makefile
new file mode 100644
index 0000000..a3dd063
--- /dev/null
+++ b/src/imagery/i.sentinel.aggregate.metadata/Makefile
@@ -0,0 +1,7 @@
+MODULE_TOPDIR = ../../
+
+PGM = i.sentinel.aggregate.metadata
+
+include $(MODULE_TOPDIR)/include/Make/Script.make
+
+default: script $(TEST_DST)
diff --git a/src/imagery/i.sentinel.aggregate.metadata/i.sentinel.aggregate.metadata.html b/src/imagery/i.sentinel.aggregate.metadata/i.sentinel.aggregate.metadata.html
new file mode 100644
index 0000000..4528c54
--- /dev/null
+++ b/src/imagery/i.sentinel.aggregate.metadata/i.sentinel.aggregate.metadata.html
@@ -0,0 +1,23 @@
+
DESCRIPTION
+i.sentinel.aggregate.metadata aggregates metadata of multiple Sentinel
+scenes into a single JSON file with collected metadata. It assumes input
+metadata produced with i.sentinel2.import. Other Sentinel-products
+are currently not yet supported.
+
+EXAMPLE
+
+Aggregating Sentinel-2 (MSI) L1C metadata for daily mosaic:
+
+i.sentinel.aggregate.metadata input=./S2_MSI_l1C/scenes product_type=S2MSIL1C \
+ output=./S2_MSI_l1C/S2_metadata.json file_pattern="**/description.json"
+
+
+SEE ALSO
+
+
+ i.sentinel2.import
+
+
+AUTHOR
+
+Stefan Blumentrath, NVE
diff --git a/src/imagery/i.sentinel.aggregate.metadata/i.sentinel.aggregate.metadata.py b/src/imagery/i.sentinel.aggregate.metadata/i.sentinel.aggregate.metadata.py
new file mode 100644
index 0000000..a02fadb
--- /dev/null
+++ b/src/imagery/i.sentinel.aggregate.metadata/i.sentinel.aggregate.metadata.py
@@ -0,0 +1,236 @@
+#!/usr/bin/env python3
+"""MODULE: t.rast.import.gdalvrt
+AUTHOR(S): Stefan Blumentrath
+PURPOSE: Aggregate Sentinel product metadata from several tiles or scenes.
+COPYRIGHT: (C) 2025 by Stefan Blumentrath, NVE
+ and 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.
+"""
+
+# %Module
+# % description: Aggregate Sentinel product metadata from several tiles or scenes.
+# % keyword: imagery
+# % keyword: metadata
+# % keyword: sentinel
+# % keyword: sentinel-1
+# % keyword: sentinel-2
+# % keyword: sentinel-3
+# %end
+
+# %option G_OPT_M_DIR
+# % key: input
+# % description: Name of input directory with json files to aggregate
+# % required: yes
+# %end
+
+# %option
+# % key: product_type
+# % description: Name of the type of Sentinel-products the metadata belongs to
+# % options: S1GRDH,S2MSIL1C,S2MSIL2A,S3OLCIL1B,S3SLSTRL1B
+# % type: string
+# % required: yes
+# %end
+
+# %option G_OPT_F_OUTPUT
+# % required: no
+# %end
+
+# %option
+# % key: file_pattern
+# % description: File name pattern of json files to aggregate (regular expression)
+# % type: string
+# % required: no
+# % answer: **/*.json
+# % guisection: Filter
+# %end
+
+
+import json
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import grass.script as gs
+
+
+def aggregate_metadata(json_files: Path, product_type: str = "S2MSIL1C") -> dict:
+ """Aggregate metadata JSON files."""
+ metadata_keys = {
+ "PRODUCT_START_TIME": datetime(1, 1, 1, 0, 0, 0), # '2025-08-11T10:37:01.024Z',
+ "PRODUCT_STOP_TIME": datetime.now()
+ + timedelta(days=365 * 1000), # '2025-08-11T10:37:01.024Z',
+ "PRODUCT_URI": set(), # 'S2A_MSIL2A_20250811T103701_N0511_R008_T33WXT_20250811T173718.SAFE',
+ "PROCESSING_LEVEL": set(), # 'Level-2A',
+ "PRODUCT_TYPE": set(), # 'S2MSI2A',
+ "PROCESSING_BASELINE": set(), # '05.11',
+ "PRODUCT_DOI": set(), # 'https://doi.org/10.5270/S2_-znk9xsj',
+ "GENERATION_TIME": [
+ datetime(1, 1, 1, 0, 0, 0),
+ datetime.now() + timedelta(days=365 * 1000),
+ ], # '2025-08-11T17:37:18.000000Z',
+ "PREVIEW_IMAGE_URL": set(), # 'Not applicable',
+ "PREVIEW_GEO_INFO": set(), # 'Not applicable',
+ "SPACECRAFT_NAME": set(), # 'Sentinel-2A',
+ "DATATAKE_TYPE": set(), # 'INS-NOBS',
+ "DATATAKE_SENSING_START": [
+ datetime(1, 1, 1, 0, 0, 0),
+ datetime.now() + timedelta(days=365 * 1000),
+ ], # '2025-08-11T10:37:01.024Z',
+ "SENSING_ORBIT_NUMBER": set(), # '8',
+ "SENSING_ORBIT_DIRECTION": set(), # 'DESCENDING',
+ "PRODUCT_FORMAT": set(), # 'SAFE_COMPACT',
+ "IMAGE_FILE": set(), # 'GRANULE/L2A_T33WXT_A052944_20250811T103955/IMG_DATA/R60m/T33WXT_20250811T103701_SCL_60m',
+ "CLOUDY_PIXEL_OVER_LAND_PERCENTAGE": 0.0, # '77.333683',
+ "CLOUDY_PIXEL_PERCENTAGE": 0.0, # '74.293292',
+ "DEGRADED_MSI_DATA_PERCENTAGE": 0.0, # '0.002200',
+ "MEAN_SUN_ZENITH_ANGLE": 0.0, # 54.6655024578069,
+ "MEAN_SUN_AZIMUTH_ANGLE": 0.0, # 177.779131794776,
+ "FORMAT_CORRECTNESS": set(), # 'PASSED',
+ "GENERAL_QUALITY": set(), # 'PASSED',
+ "GEOMETRIC_QUALITY": set(), # 'PASSED',
+ "RADIOMETRIC_QUALITY": set(), # 'PASSED',
+ "SENSOR_QUALITY": set(), # 'PASSED'
+ }
+ if product_type == "S2MSIL2A":
+ metadata_keys.update(
+ {
+ "NODATA_PIXEL_PERCENTAGE": 0.0, # '0.000000',
+ "SATURATED_DEFECTIVE_PIXEL_PERCENTAGE": 0.0, # '0.000000',
+ "CAST_SHADOW_PERCENTAGE": 0.0, # '0.442563',
+ "CLOUD_SHADOW_PERCENTAGE": 0.0, # '2.054479',
+ "VEGETATION_PERCENTAGE": 0.0, # '6.534617',
+ "NOT_VEGETATED_PERCENTAGE": 0.0, # '1.082438',
+ "WATER_PERCENTAGE": 0.0, # '13.964827',
+ "UNCLASSIFIED_PERCENTAGE": 0.0, # '1.513396',
+ "MEDIUM_PROBA_CLOUDS_PERCENTAGE": 0.0, # '13.894749',
+ "HIGH_PROBA_CLOUDS_PERCENTAGE": 0.0, # '58.243871',
+ "THIN_CIRRUS_PERCENTAGE": 0.0, # '2.154681',
+ "SNOW_ICE_PERCENTAGE": 0.0, # '0.114386',
+ "RADIATIVE_TRANSFER_ACCURACY": 0.0, # '0.0',
+ "WATER_VAPOUR_RETRIEVAL_ACCURACY": 0.0, # '0.0',
+ "AOT_RETRIEVAL_ACCURACY": 0.0, # '0.0',
+ "AOT_RETRIEVAL_METHOD": set(), # 'CAMS',
+ "GRANULE_MEAN_AOT": 0.0, # '0.070595',
+ "GRANULE_MEAN_WV": 0.0, # '1.314649',
+ "OZONE_SOURCE": set(), # 'AUX_ECMWFT',
+ "OZONE_VALUE": 0.0, # '302.702650',
+ "L2A_QUALITY": set(), # 'PASSED',
+ },
+ )
+ valid_data_total = 0.0
+ for json_file in json_files:
+ try:
+ meta_data = json.loads(json_file.read_text(encoding="utf-8"))
+ if "metadata" in meta_data:
+ meta_data = meta_data.get("metadata")
+ if "product_metadata":
+ meta_data = meta_data.get("product_metadata")
+ valid_data_percent = (
+ 1
+ if product_type == "S2MSIL1C"
+ else 100.0 - float(meta_data.get("NODATA_PIXEL_PERCENTAGE"))
+ )
+ for key, val in metadata_keys.items():
+ if key in meta_data:
+ if isinstance(val, float):
+ metadata_keys[key] += (
+ float(meta_data.get(key)) * valid_data_percent
+ )
+ elif isinstance(val, set):
+ metadata_keys[key].add(meta_data.get(key))
+ elif isinstance(val, datetime):
+ if "START" in key:
+ metadata_keys[key] = max(
+ metadata_keys[key],
+ datetime.fromisoformat(
+ meta_data.get(key).replace("Z", ""),
+ ),
+ )
+ else:
+ metadata_keys[key] = min(
+ metadata_keys[key],
+ datetime.fromisoformat(
+ meta_data.get(key).replace("Z", ""),
+ ),
+ )
+ elif isinstance(val, list):
+ metadata_keys[key][0] = max(
+ metadata_keys[key][0],
+ datetime.fromisoformat(meta_data.get(key).replace("Z", "")),
+ )
+ metadata_keys[key][1] = min(
+ metadata_keys[key][1],
+ datetime.fromisoformat(meta_data.get(key).replace("Z", "")),
+ )
+ else:
+ gs.warning(_("Expected key '{}' not in metadata {}.").format(key, str(json_file)))
+
+ valid_data_total += valid_data_percent
+ except json.JSONDecodeError as e:
+ print(f"Error decoding JSON from {json_file}: {e}")
+
+ for key, val in metadata_keys.items():
+ if isinstance(val, float):
+ metadata_keys[key] /= valid_data_total
+ elif isinstance(val, datetime):
+ metadata_keys[key] = val.strftime("%Y-%m-%dT%H:%M:%SZ")
+ elif isinstance(val, set):
+ if len(val) == 1:
+ metadata_keys[key] = next(iter(val))
+ else:
+ metadata_keys[key] = ",".join(sorted(val))
+ elif isinstance(val, list):
+ if val[0] != val[1]:
+ metadata_keys[key] = "/".join(
+ d.strftime("%Y-%m-%dT%H:%M:%SZ") for d in val
+ )
+ else:
+ metadata_keys[key] = val[0].strftime("%Y-%m-%dT%H:%M:%SZ")
+ return metadata_keys
+
+
+def main() -> None:
+ """Aggregate Sentinel scene metadata."""
+ # Get bands configuration info
+ input_dir = Path(options["input"])
+ file_pattern = options["file_pattern"]
+ jsons = list(
+ input_dir.glob("*.json") if not file_pattern else input_dir.glob(file_pattern)
+ )
+ if len(jsons) == 0:
+ gs.fatal(
+ _("No JSON files found in <{}> with file pattern <{}>").format(
+ input_dir, file_pattern
+ )
+ )
+
+ metadata = aggregate_metadata(
+ jsons,
+ product_type=options["product_type"],
+ )
+ if not options["output"]:
+ print(metadata)
+ else:
+ output_file = Path(options["output"])
+ try:
+ output_file.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
+ except OSError as e:
+ gs.fatal(
+ _("Unable to write to output file <{}>: {}").format(output_file, e)
+ )
+
+
+if __name__ == "__main__":
+ options, flags = gs.parser()
+ # lazy imports
+ try:
+ from osgeo import gdal
+
+ gdal.UseExceptions()
+ except ImportError as e:
+ gs.fatal(_("Unable to load GDAL Python bindings: {}").format(e))
+
+ sys.exit(main())
diff --git a/src/temporal/t.rast.import.gdalvrt/Makefile b/src/temporal/t.rast.import.gdalvrt/Makefile
new file mode 100644
index 0000000..0702366
--- /dev/null
+++ b/src/temporal/t.rast.import.gdalvrt/Makefile
@@ -0,0 +1,7 @@
+MODULE_TOPDIR = ../../
+
+PGM = t.rast.import.gdalvrt
+
+include $(MODULE_TOPDIR)/include/Make/Script.make
+
+default: script $(TEST_DST)
diff --git a/src/temporal/t.rast.import.gdalvrt/t.rast.import.gdalvrt.html b/src/temporal/t.rast.import.gdalvrt/t.rast.import.gdalvrt.html
new file mode 100644
index 0000000..1d21b72
--- /dev/null
+++ b/src/temporal/t.rast.import.gdalvrt/t.rast.import.gdalvrt.html
@@ -0,0 +1,27 @@
+DESCRIPTION
+t.rast.import.gdalvrt imports multiple external raster files
+as virtual mosaic (VRT) and registeres the resulting map in a Space Time
+Raster Dataset (STRDS).
+
+EXAMPLE
+
+Daily mosaic for Sentinel-3 Fractiona Snow Cover
+Patching multiband Sentinel-2 (MSI) L1C data into a daily mosaic:
+
+t.rast.import.gdalvrt input=./ output=S2_L1C_daily \
+ bands=./L1C_bands.json file_pattern="S2*L1C*20250707*.tif" -e --o
+t.info S2_L1C_daily
+
+
+SEE ALSO
+
+
+ t.rast.patch,
+ t.rast.patch,
+
+
+Temporal data processing Wiki
+
+
AUTHOR
+
+Stefan Blumentrath, NVE
diff --git a/src/temporal/t.rast.import.gdalvrt/t.rast.import.gdalvrt.py b/src/temporal/t.rast.import.gdalvrt/t.rast.import.gdalvrt.py
new file mode 100755
index 0000000..93fa615
--- /dev/null
+++ b/src/temporal/t.rast.import.gdalvrt/t.rast.import.gdalvrt.py
@@ -0,0 +1,454 @@
+#!/usr/bin/env python3
+"""MODULE: t.rast.import.gdalvrt
+AUTHOR(S): Stefan Blumentrath
+PURPOSE: Create a VRT (Virtual Raster Tile) from multiple raster files and import it to a STRDS
+COPYRIGHT: (C) 2025 by Stefan Blumentrath, NVE
+ and 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.
+"""
+
+# %Module
+# % description: Create a VRT (Virtual Raster Tile) from multiple raster files and import it to a STRDS.
+# % keyword: temporal
+# % keyword: raster
+# % keyword: strds
+# % keyword: gdal
+# % keyword: vrt
+# %end
+
+# %option G_OPT_M_DIR
+# % key: input
+# % description: Name of input directory with raster files to create VRT from
+# % required: yes
+# %end
+
+# %option
+# % key: suffix
+# % description: Suffix of files to include in VRT (default: .tif)
+# % answer: tif
+# % multiple: no
+# % required: yes
+# %end
+
+# %option G_OPT_M_DIR
+# % key: vrt_directory
+# % description: Name of directory into which VRT-files are written (default=input)
+# % required: no
+# %end
+
+# %option G_OPT_STRDS_OUTPUT
+# %end
+
+# %option
+# % key: title
+# % description: Title for the output STRDS (only used for new STRDS)
+# % type: string
+# % multiple: no
+# % required: no
+# %end
+
+# %option
+# % key: description
+# % description: Description of the output STRDS (only used for new STRDS)
+# % type: string
+# % multiple: no
+# % required: no
+# %end
+
+# %option G_OPT_F_INPUT
+# % key: bands
+# % description: JSON file with band configuration
+# % type: string
+# %end
+
+# %option
+# % key: basename
+# % description: Basename for VRT and GRASS raster maps to create
+# % type: string
+# % multiple: no
+# % required: no
+# %end
+
+# %option
+# % key: start_time
+# % description: Start time (ISO format) to register the VRT / GRASS raster maps with
+# % type: string
+# % multiple: no
+# % required: yes
+# %end
+
+# %option
+# % key: end_time
+# % description: End time (ISO format) to register the VRT / GRASS raster maps with
+# % type: string
+# % multiple: no
+# % required: no
+# %end
+
+# %option
+# % key: file_pattern
+# % description: File name pattern to import
+# % type: string
+# % multiple: no
+# % guisection: Filter
+# %end
+
+# %option
+# % key: memory
+# % type: integer
+# % required: no
+# % multiple: no
+# % label: Maximum memory to be used (in MB)
+# % description: Cache size for raster rows
+# % answer: 300
+# %end
+
+# %flag
+# % key: l
+# % description: Link the raster files using r.external
+# % guisection: Settings
+# %end
+
+# %flag
+# % key: f
+# % description: Link the raster files in a fast way, without reading metadata using r.external
+# % guisection: Settings
+# %end
+
+# %flag
+# % key: e
+# % description: Extend existing STRDS
+# % guisection: Settings
+# %end
+
+# %rules
+# % exclusive: -l,-f
+# % required: -e,title
+# % required: -e,description
+# % collective: title,description
+# %end
+
+import json
+import os
+import sys
+from datetime import datetime
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import grass.lib.raster as libraster
+import grass.script as gs
+import grass.temporal as tgis
+from grass.pygrass.gis import Mapset
+from grass.pygrass.modules import Module
+from grass.temporal.register import register_maps_in_space_time_dataset
+
+if TYPE_CHECKING:
+ from osgeo import gdal
+
+
+def open_strds(
+ strds: str,
+ title: str | None = None,
+ description: str | None = None,
+ *,
+ append: bool = False,
+ overwrite: bool = False,
+ mapset: str = Mapset().name,
+) -> str:
+ """Open a GRASS SpaceTimeRasterDataset (STRDS) for updating.
+
+ Creates a new STRDS if it does not exist, or opens an existing one for appending data.
+
+ Parameters
+ ----------
+ strds : str
+ Name of the SpaceTimeRasterDataset to open or create.
+ title : str | None
+ Title of the STRDS to create
+ description : str | None
+ Description of the STRDS to create
+ append : bool, optional
+ If True, opens the STRDS for appending data. Defaults to False.
+ overwrite : bool, optional
+ If True, overwrites the existing STRDS if it exists. Defaults to False.
+ mapset : str
+ Name of the current mapset
+
+ Returns
+ -------
+ tgis.SpaceTimeRasterDataset
+
+ """
+ # Initialize SpaceTimeRasterDataset (STRDS) using tgis
+ strds_long_name = strds if "@" in strds else f"{strds}@{mapset}"
+ tgis_strds = tgis.SpaceTimeRasterDataset(strds_long_name)
+
+ # Check if target STRDS exists and create it if not or abort if overwriting is not allowed
+ if tgis_strds.is_in_db() and not overwrite:
+ gs.fatal(
+ _(
+ "Output STRDS <{}> exists."
+ " Use --overwrite with or without -e to modify the existing STRDS.",
+ ).format(strds),
+ )
+ if not tgis_strds.is_in_db() or (overwrite and not append):
+ Module(
+ "t.create",
+ output=strds,
+ type="strds",
+ temporaltype="absolute",
+ title=title,
+ description=description,
+ verbose=True,
+ )
+ return strds_long_name
+
+
+def get_gdal_band_color_interpretation() -> dict:
+ """Get GDAL band color interpretation as a dictionary."""
+ minimal_gdal_version = 3100000
+ get_gdal_band_colors = {
+ "Undefined": gdal.GCI_Undefined,
+ "Greyscale": gdal.GCI_GrayIndex,
+ "Paletted": gdal.GCI_PaletteIndex, # (see associated color table)
+ "Red": gdal.GCI_RedBand, # RGBA image, or red spectral band [0.62 - 0.69 um]
+ "Green": gdal.GCI_GreenBand, # RGBA image, or green spectral band [0.51 - 0.60 um]
+ "Blue": gdal.GCI_BlueBand, # RGBA image, or blue spectral band [0.45 - 0.53 um]
+ "Alpha": gdal.GCI_AlphaBand, # (0=transparent, 255=opaque)
+ "Hue": gdal.GCI_HueBand, # HLS image
+ "Saturation": gdal.GCI_SaturationBand, # HLS image
+ "Lightness": gdal.GCI_LightnessBand, # HLS image
+ "Cyan": gdal.GCI_CyanBand, # CMYK image
+ "Magenta": gdal.GCI_MagentaBand, # CMYK image
+ "Yellow": gdal.GCI_YellowBand, # CMYK image, or yellow spectral band [0.58 - 0.62 um]
+ "Black": gdal.GCI_BlackBand, # CMYK image
+ "Y": gdal.GCI_YCbCr_YBand, # Luminance
+ "Cb": gdal.GCI_YCbCr_CbBand, # Chroma
+ "Cr": gdal.GCI_YCbCr_CrBand, # Chroma
+ }
+ if int(gdal.VersionInfo()) >= minimal_gdal_version:
+ get_gdal_band_colors.update(
+ {
+ "Panchromatic": gdal.GCI_PanBand, # [0.40 - 1.00 um]
+ "Coastal": gdal.GCI_CoastalBand, # [0.40 - 0.45 um]
+ "Red-edge": gdal.GCI_RedEdgeBand, # [0.69 - 0.79 um]
+ "Near-InfraRed (NIR)": gdal.GCI_NIRBand, # [0.75 - 1.40 um]
+ "Short-Wavelength InfraRed (SWIR)": gdal.GCI_SWIRBand, # [1.40 - 3.00 um]
+ "Mid-Wavelength InfraRed (MWIR)": gdal.GCI_MWIRBand, # [3.00 - 8.00 um]
+ "Long-Wavelength InfraRed (LWIR)": gdal.GCI_LWIRBand, # [8.00 - 15 um]
+ "Thermal InfraRed (TIR)": gdal.GCI_TIRBand, # (MWIR or LWIR) [3 - 15 um]
+ "Other infrared": gdal.GCI_OtherIRBand, # [0.75 - 1000 um]
+ # "Reserved value": gdal.GCI_IR_Reserved_1, # Do not set it !
+ # "Reserved value": gdal.GCI_IR_Reserved_2, # Do not set it !
+ # "Reserved value": gdal.GCI_IR_Reserved_3, # Do not set it !
+ # "Reserved value": gdal.GCI_IR_Reserved_4, # Do not set it !
+ "Synthetic Aperture Radar (SAR) Ka": gdal.GCI_SAR_Ka_Band, # [0.8 - 1.1 cm / 27 - 40 GHz]
+ "Synthetic Aperture Radar (SAR) K": gdal.GCI_SAR_K_Band, # [1.1 - 1.7 cm / 18 - 27 GHz]
+ "Synthetic Aperture Radar (SAR) Ku": gdal.GCI_SAR_Ku_Band, # [1.7 - 2.4 cm / 12 - 18 GHz]
+ "Synthetic Aperture Radar (SAR) X": gdal.GCI_SAR_X_Band, # [2.4 - 3.8 cm / 8 - 12 GHz]
+ "Synthetic Aperture Radar (SAR) C": gdal.GCI_SAR_C_Band, # [3.8 - 7.5 cm / 4 - 8 GHz]
+ "Synthetic Aperture Radar (SAR) S": gdal.GCI_SAR_S_Band, # [7.5 - 15 cm / 2 - 4 GHz]
+ "Synthetic Aperture Radar (SAR) L": gdal.GCI_SAR_L_Band, # [15 - 30 cm / 1 - 2 GHz]
+ "Synthetic Aperture Radar (SAR) P": gdal.GCI_SAR_P_Band, # [30 - 100 cm / 0.3 - 1 GHz]
+ "SAR Reserved value": gdal.GCI_SAR_Reserved_1, # Do not set it !
+ # "SAR Reserved value": gdal.GCI_SAR_Reserved_2, # Do not set it !
+ "Max current value": gdal.GCI_Max, # (equals to GCI_SAR_Reserved_2 currently)
+ },
+ )
+ return get_gdal_band_colors
+
+
+def build_vrt(
+ raster_directory: Path,
+ vrt_directory: Path,
+ band_template: dict[str, str],
+ *,
+ vrt_name: str | None = None,
+ raster_file_pattern: str = "*.tif",
+ multiband: bool = True,
+) -> tuple[str, dict]:
+ """Build a VRT file for GDAL readable raster data in a directory.
+
+ :param raster_directory: Path to the directory containing GTIFF files.
+ :param vrt_directory: Path to the directory where the VRT file
+ will be saved.
+ :param band_template: Dictionary with expected band names.
+ :param vrt_name: Name of the VRT dataset to produce.
+ :multiband: Contain the input rasters multiple bands
+
+ Assumes a specific naming convention and that:
+ 1) the suffix of the GTIFF files is .tif if no pattern is provided,
+ 2) that the the end of the file name contains
+ the name / id of the semantic label / band, and
+ 3) that the input GTIFFs are single band files.
+
+ """
+ tiffs = list(raster_directory.glob(raster_file_pattern))
+
+ if not vrt_name:
+ vrt_name = Path(os.path.commonprefix(tiffs)).stem
+ vrt_path = vrt_directory / f"{vrt_name}.vrt"
+ ds = gdal.BuildVRT(str(vrt_path), tiffs, separate=not multiband)
+
+ raster_bands = ds.RasterCount
+ if len(band_template) != raster_bands:
+ gs.fatal(
+ _(
+ "Band configuration contains {config_bands} bands, VRT file {vrt_bands}",
+ ).format(config_bands=len(band_template), vrt_bands=raster_bands),
+ )
+ ds = None
+ vrt_path.unlink()
+ for bid, band_tuple in enumerate(band_template.items()):
+ # GDAL bands are 1-indexed
+ band = ds.GetRasterBand(bid + 1)
+ # Set band name
+ band.SetDescription(band_tuple[0])
+ # Set band color interpretation
+ color_interpretation = GDAL_BAND_COLOR_INTERPRETATION.get(
+ band_tuple[1],
+ gdal.GCI_Undefined,
+ )
+ band.SetColorInterpretation(color_interpretation)
+ # Here we could set band metadata
+ # See:
+ # https://gdal.org/en/stable/drivers/raster/gtiff.html#metadata
+ # https://gdal.org/en/stable/user/raster_data_model.html#imagery-domain-remote-sensing
+
+ ds = None
+
+ return vrt_path
+
+
+def import_vrt(
+ vrt: Path,
+ band_template: dict[str, str],
+ *,
+ mapset: str = Mapset().name,
+ start_time: datetime | str | None = None,
+ end_time: datetime | str | None = None,
+ link: bool = True,
+ fast: bool = False,
+) -> list[str]:
+ """Import a VRT file into GRASS GIS as an external raster map."""
+ register_strings = []
+ for idx, band_id in enumerate(band_template):
+ output_name = f"{vrt.stem}.{band_id}"
+ Module(
+ "r.external" if link or fast else "r.in.gdal",
+ input=str(vrt),
+ output=output_name,
+ band=idx + 1,
+ flags="re" if fast else "e",
+ overwrite=gs.overwrite(),
+ )
+ semantic_label = band_id
+ register_string = (
+ f"{output_name}@{mapset}|{start_time.strftime('%Y-%m-%dT%H:%M:%S')}"
+ )
+ if end_time:
+ register_string += f"|{end_time.strftime('%Y-%m-%dT%H:%M:%S')}"
+ if semantic_label:
+ register_string += f"|{semantic_label}"
+ register_strings.append(register_string)
+ return register_strings
+
+
+GDAL_BAND_COLOR_INTERPRETATION = get_gdal_band_color_interpretation()
+
+
+def main() -> None:
+ """Import using Sentinel2Importer."""
+ # Get bands configuration info
+ try:
+ band_config = json.loads(Path(options["bands"]).read_text(encoding="UTF8"))
+ except json.JSONDecodeError:
+ gs.fatal(_("Band configuration file is not a valid JSON file."))
+
+ # Check that band IDs are valid semantic labels in GRASS
+ for band_id in band_config:
+ if libraster.Rast_legal_semantic_label(band_id) is False:
+ gs.fatal(
+ _(
+ 'Band ID "{band_id}" is not a valid semantic label.',
+ ).format(band_id=band_id),
+ )
+
+ # Create output directory if needed
+ input_dir = Path(options["input"])
+
+ # Create output directory if needed
+ output_dir = Path(options["vrt_directory"])
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ try:
+ for dt in ("end_time", "start_time"):
+ if dt == "end_time" and not options[dt]:
+ options[dt] = None
+ continue
+ options[dt] = datetime.fromisoformat(options[dt].replace("Z", ""))
+ except ValueError:
+ gs.fatal(
+ _("Input for '{}' is not a valid ISO format. Got {}").format(
+ dt, options[dt],
+ ),
+ )
+
+ vrt = build_vrt(
+ input_dir,
+ output_dir,
+ band_config,
+ vrt_name=options["basename"] or None,
+ raster_file_pattern=options["file_pattern"],
+ )
+
+ register_strings = import_vrt(
+ vrt,
+ band_config,
+ start_time=options["start_time"],
+ end_time=options["end_time"],
+ link=flags["l"] or flags["f"],
+ fast=flags["f"],
+ )
+
+ # Initialize TGIS
+ tgis.init()
+ strds = open_strds(
+ options["output"],
+ options["title"],
+ options["description"],
+ append=flags["e"],
+ overwrite=gs.overwrite(),
+ )
+
+ gs.verbose(_("Registering imported maps in STRDS..."))
+ # Create temporal register file
+ map_file = Path(gs.tempfile())
+ map_file.write_text(
+ "\n".join({r_s for r_s in register_strings if r_s is not None}),
+ encoding="UTF8",
+ )
+
+ register_maps_in_space_time_dataset(
+ "raster",
+ strds,
+ file=map_file,
+ update_cmd_list=False,
+ fs="|",
+ )
+
+
+if __name__ == "__main__":
+ options, flags = gs.parser()
+ # lazy imports
+ try:
+ from osgeo import gdal
+
+ gdal.UseExceptions()
+ except ImportError as e:
+ gs.fatal(_("Unable to load GDAL Python bindings: {}").format(e))
+
+ sys.exit(main())