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())