Skip to content

Commit 8e3a2bc

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 8e3a2bc

4 files changed

Lines changed: 249 additions & 1 deletion

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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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, ObservationType
8+
from ert.config.parsing import ObservationDict, read_file
9+
10+
11+
class YamlObservation(TypedDict):
12+
date: str
13+
value: float
14+
error: float
15+
16+
17+
class SummaryDict(TypedDict):
18+
key: str
19+
observations: list[YamlObservation]
20+
21+
22+
YamlDict = dict[Literal["smry"], list[SummaryDict]]
23+
24+
25+
class YamlConverter:
26+
target_file = "summary_observations.yaml"
27+
28+
def __init__(self, observations: list[ObservationDict]) -> None:
29+
summary_observations = [
30+
o for o in observations if o["type"] == ObservationType.SUMMARY
31+
]
32+
if not summary_observations:
33+
raise ErtCliError("No summary observations in configuration.\nExiting ...")
34+
35+
self.summary_observations = summary_observations
36+
37+
def _summary_to_yaml_dict(self) -> YamlDict:
38+
summary_keys: set[str] = {str(o["KEY"]) for o in self.summary_observations}
39+
summary_list: list[SummaryDict] = []
40+
for key in summary_keys:
41+
observations_with_key = [
42+
o for o in self.summary_observations if o["KEY"] == key
43+
]
44+
obs_dicts: list[YamlObservation] = [
45+
{
46+
"date": str(o["DATE"]),
47+
"value": float(o["VALUE"]),
48+
"error": float(o["ERROR"]),
49+
}
50+
for o in observations_with_key
51+
]
52+
summary_dict: SummaryDict = {"key": key, "observations": obs_dicts}
53+
summary_list.append(summary_dict)
54+
return {"smry": summary_list}
55+
56+
def export_yaml(self) -> None:
57+
yaml = YAML()
58+
yaml_dict = self._summary_to_yaml_dict()
59+
if Path(self.target_file).is_file():
60+
raise ErtCliError(
61+
f"A file with name '{self.target_file}' already exists. "
62+
f"Will not overwrite it and exit instead."
63+
)
64+
with Path(self.target_file).open("w", encoding="utf-8") as f:
65+
yaml.dump(yaml_dict, f)
66+
print(f"Successfully wrote summary observations to '{self.target_file}'.")
67+
68+
69+
def convert_summary_to_yaml(config: str) -> None:
70+
user_config_contents = read_file(config)
71+
config_dict = ErtConfig._config_dict_from_contents(
72+
user_config_contents,
73+
config,
74+
)
75+
file, obs_config = config_dict.get("OBS_CONFIG", (None, None))
76+
77+
if file is None or config_dict is None:
78+
raise ErtCliError("No observation configuration found.\nExiting ...")
79+
80+
yaml_exporter = YamlConverter(
81+
observations=obs_config,
82+
)
83+
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
@@ -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,
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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 import ObservationType
11+
from ert.config.parsing import ObservationDict
12+
from ert.observation_converters import convert_observations
13+
from ert.observation_converters.summary_to_yaml import YamlConverter
14+
15+
16+
def _make_obs_dict(
17+
obs_type: ObservationType = ObservationType.SUMMARY,
18+
key: str = "WOPR",
19+
well: str = "OP1",
20+
) -> ObservationDict:
21+
key = f"{key}:{well}" if well else key
22+
return ObservationDict(
23+
{
24+
"type": obs_type,
25+
"KEY": key,
26+
"ERROR": 5,
27+
"VALUE": 10,
28+
"DATE": "2010-10-10",
29+
},
30+
context=MagicMock(),
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)
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("summary_observations.yaml").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+
gen_obs = _make_obs_dict(obs_type=ObservationType.GENERAL)
79+
with pytest.raises(ErtCliError, match="No summary observations in configuration"):
80+
YamlConverter([gen_obs])
81+
82+
83+
def test_that_observations_with_same_summary_key_are_gathered_in_yaml_dict(use_tmpdir):
84+
k1, k2 = "foo", "bar"
85+
observations = 2 * [
86+
_make_obs_dict(key=k1, well=""),
87+
_make_obs_dict(key=k2, well=""),
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 len(keys) == 2
95+
assert set(keys) == {k1, k2}
96+
97+
observations = [s_d["observations"] for s_d in summary_dicts["smry"]]
98+
assert all(len(o) == 2 for o in observations)
99+
100+
101+
def test_that_observations_with_different_summary_keys_are_separated_in_yaml_dict():
102+
k1, k2 = "foo", "bar"
103+
observations = [
104+
_make_obs_dict(key=k1, well=""),
105+
_make_obs_dict(key=k2, well=""),
106+
]
107+
108+
converter = YamlConverter(observations=observations)
109+
result = converter._summary_to_yaml_dict()
110+
111+
keys = [entry["key"] for entry in result["smry"]]
112+
assert len(keys) == 2
113+
assert set(keys) == {k1, k2}
114+
115+
116+
def test_that_dumping_to_yaml_is_skipped_when_file_already_exists(use_tmpdir):
117+
observations = [_make_obs_dict()]
118+
119+
Path("summary_observations.yaml").write_text("existing", encoding="utf-8")
120+
assert Path("summary_observations.yaml").is_file()
121+
122+
converter = YamlConverter(observations=observations)
123+
with pytest.raises(
124+
ErtCliError,
125+
match=(
126+
r"A file with name 'summary_observations.yaml' already exists. "
127+
"Will not overwrite it and exit instead."
128+
),
129+
):
130+
converter.export_yaml()
131+
132+
133+
def test_that_config_warnings_are_caught_instead_of_printed_to_terminal(
134+
caplog, use_tmpdir
135+
):
136+
caplog.set_level(logging.INFO)
137+
config = "config.ert"
138+
obs_config = "obs.txt"
139+
# This setup expects the warning:
140+
# 'Config contains a SUMMARY key but no forward model steps'
141+
# to be raised
142+
config_content = f"""\
143+
NUM_REALIZATIONS 5
144+
SUMMARY *
145+
ECLBASE FOO
146+
OBS_CONFIG {obs_config}
147+
"""
148+
obs_config_content = """\
149+
SUMMARY_OBSERVATION {
150+
KEY=FOPR;
151+
VALUE=10;
152+
ERROR=5;
153+
DATE=2010-10-10;
154+
};"""
155+
Path(config).write_text(config_content, encoding="utf-8")
156+
Path(obs_config).write_text(obs_config_content, encoding="utf-8")
157+
158+
args = MagicMock(format="yaml", config=config)
159+
with warnings.catch_warnings(record=True) as w:
160+
convert_observations(args)
161+
162+
assert len(w) == 0

0 commit comments

Comments
 (0)