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,16 +1,9 @@
1
1
  """
2
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.
3
+ the volume fractions.
10
4
  """
11
5
 
12
6
  from pathlib import Path
13
- from typing import List, Tuple, Union
14
7
  from warnings import warn
15
8
 
16
9
  import numpy as np
@@ -18,70 +11,68 @@ from rock_physics_open.equinor_utilities.std_functions import (
18
11
  multi_hashin_shtrikman,
19
12
  multi_voigt_reuss_hill,
20
13
  )
14
+ from xtgeo import Grid
21
15
 
22
16
  from fmu.pem.pem_utilities import (
23
- MatrixProperties,
17
+ EffectiveMineralProperties,
24
18
  PemConfig,
19
+ RockMatrixProperties,
25
20
  SimInitProperties,
26
21
  filter_and_one_dim,
27
22
  get_shale_fraction,
28
23
  import_fractions,
29
- ntg_to_shale_fraction,
30
- read_ntg_grid,
31
24
  reverse_filter_and_restore,
32
25
  to_masked_array,
33
26
  )
34
- from fmu.pem.pem_utilities.enum_defs import MineralMixModel, VolumeFractions
27
+ from fmu.pem.pem_utilities.enum_defs import MineralMixModel
35
28
  from fmu.pem.pem_utilities.pem_config_validation import (
36
29
  MineralProperties,
37
30
  )
38
31
 
39
32
 
40
33
  def effective_mineral_properties(
41
- root_dir: Path, config: PemConfig, sim_init: SimInitProperties
42
- ) -> Tuple[Union[np.ma.MaskedArray, None], MatrixProperties]:
34
+ root_dir: Path,
35
+ matrix: RockMatrixProperties,
36
+ sim_init: SimInitProperties,
37
+ sim_grid: Grid,
38
+ ) -> tuple[np.ma.MaskedArray | None, EffectiveMineralProperties]:
43
39
  """Estimate effective mineral properties for each grid cell
44
40
 
45
41
  Args:
46
42
  root_dir: start directory for running of PEM
47
- config: configuration parameters
43
+ matrix: rock matrix parameters
48
44
  sim_init: simulation initial properties
49
45
 
50
46
  Returns:
51
47
  shale volume, effective mineral properties
52
48
  """
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
- )
49
+ fractions = import_fractions(
50
+ root_dir=root_dir,
51
+ fraction_path=matrix.volume_fractions.rel_path_fractions,
52
+ fraction_files=matrix.volume_fractions.fractions_prop_file_names,
53
+ fraction_names=matrix.fraction_names,
54
+ grd=sim_grid,
55
+ )
56
+
57
+ vsh = get_shale_fraction(
58
+ fractions,
59
+ matrix.fraction_names,
60
+ matrix.shale_fractions,
61
+ )
72
62
 
73
- mineral_names = config.rock_matrix.fraction_minerals
63
+ mineral_names = matrix.fraction_minerals
74
64
  eff_min_props = estimate_effective_mineral_properties(
75
- mineral_names, fractions, config
65
+ mineral_names, fractions, matrix, sim_init.poro
76
66
  )
77
67
  return vsh, eff_min_props
78
68
 
79
69
 
80
70
  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:
