fmu-pem 0.0.2__py3-none-any.whl → 0.0.4__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 (36) hide show
  1. fmu/pem/__init__.py +2 -0
  2. fmu/pem/__main__.py +72 -19
  3. fmu/pem/forward_models/pem_model.py +21 -26
  4. fmu/pem/pem_functions/__init__.py +2 -2
  5. fmu/pem/pem_functions/density.py +32 -38
  6. fmu/pem/pem_functions/effective_pressure.py +153 -49
  7. fmu/pem/pem_functions/estimate_saturated_rock.py +244 -52
  8. fmu/pem/pem_functions/fluid_properties.py +447 -245
  9. fmu/pem/pem_functions/mineral_properties.py +77 -74
  10. fmu/pem/pem_functions/pressure_sensitivity.py +430 -0
  11. fmu/pem/pem_functions/regression_models.py +129 -97
  12. fmu/pem/pem_functions/run_friable_model.py +106 -37
  13. fmu/pem/pem_functions/run_patchy_cement_model.py +107 -45
  14. fmu/pem/pem_functions/{run_t_matrix_and_pressure.py → run_t_matrix_model.py} +48 -27
  15. fmu/pem/pem_utilities/__init__.py +30 -10
  16. fmu/pem/pem_utilities/cumsum_properties.py +29 -37
  17. fmu/pem/pem_utilities/delta_cumsum_time.py +8 -13
  18. fmu/pem/pem_utilities/enum_defs.py +65 -8
  19. fmu/pem/pem_utilities/export_routines.py +84 -72
  20. fmu/pem/pem_utilities/fipnum_pvtnum_utilities.py +217 -0
  21. fmu/pem/pem_utilities/import_config.py +76 -50
  22. fmu/pem/pem_utilities/import_routines.py +57 -69
  23. fmu/pem/pem_utilities/pem_class_definitions.py +81 -23
  24. fmu/pem/pem_utilities/pem_config_validation.py +364 -172
  25. fmu/pem/pem_utilities/rpm_models.py +473 -100
  26. fmu/pem/pem_utilities/update_grid.py +3 -2
  27. fmu/pem/pem_utilities/utils.py +90 -38
  28. fmu/pem/run_pem.py +66 -48
  29. fmu/pem/version.py +16 -3
  30. {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.4.dist-info}/METADATA +19 -11
  31. fmu_pem-0.0.4.dist-info/RECORD +39 -0
  32. {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.4.dist-info}/WHEEL +1 -1
  33. fmu_pem-0.0.2.dist-info/RECORD +0 -37
  34. {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.4.dist-info}/entry_points.txt +0 -0
  35. {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.4.dist-info}/licenses/LICENSE +0 -0
  36. {fmu_pem-0.0.2.dist-info → fmu_pem-0.0.4.dist-info}/top_level.txt +0 -0
@@ -9,66 +9,92 @@ from .pem_config_validation import PemConfig
9
9
  from .utils import restore_dir
10
10
 
11
11
 
12
- def get_global_params_and_dates(root_dir: Path, conf_path: Path) -> dict:
12
+ def find_key_first(d: dict, key: str) -> str | None:
13
+ """Recursively search for the first occurrence of a key in nested dicts.
14
+
15
+ The search now prioritizes keys at the current dictionary level before
16
+ descending into nested dictionaries, ensuring top-level occurrences win when
17
+ duplicates exist deeper in the structure.
18
+
19
+ Args:
20
+ d: A potentially nested mapping structure where values may themselves be
21
+ dictionaries. Typically a ``dict`` originating from parsed YAML/JSON.
22
+ key: The key to search for in ``d`` and any nested dictionaries.
23
+
24
+ Returns:
25
+ The value associated with the first occurrence of ``key`` encountered during
26
+ the depth-first search, or ``None`` if the key is not present.
27
+
28
+ Example:
29
+ >>> data = {"a": 1, "b": {"target": 2, "c": {"target": 3}}}
30
+ >>> find_key_first(data, "target")
31
+ 2
32
+ """
33
+ if not isinstance(d, dict):
34
+ return None
35
+ if key in d:
36
+ return d[key]
37
+ for v in d.values():
38
+ if isinstance(v, dict):
39
+ result = find_key_first(v, key)
40
+ if result is not None:
41
+ return result
42
+ return None
43
+
44
+
45
+ def get_global_params_and_dates(
46
+ global_config_dir: Path,
47
+ global_conf_file: Path,
48
+ mod_prefix: str | None = None,
49
+ obs_prefix: str | None = None,
50
+ ) -> dict:
13
51
  """Read global configuration parameters, simulation model dates and seismic dates
14
52
  for difference calculation
15
53
 
16
54
  Args:
17
- root_dir: start dir for PEM script run
18
- conf_path: path to global variables configuration file
55
+ global_config_dir: directory path for the global config file
56
+ global_conf_file: name of the global config file
19
57
 
20
58
  Returns:
21
59
  global parameter configuration dict, list of strings for simulation dates,
22
60
  list of tuples with
23
61
  strings of dates to calculate difference properties
24
62
  """
