Skip to content

Commit a21a1d4

Browse files
committed
Add EXPORT_RFT hook workflow
1 parent cd95330 commit a21a1d4

13 files changed

Lines changed: 707 additions & 3 deletions

File tree

docs/ert/reference/configuration/data_types.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,37 @@ For loading multiple RFT observations from a CSV file, see the
519519
:ref:`RFT_OBSERVATION <rft_observation>` documentation.
520520

521521

522+
Exporting RFT data for visualization
523+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
524+
525+
The `EXPORT_RFT <../workflows/added_workflow_jobs.html#EXPORT_RFT>`_ workflow job exports RFT observations and simulated
526+
responses to CSV files compatible with the webviz-subsurface RftPlotter plugin.
527+
This workflow job is a replacement for the `MERGE_RFT_ERTOBS` forward model when
528+
using :ref:`RFT_OBSERVATION <rft_observation>` instead of the legacy `GENDATA_RFT` method.
529+
530+
To use it, create a workflow file e.g. ``export_rft_data`` with content::
531+
532+
EXPORT_RFT
533+
534+
Then add the workflow to your ERT configuration::
535+
536+
LOAD_WORKFLOW export_rft_data
537+
HOOK_WORKFLOW export_rft_data POST_SIMULATION
538+
539+
By default, the output file is written to
540+
``share/results/tables/rft_ert.csv`` in each realization's runpath.
541+
A custom filename can be specified as a parameter::
542+
543+
EXPORT_RFT custom_rft.csv
544+
545+
.. note::
546+
547+
To include SGAS, SWAT, and SOIL saturation values in the exported CSV,
548+
SGAS and SWAT properties must be listed in the :ref:`RFT <rft>` response
549+
configuration no make ERT extract these properties from the RFT files. For example::
550+
551+
RFT WELL:PROD DATE:2015-02-01 PROPERTIES:PRESSURE,SWAT,SGAS
552+
522553
General data: ``GEN_DATA``
523554
--------------------------
524555

src/ert/config/_create_observation_dataframes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ def _handle_rft_observation(
179179
f"{rft_observation.date}:"
180180
f"{rft_observation.property}"
181181
),
182+
"well": rft_observation.well,
183+
"date": rft_observation.date,
182184
"observation_key": rft_observation.name,
183185
"east": pl.Series([location[0]], dtype=pl.Float32),
184186
"north": pl.Series([location[1]], dtype=pl.Float32),

