Skip to content

Commit d2c7504

Browse files
committed
Support glob pattern in seismic observations
1 parent 55502fe commit d2c7504

4 files changed

Lines changed: 227 additions & 40 deletions

File tree

src/ert/config/_observations.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,7 +1076,7 @@ def from_obs_dict(
10761076
shape_registry: ShapeRegistry for storing geometry.
10771077
"""
10781078
name = ""
1079-
filepath: str | Path | None = None
1079+
filepath: str | None = None
10801080
boundary_filepath: str | Path | None = None
10811081

10821082
if "CSV" in observation_dict and "OBS_FILE" in observation_dict:
@@ -1105,22 +1105,6 @@ def from_obs_dict(
11051105
case _:
11061106
raise _unknown_key_error(str(key), observation_dict.context)
11071107

1108-
if filepath is None:
1109-
raise _missing_value_error(observation_dict.context, "OBS_FILE")
1110-
1111-
filepath = Path(directory) / filepath
1112-
if not filepath.exists():
1113-
raise ObservationConfigError.with_context(
1114-
f"The seismic observations file ({filepath.absolute()}) "
1115-
"does not exist or is not accessible.",
1116-
filepath,
1117-
)
1118-
1119-
if not name:
1120-
name = filepath.stem
1121-
1122-
df = cls._load_observations(filepath)
1123-
11241108
boundary_id = None
11251109
if boundary_filepath is not None:
11261110
boundary_filepath = Path(directory) / boundary_filepath
@@ -1133,6 +1117,40 @@ def from_obs_dict(
11331117
boundary = PolygonShapeConfig.from_file(str(boundary_filepath))
11341118
boundary_id = shape_registry.register(boundary)
11351119

1120+
if filepath is None:
1121+
raise _missing_value_error(observation_dict.context, "OBS_FILE")
1122+
1123+
matching_filepaths = SeismicData.resolve_pattern_filepaths(
1124+
directory,
1125+
filepath,
1126+
on_error=lambda msg: ObservationConfigError.with_context(msg, filepath),
1127+
)
1128+
seismic_observations_for_filepath: list[Self] = []
1129+
for matching_filepath in matching_filepaths:
1130+
seismic_observations_for_filepath.extend(
1131+
cls._from_filepath(
1132+
name=name,
1133+
filepath=matching_filepath,
1134+
boundary_id=boundary_id,
1135+
shape_registry=shape_registry,
1136+
)
1137+
)
1138+
return seismic_observations_for_filepath
1139+
1140+
@classmethod
1141+
def _from_filepath(
1142+
cls,
1143+
name: str,
1144+
filepath: Path,
1145+
boundary_id: int | None,
1146+
shape_registry: ShapeRegistry,
1147+
) -> list[Self]:
1148+
1149+
if not name:
1150+
name = filepath.stem
1151+
1152+
df = cls._load_observations(filepath)
1153+
11361154
seismic_observations = []
11371155
for row in df.iter_rows(named=True):
11381156
east = validate_float(str(row["X_UTME"]), "X_UTME")

src/ert/config/_reservoir_data_utils.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from collections.abc import Sequence
1+
import fnmatch
2+
import re
3+
from collections.abc import Callable, Sequence
4+
from pathlib import Path
25
from typing import ClassVar
36

47
import numpy as np
@@ -83,3 +86,54 @@ def use_observation_locations_in_respective_responses(
8386
)
8487
.drop(["east_obs", "north_obs"])
8588
)
89+
90+
@staticmethod
91+
def resolve_pattern_filepaths(
92+
basedir: str,
93+
pattern: str,
94+
on_error: Callable[[str], Exception],
95+
) -> list[Path]:
96+
"""Resolve all file paths matching a pattern against filenames only.
97+
98+
The pattern can include a directory path (e.g., "../subdir/obs-*.csv"),
99+
but the pattern itself is only matched against the filename component.
100+
Literal glob metacharacters (* ?) should be wrapped in brackets: "test[*].csv".
101+
102+
Args:
103+
basedir: Base directory.
104+
pattern: Pattern with optional directory path and possible glob-style
105+
wildcards in the filename.
106+
on_error: Callable to raise a custom exception.
107+
108+
"""
109+
pattern_path = Path(pattern)
110+
search_dir = Path(basedir) / pattern_path.parent
111+
if not search_dir.exists():
112+
raise on_error(
113+
f"Directory '{search_dir.absolute()}' does not exist "
114+
"or is not accessible."
115+
)
116+
117+
filename_pattern = pattern_path.name
118+
has_glob = any(char in filename_pattern for char in "*?[")
119+
if not has_glob:
120+
path = search_dir / filename_pattern
121+
if not path.exists():
122+
raise on_error(
123+
f"File '{path.absolute()}' does not exist or is not accessible."
124+
)
125+
return [path]
126+
127+
compiled_pattern = re.compile(fnmatch.translate(filename_pattern))
128+
matching_paths = [
129+
path
130+
for path in search_dir.iterdir()
131+
if path.is_file() and compiled_pattern.fullmatch(path.name)
132+
]
133+
134+
if not matching_paths:
135+
raise on_error(
136+
f"No files matching pattern '{filename_pattern}' found "
137+
f"in '{search_dir}'"
138+
)
139+
return sorted(matching_paths)

tests/ert/unit_tests/config/test_observation_declaration.py

Lines changed: 123 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from datetime import datetime
55
from pathlib import Path
66
from textwrap import dedent
7+
from typing import cast
78

89
import hypothesis.extra.lark as stlark
910
import polars as pl
@@ -16,6 +17,7 @@
1617
BreakthroughObservation,
1718
GeneralObservation,
1819
RFTObservation,
20+
SeismicObservation,
1921
SummaryObservation,
2022
make_observations,
2123
)
@@ -862,10 +864,13 @@ def test_that_seismic_observation_instantiates(file_context_token):
862864
]
863865

864866

867+
@pytest.mark.usefixtures("use_tmpdir")
865868
def test_that_non_existent_seismic_observation_file_raises_error(file_context_token):
869+
directory = "dir"
870+
Path(directory).mkdir()
866871
with pytest.raises(ObservationConfigError) as err:
867872
make_observations(
868-
"dir",
873+
directory,
869874
[
870875
ObservationDict(
871876
{
@@ -879,7 +884,7 @@ def test_that_non_existent_seismic_observation_file_raises_error(file_context_to
879884
shape_registry=ShapeRegistry(),
880885
)
881886

882-
assert "/dir/seismic_observations.csv) does not exist or is not accessible." in str(
887+
assert "seismic_observations.csv' does not exist or is not accessible" in str(
883888
err.value
884889
)
885890

@@ -1180,17 +1185,27 @@ def test_that_seismic_observation_coordinate_distance_below_tolerance_raises(
11801185
)
11811186

11821187

1183-
@pytest.mark.usefixtures("use_tmpdir")
1184-
def test_that_seismic_observation_reads_boundary_file(file_context_token):
1185-
Path("obs.csv").write_text(
1186-
dedent(
1187-
"""
1188-
X_UTME,Y_UTMN,OBS,OBS_ERROR,REGION
1189-
1.0,1.0,1.0,0.005,1.0
1190-
"""
1191-
),
1188+
def default_seismic_file_content() -> str:
1189+
return dedent(
1190+
"""
1191+
X_UTME,Y_UTMN,OBS,OBS_ERROR,REGION
1192+
1.0,1.0,1.0,0.005,1.0
1193+
"""
1194+
)
1195+
1196+
1197+
def write_default_seismic_file_content(
1198+
filename="horizon--amplitude_full_min_depth--20250101_20240101.csv",
1199+
):
1200+
Path(filename).write_text(
1201+
default_seismic_file_content(),
11921202
encoding="utf8",
11931203
)
1204+
1205+
1206+
@pytest.mark.usefixtures("use_tmpdir")
1207+
def test_that_seismic_observation_reads_boundary_file(file_context_token):
1208+
write_default_seismic_file_content("obs.csv")
11941209
Path("boundary.pol").write_text(
11951210
dedent(
11961211
"""
@@ -1249,15 +1264,9 @@ def test_that_non_existent_boundary_seismic_observation_file_raises_error(
12491264
):
12501265
os.makedirs("directory/right/path", exist_ok=True)
12511266
os.makedirs("directory/wrong/path", exist_ok=True)
1252-
Path("directory/obs.csv").write_text(
1253-
dedent(
1254-
"""
1255-
X_UTME,Y_UTMN,OBS,OBS_ERROR,REGION
1256-
1.0,1.0,1.0,0.005,1.0
1257-
"""
1258-
),
1259-
encoding="utf8",
1260-
)
1267+
1268+
write_default_seismic_file_content("directory/obs.csv")
1269+
12611270
Path("directory/right/path/bound.pol").write_text(
12621271
"Unexpected file location",
12631272
encoding="utf8",
@@ -1283,3 +1292,97 @@ def test_that_non_existent_boundary_seismic_observation_file_raises_error(
12831292
"/directory/wrong/path/bound.pol) does not exist or is not accessible."
12841293
in str(err.value)
12851294
)
1295+
1296+
1297+
@pytest.mark.usefixtures("use_tmpdir")
1298+
def test_that_seismic_observation_filenames_can_be_glob_pattern(file_context_token):
1299+
filename0 = "surface--amplitude_far_mean_depth--20190701_20180101.csv"
1300+
filename1 = "surface--amplitude_full_min_depth--20190901_20180101.csv"
1301+
filename2 = "surface--amplitude_full_min_depth--20180701_20180101.csv"
1302+
filename3 = ".surface--amplitude_full_mean_depth--20190701_20180101.csv.yml"
1303+
1304+
directory = "dir1/dir2/.."
1305+
os.makedirs(directory, exist_ok=True)
1306+
1307+
for filename in [filename0, filename1, filename2, filename3]:
1308+
write_default_seismic_file_content(f"{directory}/{filename}")
1309+
1310+
def make_observations_with_pattern(
1311+
pattern: str, directory: str = directory
1312+
) -> list[SeismicObservation]:
1313+
shape_registry = ShapeRegistry()
1314+
obs = make_observations(
1315+
"",
1316+
[
1317+
ObservationDict(
1318+
{
1319+
"type": ObservationType.SEISMIC,
1320+
"OBS_FILE": f"{directory}/{pattern}",
1321+
},
1322+
context=file_context_token(obs_type="SEISMIC_OBSERVATION"),
1323+
)
1324+
],
1325+
shape_registry=shape_registry,
1326+
)
1327+
return [cast(SeismicObservation, o) for o in obs]
1328+
1329+
p1 = "surface--amplitude_*_*_depth--20190[1-9]01_20180101.csv"
1330+
obs = make_observations_with_pattern(p1)
1331+
assert len(obs) == 2
1332+
assert sorted([o.filepath for o in obs]) == sorted(
1333+
[
1334+
Path(f"{directory}/{filename0}"),
1335+
Path(f"{directory}/{filename1}"),
1336+
]
1337+
)
1338+
1339+
p2 = "surface*"
1340+
obs = make_observations_with_pattern(p2)
1341+
assert len(obs) == 3
1342+
assert sorted([o.filepath for o in obs]) == sorted(
1343+
[
1344+
Path(f"{directory}/{filename0}"),
1345+
Path(f"{directory}/{filename1}"),
1346+
Path(f"{directory}/{filename2}"),
1347+
]
1348+
)
1349+
1350+
p3 = p1[:-4]
1351+
with pytest.raises(ObservationConfigError) as err:
1352+
make_observations_with_pattern(p3)
1353+
assert f"No files matching pattern '{p3}' found in '{directory}'" in str(err.value)
1354+
1355+
with pytest.raises(ObservationConfigError) as err:
1356+
make_observations_with_pattern(p1, directory="ufo")
1357+
assert "ufo' does not exist or is not accessible" in str(err.value)
1358+
1359+
1360+
@pytest.mark.usefixtures("use_tmpdir")
1361+
def test_that_filepath_can_have_literal_metacharacters(file_context_token):
1362+
def make_observations_with_pattern(pattern: str) -> list[SeismicObservation]:
1363+
shape_registry = ShapeRegistry()
1364+
obs = make_observations(
1365+
"",
1366+
[
1367+
ObservationDict(
1368+
{
1369+
"type": ObservationType.SEISMIC,
1370+
"OBS_FILE": pattern,
1371+
},
1372+
context=file_context_token(obs_type="SEISMIC_OBSERVATION"),
1373+
)
1374+
],
1375+
shape_registry=shape_registry,
1376+
)
1377+
return [cast(SeismicObservation, o) for o in obs]
1378+
1379+
path_with_literal_wildcard = r"test*.csv"
1380+
path_fitting_to_wildcard = "test123.csv"
1381+
write_default_seismic_file_content(path_with_literal_wildcard)
1382+
write_default_seismic_file_content(path_fitting_to_wildcard)
1383+
1384+
wildcard_pattern = "test**"
1385+
literal_pattern = "test[*]*"
1386+
1387+
assert len(make_observations_with_pattern(pattern=wildcard_pattern)) == 2
1388+
assert len(make_observations_with_pattern(pattern=literal_pattern)) == 1

tests/ert/unit_tests/config/test_seismic_config.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from io import BytesIO, StringIO
2+
from pathlib import Path
23
from typing import cast
34

45
import polars as pl
@@ -31,13 +32,14 @@ def _mock_seismic_response(
3132
mocked_files[path] = buf.getvalue()
3233

3334

35+
@pytest.mark.usefixtures("use_tmpdir")
3436
@pytest.mark.parametrize("suffix", [".csv", ".parquet"])
3537
def test_that_seismic_observation_response_key_matches_simulated_response_key(
3638
mocked_files, suffix
3739
):
3840
expected_response_key = "horizon--amplitude_full_min_depth--20250101_20240101"
3941
name = f"{expected_response_key}{suffix}"
40-
runpath = "/runpath"
42+
runpath = "runpath"
4143
obs_path = "share/preprocessed/tables/" + name
4244
simulated_path_relative_to_runpath = "share/results/tables/" + name
4345
simulated_path = runpath + "/" + simulated_path_relative_to_runpath
@@ -65,6 +67,16 @@ def test_that_seismic_observation_response_key_matches_simulated_response_key(
6567
_mock_seismic_response(mocked_files, obs_path, obs_frame, suffix)
6668
_mock_seismic_response(mocked_files, simulated_path, simulated_frame, suffix)
6769

70+
Path(obs_path).parent.mkdir(parents=True, exist_ok=True)
71+
Path(simulated_path).parent.mkdir(parents=True, exist_ok=True)
72+
73+
if suffix.endswith("parquet"):
74+
Path(obs_path).write_bytes(mocked_files[obs_path])
75+
Path(simulated_path).write_bytes(mocked_files[simulated_path])
76+
else:
77+
Path(obs_path).write_text(mocked_files[obs_path], encoding="utf8")
78+
Path(simulated_path).write_text(mocked_files[simulated_path], encoding="utf8")
79+
6880
config = ErtConfig.from_dict(
6981
{
7082
"SEISMIC": [simulated_path_relative_to_runpath],
@@ -119,7 +131,7 @@ def test_that_seismic_config_raises_when_reading_from_non_existing_file(tmp_path
119131
keys=["key"],
120132
)
121133
with pytest.raises(InvalidResponseFile):
122-
seismic_config.read_from_file(tmp_path / "non-existent-file.csv", 1, 1)
134+
seismic_config.read_from_file(tmp_path, 1, 1)
123135

124136

125137
@pytest.mark.parametrize("suffix", [".csv", ".parquet"])

0 commit comments

Comments
 (0)