Property Modeling ================= This tutorial introduces property modeling in Petres. Grid properties are defined per cell and they represent scalar quantities such as porosity, permeability, saturation, net-to-gross, or any other spatially distributed field. In this chapter, you will learn how to: - Create new grid properties - Fill them with constant or stochastic values - Derive one property from another - Populate values from existing NumPy arrays - Interpolate property values from wells - Assign properties zone by zone - Inspect statistics and summaries - Visualize the resulting distributions .. note:: This tutorial assumes that you are already familiar with :doc:`horizon-modeling`, :doc:`zone-modeling`, :doc:`pillar-gridding`, and grid creation workflows such as :doc:`grid-modeling-from-horizons-and-zones`. Creating a Property ------------------- The first step in property modeling is to create a property on the grid: .. code-block:: python from petres.grids import CornerPointGrid grid = CornerPointGrid.from_regular( xlim=(0, 1000), ylim=(0, 1000), zlim=(0, 100), ni=20, nj=20, nk=3, ) porosity = grid.properties.create( name="poro", eclipse_keyword="PORO", description="Porosity", ) The ``name`` defines the internal Petres identifier used to access the property. The object returned during creation is the same property and can be accessed later from the grid: .. code-block:: python porosity = grid.properties["poro"] The optional ``eclipse_keyword`` is useful when exporting the property to Eclipse ``.GRDECL`` format. The ``description`` is also optional and can be used to provide a more readable explanation of the property, if needed. Constant Property Assignment ---------------------------- A property can be filled with a constant value across the whole grid using :meth:`fill` method: .. code-block:: python porosity.fill(0.2) This assigns a porosity value of 0.2 to every cell. Visualizing a Property ---------------------- A property can be visualized directly: .. code-block:: python porosity.show() By default, inactive cells are hidden. To include them: .. code-block:: python porosity.show(show_inactive=True) The colormap can be customized: .. code-block:: python porosity.show(cmap="viridis") Any valid Matplotlib colormap name can be used, such as ``"viridis"``, ``"plasma"``, ``"coolwarm"``, or ``"inferno"``. A title is automatically generated by default (e.g., ``"Property: poro"``). You can override the title: .. code-block:: python porosity.show(title="Porosity Distribution") Or disable it entirely: .. code-block:: python porosity.show(title=None) Deriving a Property from Other Sources -------------------------------------- A new property can be computed from one or more existing sources using :meth:`apply`. For example, permeability may be derived from porosity: .. code-block:: python permeability = grid.properties.create( name="perm", eclipse_keyword="PERM", description="Permeability", ) permeability.apply(lambda poro: 100 * poro**3, source=porosity) permeability.show() The ``source`` argument may refer to: - An existing property object - The name of an existing property - One of the predefined :ref:`grid-derived attributes ` You can also refer to a property using its name: .. code-block:: python permeability.apply(lambda poro: 100 * poro**3, source="poro") Using a Named Function ~~~~~~~~~~~~~~~~~~~~~~ Instead of a lambda expression, a regular Python function can be used. .. code-block:: python def calc_perm(poro): return 100 * poro**3 permeability.apply(calc_perm, source="poro") Using a named function is often preferable when the transformation becomes more complex or needs to be reused. .. _grid-attributes: Built-In Grid Attributes ~~~~~~~~~~~~~~~~~~~~~~~~ The following built-in attribute names can be used as data sources in :meth:`GridProperty.apply`. These attributes are computed directly from the grid geometry and do not require explicit property creation. - ``"x"``: X-coordinate of cell centers - ``"y"``: Y-coordinate of cell centers - ``"depth"`` (or ``"z"``): Cell-center depth (positive downward) - ``"top"``: Depth of the top face of each cell - ``"bottom"``: Depth of the bottom face of each cell - ``"thickness"``: Cell thickness (``bottom - top``) - ``"active"``: Activity indicator (1 for active cells, 0 for inactive) These attributes can be used directly as input sources. For example, permeability can be defined as a function of depth: .. code-block:: python permeability.apply( lambda depth: depth / 1000, source="depth", ) .. important:: These names are **reserved** and cannot be used when defining new properties. Attempting to create a property with one of these names will raise an error. Using Multiple Sources ~~~~~~~~~~~~~~~~~~~~~~ The :meth:`apply` method can combine multiple sources, including both properties and grid-derived attributes. .. code-block:: python permeability.apply( lambda poro, depth: 100*poro*thickness + depth, source=(porosity, "depth", 'thickness'), ) Property Statistics ------------------- Petres provides convenient access to common summary statistics. .. code-block:: python print("Min Value:", permeability.min) print("Max Value:", permeability.max) print("Mean Value:", permeability.mean) print("Median Value:", permeability.median) print("Standard Deviation:", permeability.std) You can also print a full summary: .. code-block:: python print(permeability.summary()) This is useful for quickly checking whether the modeled values fall within reasonable physical limits. Filling with a Uniform Distribution ----------------------------------- For stochastic modeling, a property can be filled from a uniform distribution. .. code-block:: python porosity.fill_uniform(low=0.24, high=0.30) This generates values uniformly distributed between the specified lower and upper bounds. Filling with a Normal Distribution ---------------------------------- A normal distribution can be used when a property is expected to vary around a representative mean value. .. code-block:: python porosity.fill_normal(mean=0.24, std=0.03) Minimum and maximum clipping bounds may optionally be supplied: .. code-block:: python porosity.fill_normal(mean=0.24, std=0.03, min=0.20, max=0.30) To ensure reproducibility, a random seed may also be set: .. code-block:: python porosity.fill_normal(mean=0.24, std=0.03, seed=42) Filling with a Log-Normal Distribution -------------------------------------- A log-normal distribution is often more suitable for positively skewed properties, especially for quantities such as permeability. .. code-block:: python porosity.fill_lognormal(mean=0.24, std=0.03) As with normal filling, optional bounds may be provided: .. code-block:: python porosity.fill_lognormal(mean=0.24, std=0.03, min=0.20, max=0.30) A seed can also be specified for reproducibility: .. code-block:: python porosity.fill_lognormal(mean=0.24, std=0.03, seed=42) Populating a Property from a NumPy Array ---------------------------------------- If you already have a precomputed 3D array of values, it can be assigned directly to the property. .. code-block:: python import numpy as np array = np.full(grid.shape, 0.24) porosity.from_array(array) This is especially useful when the property values come from an external workflow, a simulator output, or a separate numerical calculation. Zone-Based Property Assignment ------------------------------ If the grid contains zone information, property values can be modeled separately for each zone. This is particularly useful when different formations require different property ranges or modeling assumptions. The example below assumes that the zones have already been defined, as shown in earlier tutorials (see :doc:`zone-modeling`, :doc:`grid-modeling-from-horizons-and-zones`). .. code-block:: python porosity.fill(0.20, zone="Caprock") porosity.fill(0.50, zone="Base") The same zone-based idea can be used with other population methods as well`. For example: .. code-block:: python porosity.fill_normal(mean=0.08, std=0.01, zone="Caprock") porosity.fill_normal(mean=0.24, std=0.03, zone="Base") This allows each zone to be modeled independently. Interpolating Property Values from Wells ---------------------------------------- Property values can also be assigned by interpolating measurements taken at well locations. This is useful when property data are available only at sparse sample points and need to be distributed throughout the grid. Defining Property Samples on Wells ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ There are two supported ways to assign property measurements to wells. 1. Depth-Dependent Samples ^^^^^^^^^^^^^^^^^^^^^^^^^^ Use this approach when the property varies along the well trajectory. Each sample is associated with a specific depth: .. code-block:: python well1.add_sample(name="porosity", value=100, depth=10) well1.add_sample(name="porosity", value=120, depth=20) well2.add_sample(name="porosity", value=50, depth=15) well2.add_sample(name="porosity", value=70, depth=25) Each `(property, depth)` pair must be unique per well. In other words, you cannot assign multiple values to the same property at the same depth within a well. This mode enables **3D** interpolation, where the property varies in both horizontal and vertical directions. 2. Single Sample per Well ^^^^^^^^^^^^^^^^^^^^^^^^^ Use this approach when only one representative value per well is available: .. code-block:: python well1.add_sample(name="porosity", value=100) well2.add_sample(name="porosity", value=50) In this case, the value represents the entire well, and no depth variation is assumed. Interpolation will therefore be independent of depth. .. important:: - You cannot mix sampling modes for the same property on a single well — choose either depth-dependent or single sample method. - Mixing both modes for the same property is not allowed. - When performing interpolation, all wells must use the same sampling mode for the selected property. Interpolating to the Grid ~~~~~~~~~~~~~~~~~~~~~~~~~ Once samples are defined, you can interpolate them onto the grid: .. code-block:: python from petres.interpolators import IDWInterpolator porosity.from_wells( wells=[well1, well2], interpolator=IDWInterpolator(), ) If the input samples are depth-dependent, interpolation is performed in **3D**. For single value samples, interpolation is performed in **2D** and applied uniformly along the vertical axis. .. note:: In this example, Inverse Distance Weighting (:class:`~petres.interpolators.InverseDistanceWeightingInterpolator`) is used. For more details and additional interpolation options, see :doc:`/tutorials/interpolators`. Filling Remaining Missing Values -------------------------------- In some workflows, such as zone-based modeling or interpolation, certain cells may remain unassigned and contain ``NaN`` values. These values can be replaced using :meth:`fill_nan`: .. code-block:: python porosity.fill_nan(0.0) This is particularly useful for handling inactive cells and ensuring that all grid cells have valid values before further processing or export. Complete Example ---------------- The following example combines several of the ideas introduced above. .. code-block:: python from petres.grids import CornerPointGrid import numpy as np grid = CornerPointGrid.from_regular( xlim=(0, 1000), ylim=(0, 1000), zlim=(0, 100), ni=20, nj=20, nk=3, ) porosity = grid.properties.create( name="poro", eclipse_keyword="PORO", description="Porosity", ) porosity.fill_normal(mean=0.24, std=0.03, min=0.20, max=0.30, seed=42) permeability = grid.properties.create( name="perm", eclipse_keyword="PERM", description="Permeability", ) permeability.apply( lambda poro, z: 100 * poro**3 + z, source=(porosity, "z"), ) print(porosity.summary()) print(permeability.summary()) porosity.show(cmap="viridis") permeability.show(cmap="plasma") Where to Go Next ---------------- Now that you know how to define and populate cell properties, the next step is usually to export the grid and its associated properties for simulation use (see :doc:`exporting-grid`)