src/ert/config/rft_config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,9 @@ def _props_matcher(props: list[str]) -> str:
285285
return pl.DataFrame(
286286
{
287287
"response_key": [],
288+
"well": [],
289+
"date": [],
290+
"property": [],
288291
"time": [],
289292
"depth": [],
290293
"values": [],
@@ -304,6 +307,9 @@ def _props_matcher(props: list[str]) -> str:
304307
pl.DataFrame(
305308
{
306309
"response_key": [f"{well}:{time.isoformat()}:{prop}"],
310+
"well": [well],
311+
"date": [time.isoformat()],
312+
"property": [prop],
307313
"time": [time],
308314
"depth": [fetched[well, time]["DEPTH"]],
309315
"values": [vals],

src/ert/dark_storage/endpoints/responses.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def data_for_gradient(ensemble: Ensemble, key: str) -> pd.DataFrame:
182182
response_to_pandas_x_axis_fns: dict[str, Callable[[tuple[Any, ...]], Any]] = {
183183
"summary": lambda t: pd.Timestamp(t[2]).isoformat(),
184184
"gen_data": lambda t: str(t[3]),
185-
"rft": lambda t: str(t[4]),
185+
"rft": lambda t: str(t[6]),
186186
}
187187

188188

src/ert/plugins/hook_implementations/workflows/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .csv_export import CSVExportJob
88
from .disable_parameters import DisableParametersUpdate
99
from .export_misfit_data import ExportMisfitDataJob
10+
from .export_rft import ExportRFTJob
1011
from .export_runpath import ExportRunpathJob
1112
from .gen_data_rft_export import GenDataRFTCSVExportJob
1213
from .misfit_preprocessor import MisfitPreprocessor
@@ -21,6 +22,7 @@ def ertscript_workflow(config: WorkflowConfigs) -> None:
2122
ExportMisfitDataJob, "EXPORT_MISFIT_DATA", category="observations.correlation"
2223
)
2324
config.add_workflow(ExportRunpathJob, "EXPORT_RUNPATH")
25+
config.add_workflow(ExportRFTJob, "EXPORT_RFT")
2426
config.add_workflow(DisableParametersUpdate, "DISABLE_PARAMETERS")
2527
config.add_workflow(
2628
MisfitPreprocessor, "MISFIT_PREPROCESSOR", category="observations.correlation"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING, Any
5+
6+
from ert import ErtScript
7+
8+
if TYPE_CHECKING:
9+
from ert.runpaths import Runpaths
10+
from ert.storage import Ensemble
11+
12+
13+
class ExportRFTJob(ErtScript):
14+
"""
15+
Export RFT observations and simulated responses to CSV files.
16+
17+
By default, the output file is "share/results/tables/rft_ert.csv" written to
18+
each realization's runpath. The filename can be overridden by giving it as
19+
the first parameter:
20+
21+
EXPORT_RFT custom_filename.csv
22+
23+
Each realization gets its own file containing observation data joined with
24+
simulated response values for that realization. The output is compatible
25+
with the webviz-subsurface RftPlotter.
26+
"""
27+
28+
def run(
29+
self,
30+
run_paths: Runpaths,
31+
ensemble: Ensemble,
32+
workflow_args: list[Any],
33+
) -> None:
34+
filename = (
35+
workflow_args[0] if workflow_args else "share/results/tables/rft_ert.csv"
36+
)
37+
38+
observations_and_responses = ensemble.get_rft_observations_and_responses()
39+
40+
iteration = ensemble.iteration
41+
realizations = ensemble.get_realization_list_with_responses()
42+
paths = run_paths.get_paths(realizations, iteration)
43+
44+
for realization, runpath in zip(realizations, paths, strict=True):
45+
realization_data = observations_and_responses.filter(
46+
observations_and_responses["realization"] == realization
47+
).drop("realization")
48+
49+
target_file = Path(runpath) / filename
50+
target_file.parent.mkdir(parents=True, exist_ok=True)
51+
realization_data.write_csv(target_file)

src/ert/storage/local_ensemble.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ParameterConfig,
2828
SummaryConfig,
2929
)
30+
from ert.exceptions import StorageError
3031
from ert.substitutions import substitute_runpath_name
3132

3233
from .load_status import LoadResult
@@ -1086,6 +1087,149 @@ def get_observations_and_responses(
10861087
pl.col("response_key").cast(pl.String).alias("response_key")
10871088
)
10881089

1090+
def get_rft_observations_and_responses(
1091+
self,
1092+
) -> pl.DataFrame:
1093+
"""Fetches and aligns RFT observations with their corresponding
1094+
simulated responses from an ensemble.
1095+
1096+
Returns a DataFrame with observation/response data using
1097+
column names equal to the ones used by the subscript forward model
1098+
MERGE_RFT_ERTOBS, and compatible with the webviz-subsurface RftPlotter.
1099+
"""
1100+
rft_observations = self.experiment.observations.get("rft")
1101+
if rft_observations is None or rft_observations.is_empty():
1102+
raise StorageError("No RFT observations found in experiment")
1103+
1104+
if "rft" not in self.experiment.response_configuration:
1105+
raise KeyError("No RFT response configuration found in experiment")
1106+
1107+
realizations = self.get_realization_list_with_responses()
1108+
if not realizations:
1109+
raise StorageError("No realizations with responses found")
1110+
1111+
# Build date-to-report_step mapping from summary responses if available
1112+
date_to_report_step: dict[str, int] = {}
1113+
if "summary" in self.experiment.response_configuration:
1114+
try:
1115+
summary_df = self.load_responses("summary", (realizations[0],))
1116+
times = summary_df["time"].unique().sort()
1117+
for report_step, time in enumerate(times):
1118+
date_str = time.strftime("%Y-%m-%d")
1119+
date_to_report_step[date_str] = report_step
1120+
except (KeyError, IndexError):
1121+
pass # No summary data available, will use default
1122+
1123+
observations = rft_observations.with_columns(
1124+
pl.int_range(pl.len()).over("well").alias("order"),
1125+
)
1126+
1127+
join_keys = ["well", "date", "east", "north", "tvd"]
1128+
observed_values = {k: observations[k].unique() for k in join_keys}
1129+
1130+
pivot_index = [
1131+
"well",
1132+
"date",
1133+
"realization",
1134+
"response_zone",
1135+
"east",
1136+
"north",
1137+
"tvd",
1138+
"i",
1139+
"j",
1140+
"k",
1141+
]
1142+
1143+
output_columns = [
1144+
"order",
1145+
"east",
1146+
"north",
1147+
"md",
1148+
"tvd",
1149+
"zone",
1150+
"pressure",
1151+
"swat",
1152+
"sgas",
1153+
"soil",
1154+
"valid_zone",
1155+
"is_active",
1156+
"i",
1157+
"j",
1158+
"k",
1159+
"well",
1160+
"date",
1161+
"realization",
1162+
"report_step",
1163+
"observations",
1164+
"std",
1165+
]
1166+
1167+
result_frames: list[pl.DataFrame] = []
1168+
1169+
for real in sorted(realizations):
1170+
responses = (
1171+
self.load_responses("rft", (real,))
1172+
.with_columns(
1173+
pl.col("property").str.to_lowercase(),
1174+
)
1175+
.rename({"zone": "response_zone"})
1176+
)
1177+
1178+
for col, values in observed_values.items():
1179+
responses = responses.filter(
1180+
pl.col(col).is_in(values.implode(), nulls_equal=True)
1181+
)
1182+
1183+
pivoted = responses.pivot(
1184+
on="property",
1185+
index=pivot_index,
1186+
values="values",
1187+
)
1188+
1189+
for col in ["pressure", "sgas", "swat"]:
1190+
if col not in pivoted.columns:
1191+
pivoted = pivoted.with_columns(
1192+
pl.lit(None).cast(pl.Float32).alias(col)
1193+
)
1194+
1195+
pivoted = pivoted.with_columns(
1196+
pl.col("pressure").is_not_null().alias("is_active")
1197+
)
1198+
1199+
joined = (
1200+
observations.join(
1201+
pivoted,
1202+
how="left",
1203+
on=join_keys,
1204+
nulls_equal=True,
1205+
)
1206+
.with_columns(
1207+
pl.col("zone")
1208+
.eq_missing(pl.col("response_zone"))
1209+
.alias("valid_zone"),
1210+
(1 - pl.col("sgas") - pl.col("swat")).alias("soil"),
1211+
pl.col("is_active").fill_null(False),
1212+
pl.col("date")
1213+
.replace_strict(date_to_report_step, default=0)
1214+
.alias("report_step"),
1215+
)
1216+
.select(output_columns)
1217+
)
1218+
1219+
result_frames.append(joined)
1220+
1221+
return pl.concat(result_frames, how="vertical").rename(
1222+
{
1223+
"east": "utm_x",
1224+
"north": "utm_y",
1225+
"md": "measured_depth",
1226+
"tvd": "true_vertical_depth",
1227+
"date": "time",
1228+
"observations": "observed",
1229+
"std": "error",
1230+
}
1231+
)
1232+
10891233
@property
10901234
def everest_realization_info(self) -> dict[int, EverestRealizationInfo] | None:
10911235
return self._index.everest_realization_info
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
EXPORT_RFT

test-data/ert/rft_example/rft.ert

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,7 @@ FORWARD_MODEL TEMPLATE_RENDER( \
2121

2222
FORWARD_MODEL FLOW
2323

24-
RFT WELL:PROD DATE:2015-02-01 PROPERTIES:PRESSURE
24+
RFT WELL:PROD DATE:2015-02-01 PROPERTIES:PRESSURE,SWAT,SGAS
25+
26+
LOAD_WORKFLOW export_rft_data
27+
HOOK_WORKFLOW export_rft_data POST_SIMULATION

tests/ert/unit_tests/config/test_observations.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,8 @@ def test_that_rft_config_is_created_from_observations():
328328
pl.DataFrame(
329329
{
330330
"response_key": "well:2013-03-31:PRESSURE",
331+
"well": "well",
332+
"date": "2013-03-31",
331333
"observation_key": "NAME",
332334
"east": pl.Series([30.0], dtype=pl.Float32),
333335
"north": pl.Series([71.0], dtype=pl.Float32),

0 commit comments

Comments
 (0)