Skip to content

Commit 3b61004

Browse files
committed
Add caching to PlotApi requests
1 parent 38f3a98 commit 3b61004

6 files changed

Lines changed: 161 additions & 58 deletions

File tree

src/ert/gui/plotting/plot_api.py

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
import logging
66
from dataclasses import dataclass
7-
from functools import cached_property
7+
from functools import cached_property, lru_cache
88
from itertools import combinations as combi
99
from typing import TYPE_CHECKING, Any, NamedTuple
1010
from urllib.parse import quote
@@ -26,13 +26,17 @@
2626
from ert.storage.local_experiment import _parameters_adapter as parameter_config_adapter
2727
from ert.storage.local_experiment import _responses_adapter as response_config_adapter
2828
from ert.storage.realization_storage_state import RealizationStorageState
29+
from ert.utils import process_arg
2930

3031
logger = logging.getLogger(__name__)
3132

3233
if TYPE_CHECKING:
3334
from pathlib import Path
3435

3536

37+
TIMEOUT = 120
38+
39+
3640
@dataclass(frozen=True, eq=True)
3741
class EnsembleObject:
3842
name: str
@@ -59,13 +63,12 @@ class PlotApi:
5963
def __init__(self, ens_path: Path) -> None:
6064
self.ens_path: Path = ens_path
6165
self._all_ensembles: list[EnsembleObject] | None = None
62-
self._timeout = 120
6366

6467
@property
6568
def api_version(self) -> str:
6669
with create_ertserver_client(self.ens_path) as client:
6770
try:
68-
http_response = client.get("/version", timeout=self._timeout)
71+
http_response = client.get("/version", timeout=TIMEOUT)
6972
self._check_http_response(http_response)
7073
api_version = str(http_response.json())
7174
except Exception as exc:
@@ -91,13 +94,13 @@ def get_all_ensembles(self) -> list[EnsembleObject]:
9194
self._all_ensembles = []
9295
with create_ertserver_client(self.ens_path) as client:
9396
try: # noqa: PLW0717
94-
http_response = client.get("/experiments", timeout=self._timeout)
97+
http_response = client.get("/experiments", timeout=TIMEOUT)
9598
self._check_http_response(http_response)
9699
experiments = http_response.json()
97100
for experiment in experiments:
98101
for ensemble_id in experiment["ensemble_ids"]:
99102
http_response = client.get(
100-
f"/ensembles/{ensemble_id}", timeout=self._timeout
103+
f"/ensembles/{ensemble_id}", timeout=TIMEOUT
101104
)
102105
self._check_http_response(http_response)
103106
response_json: dict[str, Any] = http_response.json()
@@ -154,7 +157,7 @@ def parameters_api_key_defs(self) -> list[PlotApiKeyDefinition]:
154157
all_params = {}
155158

156159
with create_ertserver_client(self.ens_path) as client:
157-
http_response = client.get("/experiments", timeout=self._timeout)
160+
http_response = client.get("/experiments", timeout=TIMEOUT)
158161
self._check_http_response(http_response)
159162

160163
for experiment in http_response.json():
@@ -181,7 +184,7 @@ def responses_api_key_defs(self) -> list[PlotApiKeyDefinition]:
181184
key_defs: dict[str, PlotApiKeyDefinition] = {}
182185

183186
with create_ertserver_client(self.ens_path) as client:
184-
http_response = client.get("/experiments", timeout=self._timeout)
187+
http_response = client.get("/experiments", timeout=TIMEOUT)
185188
self._check_http_response(http_response)
186189

187190
def update_keydef(plot_key_def: PlotApiKeyDefinition) -> None:
@@ -272,7 +275,7 @@ def data_for_response(
272275
params={"filter_on": json.dumps(filter_on)}
273276
if filter_on is not None
274277
else None,
275-
timeout=self._timeout,
278+
timeout=TIMEOUT,
276279
)
277280
self._check_http_response(http_response)
278281

