Skip to content

Commit 73b418b

Browse files
committed
Use blobs in experiment storage for rho matrices
This includes rhomatrix event, produced by distance based localization, which yields saving the rho matrix on the experiment storage level. Rho matrix has a name and observation keys attributes. They represent the full description of the rho matrix. Internally, the blobs themselves are responsible for storing the data as the code is used on both; experiment and ensemble level. Since the Rho matrix was not in the storage before, no storage migration in needed.
1 parent ae662b3 commit 73b418b

10 files changed

Lines changed: 570 additions & 114 deletions

File tree

src/ert/analysis/_es_update.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646

4747
from ert.config import ParameterConfig
4848
from ert.storage import Ensemble
49+
from ert.storage.local_experiment import LocalExperiment
4950

5051
logger = logging.getLogger(__name__)
5152

@@ -215,6 +216,9 @@ def perform_ensemble_update(
215216
ypos=filtered_data["north"].to_numpy()[has_location],
216217
main_range=filtered_data["radius"].to_numpy()[has_location],
217218
location_mask=has_location,
219+
observation_keys=filtered_data["observation_key"]
220+
.to_numpy()[has_location]
221+
.tolist(),
218222
)
219223

220224
obs_context = ObservationContext(
@@ -298,6 +302,7 @@ def build_strategy_map(
298302
correlation_threshold: Callable[[int], float],
299303
*,
300304
progress_callback: Callable[[AnalysisEvent], None] | None = None,
305+
experiment: LocalExperiment | None = None,
301306
) -> dict[str, UpdateStrategy]:
302307
"""Build a mapping from parameter group names to update strategies.
303308
@@ -318,6 +323,8 @@ def build_strategy_map(
318323
threshold.
319324
progress_callback : Callable[[AnalysisEvent], None] | None
320325
Callback for reporting progress.
326+
experiment : LocalExperiment | None
327+
Optional experiment for loading cached rho matrices.
321328
322329
Returns
323330
-------
@@ -330,11 +337,11 @@ def build_strategy_map(
330337
strategy_map: dict[str, UpdateStrategy] = {}
331338

332339
field_distance_strategy = DistanceLocalizationUpdate(
333-
enkf_truncation, Field, progress_callback
340+
enkf_truncation, Field, progress_callback, experiment
334341
)
335342

336343
surface_distance_strategy = DistanceLocalizationUpdate(
337-
enkf_truncation, SurfaceConfig, progress_callback
344+
enkf_truncation, SurfaceConfig, progress_callback, experiment
338345
)
339346

340347
global_strategy = GlobalESUpdate(

src/ert/analysis/_update_strategies/_distance.py

Lines changed: 109 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22

33
from __future__ import annotations
44

5+
import io
56
import time
67
from collections.abc import Callable
78
from datetime import timedelta
89
from typing import TYPE_CHECKING
910

1011
import humanize
1112
import numpy as np
13+
import scipy as sp
1214
from iterative_ensemble_smoother import LocalizedESMDA
1315

14-
from ert.analysis.event import AnalysisEvent, AnalysisStatusEvent
16+
from ert.analysis.event import (
17+
AnalysisEvent,
18+
AnalysisRhoMatrixEvent,
19+
AnalysisStatusEvent,
20+
)
1521
from ert.config import Field, SurfaceConfig
1622
from ert.field_utils import (
1723
AxisOrientation,
@@ -27,6 +33,7 @@
2733
import numpy.typing as npt
2834

2935
from ert.config import ParameterConfig
36+
from ert.storage.local_experiment import LocalExperiment
3037

3138
from ._protocol import ObservationContext
3239

@@ -39,10 +46,12 @@ def __init__(
3946
enkf_truncation: float,
4047
param_type: type[Field | SurfaceConfig],
4148
progress_callback: Callable[[AnalysisEvent], None],
49+
experiment: LocalExperiment | None = None,
4250
) -> None:
4351
self._enkf_truncation = enkf_truncation
4452
self._param_type = param_type
4553
self._progress_callback = progress_callback
54+
self._experiment = experiment
4655
self._obs_loc: ObservationLocations | None = None
4756
self._smoother: LocalizedESMDA | None = None
4857
self._ensemble_size: int = 0
@@ -125,6 +134,19 @@ def update(
125134

126135
return result
127136

137+
def _load_rho_from_storage(
138+
self, param_name: str
139+
) -> npt.NDArray[np.floating] | None:
140+
"""Try to load a cached rho matrix from experiment blob storage."""
141+
if self._experiment is None:
142+
return None
143+
obs_keys = (
144+
self._obs_loc.observation_keys
145+
if self._obs_loc is not None and self._obs_loc.observation_keys
146+
else None
147+
)
148+
return self._experiment.load_rho_matrix(param_name, obs_keys)
149+
128150
def _full_localization_matrix(
129151
self,
130152
num_params: int,
@@ -170,32 +192,50 @@ def _update_field(
170192
if ertbox.axis_orientation is None:
171193
raise ValueError("Field grid axis orientation must be defined")
172194

173-
xpos, ypos = transform_positions_to_local_field_coordinates(
174-
ertbox.origin,
175-
ertbox.rotation_angle,
176-
self._obs_loc.xpos,
177-
self._obs_loc.ypos,
178-
)
195+
cached_rho = self._load_rho_from_storage(param_config.name)
196+
if cached_rho is not None:
197+
rho_2d = cached_rho
198+
else:
199+
xpos, ypos = transform_positions_to_local_field_coordinates(
200+
ertbox.origin,
201+
ertbox.rotation_angle,
202+
self._obs_loc.xpos,
203+
self._obs_loc.ypos,
204+
)
179205

180-
ellipse_rotation = transform_local_ellipse_angle_to_local_coords(
181-
ertbox.rotation_angle,
182-
np.zeros_like(self._obs_loc.main_range),
183-
)
206+
ellipse_rotation = transform_local_ellipse_angle_to_local_coords(
207+
ertbox.rotation_angle,
208+
np.zeros_like(self._obs_loc.main_range),
209+
)
184210

185-
rho_matrix = calc_rho_for_2d_grid_layer(
186-
nx=ertbox.nx,
187-
ny=ertbox.ny,
188-
xinc=ertbox.xinc,
189-
yinc=ertbox.yinc,
190-
obs_xpos=xpos,
191-
obs_ypos=ypos,
192-
obs_main_range=self._obs_loc.main_range,
193-
obs_perp_range=self._obs_loc.main_range,
194-
obs_anisotropy_angle=ellipse_rotation,
195-
axis_orientation=ertbox.axis_orientation,
196-
)
211+
rho_matrix = calc_rho_for_2d_grid_layer(
212+
nx=ertbox.nx,
213+
ny=ertbox.ny,
214+
xinc=ertbox.xinc,
215+
yinc=ertbox.yinc,
216+
obs_xpos=xpos,
217+
obs_ypos=ypos,
218+
obs_main_range=self._obs_loc.main_range,
219+
obs_perp_range=self._obs_loc.main_range,
220+
obs_anisotropy_angle=ellipse_rotation,
221+
axis_orientation=ertbox.axis_orientation,
222+
)
197223

198-
rho_2d = rho_matrix.reshape(ertbox.nx * ertbox.ny, -1)
224+
rho_2d = rho_matrix.reshape(ertbox.nx * ertbox.ny, -1)
225+
226+
if self._obs_loc.observation_keys:
227+
rho_sparse = sp.sparse.csc_matrix(rho_2d)
228+
buf = io.BytesIO()
229+
sp.sparse.save_npz(buf, rho_sparse)
230+
self._progress_callback(
231+
AnalysisRhoMatrixEvent(
232+
param_name=param_config.name,
233+
observation_keys=self._obs_loc.observation_keys,
234+
shape=rho_2d.shape,
235+
data_type=str(rho_2d.dtype),
236+
matrix_bytes=buf.getvalue(),
237+
)
238+
)
199239

200240
for param_batch_idx in batches:
201241
update_idx = param_batch_idx[non_zero_variance_mask[param_batch_idx]]
@@ -242,37 +282,56 @@ def _update_surface(
242282

243283
assert self._obs_loc is not None
244284

245-
xpos, ypos = transform_positions_to_local_field_coordinates(
246-
(param_config.xori, param_config.yori),
247-
param_config.rotation,
248-
self._obs_loc.xpos,
249-
self._obs_loc.ypos,
250-
)
285+
cached_rho = self._load_rho_from_storage(param_config.name)
286+
if cached_rho is not None:
287+
rho_flat = cached_rho
288+
else:
289+
xpos, ypos = transform_positions_to_local_field_coordinates(
290+
(param_config.xori, param_config.yori),
291+
param_config.rotation,
292+
self._obs_loc.xpos,
293+
self._obs_loc.ypos,
294+
)
251295

252-
rotation_angle = transform_local_ellipse_angle_to_local_coords(
253-
param_config.rotation,
254-
np.zeros_like(self._obs_loc.main_range, dtype=np.float64),
255-
)
296+
rotation_angle = transform_local_ellipse_angle_to_local_coords(
297+
param_config.rotation,
298+
np.zeros_like(self._obs_loc.main_range, dtype=np.float64),
299+
)
256300

257-
if param_config.yflip != 1:
258-
raise ValueError(
259-
f"Expected SurfaceConfig.yflip == 1, got {param_config.yflip}"
301+
if param_config.yflip != 1:
302+
raise ValueError(
303+
f"Expected SurfaceConfig.yflip == 1, got {param_config.yflip}"
304+
)
305+
306+
rho_matrix = calc_rho_for_2d_grid_layer(
307+
nx=param_config.ncol,
308+
ny=param_config.nrow,
309+
xinc=param_config.xinc,
310+
yinc=param_config.yinc,
311+
obs_xpos=xpos,
312+
obs_ypos=ypos,
313+
obs_main_range=self._obs_loc.main_range,
314+
obs_perp_range=self._obs_loc.main_range,
315+
obs_anisotropy_angle=rotation_angle,
316+
axis_orientation=AxisOrientation.LEFT_HANDED,
260317
)
261318

262-
rho_matrix = calc_rho_for_2d_grid_layer(
263-
nx=param_config.ncol,
264-
ny=param_config.nrow,
265-
xinc=param_config.xinc,
266-
yinc=param_config.yinc,
267-
obs_xpos=xpos,
268-
obs_ypos=ypos,
269-
obs_main_range=self._obs_loc.main_range,
270-
obs_perp_range=self._obs_loc.main_range,
271-
obs_anisotropy_angle=rotation_angle,
272-
axis_orientation=AxisOrientation.LEFT_HANDED,
273-
)
319+
rho_flat = rho_matrix.reshape(-1, rho_matrix.shape[-1])
320+
321+
if self._obs_loc.observation_keys:
322+
rho_sparse = sp.sparse.csc_matrix(rho_flat)
323+
buf = io.BytesIO()
324+
sp.sparse.save_npz(buf, rho_sparse)
325+
self._progress_callback(
326+
AnalysisRhoMatrixEvent(
327+
param_name=param_config.name,
328+
observation_keys=self._obs_loc.observation_keys,
329+
shape=rho_flat.shape,
330+
data_type=str(rho_flat.dtype),
331+
matrix_bytes=buf.getvalue(),
332+
)
333+
)
274334

275-
rho_flat = rho_matrix.reshape(-1, rho_matrix.shape[-1])
276335
for param_batch_idx in batches:
277336
update_idx = param_batch_idx[non_zero_variance_mask[param_batch_idx]]
278337
if update_idx.size == 0:

src/ert/analysis/_update_strategies/_protocol.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import time
66
from collections.abc import Callable, Sequence
7-
from dataclasses import dataclass
7+
from dataclasses import dataclass, field
88
from typing import TYPE_CHECKING, Protocol, Self
99

1010
import numpy as np
@@ -101,6 +101,9 @@ class ObservationLocations:
101101
location_mask: npt.NDArray[np.bool_]
102102
"""Boolean mask indicating which observations have valid location data."""
103103

104+
observation_keys: list[str] = field(default_factory=list)
105+
"""Observation keys for the located observations (same order as xpos/ypos)."""
106+
104107

105108
@dataclass(frozen=True)
106109
class ObservationContext:

src/ert/analysis/event.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,12 @@ class AnalysisScalingEvent(AnalysisEvent):
102102
scaling_bytes: bytes = Field(exclude=True)
103103
num_observations: int
104104
num_groups: int
105+
106+
107+
class AnalysisRhoMatrixEvent(AnalysisEvent):
108+
event_type: Literal["AnalysisRhoMatrixEvent"] = "AnalysisRhoMatrixEvent"
109+
param_name: str
110+
observation_keys: list[str]
111+
shape: tuple[int, int]
112+
data_type: str
113+
matrix_bytes: bytes = Field(exclude=True)

src/ert/run_models/update_run_model.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
AnalysisErrorEvent,
1111
AnalysisEvent,
1212
AnalysisMatrixEvent,
13+
AnalysisRhoMatrixEvent,
1314
AnalysisScalingEvent,
1415
AnalysisStatusEvent,
1516
AnalysisTimeEvent,
@@ -67,6 +68,7 @@ def update_ensemble_parameters(
6768
enkf_truncation=self.analysis_settings.enkf_truncation,
6869
correlation_threshold=self.analysis_settings.correlation_threshold,
6970
progress_callback=progress_callback,
71+
experiment=prior.experiment,
7072
)
7173

7274
smoother_update(
@@ -195,6 +197,8 @@ def send_smoother_event(
195197
ensemble.save_blob(event)
196198
case AnalysisScalingEvent():
197199
ensemble.save_blob(event)
200+
case AnalysisRhoMatrixEvent():
201+
ensemble.experiment.save_blob(event)
198202
case AnalysisCompleteEvent():
199203
ensemble.save_blob(event)
200204
self.send_event(

0 commit comments

Comments
 (0)