Skip to content

Commit a072035

Browse files
Store optimization data using blobs (#13948)
1 parent c71413d commit a072035

7 files changed

Lines changed: 310 additions & 23 deletions

File tree

src/ert/storage/blob_data.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class BlobType(StrEnum):
1717
MATRIX = "matrix"
1818
SCALING_FACTORS = "scaling_factors"
1919
RHO_MATRIX = "rho_matrix"
20+
EVEREST_BATCH_DATA = "everest_batch_data"
2021

2122

2223
class ObservationReportData(BaseModel):
@@ -49,8 +50,17 @@ class RhoStorageData(_MatrixBase):
4950
observation_keys: list[str] = []
5051

5152

53+
class EverestBatchData(BaseModel):
54+
blob_type: Literal[BlobType.EVEREST_BATCH_DATA] = BlobType.EVEREST_BATCH_DATA
55+
dataframe_name: str
56+
57+
5258
BlobInfo = (
53-
MatrixStorageData | ObservationReportData | ScalingFactorsData | RhoStorageData
59+
MatrixStorageData
60+
| ObservationReportData
61+
| ScalingFactorsData
62+
| RhoStorageData
63+
| EverestBatchData
5464
)
5565

5666

@@ -70,7 +80,11 @@ class BlobStorageData(BaseModel):
7080
file_type: str
7181
name: str
7282
blob_info: Annotated[
73-
MatrixStorageData | ObservationReportData | ScalingFactorsData | RhoStorageData,
83+
MatrixStorageData
84+
| ObservationReportData
85+
| ScalingFactorsData
86+
| RhoStorageData
87+
| EverestBatchData,
7488
Discriminator("blob_type"),
7589
]
7690

src/ert/storage/local_ensemble.py

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from .blob_data import (
4343
BlobStorageData,
4444
BlobType,
45+
EverestBatchData,
4546
MatrixStorageData,
4647
ObservationReportData,
4748
ScalingFactorsData,
@@ -1416,13 +1417,31 @@ def save_blob(
14161417

14171418
@require_write
14181419
def save_batch_dataframes(self, dataframes: BatchDataframes) -> None:
1420+
blob_dir = self._path / BLOB_DATA_DIR
14191421
for df_name, df in dataframes.items():
1420-
if isinstance(df, pl.DataFrame):
1421-
df.write_parquet(self._path / f"{df_name}.parquet")
1422+
if not isinstance(df, pl.DataFrame):
1423+
continue
1424+
buf = io.BytesIO()
1425+
df.write_parquet(buf)
1426+
data = buf.getvalue()
1427+
BlobStorageData.save_blob(
1428+
name=df_name,
1429+
data=data,
1430+
blob_info=EverestBatchData(dataframe_name=df_name),
1431+
file_type="application/parquet",
1432+
storage=self._storage,
1433+
blob_dir=blob_dir,
1434+
)
14221435

14231436
@property
14241437
def has_function_results(self) -> bool:
1425-
return (self._path / "batch_objectives.parquet").exists()
1438+
for meta in self.load_blobs(BlobType.EVEREST_BATCH_DATA):
1439+
if (
1440+
isinstance(meta.blob_info, EverestBatchData)
1441+
and meta.blob_info.dataframe_name == "batch_objectives"
1442+
):
1443+
return meta.file_size > 0
1444+
return False
14261445

14271446
@property
14281447
def has_gradient_results(self) -> bool:
@@ -1433,10 +1452,13 @@ def has_gradient_results(self) -> bool:
14331452
info["perturbation"] != -1 for _, info in self.simulations_with_responses
14341453
)
14351454

1436-
@staticmethod
1437-
def _read_df_if_exists(path: Path) -> pl.DataFrame | None:
1438-
if path.exists():
1439-
return pl.read_parquet(path)
1455+
def _read_batch_dataframe(self, dataframe_name: str) -> pl.DataFrame | None:
1456+
for meta in self.load_blobs(BlobType.EVEREST_BATCH_DATA):
1457+
if (
1458+
isinstance(meta.blob_info, EverestBatchData)
1459+
and meta.blob_info.dataframe_name == dataframe_name
1460+
):
1461+
return pl.read_parquet(io.BytesIO(self.load_blob(meta.uri)))
14401462
return None
14411463

14421464
@property
@@ -1533,7 +1555,7 @@ def perturbation_controls(self) -> pl.DataFrame | None:
15331555

15341556
@property
15351557
def batch_objectives(self) -> pl.DataFrame | None:
1536-
return self._read_df_if_exists(self._path / "batch_objectives.parquet")
1558+
return self._read_batch_dataframe("batch_objectives")
15371559

15381560
@property
15391561
def realization_objectives(self) -> pl.DataFrame | None:
@@ -1570,7 +1592,7 @@ def realization_objectives(self) -> pl.DataFrame | None:
15701592

15711593
@property
15721594
def batch_constraints(self) -> pl.DataFrame | None:
1573-
return self._read_df_if_exists(self._path / "batch_constraints.parquet")
1595+
return self._read_batch_dataframe("batch_constraints")
15741596

15751597
@property
15761598
def realization_constraints(self) -> pl.DataFrame | None:
@@ -1610,25 +1632,19 @@ def realization_constraints(self) -> pl.DataFrame | None:
16101632

16111633
@property
16121634
def batch_bound_constraint_violations(self) -> pl.DataFrame | None:
1613-
return self._read_df_if_exists(
1614-
self._path / "batch_bound_constraint_violations.parquet"
1615-
)
1635+
return self._read_batch_dataframe("batch_bound_constraint_violations")
16161636

16171637
@property
16181638
def batch_input_constraint_violations(self) -> pl.DataFrame | None:
1619-
return self._read_df_if_exists(
1620-
self._path / "batch_input_constraint_violations.parquet"
1621-
)
1639+
return self._read_batch_dataframe("batch_input_constraint_violations")
16221640

16231641
@property
16241642
def batch_output_constraint_violations(self) -> pl.DataFrame | None:
1625-
return self._read_df_if_exists(
1626-
self._path / "batch_output_constraint_violations.parquet"
1627-
)
1643+
return self._read_batch_dataframe("batch_output_constraint_violations")
16281644

16291645
@property
16301646
def batch_objective_gradient(self) -> pl.DataFrame | None:
1631-
return self._read_df_if_exists(self._path / "batch_objective_gradient.parquet")
1647+
return self._read_batch_dataframe("batch_objective_gradient")
16321648

16331649
@property
16341650
def simulations(self) -> list[tuple[int, EverestRealizationInfo]]:
@@ -1686,7 +1702,7 @@ def perturbation_objectives(self) -> pl.DataFrame | None:
16861702

16871703
@property
16881704
def batch_constraint_gradient(self) -> pl.DataFrame | None:
1689-
return self._read_df_if_exists(self._path / "batch_constraint_gradient.parquet")
1705+
return self._read_batch_dataframe("batch_constraint_gradient")
16901706

16911707
@property
16921708
def perturbation_constraints(self) -> pl.DataFrame | None:

src/ert/storage/local_storage.py

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

3232
logger = logging.getLogger(__name__)
3333

34-
_LOCAL_STORAGE_VERSION = 37
34+
35+
_LOCAL_STORAGE_VERSION = 38
3536

3637

3738
def open_storage(
@@ -642,6 +643,7 @@ def _migrate(self, version: int) -> None:
642643
to35,
643644
to36,
644645
to37,
646+
to38,
645647
)
646648

647649
try: # ruff: ignore[too-many-statements-in-try-clause]
@@ -708,6 +710,7 @@ def _migrate(self, version: int) -> None:
708710
34: to35,
709711
35: to36,
710712
36: to37,
713+
37: to38,
711714
}
712715
for from_version in range(version, _LOCAL_STORAGE_VERSION):
713716
migrations[from_version].migrate(self.path)

src/ert/storage/migration/to38.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import logging
5+
import uuid as _uuid
6+
from pathlib import Path
7+
8+
logger = logging.getLogger(__name__)
9+
10+
info = "Move everest batch dataframes into ensemble blobs"
11+
12+
_BATCH_DATAFRAME_NAMES = (
13+
"batch_objectives",
14+
"batch_constraints",
15+
"batch_bound_constraint_violations",
16+
"batch_input_constraint_violations",
17+
"batch_output_constraint_violations",
18+
"batch_objective_gradient",
19+
"batch_constraint_gradient",
20+
)
21+
22+
23+
def _move_batch_dataframes_into_blobs(path: Path) -> None:
24+
ensembles_dir = path / "ensembles"
25+
if not ensembles_dir.exists():
26+
return
27+
28+
for ens_dir in ensembles_dir.iterdir():
29+
if not ens_dir.is_dir():
30+
continue
31+
32+
for dataframe_name in _BATCH_DATAFRAME_NAMES:
33+
parquet_file = ens_dir / f"{dataframe_name}.parquet"
34+
if not parquet_file.exists():
35+
continue
36+
37+
blob_dir = ens_dir / "blobs"
38+
blob_dir.mkdir(parents=True, exist_ok=True)
39+
40+
data = parquet_file.read_bytes()
41+
uri = f"{_uuid.uuid4().hex[:8]}.blob"
42+
blob_data = {
43+
"uri": uri,
44+
"file_size": len(data),
45+
"file_type": "application/parquet",
46+
"name": dataframe_name,
47+
"blob_info": {
48+
"blob_type": "everest_batch_data",
49+
"dataframe_name": dataframe_name,
50+
},
51+
}
52+
53+
(blob_dir / uri).write_bytes(data)
54+
(blob_dir / f"{uri}.json").write_text(
55+
json.dumps(blob_data, indent=2), encoding="utf-8"
56+
)
57+
parquet_file.unlink()
58+
logger.info("Moved %s into blob %s", parquet_file, uri)
59+
60+
61+
def migrate(path: Path) -> None:
62+
_move_batch_dataframes_into_blobs(path)

tests/ert/unit_tests/dark_storage/test_http_endpoints.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55

66
import pandas as pd
7+
import polars as pl
78
import pytest
89
from requests import Response
910
from starlette.testclient import TestClient
@@ -379,6 +380,64 @@ def test_that_blob_endpoint_returns_blob_bytes(tmp_path, monkeypatch, dark_stora
379380
assert resp.content == blob_bytes
380381

381382

383+
def test_that_blobs_endpoint_lists_everest_batch_dataframes(
384+
tmp_path, monkeypatch, dark_storage_app
385+
):
386+
storage_path = tmp_path / "storage"
387+
with open_storage(storage_path, mode="w") as storage:
388+
experiment = storage.create_experiment(name="test-experiment")
389+
ensemble = storage.create_ensemble(
390+
experiment, ensemble_size=1, iteration=0, name="batch_0"
391+
)
392+
ensemble.save_batch_dataframes(
393+
{
394+
"batch_objectives": pl.DataFrame(
395+
{"batch_id": [0], "total_objective_value": [1.5]}
396+
),
397+
"batch_objective_gradient": pl.DataFrame(
398+
{"batch_id": [0], "control_name": ["x"], "distance": [2.0]}
399+
),
400+
}
401+
)
402+
ensemble_id = ensemble.id
403+
404+
monkeypatch.setenv("ERT_STORAGE_ENS_PATH", str(storage_path))
405+
with TestClient(dark_storage_app) as client:
406+
resp = client.get(f"/ensembles/{ensemble_id}/blobs")
407+
408+
assert resp.status_code == 200
409+
blobs = resp.json()
410+
by_name = {blob["name"]: blob for blob in blobs}
411+
assert set(by_name) == {"batch_objectives", "batch_objective_gradient"}
412+
for name, blob in by_name.items():
413+
assert blob["file_type"] == "application/parquet"
414+
assert blob["blob_info"]["blob_type"] == "everest_batch_data"
415+
assert blob["blob_info"]["dataframe_name"] == name
416+
417+
418+
def test_that_blob_endpoint_returns_everest_batch_dataframe_parquet(
419+
tmp_path, monkeypatch, dark_storage_app
420+
):
421+
storage_path = tmp_path / "storage"
422+
objectives = pl.DataFrame({"batch_id": [0], "total_objective_value": [1.5]})
423+
with open_storage(storage_path, mode="w") as storage:
424+
experiment = storage.create_experiment(name="test-experiment")
425+
ensemble = storage.create_ensemble(
426+
experiment, ensemble_size=1, iteration=0, name="batch_0"
427+
)
428+
ensemble.save_batch_dataframes({"batch_objectives": objectives})
429+
[blob] = ensemble.load_blobs()
430+
ensemble_id = ensemble.id
431+
432+
monkeypatch.setenv("ERT_STORAGE_ENS_PATH", str(storage_path))
433+
with TestClient(dark_storage_app) as client:
434+
resp = client.get(f"/ensembles/{ensemble_id}/blobs/{blob.uri}")
435+
436+
assert resp.status_code == 200
437+
assert resp.headers["content-type"] == "application/octet-stream"
438+
assert pl.read_parquet(io.BytesIO(resp.content)).equals(objectives)
439+
440+
382441
@pytest.mark.slow
383442
def test_get_record_observations(poly_example_tmp_dir, dark_storage_client):
384443
resp: Response = dark_storage_client.get("/experiments")

0 commit comments

Comments
 (0)