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.
Files changed (35) hide show
  1. fmu/pem/__main__.py +32 -16
  2. fmu/pem/forward_models/pem_model.py +19 -27
  3. fmu/pem/pem_functions/__init__.py +2 -2
  4. fmu/pem/pem_functions/density.py +32 -38
  5. fmu/pem/pem_functions/effective_pressure.py +153 -49
  6. fmu/pem/pem_functions/estimate_saturated_rock.py +244 -52
  7. fmu/pem/pem_functions/fluid_properties.py +447 -245
  8. fmu/pem/pem_functions/mineral_properties.py +77 -74
  9. fmu/pem/pem_functions/pressure_sensitivity.py +430 -0
  10. fmu/pem/pem_functions/regression_models.py +129 -97
  11. fmu/pem/pem_functions/run_friable_model.py +106 -37
  12. fmu/pem/pem_functions/run_patchy_cement_model.py +107 -45
  13. fmu/pem/pem_functions/{run_t_matrix_and_pressure.py → run_t_matrix_model.py} +48 -27
  14. fmu/pem/pem_utilities/__init__.py +31 -9
  15. fmu/pem/pem_utilities/cumsum_properties.py +29 -37
  16. fmu/pem/pem_utilities/delta_cumsum_time.py +8 -13
  17. fmu/pem/pem_utilities/enum_defs.py +65 -8
  18. fmu/pem/pem_utilities/export_routines.py +84 -72
  19. fmu/pem/pem_utilities/fipnum_pvtnum_utilities.py +217 -0
  20. fmu/pem/pem_utilities/import_config.py +64 -46
  21. fmu/pem/pem_utilities/import_routines.py +57 -69
  22. fmu/pem/pem_utilities/pem_class_definitions.py +81 -23
  23. fmu/pem/pem_utilities/pem_config_validation.py +331 -139
  24. fmu/pem/pem_utilities/rpm_models.py +473 -100
  25. fmu/pem/pem_utilities/update_grid.py +3 -2
  26. fmu/pem/pem_utilities/utils.py +90 -38
  27. fmu/pem/run_pem.py +70 -39
  28. fmu/pem/version.py +16 -3
  29. {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.3.dist-info}/METADATA +18 -11
  30. fmu_pem-0.0.3.dist-info/RECORD +39 -0
  31. fmu_pem-0.0.2.dist-info/RECORD +0 -37
  32. {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.3.dist-info}/WHEEL +0 -0
  33. {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.3.dist-info}/entry_points.txt +0 -0
  34. {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.3.dist-info}/licenses/LICENSE +0 -0
  35. {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.3.dist-info}/top_level.txt +0 -0
@@ -1,286 +1,488 @@
1
- # pylint: disable=missing-module-docstring
1
+ from __future__ import annotations
2
+
2
3
  import warnings
3
- from typing import Tuple, Union
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
- 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)
28
+ if INTERNAL_EQUINOR:
29
+ from rock_physics_open.fluid_models import (
30
+ saturations_below_bubble_point, # noqa: F821
31
+ )
57
32
 
