Skip to content

Commit eaaefaa

Browse files
authored
t.rast.extract: Handle fully qualified map names and semantic labels (#6300)
This PR appends semantic labels to the basename when expression is given for extraction of raster maps and handles fully qualified STRDS names more reliably. It also adds a pytest based testcase. Fixes #6254 * add pytest for name replacement * include semantic labels in test * handle semantic labels * ensure safe name replacement * add test for inconsistent names in expression * remove unneeded assert * handle semantic_label properly * address test failures and old DB layout * fix semantic label check and cleanup * move pytest and remove duplicate case * moved
1 parent ee87f21 commit eaaefaa

File tree

2 files changed

+307
-20
lines changed

2 files changed

+307
-20
lines changed

python/grass/temporal/extract.py

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,22 @@
99
:authors: Soeren Gebbert
1010
"""
1111

12+
from __future__ import annotations
13+
14+
import re
1215
import sys
1316
from multiprocessing import Process
1417

1518
import grass.script as gs
1619
from grass.exceptions import CalledModuleError
1720

1821
from .abstract_map_dataset import AbstractMapDataset
22+
1923
from .core import (
2024
SQLDatabaseInterfaceConnection,
2125
get_current_mapset,
2226
get_tgis_message_interface,
27+
get_tgis_db_version,
2328
)
2429
from .datetime_math import (
2530
create_numeric_suffix,
@@ -31,6 +36,82 @@
3136
############################################################################
3237

3338

39+
def compile_new_map_name(
40+
sp,
41+
base: str,
42+
count: int,
43+
map_id: str,
44+
semantic_label: str | None,
45+
time_suffix: str | None,
46+
dbif: SQLDatabaseInterfaceConnection,
47+
):
48+
"""Compile new map name with suffix and semantic label.
49+
50+
:param sp: An open SpaceTimeDataSet (STDS)
51+
:param count: Running number of the map to be used as numeric suffix (if not time suffix)
52+
:param map_id: Map ID to compile new map name for
53+
:param time_suffix: Type of time suffix to use (or None)
54+
:param dbif: initialized TGIS database interface
55+
"""
56+
if semantic_label:
57+
base = f"{base}_{semantic_label}"
58+
if (
59+
sp.get_temporal_type() != "absolute"
60+
or not time_suffix
61+
or time_suffix.startswith("num")
62+
):
63+
return create_numeric_suffix(base, count, time_suffix)
64+
old_map = sp.get_new_map_instance(map_id)
65+
old_map.select(dbif)
66+
if time_suffix == "gran":
67+
suffix = create_suffix_from_datetime(
68+
old_map.temporal_extent.get_start_time(), sp.get_granularity()
69+
)
70+
else:
71+
suffix = create_time_suffix(old_map)
72+
return f"{base}_{suffix}"
73+
74+
75+
def replace_stds_names(expression: str, simple_name: str, full_name: str) -> str:
76+
"""Safely replace simple with full STDS names.
77+
78+
When users provide inconsistent input for STDS in the expression
79+
(with and without mapset componenet) or if the STDS name is part
80+
of the name of other raster maps in the expression, the final
81+
mapcalc expression may become invalid when the STDS name later is
82+
replaced with the name of the individual maps in the time series.
83+
The replacement with the fully qualified STDS names avoids that
84+
confusion.
85+
86+
:param expression: The mapcalc expression to replace names in
87+
:param simple_name: STDS name *without* mapset component
88+
:param full_name: STDS name *with* mapset component
89+
"""
90+
separators = r""" ,-+*/^:&|"'`()<>#^"""
91+
name_matches = re.finditer(simple_name, expression)
92+
new_expression = ""
93+
old_idx = 0
94+
for match in name_matches:
95+
# Fill-in expression component between matches
96+
new_expression += expression[old_idx : match.start()]
97+
# Only replace STDS name if pre- and succeeded by a separator
98+
# Match is either at the start or preceeeded by a separator
99+
if match.start() == 0 or expression[match.start() - 1] in separators:
100+
# Match is either at the end or succeeded by a separator
101+
if (
102+
match.end() + 1 > len(expression)
103+
or expression[match.end()] in separators
104+
):
105+
new_expression += full_name
106+
else:
107+
new_expression += simple_name
108+
else:
109+
new_expression += simple_name
110+
old_idx = match.end()
111+
new_expression += expression[old_idx:]
112+
return new_expression
113+
114+
34115
def extract_dataset(
35116
input,
36117
output,
@@ -82,13 +163,24 @@ def extract_dataset(
82163
dbif = SQLDatabaseInterfaceConnection()
83164
dbif.connect()
84165

166+
tgis_version = get_tgis_db_version()
167+
85168
sp = open_old_stds(input, type, dbif)
169+
has_semantic_labels = bool(
170+
tgis_version > 2 and type == "raster" and sp.metadata.semantic_labels
171+
)
172+
86173
# Check the new stds
87174
new_sp = check_new_stds(output, type, dbif, gs.overwrite())
88175
if type == "vector":
89176
rows = sp.get_registered_maps("id,name,mapset,layer", where, "start_time", dbif)
90177
else:
91-
rows = sp.get_registered_maps("id", where, "start_time", dbif)
178+
rows = sp.get_registered_maps(
179+
f"id{',semantic_label' if has_semantic_labels else ''}",
180+
where,
181+
"start_time",
182+
dbif,
183+
)
92184

93185
new_maps = {}
94186
if rows:
@@ -102,33 +194,30 @@ def extract_dataset(
102194
proc_count = 0
103195
proc_list = []
104196

197+
# Make sure STRDS is in the expression referenced with fully qualified name
198+
expression = replace_stds_names(
199+
expression, sp.base.get_name(), sp.base.get_map_id()
200+
)
105201
for row in rows:
106202
count += 1
107203

108204
if count % 10 == 0:
109205
msgr.percent(count, num_rows, 1)
110206

111-
if sp.get_temporal_type() == "absolute" and time_suffix == "gran":
112-
old_map = sp.get_new_map_instance(row["id"])
113-
old_map.select(dbif)
114-
suffix = create_suffix_from_datetime(
115-
old_map.temporal_extent.get_start_time(), sp.get_granularity()
116-
)
117-
map_name = "{ba}_{su}".format(ba=base, su=suffix)
118-
elif sp.get_temporal_type() == "absolute" and time_suffix == "time":
119-
old_map = sp.get_new_map_instance(row["id"])
120-
old_map.select(dbif)
121-
suffix = create_time_suffix(old_map)
122-
map_name = "{ba}_{su}".format(ba=base, su=suffix)
123-
else:
124-
map_name = create_numeric_suffix(base, count, time_suffix)
207+
map_name = compile_new_map_name(
208+
sp,
209+
base,
210+
count,
211+
row["id"],
212+
row["semantic_label"] if has_semantic_labels else None,
213+
time_suffix,
214+
dbif,
215+
)
125216

126217
# We need to modify the r(3).mapcalc expression
127218
if type != "vector":
128219
expr = expression
129220
expr = expr.replace(sp.base.get_map_id(), row["id"])
130-
expr = expr.replace(sp.base.get_name(), row["id"])
131-
132221
expr = "%s = %s" % (map_name, expr)
133222

134223
# We need to build the id
@@ -273,9 +362,8 @@ def extract_dataset(
273362

274363
if type == "raster":
275364
# Set the semantic label
276-
semantic_label = old_map.metadata.get_semantic_label()
277-
if semantic_label is not None:
278-
new_map.set_semantic_label(semantic_label)
365+
if has_semantic_labels:
366+
new_map.set_semantic_label(row["semantic_label"])
279367

280368
# Insert map in temporal database
281369
new_map.insert(dbif)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Test t.rast.extract.
2+
3+
This test checks that t.rast.extract works also when
4+
a) a fully qualified name is used in the expression,
5+
b) it is run with a STRDS from another mapset as input and
6+
c) the STRDS contains maps with identical temporal extent but with
7+
different semantic labels
8+
9+
(C) 2025 by the GRASS Development Team
10+
This program is free software under the GNU General Public
11+
License (>=v2). Read the file COPYING that comes with GRASS
12+
for details.
13+
14+
@author Stefan Blumentrath
15+
"""
16+
17+
import os
18+
import shutil
19+
from pathlib import Path
20+
21+
import grass.script as gs
22+
import pytest
23+
from grass.tools import Tools
24+
25+
26+
@pytest.fixture(scope="module")
27+
def session(tmp_path_factory):
28+
"""Pytest fixture to create a temporary GRASS project with a STRDS.
29+
30+
This setup initializes the GRASS environment, sets the computational region,
31+
creates a series of raster maps with semantic labels, and rgisters
32+
the maps in a new SpaceTimeRasterDataSet (STRDS).
33+
34+
Yields:
35+
session: active GRASS session with the prepared project and STRDS.
36+
37+
"""
38+
# Create a single temporary project directory once per module
39+
tmp_path = tmp_path_factory.mktemp("raster_time_series")
40+
41+
# Remove if exists (defensive cleanup)
42+
if tmp_path.exists():
43+
shutil.rmtree(tmp_path)
44+
45+
gs.create_project(tmp_path)
46+
47+
with gs.setup.init(
48+
tmp_path.parent,
49+
location=tmp_path.name,
50+
env=os.environ.copy(),
51+
) as session:
52+
tools = Tools(session=session)
53+
tools.g_mapset(mapset="perc", flags="c")
54+
tools.g_gisenv(set="TGIS_USE_CURRENT_MAPSET=1")
55+
tools.g_region(s=0, n=80, w=0, e=120, b=0, t=50, res=10, res3=10)
56+
register_strings = []
57+
for i in range(1, 4):
58+
for label in ["A", "B"]:
59+
mapname = f"perc_{label}_{i}"
60+
semantic_label = f"S2{label}_{i}"
61+
tools.r_mapcalc(expression=f"{mapname} = {i}00", overwrite=True)
62+
tools.r_semantic_label(
63+
map=mapname,
64+
semantic_label=semantic_label,
65+
overwrite=True,
66+
)
67+
register_strings.append(f"{mapname}|2025-08-0{i}|{semantic_label}\n")
68+
69+
tools.t_create(
70+
type="strds",
71+
temporaltype="absolute",
72+
output="perc",
73+
title="A test",
74+
description="A test",
75+
overwrite=True,
76+
)
77+
tmp_file = tools.g_tempfile(pid=os.getpid()).text
78+
Path(tmp_file).write_text("".join(register_strings), encoding="UTF8")
79+
tools.t_register(
80+
type="raster",
81+
input="perc",
82+
file=tmp_file,
83+
overwrite=True,
84+
)
85+
yield session
86+
87+
88+
def test_selection(session):
89+
"""Perform a simple selection by datetime."""
90+
tools = Tools(session=session)
91+
tools.t_rast_extract(
92+
input="perc",
93+
output="perc_1",
94+
where="start_time > '2025-08-01'",
95+
verbose=True,
96+
overwrite=True,
97+
)
98+
99+
100+
def test_selection_and_expression(session):
101+
"""Perform a selection and a r.mapcalc expression with full name."""
102+
result = "perc_2"
103+
tools = Tools(session=session)
104+
tools.t_rast_extract(
105+
input="perc",
106+
output=result,
107+
where="start_time > '2025-08-01'",
108+
expression=" if(perc@perc>=200,perc@perc,null())",
109+
basename=result,
110+
nprocs=2,
111+
verbose=True,
112+
overwrite=True,
113+
)
114+
strds_info = tools.t_info(input=result, flags="g").keyval
115+
expected_info = {
116+
"start_time": "'2025-08-02 00:00:00'",
117+
"end_time": "'2025-08-03 00:00:00'",
118+
"name": result,
119+
"min_min": 200.0,
120+
"min_max": 300.0,
121+
"max_min": 200.0,
122+
"max_max": 300.0,
123+
"aggregation_type": "None",
124+
"number_of_semantic_labels": 4,
125+
"semantic_labels": "S2A_2,S2A_3,S2B_2,S2B_3",
126+
"number_of_maps": 4,
127+
}
128+
for k, v in expected_info.items():
129+
assert strds_info[k] == v, (
130+
f"Expected value for key '{k}' is {v}. Got: {strds_info[k]}"
131+
)
132+
133+
134+
def test_inconsistent_selection_and_expression(session):
135+
"""Perform a selection and a r.mapcalc expression with simple and full name."""
136+
result = "perc_3"
137+
tools = Tools(session=session)
138+
tools.t_rast_extract(
139+
input="perc",
140+
output=result,
141+
where="start_time > '2025-08-01'",
142+
expression=" if(perc>=200,perc@perc,null())",
143+
basename=result,
144+
nprocs=2,
145+
verbose=True,
146+
overwrite=True,
147+
)
148+
strds_info = tools.t_info(input=result, flags="g").keyval
149+
expected_info = {
150+
"start_time": "'2025-08-02 00:00:00'",
151+
"end_time": "'2025-08-03 00:00:00'",
152+
"name": result,
153+
"min_min": 200.0,
154+
"min_max": 300.0,
155+
"max_min": 200.0,
156+
"max_max": 300.0,
157+
"aggregation_type": "None",
158+
"number_of_semantic_labels": 4,
159+
"semantic_labels": "S2A_2,S2A_3,S2B_2,S2B_3",
160+
"number_of_maps": 4,
161+
}
162+
for k, v in expected_info.items():
163+
assert strds_info[k] == v, (
164+
f"Expected value for key '{k}' is {v}. Got: {strds_info[k]}"
165+
)
166+
167+
168+
def test_selection_and_expression_simple_name(session):
169+
"""Perform a selection and a r.mapcalc expression with simple name."""
170+
result = "perc_4"
171+
tools = Tools(session=session)
172+
tools.t_rast_extract(
173+
input="perc",
174+
output=result,
175+
where="start_time > '2025-08-01'",
176+
expression=" if(perc>=200,perc@perc,null())",
177+
basename=result,
178+
nprocs=2,
179+
verbose=True,
180+
overwrite=True,
181+
)
182+
strds_info = tools.t_info(input=result, flags="g").keyval
183+
expected_info = {
184+
"start_time": "'2025-08-02 00:00:00'",
185+
"end_time": "'2025-08-03 00:00:00'",
186+
"name": result,
187+
"min_min": 200.0,
188+
"min_max": 300.0,
189+
"max_min": 200.0,
190+
"max_max": 300.0,
191+
"aggregation_type": "None",
192+
"number_of_semantic_labels": 4,
193+
"semantic_labels": "S2A_2,S2A_3,S2B_2,S2B_3",
194+
"number_of_maps": 4,
195+
}
196+
for k, v in expected_info.items():
197+
assert strds_info[k] == v, (
198+
f"Expected value for key '{k}' is {v}. Got: {strds_info[k]}"
199+
)

0 commit comments

Comments
 (0)