71
+ fraction_names: str | list[str],
72
+ fractions: np.ma.MaskedArray | list[np.ma.MaskedArray],
73
+ matrix_params: RockMatrixProperties,
74
+ porosity: np.ma.MaskedArray,
75
+ ) -> EffectiveMineralProperties:
85
76
  """Estimation of effective mineral properties must be able to handle cases where
86
77
  there is a more complex combination of minerals than the standard sand/shale case.
87
78
  For carbonates the input can be based on minerals (e.g. calcite, dolomite, quartz,
@@ -101,12 +92,16 @@ def estimate_effective_mineral_properties(
101
92
  verify_mineral_inputs(
102
93
  fraction_names,
103
94
  fractions,
104
- pem_config.rock_matrix.minerals,
105
- pem_config.rock_matrix.complement,
95
+ matrix_params.minerals,
96
+ matrix_params.complement,
106
97
  )
107
98
 
108
99
  fraction_names, fractions = normalize_mineral_fractions(
109
- fraction_names, fractions, pem_config.rock_matrix.complement
100
+ fraction_names,
101
+ fractions,
102
+ matrix_params.complement,
103
+ porosity,
104
+ matrix_params.volume_fractions.fractions_are_mineral_fraction,
110
105
  )
111
106
 
112
107
  mask, *fractions = filter_and_one_dim(*fractions)
@@ -114,12 +109,12 @@ def estimate_effective_mineral_properties(
114
109
  mu_list = []
115
110
  rho_list = []
116
111
  for name in fraction_names:
117
- mineral = pem_config.rock_matrix.minerals[name]
112
+ mineral = matrix_params.minerals[name]
118
113
  k_list.append(to_masked_array(mineral.bulk_modulus, fractions[0]))
119
114
  mu_list.append(to_masked_array(mineral.shear_modulus, fractions[0]))
120
115
  rho_list.append(to_masked_array(mineral.density, fractions[0]))
121
116
 
122
- if pem_config.rock_matrix.mineral_mix_model == MineralMixModel.HASHIN_SHTRIKMAN:
117
+ if matrix_params.mineral_mix_model == MineralMixModel.HASHIN_SHTRIKMAN:
123
118
  eff_k, eff_mu = multi_hashin_shtrikman(
124
119
  *[arr for prop in zip(k_list, mu_list, fractions) for arr in prop]
125
120
  )
@@ -134,8 +129,8 @@ def estimate_effective_mineral_properties(
134
129
  eff_min_k, eff_min_mu, eff_min_rho = reverse_filter_and_restore(
135
130
  mask, eff_k, eff_mu, eff_rho
136
131
  )
137
- return MatrixProperties(
138
- bulk_modulus=eff_min_k, shear_modulus=eff_min_mu, dens=eff_min_rho
132
+ return EffectiveMineralProperties(
133
+ bulk_modulus=eff_min_k, shear_modulus=eff_min_mu, density=eff_min_rho
139
134
  )
140
135
 
141
136
 
@@ -166,9 +161,14 @@ def normalize_mineral_fractions(
166
161
  names: str | list[str],
167
162
  fracs: np.ma.MaskedArray | list[np.ma.MaskedArray],
168
163
  complement: str,
169
- ) -> Tuple[list[str], list[np.ma.MaskedArray]]:
164
+ porosity: np.ma.MaskedArray,
165
+ mineral_fractions: bool,
166
+ ) -> tuple[list[str], list[np.ma.MaskedArray]]:
170
167
  """Normalizes mineral fractions and adds complement mineral if needed.
171
168
 
169
+ If the fractions are volume fractions, porosity must be taken into account
170
+ when the fractions are normalized.
171
+
172
172
  When the sum of specified mineral fractions is less than 1.0, adds the complement
173
173
  mineral to make up the remainder. For example, if shale is 0.6 (60%) and the
174
174
  complement mineral is quartz, then quartz will be added at 0.4 (40%) to reach 100%.
