diff --git a/python/grass/jupyter/baseseriesmap.py b/python/grass/jupyter/baseseriesmap.py index 7450794ca74..bbe1291dcbb 100644 --- a/python/grass/jupyter/baseseriesmap.py +++ b/python/grass/jupyter/baseseriesmap.py @@ -22,18 +22,54 @@ import shutil import multiprocessing +from functools import partial + import grass.script as gs from .map import Map from .utils import get_number_of_cores, save_gif +def _render_worker_base( + i: int, + tmpdir: str | None = None, + base_file: str | None = None, + width: int | None = None, + height: int | None = None, + calls: list | None = None, + indices: list | None = None, + env: dict | None = None, +): + """Render a single layer. + + Being at top-level, this function isolates rendering + from the BaseSeriesMap object or object derived from it, + because any of those objects contains any attribute that + creates a lock, parallel processing with spawn would fail, + if this function was part of the object itself. + """ + filename = os.path.join(tmpdir, f"{i}.png") + shutil.copyfile(base_file, filename) + img = Map( + width=width, + height=height, + filename=filename, + use_region=True, + env=env, + read_file=True, + ) + for grass_module, kwargs in calls[i]: + if grass_module is not None: + img.run(grass_module, **kwargs) + return indices[i], filename + + class BaseSeriesMap: """ Base class for SeriesMap and TimeSeriesMap """ - def __init__(self, width=None, height=None, env=None): + def __init__(self, width: int = 600, height: int = 400, env=None): """Creates an instance of the visualizations class. :param int width: width of map in pixels @@ -101,25 +137,8 @@ def _render_baselayers(self, img): for grass_module, kwargs in self._base_layer_calls: img.run(grass_module, **kwargs) - def _render_worker(self, i): - """Function to render a single layer.""" - filename = os.path.join(self._tmpdir.name, f"{i}.png") - shutil.copyfile(self.base_file, filename) - img = Map( - width=self._width, - height=self._height, - filename=filename, - use_region=True, - env=self._env, - read_file=True, - ) - for grass_module, kwargs in self._calls[i]: - if grass_module is not None: - img.run(grass_module, **kwargs) - return self._indices[i], filename - def render(self): - """Renders image for each raster in series. + """Render an image for each map in series. Save PNGs to temporary directory. Must be run before creating a visualization (i.e. show or save). @@ -136,6 +155,7 @@ def render(self): # Random name needed to avoid potential conflict with layer names random_name_base = gs.append_random("base", 8) + ".png" self.base_file = os.path.join(self._tmpdir.name, random_name_base) + base_file_path = os.path.join(self._tmpdir.name, random_name_base) img = Map( width=self._width, height=self._height, @@ -151,9 +171,20 @@ def render(self): self._render_baselayers(img) # Render layers in respective classes + cores = get_number_of_cores(len(tasks), env=self._env) + render_worker = partial( + _render_worker_base, + tmpdir=str(self._tmpdir.name), + base_file=base_file_path, + width=int(self._width), + height=int(self._height), + calls=self._calls, + indices=self._indices, + env=dict(self._env) if self._env else None, + ) with multiprocessing.Pool(processes=cores) as pool: - results = pool.starmap(self._render_worker, tasks) + results = pool.starmap(render_worker, tasks) for i, filename in results: self._base_filename_dict[i] = filename diff --git a/python/grass/jupyter/region.py b/python/grass/jupyter/region.py index 2090430a6de..092b5809078 100644 --- a/python/grass/jupyter/region.py +++ b/python/grass/jupyter/region.py @@ -49,7 +49,8 @@ def __init__(self, use_region, saved_region, src_env, tgt_env): self._set_bbox(self._src_env) if self._saved_region: self._src_env["GRASS_REGION"] = gs.region_env( - region=self._saved_region, env=self._src_env + region=self._saved_region, + env=self._src_env, ) set_target_region(src_env=self._src_env, tgt_env=self._tgt_env) self._resolution = self._get_psmerc_region_resolution() @@ -145,7 +146,8 @@ def __init__(self, use_region, saved_region, width, height, env): def set_region_from_env(self, env): """Copies GRASS_REGION from provided environment - to local environment to set the computational region""" + to local environment to set the computational region + """ if "GRASS_REGION" in env: self._env["GRASS_REGION"] = env["GRASS_REGION"] @@ -177,7 +179,8 @@ def set_region_from_command(self, module, **kwargs): """ if self._saved_region: self._env["GRASS_REGION"] = gs.region_env( - region=self._saved_region, env=self._env + region=self._saved_region, + env=self._env, ) return if self._use_region: @@ -194,7 +197,8 @@ def set_region_from_command(self, module, **kwargs): if module.startswith("d.vect"): if not self._resolution_set and not self._extent_set: self._env["GRASS_REGION"] = gs.region_env( - vector=name, env=self._env + vector=name, + env=self._env, ) self._extent_set = True elif not self._resolution_set and not self._extent_set: @@ -243,7 +247,8 @@ def set_region_from_rasters(self, rasters): """ if self._saved_region: self._env["GRASS_REGION"] = gs.region_env( - region=self._saved_region, env=self._env + region=self._saved_region, + env=self._env, ) return if self._use_region: @@ -267,7 +272,8 @@ def set_region_from_vectors(self, vectors): """ if self._saved_region: self._env["GRASS_REGION"] = gs.region_env( - region=self._saved_region, env=self._env + region=self._saved_region, + env=self._env, ) return if self._use_region: @@ -334,7 +340,7 @@ def __init__(self, use_region, saved_region, env): self._use_region = use_region self._saved_region = saved_region - def set_region_from_timeseries(self, timeseries, element_type="strds"): + def set_region_from_timeseries(self, stds: object | None = None) -> None: """Sets computational region for rendering. This function sets the computation region from the extent of @@ -346,24 +352,25 @@ def set_region_from_timeseries(self, timeseries, element_type="strds"): """ if self._saved_region: self._env["GRASS_REGION"] = gs.region_env( - region=self._saved_region, env=self._env + region=self._saved_region, + env=self._env, ) return if self._use_region: # use current return - # Get extent, resolution from space time dataset - info = gs.parse_command( - "t.info", input=timeseries, type=element_type, flags="g", env=self._env - ) - # Set grass region from extent + if not stds: + raise RuntimeError(_("No SpaceTimeDataset provided to set region from.")) + # Set grass region from STDS extent params = { - "n": info["north"], - "s": info["south"], - "e": info["east"], - "w": info["west"], + "n": stds.spatial_extent.north, + "s": stds.spatial_extent.south, + "e": stds.spatial_extent.east, + "w": stds.spatial_extent.west, } - if "nsres_min" in info: - params["nsres"] = info["nsres_min"] - params["ewres"] = info["ewres_min"] + region_dict = stds.metadata.__dict__["D"] + for resolution in ("nsres", "ewres"): + resolution_value = region_dict.get(f"{resolution}_min") + if resolution_value: + params[resolution] = resolution_value self._env["GRASS_REGION"] = gs.region_env(**params, env=self._env) diff --git a/python/grass/jupyter/seriesmap.py b/python/grass/jupyter/seriesmap.py index 5aa4bf4dfd8..477fa8453e0 100644 --- a/python/grass/jupyter/seriesmap.py +++ b/python/grass/jupyter/seriesmap.py @@ -42,11 +42,11 @@ class SeriesMap(BaseSeriesMap): def __init__( self, - width=None, - height=None, - env=None, - use_region=False, - saved_region=None, + width: int = 600, + height: int = 400, + env: dict | None = None, + use_region: bool = False, + saved_region: str | None = None, ): """Creates an instance of the SeriesMap visualizations class. diff --git a/python/grass/jupyter/timeseriesmap.py b/python/grass/jupyter/timeseriesmap.py index 4b302f7b878..8fe6ece648b 100644 --- a/python/grass/jupyter/timeseriesmap.py +++ b/python/grass/jupyter/timeseriesmap.py @@ -6,13 +6,15 @@ # PURPOSE: This module contains functions for visualizing raster and vector # space-time datasets in Jupyter Notebooks # -# COPYRIGHT: (C) 2022-2024 Caitlin Haedrich, and by the GRASS Development Team +# COPYRIGHT: (C) 2022-2026 Caitlin Haedrich, and 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. """Create and display visualizations for space-time datasets.""" +from datetime import datetime + import grass.script as gs from grass.tools import Tools @@ -20,15 +22,19 @@ from .baseseriesmap import BaseSeriesMap -def fill_none_values(names): - """Replace `None` values in array with previous item""" +def fill_none_values(names: list[str]) -> list[str]: + """Replace `None` values in array with previous item.""" for i, name in enumerate(names): if name is None: names[i] = names[i - 1] return names -def collect_layers(timeseries, element_type, fill_gaps): +def collect_layers( + timeseries: str, + element_type: str, + fill_gaps: bool, +) -> tuple[list, list]: """Create lists of layer names and start_times for a space-time raster or vector dataset. @@ -47,7 +53,7 @@ def collect_layers(timeseries, element_type, fill_gaps): result = tools.t_vect_list(method="gran", input=timeseries, format="json") else: raise NameError( - _("Dataset {} must be element type 'strds' or 'stvds'").format(timeseries) + _("Dataset {} must be element type 'strds' or 'stvds'").format(timeseries), ) # Get layer names and start time from json names = [item["name"] for item in result["data"]] @@ -61,25 +67,27 @@ def collect_layers(timeseries, element_type, fill_gaps): return names, dates -def check_timeseries_exists(timeseries, element_type): - """Check that timeseries is time space dataset""" +def check_timeseries_exists(timeseries: str, element_type: str) -> None: + """Check that timeseries is time space dataset.""" test = gs.read_command("t.list", type=element_type, where=f"name='{timeseries}'") if not test: raise NameError( _("Could not find space time dataset named {} of type {}").format( - timeseries, element_type - ) + timeseries, + element_type, + ), ) class TimeSeriesMap(BaseSeriesMap): - """Creates visualizations of time-space raster and vector datasets in Jupyter + """Create visualizations of time-space raster and vector datasets in Jupyter Notebooks. :Basic usage: .. code-block:: pycon - >>> img = TimeSeriesMap("series_name") + >>> img = TimeSeriesMap() + >>> img.add_raster_series("series_name") # Add STRDS >>> img.d_legend() # Add legend >>> img.show() # Create TimeSlider >>> img.save("image.gif") @@ -94,25 +102,31 @@ class TimeSeriesMap(BaseSeriesMap): def __init__( self, - width=None, - height=None, - env=None, - use_region=False, - saved_region=None, - ): - """Creates an instance of the TimeSeriesMap visualizations class. + width: int = 600, + height: int = 400, + env: dict | None = None, + use_region: bool = False, + saved_region: str | None = None, + ) -> None: + """Create an instance of the TimeSeriesMap visualizations class. :param int width: width of map in pixels :param int height: height of map in pixels :param str env: environment - :param use_region: if True, use either current or provided saved region, + :param bool use_region: if True, use either current or + provided saved region, else derive region from rendered layers - :param saved_region: if name of saved_region is provided, + :param str saved_region: if name of saved_region is provided, this region is then used for rendering """ super().__init__(width, height, env) - self._element_type = None + # TGIS requires a GRASS session thus lazy import here + global tgis + import grass.temporal as tgis # noqa: PLC0415 + + tgis.init() + self._fill_gaps = None self._legend = None self._layers = None @@ -122,74 +136,181 @@ def __init__( # Handle Regions self._region_manager = RegionManagerForTimeSeries( - use_region, saved_region, self._env + use_region, + saved_region, + self._env, ) - def add_raster_series(self, baseseries, fill_gaps=False, **kwargs): - """ - :param str baseseries: name of space-time dataset - :param bool fill_gaps: fill empty time steps with data from previous step + def _try_open_stds(self, stds_type: str) -> None: + try: + self._stds = tgis.open_old_stds(self._baseseries, stds_type) + self._stds.select() + except SystemExit: + raise RuntimeError( + _("Unable to open Space time dataset <%s> of type <%s>") + % self._baseseries, + stds_type, + ) from None + + def _check_number_of_timesteps( + self, + start_time: datetime, + end_time: datetime, + ) -> None: + """Inform about the number of maps to be added to the series map.""" + time_steps = ( + tgis.increment_datetime_by_string(start_time, self._granularity) + - start_time + ) + time_steps_n = (end_time - start_time) / time_steps + gs.info(_("Adding %i raster maps to series map.") % time_steps_n) + + def _collect_layers(self) -> None: + """Collect the maps to be added to the series map.""" + if self._granularity and not tgis.check_granularity_string( + self._granularity, + temporal_type=self._stds.get_temporal_type(), + ): + raise ValueError( + _("Invalid granularity <%s> for Space Time Dataset <%s>.") + % self._granularity, + self._stds.get_id(), + ) + if not self._granularity: + self._granularity = self._stds.get_granularity() + + if not self._where: + self._check_number_of_timesteps( + self._stds.temporal_extent.start_time, + self._stds.temporal_extent.end_time, + ) + map_list = self._stds.get_registered_maps_as_objects_by_granularity( + self._granularity, + ) + else: + map_list = self._stds.get_registered_maps_as_objects( + where=self._where, + order="start_time", + ) + self._check_number_of_timesteps( + map_list[0].temporal_extent.start_time, + map_list[-1].temporal_extent.end_time, + ) + map_list = tgis.AbstractSpaceTimeDataset.resample_maplist_by_granularity( + map_list, + map_list[0].get_temporal_extent().start_time, + map_list[-1].get_temporal_extent().start_time, + self._granularity, + ) + self._layers = [] + self._labels = [] + for map_layer in map_list: + if isinstance(map_layer, list): + map_layer = next(iter(map_layer)) + self._layers.append(map_layer.get_id()) + self._labels.append(str(map_layer.get_temporal_extent().start_time)) + + def _add_map_series( + self, + baseseries: str, + fill_gaps: bool, + where: str | None = None, + granularity: str | None = None, + element_type: str | None = None, + **kwargs, # noqa: ANN003 + ) -> None: + """Add a map series to the TimeSeriesMap object. + + :param str baseseries: name of space-time dataset (STDS) + :param str where: temporal where clause to select maps from STDS + :param str granularity: granularity string (e.g. "1 day"), + overrides granularity from STDS + :param bool fill_gaps: fill empty time steps with data from + previous step """ if self._baseseries_added and self._baseseries != baseseries: msg = "Cannot add more than one space time dataset" raise AttributeError(msg) - self._element_type = "strds" - check_timeseries_exists(baseseries, self._element_type) self._baseseries = baseseries self._fill_gaps = fill_gaps + self._try_open_stds(element_type) self._baseseries_added = True + self._where = where + self._granularity = granularity - # create list of layers to render and date/times - self._layers, self._labels = collect_layers( - self._baseseries, self._element_type, self._fill_gaps - ) - for raster in self._layers: - kwargs["map"] = raster - if raster is None: + renderer = { + "strds": "d.rast", + "stvds": "d.vect", + }.get(element_type) + + # Create list of layers to render and date/times + self._collect_layers() + for series_map in self._layers: + kwargs["map"] = series_map + if series_map is None: self._calls.append([(None, None)]) else: - self._calls.append([("d.rast", kwargs.copy())]) + self._calls.append([(renderer, kwargs.copy())]) self._date_layer_dict = { self._labels[i]: self._layers[i] for i in range(len(self._labels)) } # Update Region - self._region_manager.set_region_from_timeseries(self._baseseries) + self._region_manager.set_region_from_timeseries(self._stds) self._indices = self._labels - def add_vector_series(self, baseseries, fill_gaps=False, **kwargs): - """ - :param str baseseries: name of space-time dataset + def add_raster_series( + self, + baseseries: str, + where: str | None = None, + granularity: str | None = None, + *, + fill_gaps: bool = False, + **kwargs, # noqa: ANN003 + ) -> None: + """Add a raster map series to the TimeSeriesMap object. + + :param str baseseries: name of space-time raster dataset (STRDS) + :param str where: temporal where clause to select raster maps from STRDS + :param str granularity: granularity string (e.g. "1 day"), + overrides granularity from STRDS :param bool fill_gaps: fill empty time steps with data from previous step """ - if self._baseseries_added and self._baseseries != baseseries: - msg = "Cannot add more than one space time dataset" - raise AttributeError(msg) - self._element_type = "stvds" - check_timeseries_exists(baseseries, self._element_type) - self._baseseries = baseseries - self._fill_gaps = fill_gaps - self._baseseries_added = True - - # create list of layers to render and date/times - self._layers, self._labels = collect_layers( - self._baseseries, self._element_type, self._fill_gaps + self._add_map_series( + baseseries, + fill_gaps, + where, + granularity, + element_type="strds", + **kwargs, ) - for vector in self._layers: - kwargs["map"] = vector - if vector is None: - self._calls.append([(None, None)]) - else: - self._calls.append([("d.vect", kwargs.copy())]) - self._date_layer_dict = { - self._labels[i]: self._layers[i] for i in range(len(self._labels)) - } - # Update Region - self._region_manager.set_region_from_timeseries( - self._baseseries, element_type="stvds" + + def add_vector_series( + self, + baseseries: str, + where: str, + granularity: str, + *, + fill_gaps: bool = False, + **kwargs, # noqa: ANN003 + ) -> None: + """Add a vector map series to the TimeSeriesMap object. + + :param str baseseries: name of space-time vector dataset (STVDS) + :param str where: temporal where clause to select vector maps from STVDS + :param str granularity: granularity string (e.g. "1 day"), + overrides granularity from STVDS + :param bool fill_gaps: fill empty time steps with data from previous step + """ + self._add_map_series( + baseseries, + fill_gaps, + where, + granularity, + element_type="stvds", + **kwargs, ) - self._indices = self._labels - def d_legend(self, **kwargs): + def d_legend(self, **kwargs) -> None: # noqa: ANN003 """Display legend. Wraps d.legend and uses same keyword arguments. @@ -200,17 +321,14 @@ def d_legend(self, **kwargs): for i in range(len(self._layers)): self._calls[i].append(("d.legend", kwargs)) else: - info = gs.parse_command( - "t.info", input=self._baseseries, flags="g", env=self._env - ) for i in range(len(self._layers)): self._calls[i].append( ( "d.legend", dict( raster=self._layers[0], - range=f"{info['min_min']},{info['max_max']}", + range=f"{self._stds.metadata.min_min},{self._stds.metadata.max_max}", **kwargs, ), - ) + ), )