fmu-pem 0.0.1__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/__init__.py +2 -0
- fmu/pem/__init__.py +19 -0
- fmu/pem/__main__.py +53 -0
- fmu/pem/forward_models/__init__.py +7 -0
- fmu/pem/forward_models/pem_model.py +72 -0
- fmu/pem/hook_implementations/__init__.py +0 -0
- fmu/pem/hook_implementations/jobs.py +19 -0
- fmu/pem/pem_functions/__init__.py +17 -0
- fmu/pem/pem_functions/density.py +55 -0
- fmu/pem/pem_functions/effective_pressure.py +168 -0
- fmu/pem/pem_functions/estimate_saturated_rock.py +90 -0
- fmu/pem/pem_functions/fluid_properties.py +281 -0
- fmu/pem/pem_functions/mineral_properties.py +230 -0
- fmu/pem/pem_functions/regression_models.py +261 -0
- fmu/pem/pem_functions/run_friable_model.py +119 -0
- fmu/pem/pem_functions/run_patchy_cement_model.py +120 -0
- fmu/pem/pem_functions/run_t_matrix_and_pressure.py +186 -0
- fmu/pem/pem_utilities/__init__.py +66 -0
- fmu/pem/pem_utilities/cumsum_properties.py +104 -0
- fmu/pem/pem_utilities/delta_cumsum_time.py +104 -0
- fmu/pem/pem_utilities/enum_defs.py +54 -0
- fmu/pem/pem_utilities/export_routines.py +272 -0
- fmu/pem/pem_utilities/import_config.py +93 -0
- fmu/pem/pem_utilities/import_routines.py +161 -0
- fmu/pem/pem_utilities/pem_class_definitions.py +113 -0
- fmu/pem/pem_utilities/pem_config_validation.py +505 -0
- fmu/pem/pem_utilities/rpm_models.py +177 -0
- fmu/pem/pem_utilities/update_grid.py +54 -0
- fmu/pem/pem_utilities/utils.py +262 -0
- fmu/pem/run_pem.py +98 -0
- fmu/pem/version.py +21 -0
- fmu_pem-0.0.1.dist-info/METADATA +768 -0
- fmu_pem-0.0.1.dist-info/RECORD +37 -0
- fmu_pem-0.0.1.dist-info/WHEEL +5 -0
- fmu_pem-0.0.1.dist-info/entry_points.txt +5 -0
- fmu_pem-0.0.1.dist-info/licenses/LICENSE +674 -0
- fmu_pem-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# pylint: disable=missing-module-docstring
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import Tuple, Union
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from rock_physics_open import fluid_models as flag
|
|
7
|
+
from rock_physics_open import span_wagner
|
|
8
|
+
from rock_physics_open.equinor_utilities.std_functions import brie, multi_wood
|
|
9
|
+
|
|
10
|
+
from fmu.pem import INTERNAL_EQUINOR
|
|
11
|
+
|
|
12
|
+
if INTERNAL_EQUINOR:
|
|
13
|
+
from rock_physics_open.fluid_models import saturations_below_bubble_point
|
|
14
|
+
|
|
15
|
+
from rock_physics_open.fluid_models.oil_model.oil_bubble_point import bp_standing
|
|
16
|
+
|
|
17
|
+
from fmu.pem.pem_utilities import (
|
|
18
|
+
EffectiveFluidProperties,
|
|
19
|
+
Fluids,
|
|
20
|
+
SimRstProperties,
|
|
21
|
+
filter_and_one_dim,
|
|
22
|
+
reverse_filter_and_restore,
|
|
23
|
+
to_masked_array,
|
|
24
|
+
)
|
|
25
|
+
from fmu.pem.pem_utilities.enum_defs import CO2Models, FluidMixModel
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def effective_fluid_properties(
|
|
29
|
+
props: list[SimRstProperties] | SimRstProperties,
|
|
30
|
+
fluid_params: Fluids,
|
|
31
|
+
) -> list[EffectiveFluidProperties]:
|
|
32
|
+
"""effective_fluid_properties
|
|
33
|
+
Calculate effective fluid properties (bulk modulus, density) from a fluid mix.
|
|
34
|
+
|
|
35
|
+
6/1-25: Now also incorporating fluid properties in the case where the formation
|
|
36
|
+
pressure is below the oil bubble point.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
props : Union
|
|
41
|
+
list of dicts or a single dict with saturation, GOR and pressure per
|
|
42
|
+
time step
|
|
43
|
+
fluid_params : Fluids class
|
|
44
|
+
parameter set containing fluid parameters for FLAG models
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
list
|
|
49
|
+
fluid properties (bulk modulus and density) per time step
|
|
50
|
+
"""
|
|
51
|
+
props = _verify_inputs(props)
|
|
52
|
+
prop_list = []
|
|
53
|
+
for prop in props:
|
|
54
|
+
# Initial check of saturation for gas and brine and calculation of oil
|
|
55
|
+
# saturation
|
|
56
|
+
sat_wat, sat_gas, sat_oil = _saturation_check(prop.swat, prop.sgas)
|
|
57
|
+
|
|
58
|
+
gor = prop.rs
|
|
59
|
+
|
|
60
|
+
fluid_keys = ("vp", "density", "bulk_modulus")
|
|
61
|
+
|
|
62
|
+
# Convert pressure from bar to Pa
|
|
63
|
+
pres = 1.0e5 * prop.pressure
|
|
64
|
+
mix_model = fluid_params.mix_method
|
|
65
|
+
brie_exp = fluid_params.brie_exponent
|
|
66
|
+
|
|
67
|
+
# Salinity and temperature are either taken as constants from config file or
|
|
68
|
+
# from eclipse simulator model
|
|
69
|
+
if fluid_params.salinity_from_sim:
|
|
70
|
+
# Convert from ppk to ppm
|
|
71
|
+
salinity = 1000.0 * prop.salt
|
|
72
|
+
else:
|
|
73
|
+
salinity = to_masked_array(fluid_params.brine.salinity, sat_wat)
|
|
74
|
+
# Temperature will normally be set as a constant. It can come from eclipse in
|
|
75
|
+
# the case a compositional fluid model is run.
|
|
76
|
+
if fluid_params.temperature_from_sim:
|
|
77
|
+
temp = prop.temp
|
|
78
|
+
else:
|
|
79
|
+
temp = to_masked_array(fluid_params.temperature, sat_wat)
|
|
80
|
+
|
|
81
|
+
# Gas gravity has to be expanded to a masked array if it comes as a float
|
|
82
|
+
if isinstance(fluid_params.gas.gas_gravity, float):
|
|
83
|
+
gas_gravity = to_masked_array(fluid_params.gas.gas_gravity, sat_wat)
|
|
84
|
+
else:
|
|
85
|
+
gas_gravity = fluid_params.gas.gas_gravity
|
|
86
|
+
|
|
87
|
+
if hasattr(prop, "rv"):
|
|
88
|
+
(
|
|
89
|
+
mask,
|
|
90
|
+
sat_wat,
|
|
91
|
+
sat_gas,
|
|
92
|
+
sat_oil,
|
|
93
|
+
gor,
|
|
94
|
+
pres,
|
|
95
|
+
rv,
|
|
96
|
+
salinity,
|
|
97
|
+
temp,
|
|
98
|
+
gas_gravity,
|
|
99
|
+
) = filter_and_one_dim(
|
|
100
|
+
sat_wat,
|
|
101
|
+
sat_gas,
|
|
102
|
+
sat_oil,
|
|
103
|
+
gor,
|
|
104
|
+
pres,
|
|
105
|
+
prop.rv,
|
|
106
|
+
salinity,
|
|
107
|
+
temp,
|
|
108
|
+
gas_gravity,
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
(
|
|
112
|
+
mask,
|
|
113
|
+
sat_wat,
|
|
114
|
+
sat_gas,
|
|
115
|
+
sat_oil,
|
|
116
|
+
gor,
|
|
117
|
+
pres,
|
|
118
|
+
salinity,
|
|
119
|
+
temp,
|
|
120
|
+
) = filter_and_one_dim(sat_wat, sat_gas, sat_oil, gor, pres, salinity, temp)
|
|
121
|
+
rv = None
|
|
122
|
+
|
|
123
|
+
# Brine
|
|
124
|
+
brine_par = fluid_params.brine
|
|
125
|
+
p_na = np.array(brine_par.perc_na)
|
|
126
|
+
p_ca = np.array(brine_par.perc_ca)
|
|
127
|
+
p_k = np.array(brine_par.perc_k)
|
|
128
|
+
brine_props = flag.brine_properties(
|
|
129
|
+
temp, pres, salinity, p_nacl=p_na, p_cacl=p_ca, p_kcl=p_k
|
|
130
|
+
)
|
|
131
|
+
brine = dict(zip(fluid_keys, brine_props))
|
|
132
|
+
|
|
133
|
+
# Oil
|
|
134
|
+
oil_par = fluid_params.oil
|
|
135
|
+
oil_gr = oil_par.gas_gravity * np.ones_like(sat_wat)
|
|
136
|
+
oil_density = oil_par.reference_density * np.ones_like(sat_wat)
|
|
137
|
+
|
|
138
|
+
# If we are below bubble point, gas will come out of solution, and this has
|
|
139
|
+
# to be taken into account
|
|
140
|
+
try:
|
|
141
|
+
idx_below_bubble_point = pres <= bp_standing(oil_density, gor, oil_gr, temp)
|
|
142
|
+
if np.any(~idx_below_bubble_point):
|
|
143
|
+
warnings.warn(
|
|
144
|
+
f"Detected pressure below bubble point for oil in "
|
|
145
|
+
f"{np.sum(~idx_below_bubble_point)} cells"
|
|
146
|
+
)
|
|
147
|
+
except NotImplementedError:
|
|
148
|
+
# If the function is not available, a case above bubble point is assumed
|
|
149
|
+
warnings.warn(
|
|
150
|
+
"Function for bubble point not implemented. Conditions above "
|
|
151
|
+
"bubble point is assumed."
|
|
152
|
+
)
|
|
153
|
+
idx_below_bubble_point = np.zeros_like(sat_wat)
|
|
154
|
+
try:
|
|
155
|
+
if np.any(idx_below_bubble_point):
|
|
156
|
+
sat_gas, sat_oil, gor, gas_gravity = saturations_below_bubble_point(
|
|
157
|
+
gas_saturation_init=sat_gas,
|
|
158
|
+
oil_saturation_init=sat_oil,
|
|
159
|
+
brine_saturation_init=sat_wat,
|
|
160
|
+
gor_init=gor,
|
|
161
|
+
oil_gas_gravity=oil_gr,
|
|
162
|
+
free_gas_gravity=gas_gravity,
|
|
163
|
+
oil_density=oil_density,
|
|
164
|
+
z_factor=fluid_params.gas_z_factor,
|
|
165
|
+
pres_depl=pres,
|
|
166
|
+
temp_res=temp,
|
|
167
|
+
)
|
|
168
|
+
except (ModuleNotFoundError, NotImplementedError):
|
|
169
|
+
warnings.warn(
|
|
170
|
+
"Function to calculate effective fluid properties below "
|
|
171
|
+
"bubble point is not implemented. Estimates of fluid "
|
|
172
|
+
"properties may be uncertain."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
oil_props = flag.oil_properties(
|
|
176
|
+
temperature=temp,
|
|
177
|
+
pressure=pres,
|
|
178
|
+
gas_gravity=oil_gr,
|
|
179
|
+
rho0=oil_density,
|
|
180
|
+
gas_oil_ratio=gor,
|
|
181
|
+
)
|
|
182
|
+
oil = dict(zip(fluid_keys, oil_props))
|
|
183
|
+
|
|
184
|
+
# Gas, condensate or CO2 - select case based on CO2 flag or presence of RV
|
|
185
|
+
# property in the Eclipse restart file
|
|
186
|
+
|
|
187
|
+
if fluid_params.gas_saturation_is_co2:
|
|
188
|
+
if fluid_params.co2_model == CO2Models.FLAG and INTERNAL_EQUINOR:
|
|
189
|
+
gas_props = flag.co2_properties(temp=temp, pres=pres)
|
|
190
|
+
else:
|
|
191
|
+
gas_props = span_wagner.co2_properties(temp=temp, pres=pres)
|
|
192
|
+
else:
|
|
193
|
+
gas_par = fluid_params.gas
|
|
194
|
+
gas_gr = gas_gravity
|
|
195
|
+
gas_model = gas_par.model
|
|
196
|
+
gas_props = flag.gas_properties(temp, pres, gas_gr, model=gas_model)[0:3]
|
|
197
|
+
# The RV parameter is used to calculate condensate properties, but the inverse
|
|
198
|
+
# property (GOR) which is used as an input to the FLAG module, is Inf if RV is
|
|
199
|
+
# 0.0. An RV of 0.0 means that the gas is dry, which is already calculated
|
|
200
|
+
# NB: condensate properties calculation requires proprietary model
|
|
201
|
+
if fluid_params.calculate_condensate and (rv is not None) and INTERNAL_EQUINOR:
|
|
202
|
+
cond_par = fluid_params.condensate
|
|
203
|
+
idx_rv_zero = np.isclose(rv, 0.0, atol=1e-10)
|
|
204
|
+
if np.any(~idx_rv_zero) and INTERNAL_EQUINOR:
|
|
205
|
+
cond_gor = 1.0 / rv[~idx_rv_zero]
|
|
206
|
+
cond_gr = cond_par.gas_gravity * np.ones_like(cond_gor)
|
|
207
|
+
cond_density = cond_par.reference_density * np.ones_like(cond_gor)
|
|
208
|
+
cond_props = flag.condensate_properties(
|
|
209
|
+
temperature=temp[~idx_rv_zero],
|
|
210
|
+
pressure=pres[~idx_rv_zero],
|
|
211
|
+
rho0=cond_density,
|
|
212
|
+
gas_oil_ratio=cond_gor,
|
|
213
|
+
gas_gravity=cond_gr,
|
|
214
|
+
)
|
|
215
|
+
for i in range(len(gas_props)):
|
|
216
|
+
gas_props[i][~idx_rv_zero] = cond_props[i]
|
|
217
|
+
|
|
218
|
+
gas = dict(zip(fluid_keys, gas_props))
|
|
219
|
+
|
|
220
|
+
if mix_model == FluidMixModel.WOOD:
|
|
221
|
+
mixed_fluid_bulk_modulus = multi_wood(
|
|
222
|
+
[sat_wat, sat_gas, sat_oil],
|
|
223
|
+
[brine["bulk_modulus"], gas["bulk_modulus"], oil["bulk_modulus"]],
|
|
224
|
+
)
|
|
225
|
+
else:
|
|
226
|
+
mixed_fluid_bulk_modulus = brie(
|
|
227
|
+
sat_gas,
|
|
228
|
+
gas["bulk_modulus"],
|
|
229
|
+
sat_wat,
|
|
230
|
+
brine["bulk_modulus"],
|
|
231
|
+
sat_oil,
|
|
232
|
+
oil["bulk_modulus"],
|
|
233
|
+
brie_exp,
|
|
234
|
+
)
|
|
235
|
+
mixed_fluid_density = (
|
|
236
|
+
sat_wat * brine["density"]
|
|
237
|
+
+ sat_gas * gas["density"]
|
|
238
|
+
+ sat_oil * oil["density"]
|
|
239
|
+
)
|
|
240
|
+
mixed_fluid_density, mixed_fluid_bulk_modulus = reverse_filter_and_restore(
|
|
241
|
+
mask, mixed_fluid_density, mixed_fluid_bulk_modulus
|
|
242
|
+
)
|
|
243
|
+
fluid_props = EffectiveFluidProperties(
|
|
244
|
+
dens=mixed_fluid_density,
|
|
245
|
+
bulk_modulus=mixed_fluid_bulk_modulus,
|
|
246
|
+
)
|
|
247
|
+
prop_list.append(fluid_props)
|
|
248
|
+
return prop_list
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _saturation_check(
|
|
252
|
+
s_water: np.ma.MaskedArray, s_gas: np.ma.MaskedArray
|
|
253
|
+
) -> Tuple[np.ma.MaskedArray, ...]:
|
|
254
|
+
s_water = np.ma.MaskedArray(np.ma.clip(s_water, 0.0, 1.0))
|
|
255
|
+
s_gas = np.ma.MaskedArray(np.ma.clip(s_gas, 0.0, 1.0))
|
|
256
|
+
max_water_gas_factor = np.ma.max(s_water + s_gas)
|
|
257
|
+
if max_water_gas_factor > 1.0:
|
|
258
|
+
s_water /= max_water_gas_factor
|
|
259
|
+
s_gas /= max_water_gas_factor
|
|
260
|
+
s_oil = 1.0 - s_water - s_gas
|
|
261
|
+
return s_water, s_gas, s_oil # type: ignore
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _verify_inputs(inp_props):
|
|
265
|
+
if isinstance(inp_props, list):
|
|
266
|
+
if not all(isinstance(prop, SimRstProperties) for prop in inp_props):
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"{__file__}: input to effective fluid properties should be list of "
|
|
269
|
+
f"SimRstProperties or a single SimRstProperties instance, "
|
|
270
|
+
f"is {[type(prop) for prop in inp_props]}"
|
|
271
|
+
)
|
|
272
|
+
return inp_props
|
|
273
|
+
if isinstance(inp_props, dict):
|
|
274
|
+
return [
|
|
275
|
+
inp_props,
|
|
276
|
+
]
|
|
277
|
+
# "else ..."
|
|
278
|
+
raise ValueError(
|
|
279
|
+
f"{__file__}: input to effective fluid properties should be list of "
|
|
280
|
+
f"SimRstProperties or a single SimRstProperties instance, is {type(inp_props)}"
|
|
281
|
+
)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Effective mineral properties are calculated from the individual mineral properties of
|
|
3
|
+
the volume fractions. In the case that only a single net-to-gross fraction is
|
|
4
|
+
available, this is transformed to shale and sand fractions. A net-to-gross fraction
|
|
5
|
+
can also be estimated from porosity property.
|
|
6
|
+
|
|
7
|
+
If the ntg_calculation_flag is set in the PEM configuration parameter file, this will
|
|
8
|
+
override settings for volume fractions. In that case net-to-gross fraction is either
|
|
9
|
+
read from file, or calculated from porosity.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Tuple, Union
|
|
14
|
+
from warnings import warn
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from rock_physics_open.equinor_utilities.std_functions import (
|
|
18
|
+
multi_hashin_shtrikman,
|
|
19
|
+
multi_voigt_reuss_hill,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from fmu.pem.pem_utilities import (
|
|
23
|
+
MatrixProperties,
|
|
24
|
+
PemConfig,
|
|
25
|
+
SimInitProperties,
|
|
26
|
+
filter_and_one_dim,
|
|
27
|
+
get_shale_fraction,
|
|
28
|
+
import_fractions,
|
|
29
|
+
ntg_to_shale_fraction,
|
|
30
|
+
read_ntg_grid,
|
|
31
|
+
reverse_filter_and_restore,
|
|
32
|
+
to_masked_array,
|
|
33
|
+
)
|
|
34
|
+
from fmu.pem.pem_utilities.enum_defs import MineralMixModel, VolumeFractions
|
|
35
|
+
from fmu.pem.pem_utilities.pem_config_validation import (
|
|
36
|
+
MineralProperties,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def effective_mineral_properties(
|
|
41
|
+
root_dir: Path, config: PemConfig, sim_init: SimInitProperties
|
|
42
|
+
) -> Tuple[Union[np.ma.MaskedArray, None], MatrixProperties]:
|
|
43
|
+
"""Estimate effective mineral properties for each grid cell
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
root_dir: start directory for running of PEM
|
|
47
|
+
config: configuration parameters
|
|
48
|
+
sim_init: simulation initial properties
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
shale volume, effective mineral properties
|
|
52
|
+
"""
|
|
53
|
+
if config.rock_matrix.volume_fractions.mode == VolumeFractions.NTG_SIM:
|
|
54
|
+
# ntg_from_init_file flag takes precedence over ntg_from_porosity
|
|
55
|
+
if config.rock_matrix.volume_fractions.from_porosity:
|
|
56
|
+
vsh = calc_ntg_from_porosity(sim_init.poro)
|
|
57
|
+
else:
|
|
58
|
+
vsh = ntg_to_shale_fraction(sim_init.ntg, sim_init.poro)
|
|
59
|
+
fractions = [
|
|
60
|
+
vsh,
|
|
61
|
+
]
|
|
62
|
+
else:
|
|
63
|
+
fractions = import_fractions(root_dir, config)
|
|
64
|
+
# In case of a single fraction: it can either be NTG or a true volume fraction
|
|
65
|
+
if len(fractions) == 1 and config.rock_matrix.volume_fractions.fraction_is_ntg:
|
|
66
|
+
fractions[0] = ntg_to_shale_fraction(fractions[0], sim_init.poro)
|
|
67
|
+
vsh = get_shale_fraction(
|
|
68
|
+
fractions,
|
|
69
|
+
config.rock_matrix.fraction_names,
|
|
70
|
+
config.rock_matrix.shale_fractions,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
mineral_names = config.rock_matrix.fraction_minerals
|
|
74
|
+
eff_min_props = estimate_effective_mineral_properties(
|
|
75
|
+
mineral_names, fractions, config
|
|
76
|
+
)
|
|
77
|
+
return vsh, eff_min_props
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def estimate_effective_mineral_properties(
|
|
81
|
+
fraction_names: Union[str, List[str]],
|
|
82
|
+
fractions: Union[np.ma.MaskedArray, List[np.ma.MaskedArray]],
|
|
83
|
+
pem_config: PemConfig,
|
|
84
|
+
) -> MatrixProperties:
|
|
85
|
+
"""Estimation of effective mineral properties must be able to handle cases where
|
|
86
|
+
there is a more complex combination of minerals than the standard sand/shale case.
|
|
87
|
+
For carbonates the input can be based on minerals (e.g. calcite, dolomite, quartz,
|
|
88
|
+
smectite, ...) or PRTs (petrophysical rock types) that each have been assigned
|
|
89
|
+
elastic properties to.
|
|
90
|
+
The rock physics library is aimed at one-dimensional arrays, not masked arrays, so
|
|
91
|
+
special handling of input objects is needed.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
fraction_names: mineral names of the different fractions.
|
|
95
|
+
fractions: fraction of each mineral
|
|
96
|
+
pem_config: parameter object
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
bulk modulus [Pa], shear modulus [Pa] and density [kg/m3] of effective mineral
|
|
100
|
+
"""
|
|
101
|
+
verify_mineral_inputs(
|
|
102
|
+
fraction_names,
|
|
103
|
+
fractions,
|
|
104
|
+
pem_config.rock_matrix.minerals,
|
|
105
|
+
pem_config.rock_matrix.complement,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
fraction_names, fractions = normalize_mineral_fractions(
|
|
109
|
+
fraction_names, fractions, pem_config.rock_matrix.complement
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
mask, *fractions = filter_and_one_dim(*fractions)
|
|
113
|
+
k_list = []
|
|
114
|
+
mu_list = []
|
|
115
|
+
rho_list = []
|
|
116
|
+
for name in fraction_names:
|
|
117
|
+
mineral = pem_config.rock_matrix.minerals[name]
|
|
118
|
+
k_list.append(to_masked_array(mineral.bulk_modulus, fractions[0]))
|
|
119
|
+
mu_list.append(to_masked_array(mineral.shear_modulus, fractions[0]))
|
|
120
|
+
rho_list.append(to_masked_array(mineral.density, fractions[0]))
|
|
121
|
+
|
|
122
|
+
if pem_config.rock_matrix.mineral_mix_model == MineralMixModel.HASHIN_SHTRIKMAN:
|
|
123
|
+
eff_k, eff_mu = multi_hashin_shtrikman(
|
|
124
|
+
*[arr for prop in zip(k_list, mu_list, fractions) for arr in prop]
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
eff_k, eff_mu = multi_voigt_reuss_hill(
|
|
128
|
+
*[arr for prop in zip(k_list, mu_list, fractions) for arr in prop]
|
|
129
|
+
)
|
|
130
|
+
# Use phi masked array to restore original shape
|
|
131
|
+
eff_rho: np.ma.MaskedArray = np.ma.MaskedArray(
|
|
132
|
+
sum(rho * frac for rho, frac in zip(rho_list, fractions))
|
|
133
|
+
)
|
|
134
|
+
eff_min_k, eff_min_mu, eff_min_rho = reverse_filter_and_restore(
|
|
135
|
+
mask, eff_k, eff_mu, eff_rho
|
|
136
|
+
)
|
|
137
|
+
return MatrixProperties(
|
|
138
|
+
bulk_modulus=eff_min_k, shear_modulus=eff_min_mu, dens=eff_min_rho
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def verify_mineral_inputs(
|
|
143
|
+
names: str | list[str],
|
|
144
|
+
fracs: np.ma.MaskedArray | list[np.ma.MaskedArray],
|
|
145
|
+
minerals: dict[str, MineralProperties],
|
|
146
|
+
complement: str,
|
|
147
|
+
) -> None:
|
|
148
|
+
if isinstance(names, str):
|
|
149
|
+
names = [names]
|
|
150
|
+
|
|
151
|
+
if isinstance(fracs, np.ma.MaskedArray):
|
|
152
|
+
fracs = [fracs]
|
|
153
|
+
|
|
154
|
+
if len(names) != len(fracs):
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"mismatch between number of mineral names and fractions, "
|
|
157
|
+
f"{len(names)} vs. {len(fracs)}"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
for name in names + [complement]:
|
|
161
|
+
if name not in minerals:
|
|
162
|
+
raise ValueError(f"mineral names not listed in config file: {name}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def normalize_mineral_fractions(
|
|
166
|
+
names: str | list[str],
|
|
167
|
+
fracs: np.ma.MaskedArray | list[np.ma.MaskedArray],
|
|
168
|
+
complement: str,
|
|
169
|
+
) -> Tuple[list[str], list[np.ma.MaskedArray]]:
|
|
170
|
+
"""Normalizes mineral fractions and adds complement mineral if needed.
|
|
171
|
+
|
|
172
|
+
When the sum of specified mineral fractions is less than 1.0, adds the complement
|
|
173
|
+
mineral to make up the remainder. For example, if shale is 0.6 (60%) and the
|
|
174
|
+
complement mineral is quartz, then quartz will be added at 0.4 (40%) to reach 100%.
|
|
175
|
+
|
|
176
|
+
If fractions exceed valid range (0-1), they are clipped. If total exceeds 1.0,
|
|
177
|
+
all fractions are scaled down proportionally.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
names: Single mineral name or list of mineral names
|
|
181
|
+
fracs: Single masked array or list of masked arrays containing mineral fractions
|
|
182
|
+
complement: Name of mineral to use as complement if sum < 1.0
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Tuple containing:
|
|
186
|
+
- List of mineral names (with complement added if needed)
|
|
187
|
+
- List of normalized mineral fractions as masked arrays
|
|
188
|
+
"""
|
|
189
|
+
if isinstance(names, str):
|
|
190
|
+
names = [names]
|
|
191
|
+
|
|
192
|
+
if isinstance(fracs, np.ma.MaskedArray):
|
|
193
|
+
fracs = [fracs]
|
|
194
|
+
|
|
195
|
+
for i, frac in enumerate(fracs):
|
|
196
|
+
if np.any(frac[~frac.mask] < 0.0) or np.any(frac[~frac.mask] > 1.0):
|
|
197
|
+
warn(
|
|
198
|
+
f"mineral fraction {names[i]} has values outside of range 0.0 to 1.0,"
|
|
199
|
+
f"clipped to range",
|
|
200
|
+
UserWarning,
|
|
201
|
+
)
|
|
202
|
+
fracs[i] = np.ma.MaskedArray(np.ma.clip(frac, 0.0, 1.0))
|
|
203
|
+
|
|
204
|
+
tot_fractions = np.ma.sum(fracs, axis=0)
|
|
205
|
+
max_fraction = np.ma.max(tot_fractions)
|
|
206
|
+
TOLERANCE = 0.00001
|
|
207
|
+
|
|
208
|
+
if np.any(tot_fractions[~tot_fractions.mask] > 1.0 + TOLERANCE):
|
|
209
|
+
warn(
|
|
210
|
+
f"sum of mineral fractions are above 1.0 for "
|
|
211
|
+
f"{np.sum(tot_fractions[~tot_fractions.mask] > 1.0)} cells, is "
|
|
212
|
+
f"scaled to maximum 1.0.\n"
|
|
213
|
+
f"Max value is {np.max(tot_fractions[~tot_fractions.mask])}",
|
|
214
|
+
UserWarning,
|
|
215
|
+
)
|
|
216
|
+
for i, frac in enumerate(fracs):
|
|
217
|
+
fracs[i] /= max_fraction
|
|
218
|
+
|
|
219
|
+
comp_fraction = 1.0 - np.ma.sum(fracs, axis=0)
|
|
220
|
+
if np.any(comp_fraction > 0.0):
|
|
221
|
+
names = names + [complement]
|
|
222
|
+
fracs = fracs + [comp_fraction]
|
|
223
|
+
|
|
224
|
+
return names, fracs
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def calc_ntg_from_porosity(porosity: np.ma.MaskedArray) -> np.ma.MaskedArray:
|
|
228
|
+
vsh = to_masked_array(0, porosity)
|
|
229
|
+
vsh[porosity <= 0.33] = np.ma.power((0.33 - porosity[porosity < 0.33]) / 0.33, 2.0)
|
|
230
|
+
return (vsh / (1.0 - porosity)).clip(0.0, 1.0)
|