Skip to content

Commit 9157987

Browse files
yngve-skjonathan-eq
authored andcommitted
Simplify EverestControl to represent a single scalar value
There is now one EverestControl instance per control variable. This aligns its structure with scalar parameter types in ERT. Runpath parameter file generation logic has been adapted to handle this new structure while ensuring the final output format remains backward compatible.
1 parent 2b34020 commit 9157987

17 files changed

Lines changed: 499 additions & 307 deletions

src/ert/config/everest_control.py

Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
from __future__ import annotations
22

33
import importlib
4-
import json
54
import logging
65
from collections.abc import Iterator, Mapping, MutableMapping
7-
from dataclasses import field
86
from pathlib import Path
97
from textwrap import dedent
108
from typing import TYPE_CHECKING, Any, Literal, Self
@@ -16,8 +14,6 @@
1614
from pydantic import BaseModel, ConfigDict, Field, model_validator
1715
from ropt.workflow import find_sampler_plugin
1816

19-
from ert.substitutions import substitute_runpath_name
20-
2117
from .parameter_config import ParameterCardinality, ParameterConfig
2218

2319
if TYPE_CHECKING:
@@ -144,37 +140,38 @@ def validate_backend_and_method(self) -> Self:
144140

145141

146142
class EverestControl(ParameterConfig):
147-
"""Create an EverestControl for @key with the given @input_keys
143+
"""Create an EverestControl for a single control variable.
148144
149-
@input_keys can be either a list of keys as strings or a dict with
150-
keys as strings and a list of suffixes for each key.
151-
If a list of strings is given, the order is preserved.
145+
Each EverestControl represents one scalar value. Multiple controls can
146+
share the same group name to indicate they belong to the same logical group.
152147
"""
153148

154149
type: Literal["everest_parameters"] = "everest_parameters"
155-
input_keys: list[str] = field(default_factory=list)
150+
dimensionality: Literal[1] = 1
151+
input_key: str
156152
forward_init: bool = False
157153
output_file: str = ""
158154
forward_init_file: str = ""
159155
update: bool = False
160-
types: list[Literal["well_control", "generic_control"]]
161-
initial_guesses: list[float]
162-
control_types: list[Literal["real", "integer"]]
163-
enabled: list[bool]
164-
min: list[float]
165-
max: list[float]
166-
perturbation_types: list[Literal["absolute", "relative"]]
167-
perturbation_magnitudes: list[float]
168-
scaled_ranges: list[tuple[float, float]]
169-
samplers: list[SamplerConfig | None]
156+
control_type_: Literal["well_control", "generic_control"]
157+
initial_guess: float
158+
control_type: Literal["real", "integer"]
159+
enabled: bool
160+
min: float
161+
max: float
162+
perturbation_type: Literal["absolute", "relative"]
163+
perturbation_magnitude: float
164+
scaled_range: tuple[float, float]
165+
sampler: SamplerConfig | None
166+
group: str
170167

171168
# Set up for deprecation, but has to live here until support for the
172169
# "dotdash" notation is removed for everest controls via everest config.
173-
input_keys_dotdash: list[str] = field(default_factory=list)
170+
input_key_dotdash: str = ""
174171

175172
@property
176173
def parameter_keys(self) -> list[str]:
177-
return self.input_keys
174+
return [self.input_key]
178175

179176
@property
180177
def cardinality(self) -> ParameterCardinality:
@@ -194,31 +191,12 @@ def load_parameter_graph(self) -> nx.Graph[int]:
194191
raise NotImplementedError
195192

196193
def __len__(self) -> int:
197-
return len(self.input_keys)
194+
return 1
198195

199196
def write_to_runpath(
200197
self, run_path: Path, real_nr: int, ensemble: Ensemble
201-
) -> None:
202-
file_path: Path = run_path / substitute_runpath_name(
203-
self.output_file, real_nr, ensemble.iteration
204-
)
205-
Path.mkdir(file_path.parent, exist_ok=True, parents=True)
206-
207-
data: dict[str, Any] = {}
208-
df = ensemble.load_parameters(self.name, real_nr)
209-
assert isinstance(df, pl.DataFrame)
210-
df = df.drop("realization")
211-
df = df.rename({col: col.replace(f"{self.name}.", "", 1) for col in df.columns})
212-
for c in df.columns:
213-
if "." in c:
214-
top_key, sub_key = c.split(".", 1)
215-
if top_key not in data:
216-
data[top_key] = {}
217-
data[top_key][sub_key] = df[c].item()
218-
else:
219-
data[c] = df[c].item()
220-
221-
file_path.write_text(json.dumps(data), encoding="utf-8")
198+
) -> dict[str, dict[str, float | str]] | None:
199+
raise NotImplementedError
222200

223201
def create_storage_datasets(
224202
self,
@@ -228,7 +206,7 @@ def create_storage_datasets(
228206
df = pl.DataFrame(
229207
{
230208
"realization": iens_active_index,
231-
**{k: from_data[:, i] for i, k in enumerate(self.parameter_keys)},
209+
self.input_key: pl.Series(from_data.flatten()),
232210
},
233211
strict=False,
234212
)

src/ert/run_models/_create_run_path.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import TYPE_CHECKING, Any
1414

1515
import orjson
16+
import polars as pl
1617

1718
from _ert.utils import file_safe_timestamp
1819
from ert.config import (
@@ -140,6 +141,9 @@ def _generate_parameter_files(
140141
exports: dict[str, dict[str, float | str]] = {}
141142
log_exports: dict[str, dict[str, float | str]] = {}
142143

144+
# Group EverestControls by output_file for aggregated JSON writing
145+
everest_controls_by_file: dict[str, list[EverestControl]] = defaultdict(list)
146+
143147
for param in parameter_configs:
144148
# For the first iteration we do not write the parameter
145149
# to run path, as we expect to read if after the forward
@@ -148,8 +152,12 @@ def _generate_parameter_files(
148152
continue
149153
start_time = time.perf_counter()
150154
export_values: dict[str, dict[str, float | str]] | None = None
151-
log_export_values: dict[str, dict[str, float | str]] | None = {}
152-
if param.name in scalar_data:
155+
log_export_values: dict[str, dict[str, float | str]] = {}
156+
157+
if isinstance(param, EverestControl):
158+
everest_controls_by_file[param.output_file].append(param)
159+
continue
160+
elif param.name in scalar_data:
153161
scalar_value = scalar_data[param.name]
154162
export_values = {param.group_name: {param.name: scalar_value}}
155163
if isinstance(param, GenKwConfig) and isinstance(
@@ -175,6 +183,39 @@ def _generate_parameter_files(
175183
log_exports.setdefault(group, {}).update(vals)
176184
export_timings[param.type] += time.perf_counter() - start_time
177185
continue
186+
187+
# Write aggregated EverestControl JSON files
188+
start_time = time.perf_counter()
189+
for output_file, controls in everest_controls_by_file.items():
190+
file_path: Path = Path(run_path) / substitute_runpath_name(
191+
output_file, iens, iteration
192+
)
193+
file_path.parent.mkdir(exist_ok=True, parents=True)
194+
195+
data: dict[str, Any] = {}
196+
for control in controls:
197+
df = fs.load_parameters(control.name, iens)
198+
assert isinstance(df, pl.DataFrame)
199+
value = df[control.input_key].item()
200+
201+
key_without_group = control.input_key.replace(f"{control.group}.", "", 1)
202+
203+
if "." in key_without_group:
204+
parts = key_without_group.split(".")
205+
current = data
206+
for part in parts[:-1]:
207+
if part not in current:
208+
current[part] = {}
209+
current = current[part]
210+
current[parts[-1]] = value
211+
else:
212+
data[key_without_group] = value
213+
214+
file_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
215+
216+
if everest_controls_by_file:
217+
export_timings["everest_parameters"] += time.perf_counter() - start_time
218+
178219
start_time = time.perf_counter()
179220
_value_export_txt(run_path, export_base_name, exports | log_exports)
180221
export_timings["value_export_txt"] = time.perf_counter() - start_time

src/ert/run_models/everest_run_model.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,9 @@ def create(
296296
)
297297

298298
parameter_configs = [
299-
control.to_ert_parameter_config() for control in everest_config.controls
299+
ert_control
300+
for control in everest_config.controls
301+
for ert_control in control.to_ert_parameter_config()
300302
]
301303

302304
response_configs: list[ResponseConfig] = []

src/ert/storage/local_storage.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
logger = logging.getLogger(__name__)
3333

34-
_LOCAL_STORAGE_VERSION = 25
34+
_LOCAL_STORAGE_VERSION = 26
3535

3636

3737
class _Migrations(BaseModel):
@@ -515,6 +515,7 @@ def _migrate(self, version: int) -> None:
515515
to23,
516516
to24,
517517
to25,
518+
to26,
518519
)
519520

520521
try:
@@ -569,6 +570,7 @@ def _migrate(self, version: int) -> None:
569570
22: to23,
570571
23: to24,
571572
24: to25,
573+
25: to26,
572574
}
573575
for from_version in range(version, _LOCAL_STORAGE_VERSION):
574576
migrations[from_version].migrate(self.path)

src/ert/storage/migration/to26.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
from typing import Any
6+
7+
info = "Unroll EverestControl to one config per parameter"
8+
9+
10+
def migrate_everest_control_format(path: Path) -> None:
11+
experiments_dir = path / "experiments"
12+
if not experiments_dir.exists():
13+
return
14+
15+
for exp_dir in experiments_dir.iterdir():
16+
if not exp_dir.is_dir():
17+
continue
18+
19+
index_file = exp_dir / "index.json"
20+
index_data = json.loads(index_file.read_text(encoding="utf-8"))
21+
22+
experiment_data = index_data.get("experiment")
23+
params_config = experiment_data.get("parameter_configuration")
24+
new_params_config: list[dict[str, Any]] = []
25+
modified = False
26+
27+
for param in params_config:
28+
if param.get("type") == "everest_parameters" and "input_keys" in param:
29+
modified = True
30+
31+
common_fields = {
32+
"forward_init": False,
33+
"output_file": param["output_file"],
34+
"forward_init_file": "",
35+
"update": False,
36+
"type": "everest_parameters",
37+
"dimensionality": 1,
38+
}
39+
40+
group_name = param["name"]
41+
input_keys = param["input_keys"]
42+
43+
for i, input_key in enumerate(input_keys):
44+
new_params_config.append(
45+
{
46+
**common_fields,
47+
"input_key": input_key,
48+
"group": group_name,
49+
"name": input_key,
50+
"control_type_": param["types"][i],
51+
"initial_guess": param["initial_guesses"][i],
52+
"control_type": param["control_types"][i],
53+
"enabled": param["enabled"][i],
54+
"min": param["min"][i],
55+
"max": param["max"][i],
56+
"perturbation_type": param["perturbation_types"][i],
57+
"perturbation_magnitude": param["perturbation_magnitudes"][
58+
i
59+
],
60+
"scaled_range": param["scaled_ranges"][i],
61+
"sampler": param["samplers"][i],
62+
"input_key_dotdash": param["input_keys_dotdash"][i],
63+
}
64+
)
65+
else:
66+
new_params_config.append(param)
67+
68+
if modified:
69+
experiment_data["parameter_configuration"] = new_params_config
70+
index_file.write_text(json.dumps(index_data, indent=2), encoding="utf-8")
71+
72+
73+
def migrate(path: Path) -> None:
74+
migrate_everest_control_format(path)

0 commit comments

Comments
 (0)