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.
Files changed (37) hide show
  1. fmu/__init__.py +2 -0
  2. fmu/pem/__init__.py +19 -0
  3. fmu/pem/__main__.py +53 -0
  4. fmu/pem/forward_models/__init__.py +7 -0
  5. fmu/pem/forward_models/pem_model.py +72 -0
  6. fmu/pem/hook_implementations/__init__.py +0 -0
  7. fmu/pem/hook_implementations/jobs.py +19 -0
  8. fmu/pem/pem_functions/__init__.py +17 -0
  9. fmu/pem/pem_functions/density.py +55 -0
  10. fmu/pem/pem_functions/effective_pressure.py +168 -0
  11. fmu/pem/pem_functions/estimate_saturated_rock.py +90 -0
  12. fmu/pem/pem_functions/fluid_properties.py +281 -0
  13. fmu/pem/pem_functions/mineral_properties.py +230 -0
  14. fmu/pem/pem_functions/regression_models.py +261 -0
  15. fmu/pem/pem_functions/run_friable_model.py +119 -0
  16. fmu/pem/pem_functions/run_patchy_cement_model.py +120 -0
  17. fmu/pem/pem_functions/run_t_matrix_and_pressure.py +186 -0
  18. fmu/pem/pem_utilities/__init__.py +66 -0
  19. fmu/pem/pem_utilities/cumsum_properties.py +104 -0
  20. fmu/pem/pem_utilities/delta_cumsum_time.py +104 -0
  21. fmu/pem/pem_utilities/enum_defs.py +54 -0
  22. fmu/pem/pem_utilities/export_routines.py +272 -0
  23. fmu/pem/pem_utilities/import_config.py +93 -0
  24. fmu/pem/pem_utilities/import_routines.py +161 -0
  25. fmu/pem/pem_utilities/pem_class_definitions.py +113 -0
  26. fmu/pem/pem_utilities/pem_config_validation.py +505 -0
  27. fmu/pem/pem_utilities/rpm_models.py +177 -0
  28. fmu/pem/pem_utilities/update_grid.py +54 -0
  29. fmu/pem/pem_utilities/utils.py +262 -0
  30. fmu/pem/run_pem.py +98 -0
  31. fmu/pem/version.py +21 -0
  32. fmu_pem-0.0.1.dist-info/METADATA +768 -0
  33. fmu_pem-0.0.1.dist-info/RECORD +37 -0
  34. fmu_pem-0.0.1.dist-info/WHEEL +5 -0
  35. fmu_pem-0.0.1.dist-info/entry_points.txt +5 -0
  36. fmu_pem-0.0.1.dist-info/licenses/LICENSE +674 -0
  37. 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)