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