58
- gor = prop.rs
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
- # Convert pressure from bar to Pa
63
- pres = 1.0e5 * prop.pressure
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
- # Gas gravity has to be expanded to a masked array if it comes as a float
87
- if isinstance(fluid_params.gas.gas_gravity, float):
88
- gas_gravity = to_masked_array(fluid_params.gas.gas_gravity, sat_wat)
89
- else:
90
- gas_gravity = fluid_params.gas.gas_gravity
91
-
92
- if hasattr(prop, "rv"):
93
- (
94
- mask,
95
- sat_wat,
96
- sat_gas,
97
- sat_oil,
98
- gor,
99
- pres,
100
- rv,
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
- # If we are below bubble point, gas will come out of solution, and this has
144
- # to be taken into account
145
- try:
146
- idx_below_bubble_point = pres <= bp_standing(oil_density, gor, oil_gr, temp)
147
- if np.any(~idx_below_bubble_point):
148
- warnings.warn(
149
- f"Detected pressure below bubble point for oil in "
150
- f"{np.sum(~idx_below_bubble_point)} cells"
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
- "Function for bubble point not implemented. Conditions above "
156
- "bubble point is assumed."
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
- idx_below_bubble_point = np.zeros_like(sat_wat)
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
- if np.any(idx_below_bubble_point):
161
- sat_gas, sat_oil, gor, gas_gravity = saturations_below_bubble_point(
162
- gas_saturation_init=sat_gas,
163
- oil_saturation_init=sat_oil,
164
- brine_saturation_init=sat_wat,
165
- gor_init=gor,
166
- oil_gas_gravity=oil_gr,
167
- free_gas_gravity=gas_gravity,
168
- oil_density=oil_density,
169
- z_factor=fluid_params.gas_z_factor,
170
- pres_depl=pres,
171
- temp_res=temp,
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
- "Function to calculate effective fluid properties below "
176
- "bubble point is not implemented. Estimates of fluid "
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
- oil_props = flag.oil_properties(
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=oil_gr,
184
- rho0=oil_density,
185
- gas_oil_ratio=gor,
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
- oil = dict(zip(fluid_keys, oil_props))
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
- # Gas, condensate or CO2 - select case based on CO2 flag or presence of RV
190
- # property in the Eclipse restart file
381
+ if not np.any(mask_cells):
382
+ continue
191
383
 
192
- if fluid_params.gas_saturation_is_co2:
193
- if fluid_params.co2_model == CO2Models.FLAG and INTERNAL_EQUINOR:
194
- gas_props = flag.co2_properties(temp=temp, pres=pres)
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
- gas_props = span_wagner.co2_properties(temp=temp, pres=pres)
197
- else:
198
- gas_par = fluid_params.gas
199
- gas_gr = gas_gravity
200
- gas_model = gas_par.model
201
- gas_props = flag.gas_properties(temp, pres, gas_gr, model=gas_model)[0:3]
202
- # The RV parameter is used to calculate condensate properties, but the inverse
203
- # property (GOR) which is used as an input to the FLAG module, is Inf if RV is
204
- # 0.0. An RV of 0.0 means that the gas is dry, which is already calculated
205
- # NB: condensate properties calculation requires proprietary model
206
- if fluid_params.calculate_condensate and (rv is not None) and INTERNAL_EQUINOR:
207
- cond_par = fluid_params.condensate
208
- idx_rv_zero = np.isclose(rv, 0.0, atol=1e-10)
209
- if np.any(~idx_rv_zero) and INTERNAL_EQUINOR:
210
- cond_gor = 1.0 / rv[~idx_rv_zero]
211
- cond_gr = cond_par.gas_gravity * np.ones_like(cond_gor)
212
- cond_density = cond_par.reference_density * np.ones_like(cond_gor)
213
- cond_props = flag.condensate_properties(
214
- temperature=temp[~idx_rv_zero],
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
- for i in range(len(gas_props)):
221
- gas_props[i][~idx_rv_zero] = cond_props[i]
419
+ else None
420
+ )
222
421
 
223
- gas = dict(zip(fluid_keys, gas_props))
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
- if fluid_params.fluid_mix_method == FluidMixModel.WOOD:
226
- mixed_fluid_bulk_modulus = multi_wood(
227
- [sat_wat, sat_gas, sat_oil],
228
- [brine["bulk_modulus"], gas["bulk_modulus"], oil["bulk_modulus"]],
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
- else:
231
- mixed_fluid_bulk_modulus = brie(
232
- sat_gas,
233
- gas["bulk_modulus"],
234
- sat_wat,
235
- brine["bulk_modulus"],
236
- sat_oil,
237
- oil["bulk_modulus"],
238
- fluid_params.fluid_mix_method.brie_exponent,
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
- mixed_fluid_density = (
241
- sat_wat * brine["density"]
242
- + sat_gas * gas["density"]
243
- + sat_oil * oil["density"]
244
- )
245
- mixed_fluid_density, mixed_fluid_bulk_modulus = reverse_filter_and_restore(
246
- mask, mixed_fluid_density, mixed_fluid_bulk_modulus
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
- return inp_props
278
- if isinstance(inp_props, dict):
279
- return [
280
- inp_props,
281
- ]
282
- # "else ..."
283
- raise ValueError(
284
- f"{__file__}: input to effective fluid properties should be list of "
285
- f"SimRstProperties or a single SimRstProperties instance, is {type(inp_props)}"
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