fmu-pem 0.0.2__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 -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 +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 +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 +64 -46
- 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 +331 -139
- 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 +70 -39
- fmu/pem/version.py +16 -3
- {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.3.dist-info}/METADATA +18 -11
- fmu_pem-0.0.3.dist-info/RECORD +39 -0
- fmu_pem-0.0.2.dist-info/RECORD +0 -37
- {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.3.dist-info}/WHEEL +0 -0
- {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.3.dist-info}/entry_points.txt +0 -0
- {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.3.dist-info}/licenses/LICENSE +0 -0
- {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.3.dist-info}/top_level.txt +0 -0
|
@@ -1,286 +1,488 @@
|
|
|
1
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import warnings
|
|
3
|
-
from
|
|
4
|
+
from math import isclose
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
4
6
|
|
|
5
7
|
import numpy as np
|
|
6
8
|
from rock_physics_open import fluid_models as flag
|
|
7
9
|
from rock_physics_open import span_wagner
|
|
8
10
|
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
11
|
from rock_physics_open.fluid_models.oil_model.oil_bubble_point import bp_standing
|
|
16
12
|
|
|
13
|
+
from fmu.pem import INTERNAL_EQUINOR
|
|
17
14
|
from fmu.pem.pem_utilities import (
|
|
18
15
|
EffectiveFluidProperties,
|
|
19
16
|
Fluids,
|
|
20
17
|
SimRstProperties,
|
|
21
|
-
filter_and_one_dim,
|
|
22
|
-
reverse_filter_and_restore,
|
|
23
|
-
to_masked_array,
|
|
24
18
|
)
|
|
25
19
|
from fmu.pem.pem_utilities.enum_defs import CO2Models, FluidMixModel, TemperatureMethod
|
|
20
|
+
from fmu.pem.pem_utilities.fipnum_pvtnum_utilities import (
|
|
21
|
+
input_num_string_to_list,
|
|
22
|
+
validate_zone_coverage,
|
|
23
|
+
)
|
|
26
24
|
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from fmu.pem.pem_utilities.pem_config_validation import PVTZone
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
)
|
|
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)
|
|
28
|
+
if INTERNAL_EQUINOR:
|
|
29
|
+
from rock_physics_open.fluid_models import (
|
|
30
|
+
saturations_below_bubble_point, # noqa: F821
|
|
31
|
+
)
|
|
57
32
|
|
|
58
|
-
|
|
33
|
+
# Hard-code the default tolerance limit for fraction of cells below bubble point
|
|
34
|
+
BUBBLE_POINT_FRACTION_TOLERANCE = 0.01
|
|
59
35
|
|
|
60
|
-
fluid_keys = ("vp", "density", "bulk_modulus")
|
|
61
36
|
|
|
62
|
-
|
|
63
|
-
|
|
37
|
+
def _saturation_triplet(
|
|
38
|
+
sw: np.ma.MaskedArray, sg: np.ma.MaskedArray
|
|
39
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
40
|
+
"""Clip water/gas and derive oil saturation; renormalize if sum exceeds 1."""
|
|
41
|
+
sw = np.ma.MaskedArray(np.ma.clip(sw, 0.0, 1.0))
|
|
42
|
+
sg = np.ma.MaskedArray(np.ma.clip(sg, 0.0, 1.0))
|
|
43
|
+
# sw and sg come from the same grid, and will have the same mask, so there
|
|
44
|
+
# should be no need for special handling of possible different masks
|
|
45
|
+
s_sum = np.ma.max(sw + sg)
|
|
46
|
+
if s_sum > 1.0: # renormalize if overlapping
|
|
47
|
+
sw /= s_sum
|
|
48
|
+
sg /= s_sum
|
|
49
|
+
so = 1.0 - sw - sg
|
|
50
|
+
return sw, sg, so
|
|
64
51
|
|
|
65
|
-
# Salinity and temperature are either taken as constants from config file or
|
|
66
|
-
# from eclipse simulator model
|
|
67
|
-
if fluid_params.salinity_from_sim:
|
|
68
|
-
# Convert from ppk to ppm
|
|
69
|
-
salinity = 1000.0 * prop.salt
|
|
70
|
-
else:
|
|
71
|
-
salinity = to_masked_array(fluid_params.brine.salinity, sat_wat)
|
|
72
|
-
# Temperature will normally be set as a constant. It can come from eclipse in
|
|
73
|
-
# the case a compositional fluid model is run.
|
|
74
|
-
if fluid_params.temperature.type == TemperatureMethod.FROMSIM:
|
|
75
|
-
if hasattr(prop, "temp") and prop.temp is not None:
|
|
76
|
-
temp = prop.temp
|
|
77
|
-
else:
|
|
78
|
-
raise ValueError(
|
|
79
|
-
"eclipse simulation restart file does not have "
|
|
80
|
-
"temperature attribute. Constant temperature must "
|
|
81
|
-
"be set in parameter file"
|
|
82
|
-
)
|
|
83
|
-
else:
|
|
84
|
-
temp = to_masked_array(fluid_params.temperature.temperature_value, sat_wat)
|
|
85
52
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
salinity,
|
|
102
|
-
temp,
|
|
103
|
-
gas_gravity,
|
|
104
|
-
) = filter_and_one_dim(
|
|
105
|
-
sat_wat,
|
|
106
|
-
sat_gas,
|
|
107
|
-
sat_oil,
|
|
108
|
-
gor,
|
|
109
|
-
pres,
|
|
110
|
-
prop.rv,
|
|
111
|
-
salinity,
|
|
112
|
-
temp,
|
|
113
|
-
gas_gravity,
|
|
114
|
-
)
|
|
115
|
-
else:
|
|
116
|
-
(
|
|
117
|
-
mask,
|
|
118
|
-
sat_wat,
|
|
119
|
-
sat_gas,
|
|
120
|
-
sat_oil,
|
|
121
|
-
gor,
|
|
122
|
-
pres,
|
|
123
|
-
salinity,
|
|
124
|
-
temp,
|
|
125
|
-
) = filter_and_one_dim(sat_wat, sat_gas, sat_oil, gor, pres, salinity, temp)
|
|
126
|
-
rv = None
|
|
127
|
-
|
|
128
|
-
# Brine
|
|
129
|
-
brine_par = fluid_params.brine
|
|
130
|
-
p_na = np.array(brine_par.perc_na)
|
|
131
|
-
p_ca = np.array(brine_par.perc_ca)
|
|
132
|
-
p_k = np.array(brine_par.perc_k)
|
|
133
|
-
brine_props = flag.brine_properties(
|
|
134
|
-
temp, pres, salinity, p_nacl=p_na, p_cacl=p_ca, p_kcl=p_k
|
|
135
|
-
)
|
|
136
|
-
brine = dict(zip(fluid_keys, brine_props))
|
|
137
|
-
|
|
138
|
-
# Oil
|
|
139
|
-
oil_par = fluid_params.oil
|
|
140
|
-
oil_gr = oil_par.gas_gravity * np.ones_like(sat_wat)
|
|
141
|
-
oil_density = oil_par.reference_density * np.ones_like(sat_wat)
|
|
53
|
+
def _adjust_bubble_point(
|
|
54
|
+
pres: np.ndarray,
|
|
55
|
+
gor: np.ndarray,
|
|
56
|
+
sw: np.ndarray,
|
|
57
|
+
sg: np.ndarray,
|
|
58
|
+
so: np.ndarray,
|
|
59
|
+
temp: np.ndarray,
|
|
60
|
+
oil_density_ref: np.ndarray,
|
|
61
|
+
oil_gas_gravity: np.ndarray,
|
|
62
|
+
free_gas_gravity: np.ndarray,
|
|
63
|
+
zone: PVTZone,
|
|
64
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
65
|
+
"""
|
|
66
|
+
If we are below bubble point, gas will come out of solution, and this has
|
|
67
|
+
to be taken into account
|
|
142
68
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
69
|
+
If below bubble point: evolve saturations, GOR and free gas gravity."""
|
|
70
|
+
try:
|
|
71
|
+
bp = bp_standing(
|
|
72
|
+
density=oil_density_ref,
|
|
73
|
+
gas_oil_ratio=gor,
|
|
74
|
+
gas_gravity=oil_gas_gravity,
|
|
75
|
+
temperature=temp,
|
|
76
|
+
)
|
|
77
|
+
# Only consider cells where the oil saturation is positive
|
|
78
|
+
idx_below = np.logical_and(pres <= bp, so > 0.0)
|
|
79
|
+
if np.any(idx_below):
|
|
80
|
+
# More than 1% of cells below bubble point will be regarded as an error
|
|
81
|
+
# situation if the gas Z-factor is set to default value of 1.0. If the
|
|
82
|
+
# user modifies the Z-factor, we regard cells below bubble point to be
|
|
83
|
+
# an expected situation and not an error.
|
|
84
|
+
frac_below = np.sum(idx_below) / pres.size
|
|
85
|
+
if frac_below > BUBBLE_POINT_FRACTION_TOLERANCE and isclose(
|
|
86
|
+
zone.gas_z_factor, 1.0
|
|
87
|
+
):
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"Fraction of cells with pressure below oil bubble point is "
|
|
90
|
+
f"{frac_below:.3f}. "
|
|
91
|
+
"If you experience this, please raise an issue in "
|
|
92
|
+
"https://github.com/equinor/fmu-pem/issues. "
|
|
93
|
+
"If this is an expected situation, add a "
|
|
94
|
+
"gas Z-factor (deviation from an ideal gas) to the YAML parameter "
|
|
95
|
+
"file for each PVTNUM zone, e.g.: 'gas_z_factor: 0.97' "
|
|
96
|
+
"The gas Z-factor must deviate from 1.0."
|
|
151
97
|
)
|
|
152
|
-
except NotImplementedError:
|
|
153
|
-
# If the function is not available, a case above bubble point is assumed
|
|
154
98
|
warnings.warn(
|
|
155
|
-
"
|
|
156
|
-
"
|
|
99
|
+
f"Detected pressure below bubble point for oil in {np.sum(idx_below)} "
|
|
100
|
+
f"cells, this is {frac_below:.3f} of total number of cells."
|
|
157
101
|
)
|
|
158
|
-
|
|
102
|
+
except NotImplementedError:
|
|
103
|
+
warnings.warn("Bubble point function unavailable; assuming above bubble point.")
|
|
104
|
+
idx_below = np.zeros_like(pres, dtype=bool)
|
|
105
|
+
|
|
106
|
+
if np.any(idx_below):
|
|
159
107
|
try:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
except (ModuleNotFoundError, NotImplementedError):
|
|
108
|
+
sg, so, gor, free_gas_gravity = saturations_below_bubble_point(
|
|
109
|
+
gas_saturation_init=sg,
|
|
110
|
+
oil_saturation_init=so,
|
|
111
|
+
brine_saturation_init=sw,
|
|
112
|
+
gor_init=gor,
|
|
113
|
+
oil_gas_gravity=oil_gas_gravity,
|
|
114
|
+
free_gas_gravity=free_gas_gravity,
|
|
115
|
+
oil_density=oil_density_ref,
|
|
116
|
+
z_factor=zone.gas_z_factor,
|
|
117
|
+
pres_depl=pres,
|
|
118
|
+
temp_res=temp,
|
|
119
|
+
)
|
|
120
|
+
except (NameError, ModuleNotFoundError, NotImplementedError):
|
|
174
121
|
warnings.warn(
|
|
175
|
-
"
|
|
176
|
-
"
|
|
177
|
-
"properties may be uncertain."
|
|
122
|
+
"Estimation of oil properties below bubble point requires proprietary "
|
|
123
|
+
"model. Estimation of oil properties below bubble point are uncertain."
|
|
178
124
|
)
|
|
125
|
+
return sw, sg, so, gor, free_gas_gravity, idx_below
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _brine_props(
|
|
129
|
+
temp: np.ndarray,
|
|
130
|
+
pres: np.ndarray,
|
|
131
|
+
salinity: np.ndarray,
|
|
132
|
+
zone: PVTZone,
|
|
133
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
134
|
+
"""Estimate brine properties by FLAG or Batzle & Wang model."""
|
|
135
|
+
p_na = np.array(zone.brine.perc_na)
|
|
136
|
+
p_ca = np.array(zone.brine.perc_ca)
|
|
137
|
+
p_k = np.array(zone.brine.perc_k)
|
|
138
|
+
vp, rho, bulk = flag.brine_properties(
|
|
139
|
+
temperature=temp,
|
|
140
|
+
pressure=pres,
|
|
141
|
+
salinity=salinity,
|
|
142
|
+
p_nacl=p_na,
|
|
143
|
+
p_cacl=p_ca,
|
|
144
|
+
p_kcl=p_k,
|
|
145
|
+
)
|
|
146
|
+
return rho, bulk, vp
|
|
147
|
+
|
|
179
148
|
|
|
180
|
-
|
|
149
|
+
def _oil_props(
|
|
150
|
+
temp: np.ndarray,
|
|
151
|
+
pres: np.ndarray,
|
|
152
|
+
gor: np.ndarray,
|
|
153
|
+
oil_density_ref: np.ndarray,
|
|
154
|
+
oil_gas_gravity: np.ndarray,
|
|
155
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
156
|
+
"""Estimate oil properties by FLAG or Batzle & Wang model."""
|
|
157
|
+
vp, rho, bulk = flag.oil_properties(
|
|
158
|
+
temperature=temp,
|
|
159
|
+
pressure=pres,
|
|
160
|
+
gas_gravity=oil_gas_gravity,
|
|
161
|
+
rho0=oil_density_ref,
|
|
162
|
+
gas_oil_ratio=gor,
|
|
163
|
+
)
|
|
164
|
+
return rho, bulk, vp
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _gas_or_co2_props(
|
|
168
|
+
temp: np.ndarray,
|
|
169
|
+
pres: np.ndarray,
|
|
170
|
+
gas_gravity: np.ndarray,
|
|
171
|
+
zone: PVTZone,
|
|
172
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
173
|
+
"""Estimate gas properties by FLAG or Batzle & Wang and co2 properties by
|
|
174
|
+
FLAG or Span & Wagner model."""
|
|
175
|
+
if zone.gas_saturation_is_co2:
|
|
176
|
+
if zone.co2_model == CO2Models.FLAG and INTERNAL_EQUINOR:
|
|
177
|
+
vp, rho, bulk = flag.co2_properties( # noqa: F821
|
|
178
|
+
temp=temp,
|
|
179
|
+
pres=pres,
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
vp, rho, bulk = span_wagner.co2_properties(
|
|
183
|
+
temp=temp,
|
|
184
|
+
pres=pres,
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
vp, rho, bulk = flag.gas_properties(
|
|
181
188
|
temperature=temp,
|
|
182
189
|
pressure=pres,
|
|
183
|
-
gas_gravity=
|
|
184
|
-
|
|
185
|
-
|
|
190
|
+
gas_gravity=gas_gravity,
|
|
191
|
+
model=zone.gas.model,
|
|
192
|
+
)[0:3]
|
|
193
|
+
return rho, bulk, vp
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _apply_condensate_if_any(
|
|
197
|
+
rv: np.ndarray | None,
|
|
198
|
+
temp: np.ndarray,
|
|
199
|
+
pres: np.ndarray,
|
|
200
|
+
zone: PVTZone,
|
|
201
|
+
gas_rho: np.ndarray,
|
|
202
|
+
gas_bulk: np.ndarray,
|
|
203
|
+
gas_vp: np.ndarray,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Overwrite gas properties where condensate is present (rv > 0).
|
|
207
|
+
|
|
208
|
+
To be overly clear: Modifies gas_rho, gas_bulk, and gas_vp arrays in-place,
|
|
209
|
+
returns None.
|
|
210
|
+
|
|
211
|
+
The RV parameter is used to calculate condensate properties, but the inverse
|
|
212
|
+
property (GOR) which is used as an input to the FLAG module, is Inf if RV is
|
|
213
|
+
0.0. An RV of 0.0 means that the gas is dry, which is already calculated
|
|
214
|
+
NB: condensate properties calculation requires proprietary model
|
|
215
|
+
"""
|
|
216
|
+
if not (zone.calculate_condensate and rv is not None and INTERNAL_EQUINOR):
|
|
217
|
+
return
|
|
218
|
+
idx_dry = np.isclose(rv, 0.0, atol=1e-10)
|
|
219
|
+
if np.all(idx_dry):
|
|
220
|
+
return
|
|
221
|
+
cond = zone.condensate
|
|
222
|
+
cond_gor = 1.0 / rv[~idx_dry]
|
|
223
|
+
cond_gr = cond.gas_gravity * np.ones_like(cond_gor)
|
|
224
|
+
cond_rho0 = cond.reference_density * np.ones_like(cond_gor)
|
|
225
|
+
if rv is not None and temp is not None and pres is not None:
|
|
226
|
+
vp_c, rho_c, bulk_c = flag.condensate_properties( # noqa: F821
|
|
227
|
+
temperature=temp[~idx_dry],
|
|
228
|
+
pressure=pres[~idx_dry],
|
|
229
|
+
rho0=cond_rho0,
|
|
230
|
+
gas_oil_ratio=cond_gor,
|
|
231
|
+
gas_gravity=cond_gr,
|
|
232
|
+
)
|
|
233
|
+
gas_rho[~idx_dry] = rho_c
|
|
234
|
+
gas_bulk[~idx_dry] = bulk_c
|
|
235
|
+
gas_vp[~idx_dry] = vp_c
|
|
236
|
+
else:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
"Condensate properties calculation requires non-null `rv`, `temp`, and "
|
|
239
|
+
"`pres`."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _mix(
|
|
244
|
+
sw: np.ndarray,
|
|
245
|
+
sg: np.ndarray,
|
|
246
|
+
so: np.ndarray,
|
|
247
|
+
rho_w: np.ndarray,
|
|
248
|
+
bulk_w: np.ndarray,
|
|
249
|
+
rho_g: np.ndarray,
|
|
250
|
+
bulk_g: np.ndarray,
|
|
251
|
+
rho_o: np.ndarray,
|
|
252
|
+
bulk_o: np.ndarray,
|
|
253
|
+
method: FluidMixModel,
|
|
254
|
+
brie_exponent: float | None = None,
|
|
255
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
256
|
+
"""
|
|
257
|
+
Selects the fluid mixing function based on the `fluid_mix_method` input parameter.
|
|
258
|
+
If `fluid_mix_method` is set to WOOD, the Wood mixing function is used.
|
|
259
|
+
Otherwise, the Brie mixing function is used by default.
|
|
260
|
+
"""
|
|
261
|
+
if method == FluidMixModel.WOOD:
|
|
262
|
+
bulk_eff = multi_wood([sw, sg, so], [bulk_w, bulk_g, bulk_o]) # type: ignore
|
|
263
|
+
else:
|
|
264
|
+
bulk_eff = brie(
|
|
265
|
+
sg,
|
|
266
|
+
bulk_g,
|
|
267
|
+
sw,
|
|
268
|
+
bulk_w,
|
|
269
|
+
so,
|
|
270
|
+
bulk_o,
|
|
271
|
+
brie_exponent,
|
|
272
|
+
)
|
|
273
|
+
rho_eff = sw * rho_w + sg * rho_g + so * rho_o
|
|
274
|
+
return rho_eff, bulk_eff
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def effective_fluid_properties_zoned(
|
|
278
|
+
restart_props: list[SimRstProperties] | SimRstProperties,
|
|
279
|
+
fluids: Fluids,
|
|
280
|
+
pvtnum: np.ma.MaskedArray,
|
|
281
|
+
) -> tuple[list[EffectiveFluidProperties], list[dict[str, np.ma.MaskedArray]]]:
|
|
282
|
+
"""
|
|
283
|
+
Compute per time-step effective fluid density and bulk modulus honoring PVT zone
|
|
284
|
+
groupings.
|
|
285
|
+
|
|
286
|
+
Steps:
|
|
287
|
+
1. Normalize `restart_props` to a list.
|
|
288
|
+
2. Validate PVT zone coverage (single wildcard '*' or explicit non-overlapping
|
|
289
|
+
sets).
|
|
290
|
+
3. Build mapping from grid PVTNUM values to zone indices.
|
|
291
|
+
4. For each time-step:
|
|
292
|
+
a. Initialize result arrays with NaN (masked/inactive cells untouched).
|
|
293
|
+
b. Loop zone values, select cells, extract saturations, pressure, GOR,
|
|
294
|
+
salinity, temperature.
|
|
295
|
+
c. Apply bubble point adjustment (may release solution gas and modify GOR &
|
|
296
|
+
free gas gravity).
|
|
297
|
+
d. Compute phase properties (brine, oil, gas or CO₂).
|
|
298
|
+
e. Overwrite gas properties where condensate present (RV > 0) if enabled and
|
|
299
|
+
proprietary model available.
|
|
300
|
+
f. Mix phase properties using Wood or Brie model.
|
|
301
|
+
g. Insert zone results into full-grid arrays.
|
|
302
|
+
5. Collect `EffectiveFluidProperties` objects for all time-steps.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
restart_props (list[SimRstProperties] | SimRstProperties): One or more restart
|
|
306
|
+
property containers holding phase saturations (`swat`, `sgas`), pressure,
|
|
307
|
+
gas-oil ratio (`rs`), optional condensate ratio (`rv`), and (if modeled)
|
|
308
|
+
temperature / salinity.
|
|
309
|
+
fluids (Fluids): Fluid configuration including per-zone parameters (brine, oil,
|
|
310
|
+
gas, condensate flags), mixing method, and model selections.
|
|
311
|
+
pvtnum (np.ma.MaskedArray): Masked array of PVTNUM integers defining zone
|
|
312
|
+
partitioning on the simulation grid (masked cells are inactive).
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
list[EffectiveFluidProperties]: Ordered list matching input time-step order.
|
|
316
|
+
Each element contains:
|
|
317
|
+
density (np.ndarray): Effective fluid density (kg/m³) per active cell.
|
|
318
|
+
bulk_modulus (np.ndarray): Effective fluid bulk modulus (Pa) per active
|
|
319
|
+
cell.
|
|
320
|
+
list[dict[str, np.ma.MaskedArray]]: grids showing cells that are below
|
|
321
|
+
bubble point for each time-step
|
|
322
|
+
|
|
323
|
+
Raises:
|
|
324
|
+
ValueError: If PVT zone definitions overlap, have uncovered grid values, misuse
|
|
325
|
+
wildcard '*', or condensate calculation is requested but `rv` is missing.
|
|
326
|
+
NotImplementedError: If condensate modeling is requested without proprietary
|
|
327
|
+
implementation (`INTERNAL_EQUINOR` is False), or bubble point evolution
|
|
328
|
+
depends on an unavailable model.
|
|
329
|
+
RuntimeError: If array shape mismatches prevent assignment to result arrays.
|
|
330
|
+
TypeError: If input types do not conform to expected models / masked arrays.
|
|
331
|
+
|
|
332
|
+
Notes:
|
|
333
|
+
- Salinity and temperature are sourced from simulation only if corresponding
|
|
334
|
+
flags are set; otherwise zone constants are used.
|
|
335
|
+
- Condensate overwrite operates in-place on gas property arrays.
|
|
336
|
+
- Inactive (masked) cells retain NaN in outputs to preserve grid masking.
|
|
337
|
+
"""
|
|
338
|
+
# Validate zone coverage
|
|
339
|
+
pvtnum_strings: list[str] = [zone.pvtnum for zone in fluids.pvt_zones] # type: ignore
|
|
340
|
+
validate_zone_coverage(pvtnum_strings, pvtnum, zone_name="PVTNUM")
|
|
341
|
+
|
|
342
|
+
# Normalize input to list
|
|
343
|
+
props_list = restart_props if isinstance(restart_props, list) else [restart_props]
|
|
344
|
+
|
|
345
|
+
# Get actual PVTNUM values present in grid for use with input_num_string_to_list
|
|
346
|
+
# The PVTNUM mask from the INIT file will be the same as the mask from UNRST file
|
|
347
|
+
# for all dates
|
|
348
|
+
pvtnum_data = pvtnum.data
|
|
349
|
+
pvtnum_mask = (
|
|
350
|
+
pvtnum.mask
|
|
351
|
+
if hasattr(pvtnum, "mask")
|
|
352
|
+
else np.zeros_like(pvtnum_data, dtype=bool)
|
|
353
|
+
)
|
|
354
|
+
actual_pvtnum_values = list(np.unique(pvtnum_data[~pvtnum_mask]).astype(int))
|
|
355
|
+
|
|
356
|
+
# Allocate outputs
|
|
357
|
+
results: list[EffectiveFluidProperties] = []
|
|
358
|
+
bp_grids: list[dict[str, np.ma.MaskedArray]] = []
|
|
359
|
+
|
|
360
|
+
for rst_date_prop in props_list:
|
|
361
|
+
# Initialize masked result arrays
|
|
362
|
+
rho_eff_full = np.ma.masked_array(
|
|
363
|
+
np.full(rst_date_prop.swat.shape, np.nan, dtype=float), mask=pvtnum_mask
|
|
364
|
+
)
|
|
365
|
+
bulk_eff_full = np.ma.masked_array(
|
|
366
|
+
np.full(rst_date_prop.swat.shape, np.nan, dtype=float), mask=pvtnum_mask
|
|
186
367
|
)
|
|
187
|
-
|
|
368
|
+
below_bubble_point_grid_full = np.ma.masked_array(
|
|
369
|
+
np.full(rst_date_prop.swat.shape, np.nan, dtype=float), mask=pvtnum_mask
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Process each unique zone (may contain multiple PVTNUMs grouped)
|
|
373
|
+
for zone in fluids.pvt_zones:
|
|
374
|
+
# Get all PVTNUM values for this zone using input_num_string_to_list
|
|
375
|
+
pvtnum_values = input_num_string_to_list(zone.pvtnum, actual_pvtnum_values)
|
|
376
|
+
|
|
377
|
+
# Build combined mask for all PVTNUMs in this zone using vectorized
|
|
378
|
+
# operation
|
|
379
|
+
mask_cells = np.isin(pvtnum_data, pvtnum_values) & (~pvtnum_mask)
|
|
188
380
|
|
|
189
|
-
|
|
190
|
-
|
|
381
|
+
if not np.any(mask_cells):
|
|
382
|
+
continue
|
|
191
383
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
384
|
+
# Extract saturations, pressure, GOR, etc. (placeholder for existing logic)
|
|
385
|
+
sw = rst_date_prop.swat[mask_cells]
|
|
386
|
+
sg = rst_date_prop.sgas[mask_cells]
|
|
387
|
+
sw, sg, so = _saturation_triplet(sw, sg)
|
|
388
|
+
pres = rst_date_prop.pressure[mask_cells]
|
|
389
|
+
gor = rst_date_prop.rs[mask_cells]
|
|
390
|
+
|
|
391
|
+
# Salinity
|
|
392
|
+
if (
|
|
393
|
+
fluids.salinity_from_sim
|
|
394
|
+
and hasattr(rst_date_prop, "salt")
|
|
395
|
+
and rst_date_prop.salt is not None
|
|
396
|
+
):
|
|
397
|
+
sal = rst_date_prop.salt[mask_cells]
|
|
195
398
|
else:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
pressure=pres[~idx_rv_zero],
|
|
216
|
-
rho0=cond_density,
|
|
217
|
-
gas_oil_ratio=cond_gor,
|
|
218
|
-
gas_gravity=cond_gr,
|
|
399
|
+
sal = np.full(sw.shape, zone.brine.salinity)
|
|
400
|
+
|
|
401
|
+
# Temperature
|
|
402
|
+
if (
|
|
403
|
+
zone.temperature.type == TemperatureMethod.FROMSIM
|
|
404
|
+
and hasattr(rst_date_prop, "temp")
|
|
405
|
+
and rst_date_prop.temp is not None
|
|
406
|
+
):
|
|
407
|
+
temp = rst_date_prop.temp[mask_cells]
|
|
408
|
+
else:
|
|
409
|
+
temp = np.full(sw.shape, zone.temperature.temperature_value)
|
|
410
|
+
|
|
411
|
+
# RV (condensate)
|
|
412
|
+
rv = (
|
|
413
|
+
rst_date_prop.rv[mask_cells]
|
|
414
|
+
if (
|
|
415
|
+
zone.calculate_condensate
|
|
416
|
+
and hasattr(rst_date_prop, "rv")
|
|
417
|
+
and rst_date_prop.rv is not None
|
|
219
418
|
)
|
|
220
|
-
|
|
221
|
-
|
|
419
|
+
else None
|
|
420
|
+
)
|
|
222
421
|
|
|
223
|
-
|
|
422
|
+
# Expand scalars to numpy arrays for fluid properties
|
|
423
|
+
oil_dens = np.full(sw.shape, zone.oil.reference_density, dtype=float)
|
|
424
|
+
oil_gas_grav = np.full(sw.shape, zone.oil.gas_gravity, dtype=float)
|
|
425
|
+
gas_gravity = np.full(sw.shape, zone.gas.gas_gravity, dtype=float)
|
|
224
426
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
427
|
+
# Bubble point adjustment
|
|
428
|
+
sw, sg, so, gor, gas_gravity, below_bp = _adjust_bubble_point(
|
|
429
|
+
pres=pres,
|
|
430
|
+
gor=gor,
|
|
431
|
+
sw=sw,
|
|
432
|
+
sg=sg,
|
|
433
|
+
so=so,
|
|
434
|
+
temp=temp,
|
|
435
|
+
oil_density_ref=oil_dens,
|
|
436
|
+
oil_gas_gravity=oil_gas_grav,
|
|
437
|
+
free_gas_gravity=gas_gravity,
|
|
438
|
+
zone=zone,
|
|
229
439
|
)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
440
|
+
|
|
441
|
+
# Phase properties
|
|
442
|
+
rho_w, bulk_w, _ = _brine_props(temp, pres, sal, zone)
|
|
443
|
+
rho_o, bulk_o, _ = _oil_props(temp, pres, gor, oil_dens, oil_gas_grav)
|
|
444
|
+
rho_g, bulk_g, vp_g = _gas_or_co2_props(temp, pres, gas_gravity, zone)
|
|
445
|
+
|
|
446
|
+
# Condensate overwrite (in-place)
|
|
447
|
+
_apply_condensate_if_any(
|
|
448
|
+
rv=rv,
|
|
449
|
+
temp=temp,
|
|
450
|
+
pres=pres,
|
|
451
|
+
zone=zone,
|
|
452
|
+
gas_rho=rho_g,
|
|
453
|
+
gas_bulk=bulk_g,
|
|
454
|
+
gas_vp=vp_g,
|
|
239
455
|
)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
)
|
|
248
|
-
fluid_props = EffectiveFluidProperties(
|
|
249
|
-
dens=mixed_fluid_density,
|
|
250
|
-
bulk_modulus=mixed_fluid_bulk_modulus,
|
|
251
|
-
)
|
|
252
|
-
prop_list.append(fluid_props)
|
|
253
|
-
return prop_list
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def _saturation_check(
|
|
257
|
-
s_water: np.ma.MaskedArray, s_gas: np.ma.MaskedArray
|
|
258
|
-
) -> Tuple[np.ma.MaskedArray, ...]:
|
|
259
|
-
s_water = np.ma.MaskedArray(np.ma.clip(s_water, 0.0, 1.0))
|
|
260
|
-
s_gas = np.ma.MaskedArray(np.ma.clip(s_gas, 0.0, 1.0))
|
|
261
|
-
max_water_gas_factor = np.ma.max(s_water + s_gas)
|
|
262
|
-
if max_water_gas_factor > 1.0:
|
|
263
|
-
s_water /= max_water_gas_factor
|
|
264
|
-
s_gas /= max_water_gas_factor
|
|
265
|
-
s_oil = 1.0 - s_water - s_gas
|
|
266
|
-
return s_water, s_gas, s_oil # type: ignore
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def _verify_inputs(inp_props):
|
|
270
|
-
if isinstance(inp_props, list):
|
|
271
|
-
if not all(isinstance(prop, SimRstProperties) for prop in inp_props):
|
|
272
|
-
raise ValueError(
|
|
273
|
-
f"{__file__}: input to effective fluid properties should be list of "
|
|
274
|
-
f"SimRstProperties or a single SimRstProperties instance, "
|
|
275
|
-
f"is {[type(prop) for prop in inp_props]}"
|
|
456
|
+
|
|
457
|
+
# Mix phases
|
|
458
|
+
|
|
459
|
+
brie_exponent = (
|
|
460
|
+
fluids.fluid_mix_method.brie_exponent
|
|
461
|
+
if hasattr(fluids.fluid_mix_method, "brie_exponent")
|
|
462
|
+
else None
|
|
276
463
|
)
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
464
|
+
rho_mix, bulk_mix = _mix(
|
|
465
|
+
sw=sw,
|
|
466
|
+
sg=sg,
|
|
467
|
+
so=so,
|
|
468
|
+
rho_w=rho_w,
|
|
469
|
+
bulk_w=bulk_w,
|
|
470
|
+
rho_g=rho_g,
|
|
471
|
+
bulk_g=bulk_g,
|
|
472
|
+
rho_o=rho_o,
|
|
473
|
+
bulk_o=bulk_o,
|
|
474
|
+
method=fluids.fluid_mix_method.method,
|
|
475
|
+
brie_exponent=brie_exponent,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Assign into masked arrays (preserve mask)
|
|
479
|
+
rho_eff_full.data[mask_cells] = rho_mix
|
|
480
|
+
bulk_eff_full.data[mask_cells] = bulk_mix
|
|
481
|
+
below_bubble_point_grid_full.data[mask_cells] = below_bp
|
|
482
|
+
|
|
483
|
+
results.append(
|
|
484
|
+
EffectiveFluidProperties(density=rho_eff_full, bulk_modulus=bulk_eff_full)
|
|
485
|
+
)
|
|
486
|
+
bp_grids.append({"below_bubble_point": below_bubble_point_grid_full})
|
|
487
|
+
|
|
488
|
+
return results, bp_grids
|