25
- # prediction_mode is set to empty string if HIST else to PRED. Normally set in
26
- # env variable
27
- env_flowsim = os.getenv("FLOWSIM_IS_PREDICTION", default=False)
28
- if env_flowsim:
29
- conf_file = conf_path.joinpath("global_variables_pred.yml")
30
- date_str = "SEISMIC_PRED_DATES"
31
- diff_str = "SEISMIC_PRED_DIFFDATES"
32
- else:
33
- conf_file = conf_path.joinpath("global_variables.yml")
34
- date_str = "SEISMIC_HIST_DATES"
35
- diff_str = "SEISMIC_HIST_DIFFDATES"
36
- with restore_dir(root_dir):
37
- global_config_par = yaml_load(str(conf_file))
38
- seismic_dates = [
39
- str(sdate).replace("-", "")
40
- for sdate in global_config_par["global"]["dates"][date_str]
41
- ]
42
- diff_dates = [
43
- [str(sdate).replace("-", "") for sdate in datepairs]
44
- for datepairs in global_config_par["global"]["dates"][diff_str]
45
- ]
46
- # Grid model name can be under different top dicts - search for it. If more
47
- # than one is found, and they are not equal - raise an error
48
- found_grid_name = False
49
- for key in global_config_par:
50
- try:
51
- if not found_grid_name:
52
- grid_model_name = global_config_par[key]["ECLGRIDNAME_PEM"]
53
- found_grid_name = True
54
- else:
55
- if not grid_model_name == global_config_par[key]["ECLGRIDNAME_PEM"]:
56
- raise ValueError(
57
- f"{__file__}: inconsistent names for "
58
- f"ECLGRIDNAME_PEM in global config file"
59
- )
60
- except KeyError:
61
- pass
62
- if not found_grid_name:
63
- raise ValueError(
64
- f"{__file__}: no value for ECLGRIDNAME_PEM in global config file"
65
- )
66
- return {
67
- "grid_model": grid_model_name,
68
- "seis_dates": seismic_dates,
69
- "diff_dates": diff_dates,
70
- "global_config": global_config_par,
71
- }
63
+ global_config_par = yaml_load(
64
+ str(global_config_dir / global_conf_file),
65
+ )
66
+ grid_model_name = find_key_first(global_config_par["global"], "ECLGRIDNAME_PEM")
67
+ if grid_model_name is None:
68
+ raise ValueError(
69
+ f"{__file__}: no value for ECLGRIDNAME_PEM in global config file"
70
+ )
71
+ # Find the correct seismic dates references
72
+ dates_config = global_config_par["global"]["dates"]
73
+ return_dict = {
74
+ "global_config": global_config_par,
75
+ "grid_model": grid_model_name,
76
+ "seismic": global_config_par["global"]["seismic"],
77
+ }
78
+ if mod_prefix:
79
+ return_dict.update(
80
+ {
81
+ "mod_dates": dates_config.get(f"SEISMIC_{mod_prefix}_DATES", None),
82
+ "mod_diffdates": dates_config.get(
83
+ f"SEISMIC_{mod_prefix}_DIFFDATES", None
84
+ ),
85
+ }
86
+ )
87
+ if obs_prefix:
88
+ return_dict.update(
89
+ {
90
+ "obs_dates": dates_config.get(f"SEISMIC_{obs_prefix}_DATES", None),
91
+ "obs_diffdates": dates_config.get(
92
+ f"SEISMIC_{obs_prefix}_DIFFDATES", None
93
+ ),
94
+ }
95
+ )
96
+
97
+ return return_dict
72
98
 
