Skip to content

Commit 74e26dd

Browse files
committed
Add yaml obs converter
The purpose of this converter class is to support conversion of observation configurations to a format supported by webviz. For now, only summary observations are of interest. RFTs are manually loaded through other workflows and other observations are not of interest as of now. Localization is not supported in webviz, so those attributes are left out.
1 parent eeebf63 commit 74e26dd

5 files changed

Lines changed: 304 additions & 2 deletions

File tree

src/ert/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ def get_ert_parser(parser: ArgumentParser | None = None) -> ArgumentParser:
496496
"observation format to summary, but this can be "
497497
"configured using the --format flag to specify "
498498
"which format to convert to."
499-
"Valid formats are: bulk, summary"
499+
"Valid formats are: bulk, summary, yaml"
500500
)
501501
convert_obs_parser = subparsers.add_parser(
502502
"convert_observations",

src/ert/observation_converters/dispatcher.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,19 @@
1111
from .summary_to_bulk import (
1212
convert_summary_to_bulk,
1313
)
14+
from .summary_to_yaml import convert_summary_to_yaml
1415

1516

1617
class SupportedFormat(StrEnum):
1718
SUMMARY = "summary"
1819
BULK = "bulk"
20+
YAML = "yaml"
1921

2022

2123
_SUPPORTED_CONVERSIONS: dict[SupportedFormat, Callable[..., None]] = {
2224
SupportedFormat.BULK: convert_summary_to_bulk,
2325
SupportedFormat.SUMMARY: convert_history_to_summary,
26+
SupportedFormat.YAML: convert_summary_to_yaml,
2427
}
2528

2629

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import warnings
2+
from pathlib import Path
3+
from typing import Literal, TypedDict
4+
5+
from natsort import natsorted
6+
from ruamel.yaml import YAML
7+
8+
from ert.cli.main import ErtCliError
9+
from ert.config import ErtConfig, Observation
10+
from ert.plugins import ErtRuntimePlugins
11+
12+
13+
class YamlObservation(TypedDict):
14+
date: str
15+
value: float
16+
error: float
17+
18+
19+
class SummaryDict(TypedDict):
20+
key: str
21+
observations: list[YamlObservation]
22+
23+
24+
YamlDict = dict[Literal["smry"], list[SummaryDict]]
25+
26+
27+
class YamlConverter:
28+
TARGET_FILE = "summary_observations.yaml"
29+
30+
def __init__(self, observations: list[Observation]) -> None:
31+
summary_observations = [
32+
o for o in observations if o.type == "summary_observation"
33+
]
34+
if not summary_observations:
35+
raise ErtCliError("No summary observations in configuration.\nExiting ...")
36+
37+
self.summary_observations = summary_observations
38+
39+
def _summary_to_yaml_dict(self) -> YamlDict:
40+
summary_keys: set[str] = {o.key for o in self.summary_observations}
41+
summary_list: list[SummaryDict] = []
42+
for key in natsorted(summary_keys):
43+
observations_with_key = [
44+
o for o in self.summary_observations if o.key == key
45+
]
46+
chronological_observations = sorted(
47+
observations_with_key, key=lambda o: o.date
48+
)
49+
# Round dates without HH/MM/SS to just date
50+
for o in chronological_observations:
51+
if o.date.endswith("T00:00:00"):
52+
o.date = o.date.split("T")[0]
53+
obs_dicts: list[YamlObservation] = [
54+
{
55+
"date": o.date,
56+
"value": o.value,
57+
"error": o.error,
58+
}
59+
for o in chronological_observations
60+
]
61+
summary_dict: SummaryDict = {"key": key, "observations": obs_dicts}
62+
summary_list.append(summary_dict)
63+
return {"smry": summary_list}
64+
65+
def export_yaml(self) -> None:
66+
yaml = YAML()
67+
yaml_dict = self._summary_to_yaml_dict()
68+
try:
69+
with Path(self.TARGET_FILE).open("x", encoding="utf-8") as f:
70+
yaml.dump(yaml_dict, f)
71+
except FileExistsError as error:
72+
raise ErtCliError(
73+
f"A file with name '{self.TARGET_FILE}' already exists. "
74+
"Will not overwrite it and exit instead."
75+
) from error
76+
print(f"Successfully wrote summary observations to '{self.TARGET_FILE}'.")
77+
78+
79+
def convert_summary_to_yaml(config: str, site_plugins: ErtRuntimePlugins) -> None:
80+
with warnings.catch_warnings():
81+
warnings.filterwarnings(action="ignore")
82+
ert_config = ErtConfig.with_plugins(site_plugins).from_file(config)
83+
84+
observations = ert_config.observation_declarations
85+
86+
yaml_exporter = YamlConverter(
87+
observations=observations,
88+
)
89+
yaml_exporter.export_yaml()

tests/ert/unit_tests/cli/test_summary_to_bulk.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def _make_summary_obs(
113113
key += f":{well}" if well else ""
114114
return SummaryObservation(
115115
name="foo",
116-
key=f"{key}:{well}",
116+
key=key,
117117
value=0.5,
118118
error=0.02,
119119
date=date,
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import logging
2+
import warnings
3+
from pathlib import Path
4+
from textwrap import dedent
5+
from unittest.mock import MagicMock
6+
7+
import pytest
8+
9+
from ert.cli.main import ErtCliError
10+
from ert.config._observations import SummaryObservation
11+
from ert.observation_converters import convert_observations
12+
from ert.observation_converters.summary_to_yaml import YamlConverter
13+
from ert.plugins import ErtRuntimePlugins
14+
from tests.ert.unit_tests.cli.test_summary_to_bulk import _make_breakthrough_obs
15+
16+
17+
def _make_summary_obs(
18+
key: str = "WOPR",
19+
well: str = "",
20+
date: str = "2010-01-27",
21+
shape_id: int | None = None,
22+
) -> SummaryObservation:
23+
key += f":{well}" if well else ""
24+
return SummaryObservation(
25+
name="foo",
26+
key=key,
27+
value=0.5,
28+
error=0.02,
29+
date=date,
30+
shape_id=shape_id,
31+
)
32+
33+
34+
@pytest.mark.usefixtures("snake_oil_case")
35+
def test_that_happy_path_on_snake_oil_produces_yaml_and_stdout(capsys):
36+
args = MagicMock(format="yaml", config="snake_oil.ert")
37+
convert_observations(args, ErtRuntimePlugins())
38+
39+
expected_yaml_content = dedent("""\
40+
smry:
41+
- key: WOPR:OP1
42+
observations:
43+
- date: '2010-03-31'
44+
value: 0.1
45+
error: 0.05
46+
- date: '2010-12-26'
47+
value: 0.7
48+
error: 0.07
49+
- date: '2011-12-21'
50+
value: 0.5
51+
error: 0.05
52+
- date: '2012-12-15'
53+
value: 0.3
54+
error: 0.075
55+
- date: '2013-12-10'
56+
value: 0.2
57+
error: 0.035
58+
- date: '2015-03-15'
59+
value: 0.015
60+
error: 0.01
61+
""")
62+
yaml_content = Path(YamlConverter.TARGET_FILE).read_text(encoding="utf-8")
63+
assert yaml_content == expected_yaml_content
64+
65+
expected_stdout = (
66+
f"Successfully wrote summary observations to '{YamlConverter.TARGET_FILE}'."
67+
)
68+
stdout = capsys.readouterr().out
69+
assert expected_stdout in stdout
70+
71+
72+
def test_that_empty_observations_raises_ert_cli_error():
73+
with pytest.raises(ErtCliError, match="No summary observations in configuration"):
74+
YamlConverter([])
75+
76+
77+
def test_that_no_summary_observations_raises_ert_cli_error():
78+
brt_obs = _make_breakthrough_obs(well="OP1")
79+
with pytest.raises(ErtCliError, match="No summary observations in configuration"):
80+
YamlConverter([brt_obs])
81+
82+
83+
def test_that_observations_with_same_summary_key_are_gathered_in_yaml_dict(use_tmpdir):
84+
k1, k2 = "bar", "foo"
85+
observations = 2 * [
86+
_make_summary_obs(key=k1),
87+
_make_summary_obs(key=k2),
88+
]
89+
90+
converter = YamlConverter(observations=observations)
91+
summary_dicts = converter._summary_to_yaml_dict()
92+
93+
keys = [entry["key"] for entry in summary_dicts["smry"]]
94+
assert keys == [k1, k2]
95+
96+
observations = [s_d["observations"] for s_d in summary_dicts["smry"]]
97+
assert all(len(o) == 2 for o in observations)
98+
99+
100+
def test_that_observations_with_different_summary_keys_are_separated_in_yaml_dict():
101+
k1, k2 = "bar", "foo"
102+
observations = [
103+
_make_summary_obs(key=k1),
104+
_make_summary_obs(key=k2),
105+
]
106+
107+
converter = YamlConverter(observations=observations)
108+
result = converter._summary_to_yaml_dict()
109+
110+
keys = [entry["key"] for entry in result["smry"]]
111+
assert keys == [k1, k2]
112+
113+
114+
def test_that_dumping_to_yaml_is_skipped_when_file_already_exists(use_tmpdir):
115+
observations = [_make_summary_obs()]
116+
117+
Path(YamlConverter.TARGET_FILE).write_text("existing", encoding="utf-8")
118+
assert Path(YamlConverter.TARGET_FILE).is_file()
119+
120+
converter = YamlConverter(observations=observations)
121+
with pytest.raises(
122+
ErtCliError,
123+
match=(
124+
rf"A file with name '{YamlConverter.TARGET_FILE}' already exists. "
125+
"Will not overwrite it and exit instead."
126+
),
127+
):
128+
converter.export_yaml()
129+
130+
131+
def test_that_config_warnings_are_caught_instead_of_printed_to_terminal(
132+
caplog, use_tmpdir
133+
):
134+
caplog.set_level(logging.INFO)
135+
config = "config.ert"
136+
obs_config = "obs.txt"
137+
# This setup expects the warning:
138+
# 'Config contains a SUMMARY key but no forward model steps'
139+
# to be raised
140+
config_content = f"""\
141+
NUM_REALIZATIONS 5
142+
SUMMARY *
143+
ECLBASE FOO
144+
OBS_CONFIG {obs_config}
145+
"""
146+
obs_config_content = """\
147+
SUMMARY_OBSERVATION {
148+
KEY=FOPR;
149+
VALUE=10;
150+
ERROR=5;
151+
DATE=2010-10-10;
152+
};"""
153+
Path(config).write_text(config_content, encoding="utf-8")
154+
Path(obs_config).write_text(obs_config_content, encoding="utf-8")
155+
156+
args = MagicMock(format="yaml", config=config)
157+
with warnings.catch_warnings(record=True) as w:
158+
convert_observations(args, ErtRuntimePlugins())
159+
assert len(w) == 0
160+
161+
162+
def test_that_yaml_converter_sorts_observations_by_date(use_tmpdir):
163+
d1 = "2000-01-01"
164+
d4 = "2000-01-11"
165+
d3 = "2000-01-03"
166+
d5 = "2000-02-11"
167+
d2 = "2000-01-02"
168+
169+
unsorted_obs = [_make_summary_obs(date=d) for d in [d1, d4, d3, d5, d2]]
170+
yaml_dict = YamlConverter(unsorted_obs)._summary_to_yaml_dict()
171+
172+
obs_dicts = yaml_dict["smry"][0]["observations"]
173+
yaml_key_order = [obs_dict["date"] for obs_dict in obs_dicts]
174+
assert yaml_key_order == [d1, d2, d3, d4, d5]
175+
176+
177+
def test_that_yaml_converter_natsorts_summary_keys(use_tmpdir):
178+
k1 = "WOPR:OP1"
179+
k3 = "WOPR:OP13"
180+
k2 = "WOPR:OP2"
181+
unsorted_obs = [
182+
_make_summary_obs(key=k1),
183+
_make_summary_obs(key=k3),
184+
_make_summary_obs(key=k2),
185+
]
186+
yaml_dict = YamlConverter(unsorted_obs)._summary_to_yaml_dict()
187+
188+
yaml_key_order = [d["key"] for d in yaml_dict["smry"]]
189+
assert yaml_key_order == [k1, k2, k3]
190+
191+
192+
def test_that_yaml_converter_retains_time_precision_when_present():
193+
date_precision = "2000-01-01"
194+
second_precision = date_precision + "T00:00:01"
195+
minute_precision = date_precision + "T00:01:00"
196+
hour_precision = date_precision + "T01:00:00"
197+
obs = [
198+
_make_summary_obs(date=d)
199+
for d in [date_precision, hour_precision, minute_precision, second_precision]
200+
]
201+
202+
yaml_dict = YamlConverter(obs)._summary_to_yaml_dict()
203+
204+
yaml_obs = yaml_dict["smry"][0]["observations"]
205+
assert [o["date"] for o in yaml_obs] == [
206+
date_precision,
207+
second_precision,
208+
minute_precision,
209+
hour_precision,
210+
]

0 commit comments

Comments
 (0)