@@ -182,49 +182,52 @@ def normalize_mineral_fractions(
182
182
  complement: Name of mineral to use as complement if sum < 1.0
183
183
 
184
184
  Returns:
185
- Tuple containing:
186
- - List of mineral names (with complement added if needed)
187
- - List of normalized mineral fractions as masked arrays
185
+ tuple containing:
186
+ - list of mineral names (with complement added if needed)
187
+ - list of normalized mineral fractions as masked arrays
188
188
  """
189
- if isinstance(names, str):
190
- names = [names]
189
+ # Decide the mode of normalization - volume or mineral fractions
190
+ normalize_sum = 1.0 if mineral_fractions else 1.0 - porosity
191
191
 
192
- if isinstance(fracs, np.ma.MaskedArray):
193
- fracs = [fracs]
192
+ # Check for single or list of names and fractions
193
+ names = [names] if isinstance(names, str) else names
194
+ fracs = [fracs] if isinstance(fracs, np.ma.MaskedArray) else fracs
194
195
 
196
+ # Demand values in the range [0.0, 1.0]
195
197
  for i, frac in enumerate(fracs):
196
- if np.any(frac[~frac.mask] < 0.0) or np.any(frac[~frac.mask] > 1.0):
198
+ if np.any(frac < 0.0) or np.any(frac > 1.0):
197
199
  warn(
198
- f"mineral fraction {names[i]} has values outside of range 0.0 to 1.0,"
200
+ f"fraction {names[i]} has values outside of range 0.0 to 1.0,"
199
201
  f"clipped to range",
200
202
  UserWarning,
201
203
  )
202
204
  fracs[i] = np.ma.MaskedArray(np.ma.clip(frac, 0.0, 1.0))
203
205
 
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):
206
+ # Adjust values so that no cells exceed normalize_sum
207
+ tot_fractions = sum(fracs)
208
+ if np.ma.any(tot_fractions > normalize_sum):
209
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])}",
210
+ "sum of fractions has values above limit, rescaled to range",
214
211
  UserWarning,
215
212
  )
213
+ scale_factor = np.ma.max(tot_fractions / normalize_sum)
216
214
  for i, frac in enumerate(fracs):
217
- fracs[i] /= max_fraction
215
+ fracs[i] /= scale_factor
218
216
 
219
- comp_fraction = 1.0 - np.ma.sum(fracs, axis=0)
217
+ # Add a complement fraction if needed
218
+ comp_fraction = normalize_sum - sum(fracs)
220
219
  if np.any(comp_fraction > 0.0):
221
220
  names = names + [complement]
222
221
  fracs = fracs + [comp_fraction]
223
222
 
224
- return names, fracs
225
-
223
+ # Rescale from volume fractions to mineral fractions if needed
224
+ if not mineral_fractions:
225
+ for i, frac in enumerate(fracs):
226
+ fracs[i] /= normalize_sum
226
227
 
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)
228
+ # Final check that all fractions sum to 1.0 for all cells
229
+ try:
230
+ np.testing.assert_allclose(sum(fracs), 1.0, rtol=1.0e-6, atol=1.0e-6)
231
+ except AssertionError as e:
232
+ raise ValueError(f"mineral fractions do not sum to 1: {e}") from e
233
+ return names, fracs
@@ -0,0 +1,430 @@
1
+ # python
2
+ # File: src/fmu/pem/pem_functions/pressure_sensitivity.py
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Protocol, runtime_checkable
7
+
8
+ import numpy as np
9
+
10
+ from fmu.pem.pem_utilities.enum_defs import (
11
+ ParameterTypes,
12
+ PhysicsPressureModelTypes,
13
+ RegressionPressureParameterTypes,
14
+ )
15
+ from fmu.pem.pem_utilities.pem_class_definitions import EffectiveMineralProperties
16
+ from fmu.pem.pem_utilities.rpm_models import (
17
+ MineralProperties,
18
+ PhysicsModelPressureSensitivity,
19
+ RegressionPressureSensitivity,
20
+ )
21
+
22
+ _FEATURE_NAME_MAP = {
23
+ ParameterTypes.VP.value: "VP",
24
+ ParameterTypes.VS.value: "VSX", # Model expects VSX for Vs
25
+ ParameterTypes.K.value: "K",
26
+ ParameterTypes.MU.value: "MU",
27
+ }
28
+
29
+
30
+ @runtime_checkable
31
+ class RegressionPressureModel(Protocol):
32
+ """Protocol for regression-based pressure sensitivity models."""
33
+
34
+ mode: RegressionPressureParameterTypes
35
+
36
+ def predict_elastic_properties(
37
+ self,
38
+ prop1: np.ndarray,
39
+ prop2: np.ndarray,
40
+ in_situ_press: np.ndarray,
41
+ depl_press: np.ndarray,
42
+ ) -> tuple[np.ndarray, np.ndarray]: ...
43
+
44
+
45
+ @runtime_checkable
46
+ class PhysicsPressureModel(Protocol):
47
+ """Protocol for physics-based pressure sensitivity models."""
48
+
49
+ model_type: PhysicsPressureModelTypes
50
+
51
+ def predict_elastic_properties(
52
+ self,
53
+ k_dry: np.ndarray,
54
+ mu_dry: np.ndarray,
55
+ poro: np.ndarray,
56
+ min_prop: MineralProperties,
57
+ in_situ_press: np.ndarray,
58
+ depl_press: np.ndarray,
59
+ cem_prop: MineralProperties | None = None,
60
+ ) -> tuple[np.ndarray, np.ndarray]: ...
61
+
62
+
63
+ def _validate_array_shapes(
64
+ *arrays: np.ndarray,
65
+ names: list[str] | None = None,
66
+ ) -> None:
67
+ """
68
+ Validate that all arrays have the same first dimension.
69
+
70
+ Parameters
71
+ ----------
72
+ *arrays : np.ndarray
73
+ Arrays to validate.
74
+ names : list[str] | None
75
+ Names for error messages. If None, uses generic labels.
76
+
77
+ Raises
78
+ ------
79
+ PressureSensitivityInputError
80
+ If array shapes are inconsistent.
81
+ """
82
+ if not arrays:
83
+ return
84
+
85
+ expected_shape = arrays[0].shape[0]
86
+ names = names or [f"array_{i}" for i in range(len(arrays))]
87
+
88
+ for arr, name in zip(arrays, names):
89
+ if arr.shape[0] != expected_shape:
90
+ raise PressureSensitivityInputError(
91
+ f"Shape mismatch for '{name}': expected {expected_shape}, "
92
+ f"got {arr.shape[0]}"
93
+ )
94
+
95
+
96
+ def _validate_required_keys(
97
+ provided: dict[str, np.ndarray],
98
+ required: set[str],
99
+ dict_name: str,
100
+ ) -> None:
101
+ """
102
+ Validate that all required keys exist in provided dictionary.
103
+
104
+ Parameters
105
+ ----------
106
+ provided : dict[str, np.ndarray]
107
+ Dictionary to validate.
108
+ required : set[str]
109
+ Required keys.
110
+ dict_name : str
111
+ Name for error messages.
112
+
113
+ Raises
114
+ ------
115
+ PressureSensitivityInputError
116
+ If any required key is missing.
117
+ """
118
+ missing = required - set(provided.keys())
119
+ if missing:
120
+ raise PressureSensitivityInputError(
121
+ f"Missing keys {sorted(missing)} in {dict_name}; "
122
+ f"required={sorted(required)}"
123
+ )
124
+
125
+
126
+ def _extract_input_properties(
127
+ in_situ_dict: dict[str, np.ndarray],
128
+ mode: RegressionPressureParameterTypes,
129
+ rho: np.ndarray,
130
+ ) -> tuple[np.ndarray, np.ndarray]:
131
+ """
132
+ Extract or compute the two elastic properties needed for the model.
133
+
134
+ Parameters
135
+ ----------
136
+ in_situ_dict : dict[str, np.ndarray]
137
+ Dictionary with in-situ properties. Must contain either (vp, vs) or (k, mu).
138
+ mode : RegressionPressureParameterTypes
139
+ Model mode determining which properties are needed.
140
+ rho : np.ndarray
141
+ Density array for conversions.
142
+
143
+ Returns
144
+ -------
145
+ tuple[np.ndarray, np.ndarray]
146
+ (prop1, prop2) matching the model mode:
147
+ - VP_VS mode: (vp, vs)
148
+ - K_MU mode: (k, mu)
149
+
150
+ Raises
151
+ ------
152
+ PressureSensitivityInputError
153
+ If required properties cannot be obtained.
154
+ """
155
+ from rock_physics_open.equinor_utilities.std_functions import moduli, velocity
156
+
157
+ vp_key = ParameterTypes.VP.value
158
+ vs_key = ParameterTypes.VS.value
159
+ k_key = ParameterTypes.K.value
160
+ mu_key = ParameterTypes.MU.value
161
+
162
+ has_velocities = vp_key in in_situ_dict and vs_key in in_situ_dict
163
+ has_moduli = k_key in in_situ_dict and mu_key in in_situ_dict
164
+
165
+ if mode == RegressionPressureParameterTypes.VP_VS:
166
+ if has_velocities:
167
+ return in_situ_dict[vp_key], in_situ_dict[vs_key]
168
+ if has_moduli:
169
+ # Convert from moduli to velocities
170
+ vp, vs = velocity(in_situ_dict[k_key], in_situ_dict[mu_key], rho)[0:2]
171
+ return vp, vs
172
+ raise PressureSensitivityInputError(
173
+ f"For VP_VS mode, need either ({vp_key}, {vs_key}) or ({k_key}, {mu_key})"
174
+ )
175
+ # K_MU mode
176
+ if has_moduli:
177
+ return in_situ_dict[k_key], in_situ_dict[mu_key]
178
+ if has_velocities:
179
+ # Convert from velocities to moduli
180
+ k, mu = moduli(in_situ_dict[vp_key], in_situ_dict[vs_key], rho)
181
+ return k, mu
182
+ raise PressureSensitivityInputError(
183
+ f"For K_MU mode, need either ({k_key}, {mu_key}) or ({vp_key}, {vs_key})"
184
+ )
185
+
186
+
187
+ def _compute_all_elastic_properties(
188
+ prop1: np.ndarray,
189
+ prop2: np.ndarray,
190
+ rho: np.ndarray,
191
+ mode: RegressionPressureParameterTypes,
192
+ ) -> dict[str, np.ndarray]:
193
+ """
194
+ Compute all four elastic properties from the two predicted ones.
195
+
196
+ Parameters
197
+ ----------
198
+ prop1 : np.ndarray
199
+ First predicted property (vp or k).
200
+ prop2 : np.ndarray
201
+ Second predicted property (vs or mu).
202
+ rho : np.ndarray
203
+ Density array.
204
+ mode : RegressionPressureParameterTypes
205
+ Model mode indicating which properties were predicted.
206
+
207
+ Returns
208
+ -------
209
+ dict[str, np.ndarray]
210
+ Dictionary containing vp, vs, k, mu, and rho.
211
+ """
212
+ from rock_physics_open.equinor_utilities.std_functions import moduli, velocity
213
+
214
+ if mode == RegressionPressureParameterTypes.VP_VS:
215
+ vp, vs = prop1, prop2
216
+ k, mu = moduli(vp, vs, rho)
217
+ else: # K_MU mode
218
+ k, mu = prop1, prop2
219
+ vp, vs = velocity(k, mu, rho)[0:2]
220
+
221
+ return {
222
+ ParameterTypes.VP.value: vp,
223
+ ParameterTypes.VS.value: vs,
224
+ ParameterTypes.K.value: k,
225
+ ParameterTypes.MU.value: mu,
226
+ ParameterTypes.RHO.value: rho,
227
+ }
228
+
229
+
230
+ class PressureSensitivityInputError(ValueError):
231
+ """Raised when required pressure sensitivity inputs are missing or inconsistent."""
232
+
233
+
234
+ def _validate_required(
235
+ provided: dict[str, np.ndarray],
236
+ required: Iterable[str],
237
+ dict_name: str,
238
+ ) -> None:
239
+ """
240
+ Validate that all required keys exist in a provided dictionary.
241
+
242
+ Parameters
243
+ ----------
244
+ provided : dict[str, np.ndarray]
245
+ Dictionary containing arrays for rock properties.
246
+ required : Iterable[str]
247
+ Keys that must be present.
248
+ dict_name : str
249
+ Name used in error messages.
250
+
251
+ Raises
252
+ ------
253
+ PressureSensitivityInputError
254
+ If any required key is missing.
255
+ """
256
+ missing = [k for k in required if k not in provided]
257
+ if missing:
258
+ raise PressureSensitivityInputError(
259
+ f"Missing keys {missing} in {dict_name}; required={list(required)}"
260
+ )
261
+
262
+
263
+ def _as_enum_mode(
264
+ mode: RegressionPressureParameterTypes | str,
265
+ ) -> RegressionPressureParameterTypes:
266
+ """
267
+ Normalize mode argument to RegressionPressureParameterTypes enum.
268
+
269
+ Parameters
270
+ ----------
271
+ mode : RegressionPressureParameterTypes | str
272
+ Mode specification ('vp_vs' or 'k_mu').
273
+
274
+ Returns
275
+ -------
276
+ RegressionPressureParameterTypes
277
+ Normalized enum value.
278
+
279
+ Raises
280
+ ------
281
+ ValueError
282
+ If unsupported mode supplied.
283
+ """
284
+ if isinstance(mode, RegressionPressureParameterTypes):
285
+ return mode
286
+ try:
287
+ return RegressionPressureParameterTypes(mode)
288
+ except ValueError as exc:
289
+ raise ValueError(
290
+ f"Unsupported mode '{mode}'. Expected 'vp_vs' or 'k_mu'."
291
+ ) from exc
292
+
293
+
294
+ def apply_dry_rock_pressure_sensitivity_model(
295
+ model: RegressionPressureSensitivity | PhysicsModelPressureSensitivity,
296
+ initial_eff_pressure: np.ndarray,
297
+ depleted_eff_pressure: np.ndarray,
298
+ in_situ_dict: dict[str, np.ndarray],
299
+ mineral_properties: MineralProperties | EffectiveMineralProperties | None = None,
300
+ cement_properties: MineralProperties | EffectiveMineralProperties | None = None,
301
+ ) -> dict[str, np.ndarray]:
302
+ """
303
+ Apply pressure sensitivity model to estimate depleted elastic properties.
304
+
305
+ Handles both regression-based and physics-based pressure sensitivity models
306
+ with their different input requirements.
307
+
308
+ Parameters
309
+ ----------
310
+ model : RegressionPressureSensitivity | PhysicsModelPressureSensitivity
311
+ Pressure sensitivity model instance.
312
+ initial_eff_pressure : np.ndarray
313
+ In-situ effective (pore) pressure [Pa], shape (n,).
314
+ depleted_eff_pressure : np.ndarray
315
+ Depleted effective pressure [Pa], shape (n,).
316
+ in_situ_dict : dict[str, np.ndarray]
317
+ Dictionary with in-situ properties. Must contain 'rho'.
318
+ For regression models: requires ('vp', 'vs') or ('k', 'mu').
319
+ For physics models: requires ('k', 'mu', 'porosity').
320
+ mineral_properties : MineralProperties | None
321
+ Required for physics-based models. Mineral elastic properties.
322
+ cement_properties : MineralProperties | None
323
+ Required for patchy cement physics model.
324
+
325
+ Returns
326
+ -------
327
+ dict[str, np.ndarray]
328
+ Dictionary with 'vp', 'vs', 'k', 'mu', 'rho'.
329
+
330
+ Raises
331
+ ------
332
+ PressureSensitivityInputError
333
+ If required inputs are missing or inconsistent.
334
+ """
335
+ # Validate common inputs
336
+ _validate_required_keys(in_situ_dict, {ParameterTypes.RHO.value}, "in_situ_dict")
337
+ rho = in_situ_dict[ParameterTypes.RHO.value]
338
+
339
+ # Route to appropriate handler based on model type
340
+ if isinstance(model, RegressionPressureSensitivity):
341
+ return _apply_regression_model(
342
+ model, in_situ_dict, rho, initial_eff_pressure, depleted_eff_pressure
343
+ )
344
+ if isinstance(model, PhysicsModelPressureSensitivity):
345
+ return _apply_physics_model(
346
+ model,
347
+ in_situ_dict,
348
+ rho,
349
+ initial_eff_pressure,
350
+ depleted_eff_pressure,
351
+ mineral_properties,
352
+ cement_properties,
353
+ )
354
+ raise TypeError(f"Unsupported model type: {type(model)}")
355
+
356
+
357
+ def _apply_regression_model(
358
+ model: RegressionPressureSensitivity,
359
+ in_situ_dict: dict[str, np.ndarray],
360
+ rho: np.ndarray,
361
+ pres_in_situ: np.ndarray,
362
+ pres_depleted: np.ndarray,
363
+ ) -> dict[str, np.ndarray]:
364
+ """Apply regression-based pressure sensitivity model."""
365
+ # Extract or compute input properties matching model mode
366
+ prop1_in_situ, prop2_in_situ = _extract_input_properties(
367
+ in_situ_dict, model.mode, rho
368
+ )
369
+
370
+ # Predict depleted properties
371
+ prop1_depleted, prop2_depleted = model.predict_elastic_properties(
372
+ prop1_in_situ, prop2_in_situ, pres_in_situ, pres_depleted
373
+ )
374
+
375
+ # Compute all elastic properties
376
+ return _compute_all_elastic_properties(
377
+ prop1_depleted, prop2_depleted, rho, model.mode
378
+ )
379
+
380
+
381
+ def _apply_physics_model(
382
+ model: PhysicsModelPressureSensitivity,
383
+ in_situ_dict: dict[str, np.ndarray],
384
+ rho: np.ndarray,
385
+ pres_in_situ: np.ndarray,
386
+ pres_depleted: np.ndarray,
387
+ mineral_properties: MineralProperties | EffectiveMineralProperties | None,
388
+ cement_properties: MineralProperties | EffectiveMineralProperties | None,
389
+ ) -> dict[str, np.ndarray]:
390
+ """Apply physics-based pressure sensitivity model."""
391
+ from rock_physics_open.equinor_utilities.std_functions import velocity
392
+
393
+ # Validate required inputs for physics models
394
+ if mineral_properties is None:
395
+ raise PressureSensitivityInputError(
396
+ "Physics-based models require mineral_properties"
397
+ )
398
+
399
+ required_keys = {
400
+ ParameterTypes.K.value,
401
+ ParameterTypes.MU.value,
402
+ ParameterTypes.POROSITY.value,
403
+ }
404
+ _validate_required_keys(in_situ_dict, required_keys, "in_situ_dict")
405
+
406
+ k_dry = in_situ_dict[ParameterTypes.K.value]
407
+ mu_dry = in_situ_dict[ParameterTypes.MU.value]
408
+ poro = in_situ_dict[ParameterTypes.POROSITY.value]
409
+
410
+ # Predict depleted moduli
411
+ k_depleted, mu_depleted = model.predict_elastic_properties(
412
+ k_dry=k_dry,
413
+ mu_dry=mu_dry,
414
+ poro=poro,
415
+ min_prop=mineral_properties,
416
+ in_situ_press=pres_in_situ,
417
+ depl_press=pres_depleted,
418
+ cem_prop=cement_properties,
419
+ )
420
+
421
+ # Convert to velocities
422
+ vp, vs = velocity(k_depleted, mu_depleted, rho)[0:2]
423
+
424
+ return {
425
+ ParameterTypes.VP.value: vp,
426
+ ParameterTypes.VS.value: vs,
427
+ ParameterTypes.K.value: k_depleted,
428
+ ParameterTypes.MU.value: mu_depleted,
429
+ ParameterTypes.RHO.value: rho,
430
+ }