73
99
 
74
100
  def read_pem_config(yaml_file: Path) -> PemConfig:
@@ -1,61 +1,47 @@
1
1
  from pathlib import Path
2
- from typing import List, Tuple
3
2
 
4
- import numpy as np
5
3
  import xtgeo
6
4
 
7
5
  from .pem_class_definitions import SimInitProperties, SimRstProperties
8
- from .pem_config_validation import PemConfig
9
- from .utils import restore_dir
10
-
11
-
12
- def read_geogrid(root_dir: Path, config: PemConfig) -> dict:
13
- """Not in use? Read porosity from geo-grid
14
-
15
- Args:
16
- root_dir: start dir for PEM script run
17
- config: PEM specific parameters
18
-
19
- Returns:
20
- Dict object with porosity
21
- """
22
- with restore_dir(root_dir.joinpath(config.paths.rel_path_geogrid)):
23
- return {"poro": xtgeo.gridproperty_from_file("geogrid--phit.roff").values}
6
+ from .utils import bar_to_pa, restore_dir
24
7
 
25
8
 
26
9
  def read_init_properties(
27
- property_file: Path, sim_grid: xtgeo.Grid
10
+ property_file: Path,
11
+ sim_grid: xtgeo.Grid,
12
+ fipnum_param: str,
28
13
  ) -> SimInitProperties:
29
14
  """Read initial properties from INIT file
30
15
  Args:
31
16
  property_file: Full path to the .INIT file
32
17
  sim_grid: The simulation grid to use for reading properties
18
+ fipnum_param: Name for zone/region parameter, normally 'FIPNUM'
33
19
  Returns:
34
20
  SimInitProperties: The loaded initial grid properties
35
21
  """
36
- INIT_PROPS = ["PORO", "DEPTH", "NTG"]
22
+ init_props = ["PORO", "DEPTH", "PVTNUM"] + [fipnum_param]
37
23
  sim_init_props = xtgeo.gridproperties_from_file(
38
- property_file, fformat="init", names=INIT_PROPS, grid=sim_grid
24
+ property_file, fformat="init", names=init_props, grid=sim_grid
39
25
  )
40
26
  props_dict = {
41
27
  sim_init_props[name].name.lower(): sim_init_props[name].values
42
- for name in INIT_PROPS
28
+ for name in init_props
43
29
  }
44
30
  return SimInitProperties(**props_dict)
45
31
 
46
32
 
47
33
  def create_rst_list(
48
34
  rst_props: xtgeo.GridProperties,
49
- seis_dates: List[str],
50
- rst_prop_names: List[str],
51
- ) -> List[SimRstProperties]:
35
+ seis_dates: list[str],
36
+ rst_prop_names: list[str],
37
+ ) -> list[SimRstProperties]:
52
38
  """Create list of SimRstProperties from raw restart properties
53
39
  Args:
54
40
  rst_props: Raw restart properties
55
- seis_dates: List of dates to process
56
- rst_prop_names: List of property names to include
41
+ seis_dates: list of dates to process
42
+ rst_prop_names: list of property names to include
57
43
  Returns:
58
- List[SimRstProperties]: List of processed restart properties by date
44
+ list[SimRstProperties]: list of processed restart properties by date
59
45
  """
