fmu-pem 0.0.2__py3-none-any.whl → 0.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fmu/pem/__init__.py +2 -0
- fmu/pem/__main__.py +72 -19
- fmu/pem/forward_models/pem_model.py +21 -26
- fmu/pem/pem_functions/__init__.py +2 -2
- fmu/pem/pem_functions/density.py +32 -38
- fmu/pem/pem_functions/effective_pressure.py +153 -49
- fmu/pem/pem_functions/estimate_saturated_rock.py +244 -52
- fmu/pem/pem_functions/fluid_properties.py +447 -245
- fmu/pem/pem_functions/mineral_properties.py +77 -74
- fmu/pem/pem_functions/pressure_sensitivity.py +430 -0
- fmu/pem/pem_functions/regression_models.py +129 -97
- fmu/pem/pem_functions/run_friable_model.py +106 -37
- fmu/pem/pem_functions/run_patchy_cement_model.py +107 -45
- fmu/pem/pem_functions/{run_t_matrix_and_pressure.py → run_t_matrix_model.py} +48 -27
- fmu/pem/pem_utilities/__init__.py +30 -10
- fmu/pem/pem_utilities/cumsum_properties.py +29 -37
- fmu/pem/pem_utilities/delta_cumsum_time.py +8 -13
- fmu/pem/pem_utilities/enum_defs.py +65 -8
- fmu/pem/pem_utilities/export_routines.py +84 -72
- fmu/pem/pem_utilities/fipnum_pvtnum_utilities.py +217 -0
- fmu/pem/pem_utilities/import_config.py +76 -50
- fmu/pem/pem_utilities/import_routines.py +57 -69
- fmu/pem/pem_utilities/pem_class_definitions.py +81 -23
- fmu/pem/pem_utilities/pem_config_validation.py +364 -172
- fmu/pem/pem_utilities/rpm_models.py +473 -100
- fmu/pem/pem_utilities/update_grid.py +3 -2
- fmu/pem/pem_utilities/utils.py +90 -38
- fmu/pem/run_pem.py +66 -48
- fmu/pem/version.py +16 -3
- {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.4.dist-info}/METADATA +19 -11
- fmu_pem-0.0.4.dist-info/RECORD +39 -0
- {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.4.dist-info}/WHEEL +1 -1
- fmu_pem-0.0.2.dist-info/RECORD +0 -37
- {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.4.dist-info}/entry_points.txt +0 -0
- {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.4.dist-info}/licenses/LICENSE +0 -0
- {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.4.dist-info}/top_level.txt +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from datetime import date
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Any,
|
|
4
|
+
from typing import Any, Self
|
|
5
5
|
|
|
6
6
|
import numpy as np
|
|
7
7
|
from pydantic import (
|
|
8
|
+
AliasChoices,
|
|
8
9
|
BaseModel,
|
|
9
10
|
ConfigDict,
|
|
10
11
|
DirectoryPath,
|
|
@@ -15,54 +16,86 @@ from pydantic import (
|
|
|
15
16
|
from pydantic.json_schema import SkipJsonSchema
|
|
16
17
|
from pydantic_core.core_schema import ValidationInfo
|
|
17
18
|
|
|
19
|
+
from fmu.datamodels.fmu_results.global_configuration import GlobalConfiguration
|
|
18
20
|
from fmu.pem import INTERNAL_EQUINOR
|
|
19
21
|
|
|
20
22
|
from .enum_defs import (
|
|
21
23
|
CO2Models,
|
|
24
|
+
DifferenceAttribute,
|
|
25
|
+
DifferenceMethod,
|
|
22
26
|
FluidMixModel,
|
|
23
27
|
GasModels,
|
|
24
28
|
MineralMixModel,
|
|
25
29
|
OverburdenPressureTypes,
|
|
30
|
+
RPMType,
|
|
26
31
|
TemperatureMethod,
|
|
27
|
-
VolumeFractions,
|
|
28
32
|
)
|
|
29
|
-
from .
|
|
33
|
+
from .fipnum_pvtnum_utilities import (
|
|
34
|
+
detect_overlaps,
|
|
35
|
+
input_num_string_to_list,
|
|
36
|
+
)
|
|
37
|
+
from .rpm_models import (
|
|
38
|
+
FriableRPM,
|
|
39
|
+
MineralProperties,
|
|
40
|
+
OptionalField,
|
|
41
|
+
PatchyCementRPM,
|
|
42
|
+
PhysicsModelPressureSensitivity,
|
|
43
|
+
RegressionPressureSensitivity,
|
|
44
|
+
RegressionRPM,
|
|
45
|
+
TMatrixRPM,
|
|
46
|
+
)
|
|
30
47
|
|
|
48
|
+
REGEX_FIPNUM_PVTNUM = r"^(?:\*|(?:\d+(?:-\d+)?)(?:,(?:\d+(?:-\d+)?))*)$"
|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
default=
|
|
36
|
-
description="
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
|
|
51
|
+
class EclipseFiles(BaseModel):
|
|
52
|
+
rel_path_simgrid: DirectoryPath = Field(
|
|
53
|
+
default=Path("../../sim2seis/input/pem"),
|
|
54
|
+
description="Relative path of the simulation grid",
|
|
55
|
+
)
|
|
56
|
+
egrid_file: Path = Field(
|
|
57
|
+
default=Path("ECLIPSE.EGRID"),
|
|
58
|
+
description="Name of Eclipse EGRID file",
|
|
59
|
+
)
|
|
60
|
+
init_property_file: Path = Field(
|
|
61
|
+
default=Path("ECLIPSE.INIT"),
|
|
62
|
+
description="Name of Eclipse INIT file",
|
|
63
|
+
)
|
|
64
|
+
restart_property_file: Path = Field(
|
|
65
|
+
default=Path("ECLIPSE.UNRST"),
|
|
66
|
+
description="Name of Eclipse UNRST file",
|
|
39
67
|
)
|
|
40
68
|
|
|
69
|
+
@model_validator(mode="after")
|
|
70
|
+
def check_fractions(self) -> Self:
|
|
71
|
+
for sim_file in [
|
|
72
|
+
self.egrid_file,
|
|
73
|
+
self.init_property_file,
|
|
74
|
+
self.restart_property_file,
|
|
75
|
+
]:
|
|
76
|
+
full_name = self.rel_path_simgrid / sim_file
|
|
77
|
+
if not full_name.exists():
|
|
78
|
+
raise FileNotFoundError(f"fraction prop file is missing: {full_name}")
|
|
79
|
+
return self
|
|
80
|
+
|
|
41
81
|
|
|
42
82
|
class FractionFiles(BaseModel):
|
|
43
|
-
mode: SkipJsonSchema[Literal[VolumeFractions.VOL_FRAC]]
|
|
44
83
|
rel_path_fractions: DirectoryPath = Field(
|
|
45
84
|
default=Path("../../sim2seis/input/pem"),
|
|
46
85
|
description="Directory for volume fractions",
|
|
47
86
|
)
|
|
48
|
-
fractions_grid_file_name: Path = Field(
|
|
49
|
-
description="Grid definition of the volume fractions"
|
|
50
|
-
)
|
|
51
87
|
fractions_prop_file_names: list[Path] = Field(description="Volume fractions")
|
|
52
|
-
|
|
53
|
-
default=
|
|
54
|
-
description="
|
|
55
|
-
"
|
|
56
|
-
"
|
|
88
|
+
fractions_are_mineral_fraction: bool = Field(
|
|
89
|
+
default=False,
|
|
90
|
+
description="Fractions can either be mineral fractions or volume fractions."
|
|
91
|
+
"If they are mineral fractions,the sum of fractions and a"
|
|
92
|
+
"complement is 1.0. If they are volume fractions, the sum of"
|
|
93
|
+
"fractions, a complement and porosity is 1.0."
|
|
94
|
+
"Default value is False.",
|
|
57
95
|
)
|
|
58
96
|
|
|
59
97
|
@model_validator(mode="after")
|
|
60
98
|
def check_fractions(self) -> Self:
|
|
61
|
-
full_fraction_grid = self.rel_path_fractions / self.fractions_grid_file_name
|
|
62
|
-
if not full_fraction_grid.exists():
|
|
63
|
-
raise FileNotFoundError(
|
|
64
|
-
f"fraction grid file is missing: {full_fraction_grid}"
|
|
65
|
-
)
|
|
66
99
|
for frac_prop in self.fractions_prop_file_names:
|
|
67
100
|
full_fraction_prop = self.rel_path_fractions / frac_prop
|
|
68
101
|
if not full_fraction_prop.exists():
|
|
@@ -72,6 +105,44 @@ class FractionFiles(BaseModel):
|
|
|
72
105
|
return self
|
|
73
106
|
|
|
74
107
|
|
|
108
|
+
class ZoneRegionMatrixParams(BaseModel):
|
|
109
|
+
fipnum: str = Field(
|
|
110
|
+
description="Each grid cell in a reservoir model is assigned a FIPNUM "
|
|
111
|
+
"integer, where each FIPNUM integer represents a combination of zone and "
|
|
112
|
+
"segment. `fmu-pem` reuses FIPNUM by letting you define the FIPNUM integers "
|
|
113
|
+
"where a defined rock matrix should be used. Explicit definitions like "
|
|
114
|
+
"`1-10,15` matches the FIPNUMs "
|
|
115
|
+
"`1, 2, ..., 10, 15`. By doing it this way you can have different "
|
|
116
|
+
"rock physics models for e.g. individual zones and segments. "
|
|
117
|
+
"Leaving this field empty means that all zones and segments are"
|
|
118
|
+
"treated as one",
|
|
119
|
+
pattern=REGEX_FIPNUM_PVTNUM,
|
|
120
|
+
)
|
|
121
|
+
model: FriableRPM | PatchyCementRPM | TMatrixRPM | RegressionRPM = Field(
|
|
122
|
+
description="Selection of rock physics model and parameter set",
|
|
123
|
+
)
|
|
124
|
+
pressure_sensitivity: bool = Field(
|
|
125
|
+
default=True,
|
|
126
|
+
description="All RPM models can be run with or without pressure sensitivity.",
|
|
127
|
+
)
|
|
128
|
+
pressure_sensitivity_model: (
|
|
129
|
+
RegressionPressureSensitivity | PhysicsModelPressureSensitivity | OptionalField
|
|
130
|
+
) = Field(
|
|
131
|
+
default=OptionalField(),
|
|
132
|
+
description="For most RPM models, it is possible to choose between a "
|
|
133
|
+
"regression based pressure sensitivity model from plug measurements "
|
|
134
|
+
"or a theoretical one. For `T Matrix` model a calibrated model is set "
|
|
135
|
+
"as default, and any model selection in this interface will be disregarded.",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@field_validator("model", mode="before")
|
|
139
|
+
@classmethod
|
|
140
|
+
def model_check(cls, v: dict, info: ValidationInfo) -> dict:
|
|
141
|
+
if v["model_name"] not in list(RPMType):
|
|
142
|
+
raise ValueError(f"unknown model: {v['model_name']}")
|
|
143
|
+
return v
|
|
144
|
+
|
|
145
|
+
|
|
75
146
|
class RockMatrixProperties(BaseModel):
|
|
76
147
|
"""Configuration for rock matrix properties.
|
|
77
148
|
|
|
@@ -82,10 +153,10 @@ class RockMatrixProperties(BaseModel):
|
|
|
82
153
|
|
|
83
154
|
model_config = ConfigDict(title="Rock matrix properties:")
|
|
84
155
|
|
|
85
|
-
|
|
86
|
-
description="
|
|
156
|
+
zone_regions: list[ZoneRegionMatrixParams] = Field(
|
|
157
|
+
description="Per-zone or -region parameters"
|
|
87
158
|
)
|
|
88
|
-
minerals:
|
|
159
|
+
minerals: dict[str, MineralProperties] = Field(
|
|
89
160
|
default={
|
|
90
161
|
"shale": {
|
|
91
162
|
"bulk_modulus": 25.0e9,
|
|
@@ -107,29 +178,31 @@ class RockMatrixProperties(BaseModel):
|
|
|
107
178
|
"shear_modulus": 45.0e9,
|
|
108
179
|
"density": 2870.0,
|
|
109
180
|
},
|
|
110
|
-
"stevensite": {
|
|
111
|
-
"bulk_modulus": 32.5e9,
|
|
112
|
-
"shear_modulus": 45.0e9,
|
|
113
|
-
"density": 2490.0,
|
|
114
|
-
},
|
|
115
181
|
},
|
|
116
|
-
description="
|
|
117
|
-
"
|
|
118
|
-
"
|
|
182
|
+
description="Define minerals relevant for the field. Default values are set "
|
|
183
|
+
"for `shale`, `quartz`, `calcite` and `dolomite` (you can't "
|
|
184
|
+
"delete these minerals, but you can override their default values and/or "
|
|
185
|
+
"ignore their definition).",
|
|
186
|
+
)
|
|
187
|
+
cement: str = Field(
|
|
188
|
+
default="quartz",
|
|
189
|
+
description="For the patchy cement model, the cement mineral must be defined, "
|
|
190
|
+
"and its properties must be defined in the mineral properties' "
|
|
191
|
+
"dictionary",
|
|
119
192
|
)
|
|
120
|
-
volume_fractions:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
"
|
|
193
|
+
volume_fractions: FractionFiles = Field(
|
|
194
|
+
description="Choice of volume fraction files. Volume fractions are defined"
|
|
195
|
+
"in the geomodel, but they must be resampled to the simulator grid"
|
|
196
|
+
"when used in PEM",
|
|
124
197
|
)
|
|
125
|
-
fraction_names:
|
|
198
|
+
fraction_names: list[str] = Field(
|
|
126
199
|
description="Fraction names must match the names in the volume fractions file",
|
|
127
200
|
)
|
|
128
|
-
fraction_minerals:
|
|
201
|
+
fraction_minerals: list[str] = Field(
|
|
129
202
|
description="The list of minerals matching the fractions' definition. Each "
|
|
130
203
|
"mineral must be defined in the mineral properties dictionary"
|
|
131
204
|
)
|
|
132
|
-
shale_fractions:
|
|
205
|
+
shale_fractions: list[str] = Field(
|
|
133
206
|
description="List the fractions that should be regarded as non-net reservoir"
|
|
134
207
|
)
|
|
135
208
|
complement: str = Field(
|
|
@@ -137,24 +210,56 @@ class RockMatrixProperties(BaseModel):
|
|
|
137
210
|
"up to 1.0, the remainder is filled with the complement mineral, "
|
|
138
211
|
"e.g. when using net-to-gross instead of volume fractions"
|
|
139
212
|
)
|
|
140
|
-
pressure_sensitivity: bool = Field(
|
|
141
|
-
default=True,
|
|
142
|
-
description="For the RPM models where pressure sensitivity is not part of "
|
|
143
|
-
"the model, as for friable and patchy cement models, a separate "
|
|
144
|
-
"pressure sensitivity model, based on plug measurements is added",
|
|
145
|
-
)
|
|
146
|
-
cement: str = Field(
|
|
147
|
-
default="quartz",
|
|
148
|
-
description="For the patchy cement model, the cement mineral must be defined, "
|
|
149
|
-
"and its properties must be defined in the mineral properties' "
|
|
150
|
-
"dictionary",
|
|
151
|
-
)
|
|
152
213
|
mineral_mix_model: MineralMixModel = Field(
|
|
153
214
|
default="voigt-reuss-hill",
|
|
154
215
|
description="Effective medium model selection: either "
|
|
155
|
-
"
|
|
216
|
+
"`hashin-shtrikman-average` or `voigt-reuss-hill`",
|
|
156
217
|
)
|
|
157
218
|
|
|
219
|
+
@field_validator("zone_regions", mode="before")
|
|
220
|
+
@classmethod
|
|
221
|
+
def fipnum_check(cls, v: list[dict]) -> list[dict]:
|
|
222
|
+
"""
|
|
223
|
+
At this point in time we don't have access to the simulator init file,
|
|
224
|
+
so we just have to guess that it contains all numbers from 1 to the
|
|
225
|
+
max number given in the strings. Validate that there are no overlaps.
|
|
226
|
+
|
|
227
|
+
Validation must be made here, not under individual ZoneRegion objects
|
|
228
|
+
to get the combined information in all ZoneRegion groups.
|
|
229
|
+
|
|
230
|
+
Earlier wildcard symbol was '*'. Empty string (new wildcard) is
|
|
231
|
+
changed into '*' for backward compatibility
|
|
232
|
+
"""
|
|
233
|
+
fipnum_strings = [rock["fipnum"] for rock in v]
|
|
234
|
+
# Enforce single wildcard usage
|
|
235
|
+
if any(s is None or not str(s).strip() or s == "*" for s in fipnum_strings):
|
|
236
|
+
if len(v) > 1:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
"Setting wildcard ('*' or empty string) means that "
|
|
239
|
+
"all FIPNUM should be treated as one group, no "
|
|
240
|
+
"other groups can be specified"
|
|
241
|
+
)
|
|
242
|
+
# Enforce old style wildcard
|
|
243
|
+
v[0]["fipnum"] = "*"
|
|
244
|
+
return v
|
|
245
|
+
# Build temporary range to detect overlaps
|
|
246
|
+
tmp_max = 1
|
|
247
|
+
tmp_num_array = [1]
|
|
248
|
+
for num_string in fipnum_strings:
|
|
249
|
+
num_array = input_num_string_to_list(num_string, tmp_num_array)
|
|
250
|
+
if tmp_max < max(num_array):
|
|
251
|
+
tmp_max = max(num_array)
|
|
252
|
+
tmp_num_array = list(range(1, tmp_max + 1))
|
|
253
|
+
if detect_overlaps(fipnum_strings, tmp_num_array):
|
|
254
|
+
raise ValueError(f"Overlaps in group definitions: {fipnum_strings}")
|
|
255
|
+
return v
|
|
256
|
+
|
|
257
|
+
@field_validator("cement", mode="before")
|
|
258
|
+
def cement_check(cls, v: str, info: ValidationInfo) -> str:
|
|
259
|
+
if v not in info.data["minerals"]:
|
|
260
|
+
raise ValueError(f'{__file__}: cement mineral "{v}" not listed in minerals')
|
|
261
|
+
return v
|
|
262
|
+
|
|
158
263
|
@field_validator("shale_fractions", mode="before")
|
|
159
264
|
@classmethod
|
|
160
265
|
def shale_fraction_check(cls, v: list, info: ValidationInfo) -> list:
|
|
@@ -168,7 +273,7 @@ class RockMatrixProperties(BaseModel):
|
|
|
168
273
|
|
|
169
274
|
@field_validator("complement", mode="before")
|
|
170
275
|
@classmethod
|
|
171
|
-
def complement_fraction_check(cls, v:
|
|
276
|
+
def complement_fraction_check(cls, v: str, info: ValidationInfo) -> str:
|
|
172
277
|
if v not in info.data["minerals"]:
|
|
173
278
|
raise ValueError(
|
|
174
279
|
f'{__file__}: shale fraction mineral "{v}" not listed in fraction '
|
|
@@ -176,31 +281,49 @@ class RockMatrixProperties(BaseModel):
|
|
|
176
281
|
)
|
|
177
282
|
return v
|
|
178
283
|
|
|
179
|
-
@field_validator("cement", mode="before")
|
|
180
|
-
def cement_check(cls, v: list, info: ValidationInfo) -> list:
|
|
181
|
-
if v not in info.data["minerals"]:
|
|
182
|
-
raise ValueError(f'{__file__}: cement mineral "{v}" not listed in minerals')
|
|
183
|
-
return v
|
|
184
|
-
|
|
185
284
|
@model_validator(mode="after")
|
|
186
|
-
def
|
|
285
|
+
def _validate_rock_matrix_properties(self):
|
|
187
286
|
for frac_min in self.fraction_minerals:
|
|
188
287
|
if frac_min not in self.minerals:
|
|
189
288
|
raise ValueError(
|
|
190
289
|
f"{__file__}: volume fraction mineral {frac_min} is not defined"
|
|
191
290
|
)
|
|
291
|
+
for fipnum_group in self.zone_regions:
|
|
292
|
+
if fipnum_group.model.model_name != RPMType.T_MATRIX and (
|
|
293
|
+
fipnum_group.pressure_sensitivity
|
|
294
|
+
and not fipnum_group.pressure_sensitivity_model
|
|
295
|
+
):
|
|
296
|
+
raise ValueError("a model is required when pressure sensitivity is set")
|
|
192
297
|
return self
|
|
193
298
|
|
|
194
299
|
|
|
195
300
|
# Pressure
|
|
196
301
|
class OverburdenPressureTrend(BaseModel):
|
|
197
302
|
type: SkipJsonSchema[OverburdenPressureTypes] = "trend"
|
|
303
|
+
fipnum: str = Field(
|
|
304
|
+
description="Each grid cell in a reservoir model is assigned a FIPNUM "
|
|
305
|
+
"integer. `fmu-pem` reuses FIPNUM by letting you define the FIPNUM "
|
|
306
|
+
"integers where a given overburden pressure should be used. Explicit "
|
|
307
|
+
"definitions like `1-10,15` matches the PVTNUMs `1, 2, ..., 10, 15`. "
|
|
308
|
+
"Leaving this field empty means that all zones and segments are"
|
|
309
|
+
"treated as one",
|
|
310
|
+
pattern=REGEX_FIPNUM_PVTNUM,
|
|
311
|
+
)
|
|
198
312
|
intercept: float = Field(description="Intercept in pressure depth trend")
|
|
199
313
|
gradient: float = Field(description="Gradient in pressure depth trend")
|
|
200
314
|
|
|
201
315
|
|
|
202
316
|
class OverburdenPressureConstant(BaseModel):
|
|
203
317
|
type: SkipJsonSchema[OverburdenPressureTypes] = "constant"
|
|
318
|
+
fipnum: str = Field(
|
|
319
|
+
description="Each grid cell in a reservoir model is assigned a FIPNUM "
|
|
320
|
+
"integer. `fmu-pem` reuses FIPNUM by letting you define the FIPNUM "
|
|
321
|
+
"integers where a given overburden pressure should be used. Explicit "
|
|
322
|
+
"definitions like `1-10,15` matches the FIPNUMs `1, 2, ..., 10, 15`. "
|
|
323
|
+
"Leaving this field empty means that all zones and segments are"
|
|
324
|
+
"treated as one",
|
|
325
|
+
pattern=REGEX_FIPNUM_PVTNUM,
|
|
326
|
+
)
|
|
204
327
|
value: float = Field(description="Constant pressure")
|
|
205
328
|
|
|
206
329
|
|
|
@@ -209,25 +332,27 @@ class Brine(BaseModel):
|
|
|
209
332
|
salinity: float = Field(
|
|
210
333
|
default=35000.0,
|
|
211
334
|
gt=0.0,
|
|
212
|
-
description="Salinity of brine, with unit ppm (parts per million)"
|
|
335
|
+
description="Salinity of brine, with unit `ppm` (parts per million)."
|
|
336
|
+
"The composition (NaCl, CaCl2, KCl) is of secondary"
|
|
337
|
+
" importance, unless the salinity is extremely high.",
|
|
213
338
|
)
|
|
214
339
|
perc_na: float = Field(
|
|
215
340
|
ge=0.0,
|
|
216
341
|
le=100.0,
|
|
217
|
-
default=
|
|
218
|
-
description="Percentage of NaCl in the dissolved salts in brine",
|
|
342
|
+
default=100.0,
|
|
343
|
+
description="Percentage of `NaCl` in the dissolved salts in brine",
|
|
219
344
|
)
|
|
220
345
|
perc_ca: float = Field(
|
|
221
346
|
ge=0.0,
|
|
222
347
|
le=100.0,
|
|
223
|
-
default=
|
|
224
|
-
description="Percentage of
|
|
348
|
+
default=0.0,
|
|
349
|
+
description="Percentage of `CaCl2` in the dissolved salts in brine",
|
|
225
350
|
)
|
|
226
351
|
perc_k: float = Field(
|
|
227
352
|
ge=0.0,
|
|
228
353
|
le=100.0,
|
|
229
354
|
default=0.0,
|
|
230
|
-
description="Percentage of KCl in the dissolved salts in brine",
|
|
355
|
+
description="Percentage of `KCl` in the dissolved salts in brine",
|
|
231
356
|
)
|
|
232
357
|
|
|
233
358
|
@model_validator(mode="after")
|
|
@@ -262,14 +387,8 @@ class Oil(BaseModel):
|
|
|
262
387
|
default=865.0,
|
|
263
388
|
ge=700,
|
|
264
389
|
le=950,
|
|
265
|
-
description="Oil density in kg/m
|
|
266
|
-
"and 101 kPa",
|
|
267
|
-
)
|
|
268
|
-
gor: float = Field(
|
|
269
|
-
default=123.0,
|
|
270
|
-
ge=0.0,
|
|
271
|
-
description="Gas-oil volume ratio in liter/liter when the oil it brought to "
|
|
272
|
-
"the surface at standard conditions",
|
|
390
|
+
description="Oil density in `kg/m³` at standard conditions, i.e. `15.6 °C`"
|
|
391
|
+
"and `101 kPa`",
|
|
273
392
|
)
|
|
274
393
|
|
|
275
394
|
|
|
@@ -280,22 +399,23 @@ class Gas(BaseModel):
|
|
|
280
399
|
le=0.87,
|
|
281
400
|
description="Gas gravity is a ratio of gas molecular weight to that air",
|
|
282
401
|
)
|
|
283
|
-
model: GasModels = Field(
|
|
402
|
+
model: SkipJsonSchema[GasModels] = Field(
|
|
284
403
|
default="HC2016",
|
|
285
|
-
description="Gas model is one of
|
|
404
|
+
description="Gas model is one of `Global`, `Light`, or `HC2016` (default)",
|
|
286
405
|
)
|
|
287
406
|
|
|
288
407
|
|
|
289
408
|
class MixModelWood(BaseModel):
|
|
290
|
-
method:
|
|
409
|
+
method: FluidMixModel = "wood"
|
|
291
410
|
|
|
292
411
|
|
|
293
412
|
class MixModelBrie(BaseModel):
|
|
294
|
-
method:
|
|
413
|
+
method: FluidMixModel = "brie"
|
|
295
414
|
brie_exponent: float = Field(
|
|
296
415
|
default=3.0,
|
|
297
|
-
description="Brie exponent selects the mixing curve shape, from linear mix
|
|
298
|
-
"harmonic mean"
|
|
416
|
+
description="Brie exponent selects the mixing curve shape, from linear mix "
|
|
417
|
+
"(exponent = 2.0) to approximate harmonic mean for high values "
|
|
418
|
+
"(exponent > 10.0). Default value is 3.0.",
|
|
299
419
|
)
|
|
300
420
|
|
|
301
421
|
|
|
@@ -308,57 +428,60 @@ class TemperatureFromSim(BaseModel):
|
|
|
308
428
|
type: SkipJsonSchema[TemperatureMethod] = "from_sim"
|
|
309
429
|
|
|
310
430
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
431
|
+
class SalinityFromSim(BaseModel):
|
|
432
|
+
enabled: bool = False
|
|
433
|
+
|
|
434
|
+
def __bool__(self):
|
|
435
|
+
return self.enabled
|
|
436
|
+
|
|
437
|
+
model_config = ConfigDict(title="Salinity from SIM")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class PVTZone(BaseModel):
|
|
441
|
+
pvtnum: str = Field(
|
|
442
|
+
description="Each grid cell in a reservoir model is assigned a PVTNUM "
|
|
443
|
+
"integer. `fmu-pem` reuses PVTNUM by letting you define the PVTNUM "
|
|
444
|
+
"integers where a given fluid definition should be used. Explicit "
|
|
445
|
+
"definitions like `1-10,15` matches the PVTNUMs `1, 2, ..., 10, 15`. "
|
|
446
|
+
"Leaving this field empty means that all PVTNUM zones are treated as one",
|
|
447
|
+
pattern=REGEX_FIPNUM_PVTNUM,
|
|
448
|
+
)
|
|
314
449
|
brine: Brine = Field(
|
|
315
|
-
description="Brine model parameters",
|
|
450
|
+
description="Brine model parameters.",
|
|
316
451
|
)
|
|
317
|
-
oil: Oil = Field(
|
|
452
|
+
oil: Oil = Field(
|
|
453
|
+
description="Oil model parameters. Note that GOR (gas-oil ratio) is read from"
|
|
454
|
+
" eclipse restart file"
|
|
455
|
+
)
|
|
456
|
+
# Note that CO2 does not require a separate definition here, as it's properties only
|
|
457
|
+
# depend on temperature and pressure
|
|
318
458
|
gas: Gas = Field(description="Gas model parameters")
|
|
319
|
-
condensate:
|
|
320
|
-
default=None,
|
|
459
|
+
condensate: OptionalField | Oil = Field(
|
|
321
460
|
title="Condensate properties",
|
|
322
|
-
description="Condensate
|
|
323
|
-
"optional setting for condensate
|
|
324
|
-
|
|
325
|
-
fluid_mix_method: MixModelBrie | MixModelWood = Field(
|
|
326
|
-
default=MixModelBrie,
|
|
327
|
-
description="Selection between Wood's or Brie model. Wood's model gives more "
|
|
328
|
-
"radical response to adding small amounts of gas in brine or oil",
|
|
329
|
-
)
|
|
330
|
-
temperature: ConstantTemperature | TemperatureFromSim = Field(
|
|
331
|
-
description="In most cases it is sufficient with a constant temperature "
|
|
332
|
-
"setting for the reservoir. If temperature is modelled in the "
|
|
333
|
-
"simulation model, it is preferred to use that"
|
|
334
|
-
)
|
|
335
|
-
salinity_from_sim: bool = Field(
|
|
336
|
-
default=False,
|
|
337
|
-
description="In most cases it is sufficient with a constant salinity "
|
|
338
|
-
"setting for the reservoir, unless there is large contrast"
|
|
339
|
-
"between formation water and injected water. If salinity is "
|
|
340
|
-
"modelled in the simulation model, it is preferred to use that",
|
|
461
|
+
description="Condensate model requires a similar set of parameters as"
|
|
462
|
+
"the oil model, this is an optional setting for condensate"
|
|
463
|
+
" cases",
|
|
341
464
|
)
|
|
342
465
|
gas_saturation_is_co2: bool = Field(
|
|
343
466
|
default=False,
|
|
344
467
|
description="Eclipse model only provides one parameter for gas saturation, "
|
|
345
|
-
"this flag sets the gas type to be
|
|
468
|
+
"this flag sets the gas type to be CO₂ instead of hydrocarbon gas",
|
|
346
469
|
)
|
|
347
470
|
calculate_condensate: bool = Field(
|
|
348
471
|
default=False,
|
|
349
472
|
description="Flag to control if gas should be modelled with condensate model, "
|
|
350
|
-
"in which case RV parameter must be present in the Eclipse model",
|
|
473
|
+
"in which case `RV` parameter must be present in the Eclipse model",
|
|
351
474
|
)
|
|
352
|
-
gas_z_factor: float = Field(
|
|
475
|
+
gas_z_factor: SkipJsonSchema[float] = Field(
|
|
353
476
|
default=1.0,
|
|
354
477
|
description="Factor for deviation from an ideal gas in terms of volume change "
|
|
355
478
|
"as a function of temperature and pressure",
|
|
356
479
|
)
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
description="
|
|
360
|
-
"
|
|
361
|
-
"
|
|
480
|
+
# Temperature may be set per zone
|
|
481
|
+
temperature: ConstantTemperature | TemperatureFromSim = Field(
|
|
482
|
+
description="In most cases it is sufficient with a constant temperature "
|
|
483
|
+
"setting for the reservoir. If temperature is modelled in the "
|
|
484
|
+
"simulation model, it is preferred to use that"
|
|
362
485
|
)
|
|
363
486
|
|
|
364
487
|
@model_validator(mode="after")
|
|
@@ -370,53 +493,116 @@ class Fluids(BaseModel):
|
|
|
370
493
|
return self
|
|
371
494
|
|
|
372
495
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
496
|
+
class Fluids(BaseModel):
|
|
497
|
+
pvt_zones: list[PVTZone] = Field(
|
|
498
|
+
description="Define fluid parameters for each phase in each PVT zone "
|
|
499
|
+
"or group of PVT zones"
|
|
500
|
+
)
|
|
501
|
+
fluid_mix_method: MixModelWood | MixModelBrie = Field(
|
|
502
|
+
default_factory=MixModelWood,
|
|
503
|
+
description="Selection between Wood's or Brie model. Wood's model gives more "
|
|
504
|
+
"radical response to adding small amounts of gas in brine or oil",
|
|
505
|
+
)
|
|
506
|
+
# Handling of salinity will be a common factor, not zone-based
|
|
507
|
+
salinity_from_sim: SalinityFromSim = Field(
|
|
508
|
+
default_factory=SalinityFromSim,
|
|
509
|
+
description="In most cases it is sufficient with a constant salinity "
|
|
510
|
+
"setting for the reservoir, unless there is large contrast"
|
|
511
|
+
"between formation water and injected water. If salinity is "
|
|
512
|
+
"modelled in the simulation model, it is preferred to use that",
|
|
513
|
+
)
|
|
514
|
+
co2_model: CO2Models = Field(
|
|
515
|
+
default="span_wagner",
|
|
516
|
+
description="Selection of model for CO₂ properties, `span_wagner` equation "
|
|
517
|
+
"of state model or `flag`. Note that access to flag model depends "
|
|
518
|
+
"on licence",
|
|
519
|
+
)
|
|
376
520
|
|
|
377
|
-
|
|
378
|
-
|
|
521
|
+
@field_validator("pvt_zones", mode="before")
|
|
522
|
+
@classmethod
|
|
523
|
+
def pvtnum_check(cls, v: list[dict]) -> list[dict]:
|
|
524
|
+
"""
|
|
525
|
+
At this point in time we don't have access to the simulator init file,
|
|
526
|
+
so we just have to guess that it contains all numbers from 1 to the
|
|
527
|
+
max number given in the strings. Validate that there are no overlaps.
|
|
528
|
+
|
|
529
|
+
Validation must be made here, not under individual PVTZone objects
|
|
530
|
+
to get the combined information in all PVTZone groups.
|
|
531
|
+
|
|
532
|
+
Earlier wildcard symbol was '*'. Empty string (new wildcard) is
|
|
533
|
+
changed into '*' for backward compatibility
|
|
534
|
+
"""
|
|
535
|
+
pvtnum_strings = [zone["pvtnum"] for zone in v]
|
|
536
|
+
# Enforce single wildcard usage
|
|
537
|
+
if any(s is None or not str(s).strip() or s == "*" for s in pvtnum_strings):
|
|
538
|
+
if len(v) > 1:
|
|
539
|
+
raise ValueError(
|
|
540
|
+
"Setting wildcard ('*' or empty string) means that "
|
|
541
|
+
"all PVTNUM should be treated as one group, no "
|
|
542
|
+
"other groups can be specified"
|
|
543
|
+
)
|
|
544
|
+
# Enforce old style wildcard
|
|
545
|
+
v[0]["pvtnum"] = "*"
|
|
546
|
+
return v
|
|
547
|
+
# Build temporary range to detect overlaps
|
|
548
|
+
tmp_max = 1
|
|
549
|
+
tmp_num_array = [1]
|
|
550
|
+
for num_string in pvtnum_strings:
|
|
551
|
+
nums = input_num_string_to_list(num_string, tmp_num_array)
|
|
552
|
+
m = max(nums)
|
|
553
|
+
if m > tmp_max:
|
|
554
|
+
tmp_max = m
|
|
555
|
+
tmp_num_array = list(range(1, tmp_max + 1))
|
|
556
|
+
if detect_overlaps(pvtnum_strings, tmp_num_array):
|
|
557
|
+
raise ValueError(f"Overlaps in PVT zone definitions: {pvtnum_strings}")
|
|
558
|
+
return v
|
|
379
559
|
|
|
380
|
-
Returns:
|
|
381
|
-
bool: True if all strings are valid dates
|
|
382
560
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
"""
|
|
386
|
-
for date_string in date_strings:
|
|
387
|
-
if len(date_string) != 8:
|
|
388
|
-
raise ValueError(
|
|
389
|
-
f"Invalid date format: '{date_string}' must be exactly 8 characters"
|
|
390
|
-
)
|
|
391
|
-
try:
|
|
392
|
-
date(
|
|
393
|
-
year=int(date_string[0:4]),
|
|
394
|
-
month=int(date_string[4:6]),
|
|
395
|
-
day=int(date_string[6:]),
|
|
396
|
-
)
|
|
397
|
-
except ValueError:
|
|
398
|
-
raise ValueError(
|
|
399
|
-
f"Invalid date: '{date_string}' must be a valid date in YYYYMMDD format"
|
|
400
|
-
)
|
|
401
|
-
return True
|
|
561
|
+
def date_to_string(date_obj: date) -> str:
|
|
562
|
+
return date_obj.strftime(format="%Y%m%d")
|
|
402
563
|
|
|
403
564
|
|
|
404
|
-
class
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
global_config: Dict[str, Any]
|
|
565
|
+
class SeismicSurvey(BaseModel):
|
|
566
|
+
ecldate: list[str]
|
|
567
|
+
time: dict[str, str] | None = None
|
|
568
|
+
depth: dict[str, str] | None = None
|
|
409
569
|
|
|
410
|
-
@field_validator("
|
|
411
|
-
def
|
|
412
|
-
|
|
413
|
-
return v
|
|
570
|
+
@field_validator("ecldate", mode="before")
|
|
571
|
+
def convert_ecldate_strings(cls, v: list[date]) -> list[str]:
|
|
572
|
+
return [date_to_string(date) for date in v]
|
|
414
573
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
574
|
+
|
|
575
|
+
class SeismicSection(BaseModel):
|
|
576
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
577
|
+
|
|
578
|
+
templatecube_4d: str = Field(
|
|
579
|
+
validation_alias=AliasChoices("4d_templatecube", "templatecube_4d"),
|
|
580
|
+
serialization_alias="4d_templatecube",
|
|
581
|
+
)
|
|
582
|
+
real_4d_cropped_path: Path
|
|
583
|
+
real_4d: dict[str, SeismicSurvey]
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class FromGlobal(BaseModel):
|
|
587
|
+
grid_model: str
|
|
588
|
+
mod_dates: list[str] | None = None
|
|
589
|
+
mod_diffdates: list[list[str]] | None = None
|
|
590
|
+
obs_dates: list[str] | None = None
|
|
591
|
+
obs_diffdates: list[list[str]] | None = None
|
|
592
|
+
seismic: SeismicSection
|
|
593
|
+
global_config: GlobalConfiguration
|
|
594
|
+
|
|
595
|
+
@field_validator("mod_dates", "obs_dates", mode="before")
|
|
596
|
+
def make_date_strings(cls, v: list[date]) -> list[str] | None:
|
|
597
|
+
if v:
|
|
598
|
+
return [date_to_string(date) for date in v]
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
@field_validator("mod_diffdates", "obs_diffdates", mode="before")
|
|
602
|
+
def make_diffdate_strings(cls, v: list[list[str]]) -> list[list[str]] | None:
|
|
603
|
+
if v:
|
|
604
|
+
return [[date_to_string(date) for date in diffdate] for diffdate in v]
|
|
605
|
+
return None
|
|
420
606
|
|
|
421
607
|
|
|
422
608
|
class PemPaths(BaseModel):
|
|
@@ -443,11 +629,6 @@ class PemPaths(BaseModel):
|
|
|
443
629
|
"file for the FMU workflow",
|
|
444
630
|
frozen=True,
|
|
445
631
|
)
|
|
446
|
-
rel_ntg_grid_path: SkipJsonSchema[DirectoryPath] = Field(
|
|
447
|
-
default=Path("../../sim2seis/input/pem"),
|
|
448
|
-
description="This is the relative path to the ntg grid file. If the "
|
|
449
|
-
"ntg_calculation_flag is False, it is disregarded, cfr. fractions",
|
|
450
|
-
)
|
|
451
632
|
rel_path_simgrid: SkipJsonSchema[DirectoryPath] = Field(
|
|
452
633
|
default=Path("../../sim2seis/input/pem"),
|
|
453
634
|
description="Directory for eclipse simulation grid",
|
|
@@ -484,33 +665,43 @@ class PemConfig(BaseModel):
|
|
|
484
665
|
description="Default path settings exist, it is possible to override them, "
|
|
485
666
|
"mostly relevant for input paths",
|
|
486
667
|
)
|
|
668
|
+
eclipse_files: EclipseFiles
|
|
487
669
|
rock_matrix: RockMatrixProperties = Field(
|
|
488
670
|
description="Settings related to effective mineral properties and rock "
|
|
489
671
|
"physics model",
|
|
490
672
|
)
|
|
673
|
+
alternative_fipnum_name: SkipJsonSchema[str] = Field(
|
|
674
|
+
default="fipnum".upper(), # Should be upper case
|
|
675
|
+
description="If it is needed to deviate from Equinor standard to use "
|
|
676
|
+
"FIPNUM for zone/region class indicator",
|
|
677
|
+
)
|
|
491
678
|
fluids: Fluids = Field(
|
|
492
|
-
description="
|
|
679
|
+
description="Values for brine, oil and gas are required, but only the fluid "
|
|
680
|
+
"phases that are present in the simulation model will in practice be used in "
|
|
681
|
+
"calculation of effective fluid properties. You can have multiple fluid PVT "
|
|
682
|
+
"definitions, representing e.g. different regions and/or zones in your model.",
|
|
493
683
|
)
|
|
494
|
-
pressure: OverburdenPressureTrend | OverburdenPressureConstant = Field(
|
|
495
|
-
|
|
684
|
+
pressure: list[OverburdenPressureTrend | OverburdenPressureConstant] = Field(
|
|
685
|
+
default_factory=OverburdenPressureTrend,
|
|
496
686
|
description="Definition of overburden pressure model - constant or trend",
|
|
497
687
|
)
|
|
498
688
|
results: Results = Field(
|
|
499
689
|
description="Flags for saving results of the PEM",
|
|
500
690
|
)
|
|
501
|
-
diff_calculation:
|
|
691
|
+
diff_calculation: dict[DifferenceAttribute, list[DifferenceMethod]] = Field(
|
|
502
692
|
description="Difference properties of the PEM can be calculated for the dates "
|
|
503
|
-
"in the Eclipse UNRST file. The settings decide which parameters "
|
|
693
|
+
"in the Eclipse `.UNRST` file. The settings decide which parameters "
|
|
504
694
|
"difference properties will be generated for, and what kind of "
|
|
505
|
-
"difference calculation is run - normal difference, percent "
|
|
506
|
-
"difference or ratio"
|
|
695
|
+
"difference calculation is run - normal difference (`diff`), percent "
|
|
696
|
+
"difference (`diffperc`) or ratio (`ratio`). Multiple kinds of differences "
|
|
697
|
+
"can be estimated for each parameter"
|
|
507
698
|
)
|
|
508
699
|
global_params: SkipJsonSchema[FromGlobal | None] = Field(
|
|
509
700
|
default=None,
|
|
510
701
|
)
|
|
511
702
|
|
|
512
703
|
@field_validator("paths", mode="before")
|
|
513
|
-
def check_and_create_directories(cls, v:
|
|
704
|
+
def check_and_create_directories(cls, v: dict, info: ValidationInfo):
|
|
514
705
|
if v is None:
|
|
515
706
|
return PemPaths()
|
|
516
707
|
for key, path in v.items():
|
|
@@ -522,14 +713,15 @@ class PemConfig(BaseModel):
|
|
|
522
713
|
return v
|
|
523
714
|
|
|
524
715
|
@field_validator("diff_calculation", mode="before")
|
|
525
|
-
def to_list(cls, v:
|
|
526
|
-
v_keys =
|
|
716
|
+
def to_list(cls, v: dict) -> dict:
|
|
717
|
+
v_keys = [key.lower() for key in v]
|
|
527
718
|
v_val = list(v.values())
|
|
528
719
|
for i, val_item in enumerate(v_val):
|
|
529
720
|
if not isinstance(val_item, list):
|
|
530
721
|
v_val[i] = [
|
|
531
722
|
val_item,
|
|
532
723
|
]
|
|
724
|
+
v_val[i] = [v.lower() for v in v_val[i]]
|
|
533
725
|
return dict(zip(v_keys, v_val))
|
|
534
726
|
|
|
535
727
|
# Add global parameters used in the PEM
|