@@ -341,16 +344,17 @@ def data_for_response(
341344
except ValueError:
342345
return df
343346

344-
def data_for_gradient(self, ensemble_id: str, key: str) -> pd.DataFrame:
345-
if "@" in key:
346-
key = key.split("@", maxsplit=1)[0]
347-
with create_ertserver_client(self.ens_path) as client:
347+
@staticmethod
348+
@process_arg(key="key", process=lambda s: s.split("@", maxsplit=1)[0])
349+
@lru_cache(maxsize=256)
350+
def data_for_gradient(ensemble_id: str, key: str, ens_path: Path) -> pd.DataFrame:
351+
with create_ertserver_client(ens_path) as client:
348352
http_response = client.get(
349353
f"/ensembles/{ensemble_id}/gradients/{PlotApi.escape(key)}",
350354
headers={"accept": "application/x-parquet"},
351-
timeout=self._timeout,
355+
timeout=TIMEOUT,
352356
)
353-
self._check_http_response(http_response)
357+
PlotApi._check_http_response(http_response)
354358

355359
stream = io.BytesIO(http_response.content)
356360
df = pd.read_parquet(stream)
@@ -366,13 +370,15 @@ def data_for_gradient(self, ensemble_id: str, key: str) -> pd.DataFrame:
366370
}
367371
)
368372

373+
@staticmethod
374+
@lru_cache(maxsize=128)
369375
def data_for_controls(
370-
self, ensemble_id: str, parameter_keys: list[str]
376+
ensemble_id: str, parameter_keys: tuple[str, ...], ens_path: Path
371377
) -> pd.DataFrame:
372378
frames = []
373379