60
46
  return [
61
47
  SimRstProperties(
@@ -70,93 +56,95 @@ def create_rst_list(
70
56
 
71
57
 
72
58
  def read_sim_grid_props(
59
+ rel_dir_sim_files: Path,
73
60
  egrid_file: Path,
74
61
  init_property_file: Path,
75
62
  restart_property_file: Path,
76
- seis_dates: List[str],
77
- ) -> Tuple[xtgeo.Grid, SimInitProperties, List[SimRstProperties]]:
63
+ seis_dates: list[str],
64
+ fipnum_name: str = "FIPNUM",
65
+ ) -> tuple[xtgeo.Grid, SimInitProperties, list[SimRstProperties]]:
78
66
  """Read grid and properties from simulation run, both initial and restart properties
79
67
 
80
68
  Args:
69
+ rel_dir_sim_files: start dir for PEM script run
81
70
  egrid_file: Path to the EGRID file
82
71
  init_property_file: Path to the INIT file
83
72
  restart_property_file: Path to the UNRST file
84
- seis_dates: List of dates for which to read restart properties
73
+ seis_dates: list of dates for which to read restart properties
85
74
 
86
75
  Returns:
87
76
  sim_grid: grid definition for eclipse input
88
77
  init_props: object with initial properties of simulation grid
89
78
  rst_list: list with time-dependent simulation properties
90
79
  """
91
- sim_grid = xtgeo.grid_from_file(egrid_file)
80
+ sim_grid = xtgeo.grid_from_file(rel_dir_sim_files / egrid_file)
92
81
 
93
- init_props = read_init_properties(init_property_file, sim_grid)
82
+ init_props = read_init_properties(
83
+ rel_dir_sim_files / init_property_file, sim_grid, fipnum_name
84
+ )
94
85
 
95
86
  # TEMP will only be available for eclipse-300
96
- RST_PROPS = ["SWAT", "SGAS", "SOIL", "RS", "RV", "PRESSURE", "SALT", "TEMP"]
87
+ rst_props_names = ["SWAT", "SGAS", "SOIL", "RS", "RV", "PRESSURE", "SALT", "TEMP"]
97
88
 
98
89
  # Restart properties - set strict to False, False in case RV is not included in
99
- # the UNRST file
90
+ # the UNRST file. NB: This has the effect that other missing parameters will not
91
+ # raise an error here, but that is handled by the following try-except statement.
100
92
  rst_props = xtgeo.gridproperties_from_file(
101
- restart_property_file,
93
+ rel_dir_sim_files / restart_property_file,
102
94
  fformat="unrst",
103
- names=RST_PROPS,
95
+ names=rst_props_names,
104
96
  dates=seis_dates,
105
97
  grid=sim_grid,
106
98
  strict=(False, False),
107
99
  )
108
100
 
109
- rst_list = create_rst_list(rst_props, seis_dates, RST_PROPS)
110
-
111
- return sim_grid, init_props, rst_list
101
+ # Formation pressure has unit `bar` in eclipse, but in the PEM models, unit
102
+ # `Pa` is expected. Perform unit conversion before class objects are populated
103
+ for date in seis_dates:
104
+ rst_props["PRESSURE" + "_" + date].values = bar_to_pa(
105
+ rst_props["PRESSURE" + "_" + date].values
106
+ )
112
107
 
108
+ try:
109
+ rst_list = create_rst_list(rst_props, seis_dates, rst_props_names)
110
+ except (AttributeError, TypeError) as e:
111
+ raise ValueError(f"eclipse simulator restart file is missing parameters: {e}")
113
112
 
114
- def read_ntg_grid(ntg_grid_file: Path) -> np.ma.MaskedArray:
115
- """Read PEM specific NTG property
116
- Args:
117
- ntg_grid_file: path to the NTG grid file
118
- Returns:
119
- net to gross property from simgrid adapted to PEM definition
120
- """
121
- return xtgeo.gridproperty_from_file(ntg_grid_file).values
113
+ return sim_grid, init_props, rst_list
122
114
 
123
115
 
124
- def import_fractions(root_dir: Path, config: PemConfig) -> list:
116
+ def import_fractions(
117
+ root_dir: Path,
118
+ fraction_path: Path,
119
+ fraction_files: list[Path],
120
+ fraction_names: list[str],
121
+ grd: xtgeo.Grid,
122
+ ) -> list:
125
123
  """Import volume fractions
126
124
 
127
125
  Args:
128
126
  root_dir (str): model directory, relative paths refer to it
129
- config (PemConfig): configuration file with PEM parameters
127
+ fraction_path: path to the fractions files
128
+ fraction_files: list of fraction files
129
+ fraction_names: list of parameter names in fraction files
130
+ grd (xtgeo.Grid): model grid
130
131
 
131
132
  Returns:
132
133
  list: fraction properties
133
134
  """
134
- with restore_dir(
135
- root_dir.joinpath(config.rock_matrix.volume_fractions.rel_path_fractions)
136
- ):
137
- try:
138
- grd = xtgeo.grid_from_file(
139
- config.rock_matrix.volume_fractions.fractions_grid_file_name,
140
- )
141
- except ValueError as exc:
142
- raise ImportError(
143
- f"{__file__}: failed to import volume fractions file "
144
- f"{config.rock_matrix.volume_fractions.fractions_grid_file_name}"
145
- ) from exc
135
+ with restore_dir(root_dir / fraction_path):
146
136
  try:
147
- fracs = config.rock_matrix.fraction_names
148
137
  grid_props = [
149
138
  xtgeo.gridproperty_from_file(
150
139
  file,
151
- name=name,
152
- grid=grd,
140
+ name=name, # type: ignore
141
+ grid=grd, # type: ignore
153
142
  )
154
- for name in fracs
155
- for file in config.rock_matrix.volume_fractions.fractions_prop_file_names # noqa: E501
143
+ for name in fraction_names
144
+ for file in fraction_files
156
145
  ]
157
146
  except ValueError as exc:
158
147
  raise ImportError(
159
- f"{__file__}: failed to import volume fractions files "
160
- f"{config.rock_matrix.volume_fractions.fractions_prop_file_names}"
148
+ f"{__file__}: failed to import volume fractions files {fraction_files}"
161
149
  ) from exc
162
150
  return [grid_prop.values for grid_prop in grid_props]
@@ -1,17 +1,52 @@
1
- from dataclasses import dataclass, field
2
- from typing import Optional
1
+ from dataclasses import dataclass, fields
2
+ from typing import Self
3
3
 
4
4
  import numpy as np
5
5
  from numpy.ma import MaskedArray
6
6
 
7
7
 
8
+ class PropertiesSubgridMasked:
9
+ """
10
+ Class to derive object properties in a subgrid. The mask is assumed to
11
+ come from a numpy masked array.
12
+
13
+ In a numpy masked array, True means masked, False means not masked
14
+ """
15
+
16
+ def masked_where(self: Self, mask: np.ndarray, invert_mask: bool = True) -> Self:
17
+ """
18
+ Method to derive object properties in a subgrid. The mask is assumed to
19
+ come from a numpy masked array.
20
+
21
+ In a numpy masked array, True means masked, False means not masked
22
+ Args:
23
+ self: object with np.ndarray or np.ma.MaskedArray attributes
24
+ mask: Boolean mask to apply
25
+ invert_mask: If True, invert the mask with ~mask
26
+
27
+ Returns:
28
+ New instance of the same type with masked arrays
29
+ """
30
+ actual_mask = ~mask if invert_mask else mask
31
+
32
+ field_values = {}
33
+ for field in fields(self):
34
+ value = getattr(self, field.name)
35
+ if value is None:
36
+ field_values[field.name] = None
37
+ else:
38
+ field_values[field.name] = np.ma.masked_where(actual_mask, value.data)
39
+ return type(self)(**field_values)
40
+
41
+
8
42
  # Eclipse simulator file classes - SimInitProperties and time step SimRstProperties
9
43
  @dataclass
10
- class SimInitProperties:
44
+ class SimInitProperties(PropertiesSubgridMasked):
11
45
  poro: MaskedArray
12
46
  depth: MaskedArray
13
- ntg: MaskedArray
14
- ntg_pem: Optional[MaskedArray] = None
47
+ vsh_pem: MaskedArray | None = None
48
+ pvtnum: MaskedArray | None = None
49
+ fipnum: MaskedArray | None = None
15
50
 
16
51
  @property
17
52
  def delta_z(self) -> MaskedArray:
@@ -40,7 +75,7 @@ class SimInitProperties:
40
75
 
41
76
 
42
77
  @dataclass
43
- class SimRstProperties:
78
+ class SimRstProperties(PropertiesSubgridMasked):
44
79
  swat: MaskedArray
45
80
  sgas: MaskedArray
46
81
  soil: MaskedArray
@@ -53,35 +88,45 @@ class SimRstProperties:
53
88
 
54
89
  # Elastic properties for matrix, i.e. mixed minerals and volume fractions
55
90
  @dataclass
56
- class MatrixProperties:
57
- bulk_modulus: MaskedArray
58
- shear_modulus: MaskedArray
59
- dens: MaskedArray
91
+ class EffectiveMineralProperties(PropertiesSubgridMasked):
92
+ bulk_modulus: MaskedArray | np.ndarray
93
+ shear_modulus: MaskedArray | np.ndarray
94
+ density: MaskedArray | np.ndarray
95
+
96
+ def __post_init__(self):
97
+ self.vs = np.sqrt(self.shear_modulus * self.density)
98
+ self.vp = np.sqrt(
99
+ (self.bulk_modulus + 4 / 3 * self.shear_modulus) / self.density
100
+ )
60
101
 
61
102
 
62
- # Separate class for dry rock, i.e. with porosity, can use MatrixProperties as base
103
+ # Separate class for dry rock, can use MatrixProperties as base
63
104
  # class
64
105
  @dataclass
65
- class DryRockProperties(MatrixProperties):
106
+ class DryRockProperties(EffectiveMineralProperties):
66
107
  pass
67
108
 
68
109
 
69
110
  # Acoustic properties for mixed fluids. If non-Newtonian fluids are to be considered,
70
111
  # shear modulus and vs must be added
71
112
  @dataclass
72
- class EffectiveFluidProperties:
113
+ class EffectiveFluidProperties(PropertiesSubgridMasked):
73
114
  bulk_modulus: MaskedArray
74
- dens: MaskedArray
115
+ density: MaskedArray
75
116
 
76
117
  @property
77
118
  def vp(self):
78
- return np.sqrt(self.bulk_modulus / self.dens)
119
+ return np.sqrt(self.bulk_modulus / self.density)
79
120
 
80
121
 
81
122
  # Pressure properties - overburden, formation and effective (strictly speaking
82
123
  # differential) pressure
83
124
  @dataclass
84
- class PressureProperties:
125
+ class PressureProperties(PropertiesSubgridMasked):
126
+ """
127
+ All attributes shall have unit Pa
128
+ """
129
+
85
130
  formation_pressure: MaskedArray
86
131
  effective_pressure: MaskedArray
87
132
  overburden_pressure: MaskedArray
@@ -99,15 +144,28 @@ class TwoWayTime:
99
144
  # to be defined, others can be derived from them, but this construction is needed
100
145
  # to have all properties recognised by dataclasses.asdict()
101
146
  @dataclass
102
- class SaturatedRockProperties:
147
+ class SaturatedRockProperties(PropertiesSubgridMasked):
103
148
  vp: MaskedArray
104
149
  vs: MaskedArray
105
- dens: MaskedArray
106
- ai: MaskedArray = field(init=False)
107
- si: MaskedArray = field(init=False)
108
- vpvs: MaskedArray = field(init=False)
150
+ density: MaskedArray
151
+ ai: MaskedArray | None = None
152
+ si: MaskedArray | None = None
153
+ vpvs: MaskedArray | None = None
109
154
 
110
155
  def __post_init__(self):
111
- self.ai = self.vp * self.dens
112
- self.si = self.vs * self.dens
156
+ """Calculate derived properties from independent variables.
157
+
158
+ This runs both at initialization and can be called manually after
159
+ updating vp/vs/density arrays (e.g., after zone merging).
160
+ """
161
+ self.recalculate_derived()
162
+
163
+ def recalculate_derived(self):
164
+ """Recalculate derived properties (ai, si, vpvs) from current vp, vs, density.
165
+
166
+ Call this method after modifying vp, vs, or density arrays to update
167
+ the derived properties.
168
+ """
169
+ self.ai = self.vp * self.density
170
+ self.si = self.vs * self.density
113
171
  self.vpvs = self.vp / self.vs