Skip to content

Commit dc3f855

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 321a50d commit dc3f855

4 files changed

Lines changed: 216 additions & 14 deletions

File tree

src/ert/observation_converters/dispatcher.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,21 @@
99
from .summary_to_bulk import (
1010
convert_summary_to_bulk,
1111
)
12+
from .summary_to_yaml import convert_summary_to_yaml
1213

1314

1415
class SupportedFormat(StrEnum):
1516
SUMMARY = "summary"
1617
BULK = "bulk"
18+
YAML = "yaml"
1719

1820

1921
ConverterFunction = Callable[[str], None]
2022

2123
_SUPPORTED_CONVERSIONS: dict[SupportedFormat, ConverterFunction] = {
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: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from pathlib import Path
2+
from typing import Literal, TypedDict
3+
4+
from ruamel.yaml import YAML
5+
6+
from ert.cli.main import ErtCliError
7+
from ert.config import ErtConfig, Observation
8+
9+
10+
class YamlObservation(TypedDict):
11+
date: str
12+
value: float
13+
error: float
14+
15+
16+
class SummaryDict(TypedDict):
17+
key: str
18+
observations: list[YamlObservation]
19+
20+
21+
YamlDict = dict[Literal["smry"], list[SummaryDict]]
22+
23+
24+
class YamlConverter:
25+
target_file = "summary_observations.yaml"
26+
27+
def __init__(self, observations: list[Observation]) -> None:
28+
summary_observations = [
29+
o for o in observations if o.type == "summary_observation"
30+
]
31+
if not summary_observations:
32+
raise ErtCliError("No summary observations found.\nExiting ...")
33+
34+
self.summary_observations = summary_observations
35+
36+
def _summary_to_yaml_dict(self) -> YamlDict:
37+
summary_observations = [
38+
o for o in self.summary_observations if o.type == "summary_observation"
39+
]
40+
summary_keys: set[str] = {o.key for o in summary_observations}
41+
summary_list: list[SummaryDict] = []
42+
for key in summary_keys:
43+
observations_with_key = [o for o in summary_observations if o.key == key]
44+
obs_dicts: list[YamlObservation] = [
45+
{"date": o.date, "value": o.value, "error": o.error}
46+
for o in observations_with_key
47+
]
48+
summary_dict: SummaryDict = {"key": key, "observations": obs_dicts}
49+
summary_list.append(summary_dict)
50+
return {"smry": summary_list}
51+
52+
def export_yaml(self) -> None:
53+
yaml = YAML()
54+
yaml_dict = self._summary_to_yaml_dict()
55+
if Path(self.target_file).is_file():
56+
raise ErtCliError(
57+
f"A file with name '{self.target_file}' already exists. "
58+
f"Will not overwrite it and exit instead."
59+
)
60+
with Path(self.target_file).open("w", encoding="utf-8") as f:
61+
yaml.dump(yaml_dict, f)
62+
print(f"Successfully wrote summary observations to '{self.target_file}'.")
63+
64+
65+
def convert_summary_to_yaml(config: str) -> None:
66+
ert_config = ErtConfig.from_file(config)
67+
yaml_exporter = YamlConverter(
68+
observations=ert_config.observation_declarations,
69+
)
70+
yaml_exporter.export_yaml()

tests/ert/unit_tests/cli/test_summary_to_bulk.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ def test_that_happy_path_on_snake_oil_produces_csv_and_stdout(capsys):
8888
assert old_obs == new_obs
8989

9090

91-
@pytest.fixture(name="patched_csv_writer")
92-
def patched_csv_writing(monkeypatch):
91+
@pytest.fixture(name="patched_writer")
92+
def patched_writing(monkeypatch):
9393
"""Avoid writing to file.
9494
Fixture mock can be used to assert what has been written to file.
9595
"""
@@ -112,7 +112,7 @@ def _make_summary_obs(
112112
key += f":{well}" if well else ""
113113
return SummaryObservation(
114114
name="foo",
115-
key=f"{key}:{well}",
115+
key=key,
116116
value=0.5,
117117
error=0.02,
118118
date=date,
@@ -135,7 +135,7 @@ def _make_breakthrough_obs(
135135
)
136136

137137

138-
@pytest.mark.usefixtures("patched_csv_writer")
138+
@pytest.mark.usefixtures("patched_writer")
139139
def test_that_convert_summary_observations_extracts_localization_information(capsys):
140140
shape_registry = ShapeRegistry()
141141

@@ -166,9 +166,9 @@ def test_that_convert_summary_observations_extracts_localization_information(cap
166166
assert expected_print_with_localization in capsys.readouterr().out
167167

168168

169-
@pytest.mark.usefixtures("patched_csv_writer")
169+
@pytest.mark.usefixtures("patched_writer")
170170
def test_that_convert_summary_observations_produces_natsorted_csv_rows(
171-
monkeypatch, patched_csv_writer
171+
monkeypatch, patched_writer
172172
):
173173
observations = [
174174
_make_summary_obs("OP30"),
@@ -182,15 +182,15 @@ def test_that_convert_summary_observations_produces_natsorted_csv_rows(
182182
).write_csv()
183183

184184
ordered_wells = ["OP2", "OP4", "OP10", "OP30"]
185-
csv_content = patched_csv_writer.getvalue()
185+
csv_content = patched_writer.getvalue()
186186
obs_rows = csv_content.strip().split("\n")[1:]
187187
csv_well_ordering = [row.split(",")[0].strip() for row in obs_rows]
188188
assert csv_well_ordering == ordered_wells
189189

190190

191-
@pytest.mark.usefixtures("patched_csv_writer")
191+
@pytest.mark.usefixtures("patched_writer")
192192
def test_that_convert_summary_observations_chronologically_sorts_within_well(
193-
monkeypatch, patched_csv_writer
193+
monkeypatch, patched_writer
194194
):
195195
observations = [
196196
_make_summary_obs(well="OP1", date="2010-01-01"),
@@ -206,7 +206,7 @@ def test_that_convert_summary_observations_chronologically_sorts_within_well(
206206
).write_csv()
207207

208208
ordered_wells = ["OP1"] * 3 + ["OP2"] * 3
209-
csv_content = patched_csv_writer.getvalue()
209+
csv_content = patched_writer.getvalue()
210210
obs_rows = csv_content.strip().split("\n")[1:]
211211
csv_well_ordering = [row.split(",")[1].strip() for row in obs_rows]
212212
assert csv_well_ordering == ordered_wells
@@ -217,7 +217,7 @@ def test_that_convert_summary_observations_chronologically_sorts_within_well(
217217
assert ordered_dates == csv_date_ordering
218218

219219

220-
@pytest.mark.usefixtures("patched_csv_writer")
220+
@pytest.mark.usefixtures("patched_writer")
221221
def test_that_localization_can_be_gathered_from_breakthrough(capsys):
222222
shape_registry = ShapeRegistry()
223223
shape_id = shape_registry.register(
@@ -242,7 +242,7 @@ def test_that_localization_can_be_gathered_from_breakthrough(capsys):
242242
) in capsys.readouterr().out
243243

244244

245-
@pytest.mark.usefixtures("patched_csv_writer")
245+
@pytest.mark.usefixtures("patched_writer")
246246
def test_that_multiple_breakthrough_observations_for_the_same_well_raises_cli_error(
247247
monkeypatch,
248248
):
@@ -257,7 +257,7 @@ def test_that_multiple_breakthrough_observations_for_the_same_well_raises_cli_er
257257
BulkConfigConverter([brt1, brt2])
258258

259259

260-
@pytest.mark.usefixtures("patched_csv_writer")
260+
@pytest.mark.usefixtures("patched_writer")
261261
def test_that_the_correct_number_of_observations_are_mentioned_in_helper_text(capsys):
262262
observations = [
263263
_make_summary_obs(well="OP1"),
@@ -269,7 +269,7 @@ def test_that_the_correct_number_of_observations_are_mentioned_in_helper_text(ca
269269
assert "4 observations can be replaced" in capsys.readouterr().out
270270

271271

272-
@pytest.mark.usefixtures("patched_csv_writer")
272+
@pytest.mark.usefixtures("patched_writer")
273273
def test_that_bpr_observation_populates_ijk_columns_while_others_are_left_empty(
274274
patched_csv_writer,
275275
):
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import io
2+
from contextlib import contextmanager
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 GeneralObservation
11+
from ert.observation_converters import convert_observations
12+
from ert.observation_converters.summary_to_yaml import YamlConverter
13+
from tests.ert.unit_tests.cli.test_summary_to_bulk import _make_summary_obs
14+
15+
16+
@pytest.fixture(name="patched_writer")
17+
def patched_writing(monkeypatch):
18+
"""Avoid writing to file.
19+
Fixture mock can be used to assert what has been written to file.
20+
"""
21+
write_buffer = io.StringIO()
22+
23+
@contextmanager
24+
def mock_open(*args, **kwargs):
25+
yield write_buffer
26+
27+
monkeypatch.setattr(Path, "open", mock_open)
28+
return write_buffer
29+
30+
31+
@pytest.mark.usefixtures("snake_oil_case")
32+
def test_that_happy_path_on_snake_oil_produces_yaml_and_stdout(capsys):
33+
args = MagicMock(format="yaml", config="snake_oil.ert")
34+
convert_observations(args)
35+
36+
expected_yaml_content = dedent("""\
37+
smry:
38+
- key: WOPR:OP1
39+
observations:
40+
- date: '2010-03-31T00:00:00'
41+
value: 0.1
42+
error: 0.05
43+
- date: '2010-12-26T00:00:00'
44+
value: 0.7
45+
error: 0.07
46+
- date: '2011-12-21T00:00:00'
47+
value: 0.5
48+
error: 0.05
49+
- date: '2012-12-15T00:00:00'
50+
value: 0.3
51+
error: 0.075
52+
- date: '2013-12-10T00:00:00'
53+
value: 0.2
54+
error: 0.035
55+
- date: '2015-03-15T00:00:00'
56+
value: 0.015
57+
error: 0.01
58+
""")
59+
yaml_content = Path("summary_observations.yaml").read_text(encoding="utf-8")
60+
assert yaml_content == expected_yaml_content
61+
62+
expected_stdout = (
63+
f"Successfully wrote summary observations to '{YamlConverter.target_file}'."
64+
)
65+
stdout = capsys.readouterr().out
66+
assert expected_stdout in stdout
67+
68+
69+
def test_that_empty_observations_raises_ert_cli_error():
70+
with pytest.raises(ErtCliError, match="No summary observations found"):
71+
YamlConverter([])
72+
73+
74+
def test_that_no_summary_observations_raises_ert_cli_error():
75+
gen_obs = GeneralObservation(
76+
name="foo", data="foo", value=1.0, error=1.0, restart=5, index=5
77+
)
78+
with pytest.raises(ErtCliError, match="No summary observations found"):
79+
YamlConverter([gen_obs])
80+
81+
82+
def test_that_observations_with_same_summary_key_are_gathered_in_yaml_dict(use_tmpdir):
83+
k1, k2 = "foo", "bar"
84+
observations = 2 * [
85+
_make_summary_obs(key=k1, well=None),
86+
_make_summary_obs(key=k2, well=None),
87+
]
88+
89+
converter = YamlConverter(observations=observations)
90+
summary_dicts = converter._summary_to_yaml_dict()
91+
92+
keys = [entry["key"] for entry in summary_dicts["smry"]]
93+
assert len(keys) == 2
94+
assert set(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_seperated_in_yaml_dict():
101+
k1, k2 = "foo", "bar"
102+
observations = [
103+
_make_summary_obs(key=k1, well=None),
104+
_make_summary_obs(key=k2, well=None),
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 len(keys) == 2
112+
assert set(keys) == {k1, k2}
113+
114+
115+
def test_that_dumping_to_yaml_is_skipped_when_file_already_exists(use_tmpdir):
116+
observations = [_make_summary_obs()]
117+
118+
Path("summary_observations.yaml").write_text("existing", encoding="utf-8")
119+
assert Path("summary_observations.yaml").is_file()
120+
121+
converter = YamlConverter(observations=observations)
122+
with pytest.raises(
123+
ErtCliError,
124+
match=(
125+
r"A file with name 'summary_observations.yaml' already exists. "
126+
"Will not overwrite it and exit instead."
127+
),
128+
):
129+
converter.export_yaml()

0 commit comments

Comments
 (0)