374380
for parameter_key in parameter_keys:
375-
df = self.data_for_parameter(ensemble_id, parameter_key)
381+
df = PlotApi.data_for_parameter(ensemble_id, parameter_key, ens_path)
376382
if not df.empty and {"batch_id", "realization"}.issubset(df.columns):
377383
value_cols = [
378384
c for c in df.columns if c not in {"batch_id", "realization"}
@@ -388,14 +394,18 @@ def data_for_controls(
388394
return pd.DataFrame()
389395
return pd.concat(frames, ignore_index=True)
390396

391-
def data_for_parameter(self, ensemble_id: str, parameter_key: str) -> pd.DataFrame:
392-
with create_ertserver_client(self.ens_path) as client:
397+
@staticmethod
398+
@lru_cache(maxsize=256)
399+
def data_for_parameter(
400+
ensemble_id: str, parameter_key: str, ens_path: Path
401+
) -> pd.DataFrame:
402+
with create_ertserver_client(ens_path) as client:
393403
http_response = client.get(
394404
f"/ensembles/{ensemble_id}/parameters/{PlotApi.escape(parameter_key)}",
395405
headers={"accept": "application/x-parquet"},
396-
timeout=self._timeout,
406+
timeout=TIMEOUT,
397407
)
398-
self._check_http_response(http_response)
408+
PlotApi._check_http_response(http_response)
399409

400410
stream = io.BytesIO(http_response.content)
401411
df = pd.read_parquet(stream)
@@ -415,7 +425,7 @@ def data_for_parameter(self, ensemble_id: str, parameter_key: str) -> pd.DataFra
415425

416426
def observation_locations(self) -> pd.DataFrame:
417427
with create_ertserver_client(self.ens_path) as client:
418-
http_response = client.get("/experiments", timeout=self._timeout)
428+
http_response = client.get("/experiments", timeout=TIMEOUT)
419429
self._check_http_response(http_response)
420430
experiments = http_response.json()
421431

@@ -424,7 +434,7 @@ def observation_locations(self) -> pd.DataFrame:
424434
experiment_id = str(experiment["id"])
425435
http_response = client.get(
426436
f"/experiments/{experiment_id}/observations",
427-
timeout=self._timeout,
437+
timeout=TIMEOUT,
428438
)
429439
self._check_http_response(http_response)
430440
observations = http_response.json()
@@ -483,7 +493,7 @@ def observations_for_key(self, ensemble_ids: list[str], key: str) -> pd.DataFram
483493
with create_ertserver_client(self.ens_path) as client:
484494
http_response = client.get(
485495
f"/ensembles/{ensemble.id}/responses/{PlotApi.escape(actual_response_key)}/observations",
486-
timeout=self._timeout,
496+
timeout=TIMEOUT,
487497
params={"filter_on": json.dumps(filter_on)}
488498
if filter_on is not None
489499
else None,
@@ -574,7 +584,7 @@ def std_dev_for_parameter(
574584
http_response = client.get(
575585
f"/ensembles/{ensemble.id}/parameters/{PlotApi.escape(key)}/std_dev",
576586
params={"z": z},
577-
timeout=self._timeout,
587+
timeout=TIMEOUT,
578588
)
579589

580590
if http_response.status_code == 200:

src/ert/gui/plotting/plot_window.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ def __init__(
177177
self.setWindowTitle(f"Plotting - {config_file}")
178178
self.activateWindow()
179179
self._preferred_ensemble_x_axis_format = PlotContext.INDEX_AXIS
180+
self._ens_path = ens_path
180181
self._api = PlotApi(ens_path)
181182

182183
self.local_version = get_storage_api_version()
@@ -476,7 +477,9 @@ def fetch_data(
476477
try: # noqa: PLW0717
477478
data = None
478479
if is_gradient_plot:
479-
data = self._api.data_for_gradient(ensemble.id, key)
480+
data = PlotApi.data_for_gradient(
481+
ensemble.id, key, self._ens_path
482+
)
480483
elif (
481484
key_def.response is not None
482485
or key_def.metadata.get("data_origin")
@@ -488,18 +491,20 @@ def fetch_data(
488491
filter_on=key_def.filter_on,
489492
)
490493
elif is_controls_plot:
491-
data = self._api.data_for_controls(
494+
data = PlotApi.data_for_controls(
492495
ensemble_id=ensemble.id,
493-
parameter_keys=selected_controls
494-
or self._everest_parameters,
496+
parameter_keys=tuple(selected_controls)
497+
or tuple(self._everest_parameters),
498+
ens_path=self._ens_path,
495499
)
496500
elif key_def.parameter is not None and (
497501
key_def.parameter.type
498502
in {"gen_kw", "everest_parameters", "everest_objective"}
499503
):
500-
data = self._api.data_for_parameter(
504+
data = PlotApi.data_for_parameter(
501505
ensemble_id=ensemble.id,
502506
parameter_key=key_def.parameter.name,
507+
ens_path=self._ens_path,
503508
)
504509
except BaseException as e:
505510
return ensemble, e

src/ert/utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import Callable
44
from datetime import UTC, datetime
55
from functools import wraps
6+
from inspect import signature
67
from pathlib import Path
78
from typing import Any, ParamSpec, TypeVar
89

@@ -70,3 +71,36 @@ def assert_schema(
7071
msg = f"Expected schema {schema}, got {df.schema}."
7172
raise AssertionError(msg)
7273
return df
74+
75+
76+
def process_arg(
77+
key: str,
78+
process: Callable[[Any], Any],
79+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
80+
"""
81+
Returns a decorator that processes a function argument before calling it.
82+
Useful in combination with caching,
83+
where you want to normalize an argument before hashing.
84+
85+
Args:
86+
key (str): Name of the argument to process.
87+
process (Callable[[Any], Any]): Function to process the argument value.
88+
"""
89+
90+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
91+
sig = signature(func)
92+
93+
@wraps(func)
94+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
95+
bound = sig.bind(*args, **kwargs)
96+
value = bound.arguments.get(key)
97+
bound.arguments[key] = process(value)
98+
return func(*bound.args, **bound.kwargs)
99+
100+
for cache_attr in ("cache_info", "cache_clear", "cache_parameters"):
101+
if (member := getattr(func, cache_attr, None)) is not None:
102+
setattr(wrapper, cache_attr, member)
103+
104+
return wrapper
105+
106+
return decorator

tests/ert/performance_tests/test_dark_storage_performance.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,10 @@ def run():
365365
# Cycle through all ensembles and get all responses
366366
for key_info in key_infos_params:
367367
for ensemble in all_ensembles:
368-
api.data_for_parameter(
369-
ensemble_id=ensemble.id, parameter_key=key_info.parameter.name
368+
PlotApi.data_for_parameter(
369+
ensemble_id=ensemble.id,
370+
parameter_key=key_info.parameter.name,
371+
ens_path=api.ens_path,
370372
)
371373

372374
for key_info in key_infos_responses:

0 commit comments

Comments
 (0)