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,7 +1,7 @@
1
1
  import os
2
2
  from datetime import date
3
3
  from pathlib import Path
4
- from typing import Any, Dict, List, Literal, Optional, Self, Union
4
+ from typing import Any, Self
5
5
 
6
6
  import numpy as np
7
7
  from pydantic import (
@@ -19,50 +19,81 @@ from fmu.pem import INTERNAL_EQUINOR
19
19
 
20
20
  from .enum_defs import (
21
21
  CO2Models,
22
+ DifferenceAttribute,
23
+ DifferenceMethod,
22
24
  FluidMixModel,
23
25
  GasModels,
24
26
  MineralMixModel,
25
27
  OverburdenPressureTypes,
28
+ RPMType,
26
29
  TemperatureMethod,
27
- VolumeFractions,
28
30
  )
29
- from .rpm_models import MineralProperties, PatchyCementRPM, RegressionRPM, TMatrixRPM
31
+ from .fipnum_pvtnum_utilities import (
32
+ detect_overlaps,
33
+ input_num_string_to_list,
34
+ )
35
+ from .rpm_models import (
36
+ FriableRPM,
37
+ MineralProperties,
38
+ OptionalField,
39
+ PatchyCementRPM,
40
+ PhysicsModelPressureSensitivity,
41
+ RegressionPressureSensitivity,
42
+ RegressionRPM,
43
+ TMatrixRPM,
44
+ )
30
45
 
46
+ REGEX_FIPNUM_PVTNUM = r"^(?:\*|(?:\d+(?:-\d+)?)(?:,(?:\d+(?:-\d+)?))*)$"
31
47
 
32
- class NTGFraction(BaseModel):
33
- mode: SkipJsonSchema[Literal[VolumeFractions.NTG_SIM]]
34
- from_porosity: bool = Field(
35
- default=False,
36
- description="If True, net-to-gross is estimated from porosity parameter in "
37
- "reservoir simulator INIT file. If False, net-to-gross is read "
38
- "from the NTG parameter in the INIT file.",
48
+
49
+ class EclipseFiles(BaseModel):
50
+ rel_path_simgrid: DirectoryPath = Field(
51
+ default=Path("../../sim2seis/input/pem"),
52
+ description="Relative path of the simulation grid",
53
+ )
54
+ egrid_file: Path = Field(
55
+ default=Path("ECLIPSE.EGRID"),
56
+ description="Name of Eclipse EGRID file",
57
+ )
58
+ init_property_file: Path = Field(
59
+ default=Path("ECLIPSE.INIT"),
60
+ description="Name of Eclipse INIT file",
61
+ )
62
+ restart_property_file: Path = Field(
63
+ default=Path("ECLIPSE.UNRST"),
64
+ description="Name of Eclipse UNRST file",
39
65
  )
40
66
 
67
+ @model_validator(mode="after")
68
+ def check_fractions(self) -> Self:
69
+ for sim_file in [
70
+ self.egrid_file,
71
+ self.init_property_file,
72
+ self.restart_property_file,
73
+ ]:
74
+ full_name = self.rel_path_simgrid / sim_file
75
+ if not full_name.exists():
76
+ raise FileNotFoundError(f"fraction prop file is missing: {full_name}")
77
+ return self
78
+
41
79
 
42
80
  class FractionFiles(BaseModel):
43
- mode: SkipJsonSchema[Literal[VolumeFractions.VOL_FRAC]]
44
81
  rel_path_fractions: DirectoryPath = Field(
45
82
  default=Path("../../sim2seis/input/pem"),
46
83
  description="Directory for volume fractions",
47
84
  )
48
- fractions_grid_file_name: Path = Field(
49
- description="Grid definition of the volume fractions"
50
- )
51
85
  fractions_prop_file_names: list[Path] = Field(description="Volume fractions")
52
- fraction_is_ntg: bool = Field(
53
- default=True,
54
- description="In case of a single fraction, it can either be a real volume "
55
- "fraction or a net-to-gross parameter. If there is more than one fraction, "
56
- "they have to represent real volume fractions, and this will be ignored",
86
+ fractions_are_mineral_fraction: bool = Field(
87
+ default=False,
88
+ description="Fractions can either be mineral fractions or volume fractions."
89
+ "If they are mineral fractions,the sum of fractions and a"
90
+ "complement is 1.0. If they are volume fractions, the sum of"
91
+ "fractions, a complement and porosity is 1.0."
92
+ "Default value is False.",
57
93
  )
58
94
 
59
95
  @model_validator(mode="after")
60
96
  def check_fractions(self) -> Self:
61
- full_fraction_grid = self.rel_path_fractions / self.fractions_grid_file_name
62
- if not full_fraction_grid.exists():
63
- raise FileNotFoundError(
64
- f"fraction grid file is missing: {full_fraction_grid}"
65
- )
66
97
  for frac_prop in self.fractions_prop_file_names:
67
98
  full_fraction_prop = self.rel_path_fractions / frac_prop
68
99
  if not full_fraction_prop.exists():
@@ -72,6 +103,44 @@ class FractionFiles(BaseModel):
72
103
  return self
73
104
 
74
105
 
106
+ class ZoneRegionMatrixParams(BaseModel):
107
+ fipnum: str = Field(
108
+ description="Each grid cell in a reservoir model is assigned a FIPNUM "
109
+ "integer, where each FIPNUM integer represents a combination of zone and "
110
+ "segment. `fmu-pem` reuses FIPNUM by letting you define the FIPNUM integers "
111
+ "where a defined rock matrix should be used. Explicit definitions like "
112
+ "`1-10,15` matches the FIPNUMs "
113
+ "`1, 2, ..., 10, 15`. By doing it this way you can have different "
114
+ "rock physics models for e.g. individual zones and segments. "
115
+ "Leaving this field empty means that all zones and segments are"
116
+ "treated as one",
117
+ pattern=REGEX_FIPNUM_PVTNUM,
118
+ )
119
+ model: FriableRPM | PatchyCementRPM | TMatrixRPM | RegressionRPM = Field(
120
+ description="Selection of rock physics model and parameter set",
121
+ )
122
+ pressure_sensitivity: bool = Field(
123
+ default=True,
124
+ description="All RPM models can be run with or without pressure sensitivity.",
125
+ )
126
+ pressure_sensitivity_model: (
127
+ RegressionPressureSensitivity | PhysicsModelPressureSensitivity | OptionalField
128
+ ) = Field(
129
+ default=OptionalField(),
130
+ description="For most RPM models, it is possible to choose between a "
131
+ "regression based pressure sensitivity model from plug measurements "
132
+ "or a theoretical one. For `T Matrix` model a calibrated model is set "
133
+ "as default, and any model selection in this interface will be disregarded.",
134
+ )
135
+
136
+ @field_validator("model", mode="before")
137
+ @classmethod
138
+ def model_check(cls, v: dict, info: ValidationInfo) -> dict:
139
+ if v["model_name"] not in list(RPMType):
140
+ raise ValueError(f"unknown model: {v['model_name']}")
141
+ return v
142
+
143
+
75
144
  class RockMatrixProperties(BaseModel):
76
145
  """Configuration for rock matrix properties.
77
146
 
@@ -82,10 +151,10 @@ class RockMatrixProperties(BaseModel):
82
151
 
83
152
  model_config = ConfigDict(title="Rock matrix properties:")
84
153
 
85
- model: Union[PatchyCementRPM, TMatrixRPM, RegressionRPM] = Field(
86
- description="Selection of parameter set for rock physics model"
154
+ zone_regions: list[ZoneRegionMatrixParams] = Field(
155
+ description="Per-zone or -region parameters"
87
156
  )
88
- minerals: Dict[str, MineralProperties] = Field(
157
+ minerals: dict[str, MineralProperties] = Field(
89
158
  default={
90
159
  "shale": {
91
160
  "bulk_modulus": 25.0e9,
@@ -107,29 +176,31 @@ class RockMatrixProperties(BaseModel):
107
176
  "shear_modulus": 45.0e9,
108
177
  "density": 2870.0,
109
178
  },
110
- "stevensite": {
111
- "bulk_modulus": 32.5e9,
112
- "shear_modulus": 45.0e9,
113
- "density": 2490.0,
114
- },
115
179
  },
116
- description="Standard values are set for shale, quartz, calcite, dolomite and "
117
- "stevensite. All settings can be changed by re-defining them in "
118
- "the parameter file",
180
+ description="Define minerals relevant for the field. Default values are set "
181
+ "for `shale`, `quartz`, `calcite` and `dolomite` (you can't "
182
+ "delete these minerals, but you can override their default values and/or "
183
+ "ignore their definition).",
184
+ )
185
+ cement: str = Field(
186
+ default="quartz",
187
+ description="For the patchy cement model, the cement mineral must be defined, "
188
+ "and its properties must be defined in the mineral properties' "
189
+ "dictionary",
119
190
  )
120
- volume_fractions: NTGFraction | FractionFiles = Field(
121
- default=NTGFraction,
122
- description=r"Choice of volume fractions based on NTG from "
123
- "simulator INIT file or from grid property file ",
191
+ volume_fractions: FractionFiles = Field(
192
+ description="Choice of volume fraction files. Volume fractions are defined"
193
+ "in the geomodel, but they must be resampled to the simulator grid"
194
+ "when used in PEM",
124
195
  )
125
- fraction_names: List[str] = Field(
196
+ fraction_names: list[str] = Field(
126
197
  description="Fraction names must match the names in the volume fractions file",
127
198
  )
128
- fraction_minerals: List[str] = Field(
199
+ fraction_minerals: list[str] = Field(
129
200
  description="The list of minerals matching the fractions' definition. Each "
130
201
  "mineral must be defined in the mineral properties dictionary"
131
202
  )
132
- shale_fractions: List[str] = Field(
203
+ shale_fractions: list[str] = Field(
133
204
  description="List the fractions that should be regarded as non-net reservoir"
134
205
  )
135
206
  complement: str = Field(
@@ -137,24 +208,56 @@ class RockMatrixProperties(BaseModel):
137
208
  "up to 1.0, the remainder is filled with the complement mineral, "
138
209
  "e.g. when using net-to-gross instead of volume fractions"
139
210
  )
140
- pressure_sensitivity: bool = Field(
141
- default=True,
142
- description="For the RPM models where pressure sensitivity is not part of "
143
- "the model, as for friable and patchy cement models, a separate "
144
- "pressure sensitivity model, based on plug measurements is added",
145
- )
146
- cement: str = Field(
147
- default="quartz",
148
- description="For the patchy cement model, the cement mineral must be defined, "
149
- "and its properties must be defined in the mineral properties' "
150
- "dictionary",
151
- )
152
211
  mineral_mix_model: MineralMixModel = Field(
153
212
  default="voigt-reuss-hill",
154
213
  description="Effective medium model selection: either "
155
- "'hashin-shtrikman-average' or 'voigt-reuss-hill'",
214
+ "`hashin-shtrikman-average` or `voigt-reuss-hill`",
156
215
  )
157
216
 
217
+ @field_validator("zone_regions", mode="before")
218
+ @classmethod
219
+ def fipnum_check(cls, v: list[dict]) -> list[dict]:
220
+ """
221
+ At this point in time we don't have access to the simulator init file,
222
+ so we just have to guess that it contains all numbers from 1 to the
223
+ max number given in the strings. Validate that there are no overlaps.
224
+
225
+ Validation must be made here, not under individual ZoneRegion objects
226
+ to get the combined information in all ZoneRegion groups.
227
+
228
+ Earlier wildcard symbol was '*'. Empty string (new wildcard) is
229
+ changed into '*' for backward compatibility
230
+ """
231
+ fipnum_strings = [rock["fipnum"] for rock in v]
232
+ # Enforce single wildcard usage
233
+ if any(s is None or not str(s).strip() or s == "*" for s in fipnum_strings):
234
+ if len(v) > 1:
235
+ raise ValueError(
236
+ "Setting wildcard ('*' or empty string) means that "
237
+ "all FIPNUM should be treated as one group, no "
238
+ "other groups can be specified"
239
+ )
240
+ # Enforce old style wildcard
241
+ v[0]["fipnum"] = "*"
242
+ return v
243
+ # Build temporary range to detect overlaps
244
+ tmp_max = 1
245
+ tmp_num_array = [1]
246
+ for num_string in fipnum_strings:
247
+ num_array = input_num_string_to_list(num_string, tmp_num_array)
248
+ if tmp_max < max(num_array):
249
+ tmp_max = max(num_array)
250
+ tmp_num_array = list(range(1, tmp_max + 1))
251
+ if detect_overlaps(fipnum_strings, tmp_num_array):
252
+ raise ValueError(f"Overlaps in group definitions: {fipnum_strings}")
253
+ return v
254
+
255
+ @field_validator("cement", mode="before")
256
+ def cement_check(cls, v: str, info: ValidationInfo) -> str:
257
+ if v not in info.data["minerals"]:
258
+ raise ValueError(f'{__file__}: cement mineral "{v}" not listed in minerals')
259
+ return v
260
+
158
261
  @field_validator("shale_fractions", mode="before")
159
262
  @classmethod
160
263
  def shale_fraction_check(cls, v: list, info: ValidationInfo) -> list:
@@ -168,7 +271,7 @@ class RockMatrixProperties(BaseModel):
168
271
 
169
272
  @field_validator("complement", mode="before")
170
273
  @classmethod
171
- def complement_fraction_check(cls, v: list, info: ValidationInfo) -> list:
274
+ def complement_fraction_check(cls, v: str, info: ValidationInfo) -> str:
172
275
  if v not in info.data["minerals"]:
173
276
  raise ValueError(
174
277
  f'{__file__}: shale fraction mineral "{v}" not listed in fraction '
@@ -176,31 +279,49 @@ class RockMatrixProperties(BaseModel):
176
279
  )
177
280
  return v
178
281
 
179
- @field_validator("cement", mode="before")
180
- def cement_check(cls, v: list, info: ValidationInfo) -> list:
181
- if v not in info.data["minerals"]:
182
- raise ValueError(f'{__file__}: cement mineral "{v}" not listed in minerals')
183
- return v
184
-
185
282
  @model_validator(mode="after")
186
- def mineral_fraction_check(self):
283
+ def _validate_rock_matrix_properties(self):
187
284
  for frac_min in self.fraction_minerals:
188
285
  if frac_min not in self.minerals:
189
286
  raise ValueError(
190
287
  f"{__file__}: volume fraction mineral {frac_min} is not defined"
191
288
  )
289
+ for fipnum_group in self.zone_regions:
290
+ if fipnum_group.model.model_name != RPMType.T_MATRIX and (
291
+ fipnum_group.pressure_sensitivity
292
+ and not fipnum_group.pressure_sensitivity_model
293
+ ):
294
+ raise ValueError("a model is required when pressure sensitivity is set")
192
295
  return self
193
296
 
194
297
 
195
298
  # Pressure
196
299
  class OverburdenPressureTrend(BaseModel):
197
300
  type: SkipJsonSchema[OverburdenPressureTypes] = "trend"
301
+ fipnum: str = Field(
302
+ description="Each grid cell in a reservoir model is assigned a FIPNUM "
303
+ "integer. `fmu-pem` reuses FIPNUM by letting you define the FIPNUM "
304
+ "integers where a given overburden pressure should be used. Explicit "
305
+ "definitions like `1-10,15` matches the PVTNUMs `1, 2, ..., 10, 15`. "
306
+ "Leaving this field empty means that all zones and segments are"
307
+ "treated as one",
308
+ pattern=REGEX_FIPNUM_PVTNUM,
309
+ )
198
310
  intercept: float = Field(description="Intercept in pressure depth trend")
199
311
  gradient: float = Field(description="Gradient in pressure depth trend")
200
312
 
201
313
 
202
314
  class OverburdenPressureConstant(BaseModel):
203
315
  type: SkipJsonSchema[OverburdenPressureTypes] = "constant"
316
+ fipnum: str = Field(
317
+ description="Each grid cell in a reservoir model is assigned a FIPNUM "
318
+ "integer. `fmu-pem` reuses FIPNUM by letting you define the FIPNUM "
319
+ "integers where a given overburden pressure should be used. Explicit "
320
+ "definitions like `1-10,15` matches the FIPNUMs `1, 2, ..., 10, 15`. "
321
+ "Leaving this field empty means that all zones and segments are"
322
+ "treated as one",
323
+ pattern=REGEX_FIPNUM_PVTNUM,
324
+ )
204
325
  value: float = Field(description="Constant pressure")
205
326
 
206
327
 
@@ -209,25 +330,27 @@ class Brine(BaseModel):
209
330
  salinity: float = Field(
210
331
  default=35000.0,
211
332
  gt=0.0,
212
- description="Salinity of brine, with unit ppm (parts per million)",
333
+ description="Salinity of brine, with unit `ppm` (parts per million)."
334
+ "The composition (NaCl, CaCl2, KCl) is of secondary"
335
+ " importance, unless the salinity is extremely high.",
213
336
  )
214
337
  perc_na: float = Field(
215
338
  ge=0.0,
216
339
  le=100.0,
217
- default=0.0,
218
- description="Percentage of NaCl in the dissolved salts in brine",
340
+ default=100.0,
341
+ description="Percentage of `NaCl` in the dissolved salts in brine",
219
342
  )
220
343
  perc_ca: float = Field(
221
344
  ge=0.0,
222
345
  le=100.0,
223
- default=100.0,
224
- description="Percentage of CaCl in the dissolved salts in brine",
346
+ default=0.0,
347
+ description="Percentage of `CaCl2` in the dissolved salts in brine",
225
348
  )
226
349
  perc_k: float = Field(
227
350
  ge=0.0,
228
351
  le=100.0,
229
352
  default=0.0,
230
- description="Percentage of KCl in the dissolved salts in brine",
353
+ description="Percentage of `KCl` in the dissolved salts in brine",
231
354
  )
232
355
 
233
356
  @model_validator(mode="after")
@@ -262,14 +385,8 @@ class Oil(BaseModel):
262
385
  default=865.0,
263
386
  ge=700,
264
387
  le=950,
265
- description="Oil density in kg/m^3 at standard conditions, i.e. 15.6 deg. C "
266
- "and 101 kPa",
267
- )
268
- gor: float = Field(
269
- default=123.0,
270
- ge=0.0,
271
- description="Gas-oil volume ratio in liter/liter when the oil it brought to "
272
- "the surface at standard conditions",
388
+ description="Oil density in `kg/m³` at standard conditions, i.e. `15.6 °C`"
389
+ "and `101 kPa`",
273
390
  )
274
391
 
275
392
 
@@ -280,22 +397,23 @@ class Gas(BaseModel):
280
397
  le=0.87,
281
398
  description="Gas gravity is a ratio of gas molecular weight to that air",
282
399
  )
283
- model: GasModels = Field(
400
+ model: SkipJsonSchema[GasModels] = Field(
284
401
  default="HC2016",
285
- description="Gas model is one of 'Global', 'Light', or 'HC2016' (default)",
402
+ description="Gas model is one of `Global`, `Light`, or `HC2016` (default)",
286
403
  )
287
404
 
288
405
 
289
406
  class MixModelWood(BaseModel):
290
- method: SkipJsonSchema[FluidMixModel] = "wood"
407
+ method: FluidMixModel = "wood"
291
408
 
292
409
 
293
410
  class MixModelBrie(BaseModel):
294
- method: SkipJsonSchema[FluidMixModel] = "brie"
411
+ method: FluidMixModel = "brie"
295
412
  brie_exponent: float = Field(
296
413
  default=3.0,
297
- description="Brie exponent selects the mixing curve shape, from linear mix to "
298
- "harmonic mean",
414
+ description="Brie exponent selects the mixing curve shape, from linear mix "
415
+ "(exponent = 2.0) to approximate harmonic mean for high values "
416
+ "(exponent > 10.0). Default value is 3.0.",
299
417
  )
300
418
 
301
419
 
@@ -308,57 +426,60 @@ class TemperatureFromSim(BaseModel):
308
426
  type: SkipJsonSchema[TemperatureMethod] = "from_sim"
309
427
 
310
428
 
311
- # Note that CO2 does not require a separate definition here, as it's properties only
312
- # depend on temperature and pressure
313
- class Fluids(BaseModel):
429
+ class SalinityFromSim(BaseModel):
430
+ enabled: bool = False
431
+
432
+ def __bool__(self):
433
+ return self.enabled
434
+
435
+ model_config = ConfigDict(title="Salinity from SIM")
436
+
437
+
438
+ class PVTZone(BaseModel):
439
+ pvtnum: str = Field(
440
+ description="Each grid cell in a reservoir model is assigned a PVTNUM "
441
+ "integer. `fmu-pem` reuses PVTNUM by letting you define the PVTNUM "
442
+ "integers where a given fluid definition should be used. Explicit "
443
+ "definitions like `1-10,15` matches the PVTNUMs `1, 2, ..., 10, 15`. "
444
+ "Leaving this field empty means that all PVTNUM zones are treated as one",
445
+ pattern=REGEX_FIPNUM_PVTNUM,
446
+ )
314
447
  brine: Brine = Field(
315
- description="Brine model parameters",
448
+ description="Brine model parameters.",
449
+ )
450
+ oil: Oil = Field(
451
+ description="Oil model parameters. Note that GOR (gas-oil ratio) is read from"
452
+ " eclipse restart file"
316
453
  )
317
- oil: Oil = Field(description="Oil model parameters")
454
+ # Note that CO2 does not require a separate definition here, as it's properties only
455
+ # depend on temperature and pressure
318
456
  gas: Gas = Field(description="Gas model parameters")
319
- condensate: Oil | None = Field(
320
- default=None,
457
+ condensate: OptionalField | Oil = Field(
321
458
  title="Condensate properties",
322
- description="Condensate is defined by the same set of parameters as oil, "
323
- "optional setting for condensate cases",
324
- )
325
- fluid_mix_method: MixModelBrie | MixModelWood = Field(
326
- default=MixModelBrie,
327
- description="Selection between Wood's or Brie model. Wood's model gives more "
328
- "radical response to adding small amounts of gas in brine or oil",
329
- )
330
- temperature: ConstantTemperature | TemperatureFromSim = Field(
331
- description="In most cases it is sufficient with a constant temperature "
332
- "setting for the reservoir. If temperature is modelled in the "
333
- "simulation model, it is preferred to use that"
334
- )
335
- salinity_from_sim: bool = Field(
336
- default=False,
337
- description="In most cases it is sufficient with a constant salinity "
338
- "setting for the reservoir, unless there is large contrast"
339
- "between formation water and injected water. If salinity is "
340
- "modelled in the simulation model, it is preferred to use that",
459
+ description="Condensate model requires a similar set of parameters as"
460
+ "the oil model, this is an optional setting for condensate"
461
+ " cases",
341
462
  )
342
463
  gas_saturation_is_co2: bool = Field(
343
464
  default=False,
344
465
  description="Eclipse model only provides one parameter for gas saturation, "
345
- "this flag sets the gas type to be CO2 instead of hydrocarbon gas",
466
+ "this flag sets the gas type to be CO₂ instead of hydrocarbon gas",
346
467
  )
347
468
  calculate_condensate: bool = Field(
348
469
  default=False,
349
470
  description="Flag to control if gas should be modelled with condensate model, "
350
- "in which case RV parameter must be present in the Eclipse model",
471
+ "in which case `RV` parameter must be present in the Eclipse model",
351
472
  )
352
- gas_z_factor: float = Field(
473
+ gas_z_factor: SkipJsonSchema[float] = Field(
353
474
  default=1.0,
354
475
  description="Factor for deviation from an ideal gas in terms of volume change "
355
476
  "as a function of temperature and pressure",
356
477
  )
357
- co2_model: CO2Models = Field(
358
- default="span_wagner",
359
- description="Selection of model for CO2 properties, 'span_wagner' equation "
360
- "of state model or 'flag'. Note that access to flag model depends "
361
- "on licence",
478
+ # Temperature may be set per zone
479
+ temperature: ConstantTemperature | TemperatureFromSim = Field(
480
+ description="In most cases it is sufficient with a constant temperature "
481
+ "setting for the reservoir. If temperature is modelled in the "
482
+ "simulation model, it is preferred to use that"
362
483
  )
363
484
 
364
485
  @model_validator(mode="after")
@@ -370,12 +491,77 @@ class Fluids(BaseModel):
370
491
  return self
371
492
 
372
493
 
373
- def possible_date_string(date_strings: List[str]) -> bool:
494
+ class Fluids(BaseModel):
495
+ pvt_zones: list[PVTZone] = Field(
496
+ description="Define fluid parameters for each phase in each PVT zone "
497
+ "or group of PVT zones"
498
+ )
499
+ fluid_mix_method: MixModelWood | MixModelBrie = Field(
500
+ default_factory=MixModelWood,
501
+ description="Selection between Wood's or Brie model. Wood's model gives more "
502
+ "radical response to adding small amounts of gas in brine or oil",
503
+ )
504
+ # Handling of salinity will be a common factor, not zone-based
505
+ salinity_from_sim: SalinityFromSim = Field(
506
+ default_factory=SalinityFromSim,
507
+ description="In most cases it is sufficient with a constant salinity "
508
+ "setting for the reservoir, unless there is large contrast"
509
+ "between formation water and injected water. If salinity is "
510
+ "modelled in the simulation model, it is preferred to use that",
511
+ )
512
+ co2_model: CO2Models = Field(
513
+ default="span_wagner",
514
+ description="Selection of model for CO₂ properties, `span_wagner` equation "
515
+ "of state model or `flag`. Note that access to flag model depends "
516
+ "on licence",
517
+ )
518
+
519
+ @field_validator("pvt_zones", mode="before")
520
+ @classmethod
521
+ def pvtnum_check(cls, v: list[dict]) -> list[dict]:
522
+ """
523
+ At this point in time we don't have access to the simulator init file,
524
+ so we just have to guess that it contains all numbers from 1 to the
525
+ max number given in the strings. Validate that there are no overlaps.
526
+
527
+ Validation must be made here, not under individual PVTZone objects
528
+ to get the combined information in all PVTZone groups.
529
+
530
+ Earlier wildcard symbol was '*'. Empty string (new wildcard) is
531
+ changed into '*' for backward compatibility
532
+ """
533
+ pvtnum_strings = [zone["pvtnum"] for zone in v]
534
+ # Enforce single wildcard usage
535
+ if any(s is None or not str(s).strip() or s == "*" for s in pvtnum_strings):
536
+ if len(v) > 1:
537
+ raise ValueError(
538
+ "Setting wildcard ('*' or empty string) means that "
539
+ "all PVTNUM should be treated as one group, no "
540
+ "other groups can be specified"
541
+ )
542
+ # Enforce old style wildcard
543
+ v[0]["pvtnum"] = "*"
544
+ return v
545
+ # Build temporary range to detect overlaps
546
+ tmp_max = 1
547
+ tmp_num_array = [1]
548
+ for num_string in pvtnum_strings:
549
+ nums = input_num_string_to_list(num_string, tmp_num_array)
550
+ m = max(nums)
551
+ if m > tmp_max:
552
+ tmp_max = m
553
+ tmp_num_array = list(range(1, tmp_max + 1))
554
+ if detect_overlaps(pvtnum_strings, tmp_num_array):
555
+ raise ValueError(f"Overlaps in PVT zone definitions: {pvtnum_strings}")
556
+ return v
557
+
558
+
559
+ def possible_date_string(date_strings: list[str]) -> bool:
374
560
  """
375
561
  Validate a list of date strings in YYYYMMDD format.
376
562
 
377
563
  Args:
378
- date_strings: List of strings to validate
564
+ date_strings: list of strings to validate
379
565
 
380
566
  Returns:
381
567
  bool: True if all strings are valid dates
@@ -403,17 +589,17 @@ def possible_date_string(date_strings: List[str]) -> bool:
403
589
 
404
590
  class FromGlobal(BaseModel):
405
591
  grid_model: str
406
- seis_dates: List[str]
407
- diff_dates: List[List[str]]
408
- global_config: Dict[str, Any]
592
+ seis_dates: list[str]
593
+ diff_dates: list[list[str]]
594
+ global_config: dict[str, Any]
409
595
 
410
596
  @field_validator("seis_dates", mode="before")
411
- def check_date_string(cls, v: List[str]) -> List[str]:
597
+ def check_date_string(cls, v: list[str]) -> list[str]:
412
598
  possible_date_string(v)
413
599
  return v
414
600
 
415
601
  @field_validator("diff_dates", mode="before")
416
- def check_diffdate_string(cls, v: List[List[str]]) -> List[List[str]]:
602
+ def check_diffdate_string(cls, v: list[list[str]]) -> list[list[str]]:
417
603
  for ll in v:
418
604
  possible_date_string(ll)
419
605
  return v
@@ -443,11 +629,6 @@ class PemPaths(BaseModel):
443
629
  "file for the FMU workflow",
444
630
  frozen=True,
445
631
  )
446
- rel_ntg_grid_path: SkipJsonSchema[DirectoryPath] = Field(
447
- default=Path("../../sim2seis/input/pem"),
448
- description="This is the relative path to the ntg grid file. If the "
449
- "ntg_calculation_flag is False, it is disregarded, cfr. fractions",
450
- )
451
632
  rel_path_simgrid: SkipJsonSchema[DirectoryPath] = Field(
452
633
  default=Path("../../sim2seis/input/pem"),
453
634
  description="Directory for eclipse simulation grid",
@@ -484,33 +665,43 @@ class PemConfig(BaseModel):
484
665
  description="Default path settings exist, it is possible to override them, "
485
666
  "mostly relevant for input paths",
486
667
  )
668
+ eclipse_files: EclipseFiles
487
669
  rock_matrix: RockMatrixProperties = Field(
488
670
  description="Settings related to effective mineral properties and rock "
489
671
  "physics model",
490
672
  )
673
+ alternative_fipnum_name: SkipJsonSchema[str] = Field(
674
+ default="fipnum".upper(), # Should be upper case
675
+ description="If it is needed to deviate from Equinor standard to use "
676
+ "FIPNUM for zone/region class indicator",
677
+ )
491
678
  fluids: Fluids = Field(
492
- description="Settings related to fluid composition",
679
+ description="Values for brine, oil and gas are required, but only the fluid "
680
+ "phases that are present in the simulation model will in practice be used in "
681
+ "calculation of effective fluid properties. You can have multiple fluid PVT "
682
+ "definitions, representing e.g. different regions and/or zones in your model.",
493
683
  )
494
- pressure: OverburdenPressureTrend | OverburdenPressureConstant = Field(
495
- default=OverburdenPressureTrend,
684
+ pressure: list[OverburdenPressureTrend | OverburdenPressureConstant] = Field(
685
+ default_factory=OverburdenPressureTrend,
496
686
  description="Definition of overburden pressure model - constant or trend",
497
687
  )
498
688
  results: Results = Field(
499
689
  description="Flags for saving results of the PEM",
500
690
  )
501
- diff_calculation: Dict[str, List[Literal["ratio", "diff", "diffpercent"]]] = Field(
691
+ diff_calculation: dict[DifferenceAttribute, list[DifferenceMethod]] = Field(
502
692
  description="Difference properties of the PEM can be calculated for the dates "
503
- "in the Eclipse UNRST file. The settings decide which parameters "
693
+ "in the Eclipse `.UNRST` file. The settings decide which parameters "
504
694
  "difference properties will be generated for, and what kind of "
505
- "difference calculation is run - normal difference, percent "
506
- "difference or ratio"
695
+ "difference calculation is run - normal difference (`diff`), percent "
696
+ "difference (`diffperc`) or ratio (`ratio`). Multiple kinds of differences "
697
+ "can be estimated for each parameter"
507
698
  )
508
699
  global_params: SkipJsonSchema[FromGlobal | None] = Field(
509
700
  default=None,
510
701
  )
511
702
 
512
703
  @field_validator("paths", mode="before")
513
- def check_and_create_directories(cls, v: Dict, info: ValidationInfo):
704
+ def check_and_create_directories(cls, v: dict, info: ValidationInfo):
514
705
  if v is None:
515
706
  return PemPaths()
516
707
  for key, path in v.items():
@@ -522,14 +713,15 @@ class PemConfig(BaseModel):
522
713
  return v
523
714
 
524
715
  @field_validator("diff_calculation", mode="before")
525
- def to_list(cls, v: Dict) -> Dict:
526
- v_keys = list(v.keys())
716
+ def to_list(cls, v: dict) -> dict:
717
+ v_keys = [key.lower() for key in v]
527
718
  v_val = list(v.values())
528
719
  for i, val_item in enumerate(v_val):
529
720
  if not isinstance(val_item, list):
530
721
  v_val[i] = [
531
722
  val_item,
532
723
  ]
724
+ v_val[i] = [v.lower() for v in v_val[i]]
533
725
  return dict(zip(v_keys, v_val))
534
726
 
535
727
  # Add global parameters used in the PEM