Parsing and processing of corner-point grids

Corner-point meshes are the de-facto standard format for simulation of subsurface flow. These meshes are semi-structured, but can have quite complex structure in practice due to eroded and collapsed cells and the presence of faults. This module includes a processor to convert the input format into a mesh that can be used for simulation. Converting the corner-points into a mesh with a connected topology is non-trivial, but the included algorithm has been verified on a number of real-field assets.

There are two main functions to parse and process corner-point inputs:

GeoEnergyIO.InputParser.parse_grdecl_fileFunction
parse_grdecl_file("mygrid.grdecl"; actnum_path = missing, kwarg...)

Parse a GRDECL file separately from the full input file. Note that the GRID section does not contain units - passing the input_units keyword is therefore highly recommended.

Keyword arguments

  • actnum_path=missing: Path to ACTNUM file, if this is not included in the main file.
  • units=:si: Units to use for return values. Requires input_units to be set.
  • input_units=nothing: The units the file is given in.
  • verbose=false: Toggle verbosity.
  • extra_paths: List of extra paths to parse as a part of grid section, ex: ["PORO.inc", "PERM.inc"].
source
GeoEnergyIO.CornerPointGrid.mesh_from_grid_sectionFunction
mesh_from_grid_section(f, actnum = missing, repair_zcorn = true, process_pinch = false)

Generate a Jutul unstructured mesh from a grid section. The input arugment f can be one of the following:

  • (1) An already parsed complete data file read using parse_data_file. The "GRID" field will be used.
  • (2) A parsed "GRID" section from parse_grdecl_file.
  • (3) The file-name of a .GRDECL file to be parsed before processing.

Optionally the actnum can be specified separately. The actnum should have equal length to the number of logical cells in the grid with true/false indicating if a cell is to be included in the processed mesh.

The additional argument repair_zcorn only applies when the grid is defined using COORD/ZCORN arrays. If set to true, the monotonicity of the ZCORN coordinates in each corner-point pillar will be checked and optionally fixed prior to mesh construction. Note that if non-monotone ZCORN are fixed, if the first input argument to this function is an already parsed data structure, the ZCORN array will be mutated during fixing to avoid a copy.

source

Example corner point meshes

The module ships with several corner point grids suitable for testing. These include partially collapsed cells, faults and other degenerate cases that the parser should be able to handle. We can make a few plots of such test grids. The first example is a single hexahedral cell:

using GeoEnergyIO, Jutul, GLMakie
pth = GeoEnergyIO.test_input_file_path("grdecl", "1cell.txt")
grdecl = parse_grdecl_file(pth)
g = mesh_from_grid_section(grdecl)
fig, ax, plt = plot_mesh(g)
Jutul.plot_mesh_edges!(ax, g)
fig
Example block output

To understand a bit more of how this format behaves in practice, we can look at a faulted mesh:

using GeoEnergyIO, Jutul, GLMakie
pth = GeoEnergyIO.test_input_file_path("grdecl", "raised_col_sloped.txt")
grdecl = parse_grdecl_file(pth)
g = mesh_from_grid_section(grdecl)
fig, ax, plt = plot_mesh(g)
Jutul.plot_mesh_edges!(ax, g)
fig
Example block output

More complicated meshes include multiple faults. One synthetic test model is the model3 case from MRST:

using GeoEnergyIO, Jutul, GLMakie
pth = GeoEnergyIO.test_input_file_path("grdecl", "model3_5_5_5.txt")
grdecl = parse_grdecl_file(pth)
g = mesh_from_grid_section(grdecl)
ix = collect(1:number_of_cells(g))
fig = Figure()
ax = Axis3(fig[1,1], zreversed = true, azimuth = 2.0)
plot_cell_data!(ax, g, ix, colormap = :seaborn_icefire_gradient)
fig
Example block output

We can also parse a high-resolution version of the same case:

using GeoEnergyIO, Jutul, GLMakie
pth = GeoEnergyIO.test_input_file_path("grdecl", "model3_20_20_50.txt")
grdecl = parse_grdecl_file(pth)
g = mesh_from_grid_section(grdecl)
ix = collect(1:number_of_cells(g))
fig = Figure()
ax = Axis3(fig[1,1], zreversed = true, azimuth = 2.0)
plot_cell_data!(ax, g, ix, colormap = :seaborn_icefire_gradient)
fig
Example block output

The parser has been tested on many complex models. Here is an example mesh parsed from the OLYMPUS Optimization Benchmark Challenge where the parsed porosity is plotted together with the wells:

image

We can parse this mesh in the same manner as before:

using GeoEnergyIO, Jutul, GLMakie
pth = GeoEnergyIO.test_input_file_path("OLYMPUS_1", "OLYMPUS_GRID.GRDECL")
grdecl = parse_grdecl_file(pth)
g = mesh_from_grid_section(grdecl)
ix = collect(1:number_of_cells(g))
fig = Figure()
ax = Axis3(fig[1,1], zreversed = true, azimuth = 2.0)
plot_cell_data!(ax, g, ix, colormap = :seaborn_icefire_gradient)
fig
Example block output

Generation of corner-point meshes

The package also contains functionality for generating corner-point meshes.

GeoEnergyIO.CornerPointGrid.cpgrid_from_horizonsFunction
cpgrid_from_horizons(X, Y, depths)
cpgrid_from_horizons(X, Y, depths, (100, 100))
cpgrid_from_horizons(X, Y, depths, sz = missing;
    layer_width = 1,
    transforms = [(x, y, z, x_c, y_c, i, j, k) -> z],
    xy_transform = (x, y, i, j, z_t, z_b) -> (x, y, x, y)
)

Create a CornerPointGrid from a set of horizons. The horizons are given as a set of 2D arrays, where each array represents the depth of a horizon at each point in the grid. The horizons must be the same size and will be used to create the top and bottom of each cell in the grid. At least two horizons must be provided, one for the top and one for the bottom of the grid, and additional horizons can be provided. If horizons intersect, the cells will be pinched so that the lowest horizon is preserved.

The grid will be created with the given X and Y coordinates which are vectors/ranges of equal length to the number of rows and columns in the depths arrays. The sz argument can be used to resample the grid to a different size in the I/J directions. If sz is not provided, the grid will have the same size as the horizons.

Keyword arguments

  • layer_width: Number of cells inside each layer. Can be a single integer or an array of integers with the same length as the number of horizons/depths minus one. Default is 1, i.e. that each layer has one cell in the vertical direction.
  • transforms: A function or an array of functions that can be used to modify the depth of each cell. The function(s) should take the following arguments: x, y, z, x_c, y_c, i, j, k, where x, y and z are the coordinates of the point to be modified, x_c and y_c are the coordinates of the cell center that the point belongs to, i and j are the indices of the cell in the I/J directions, and k is the index of the cell in the K direction. The function(s) should return the new depth of the point.
  • xy_transform: A function that can be used to modify the X and Y coordinates of each pillar. The function should take the following arguments: x, y, i, j, z_t, z_b, where x and y are the original X and Y coordinates of the line, i and j are the indices of the line in the I/J directions, and z_t and z_b are the top and bottom depths of the line. The function should return the new X and Y coordinates of the line.
source

Example of mesh generation

Let us look at how the corner-point generator can be used in practice. A common concept of horizons, a more or less continious surface where the litography changes significantly. Typically these horizons would come from data, but for the purpose of this example we will generate some noisy surfaces with different averages and trends. One matrix for the top surface, one for the middle and one for the bottom of our domain.

using GeoEnergyIO, Jutul, GLMakie
nx = ny = 25
Lx = 1000.0
Ly = 800.0
xrng = range(0.0, Lx, nx)
yrng = range(0.0, Ly, ny)
depths = [
    5 .*rand(nx, ny),
    5 .*rand(nx, ny) .+ 30.0,
    5 .*rand(nx, ny) .+ 100.0 .- xrng/Lx*40.0
]
fig = Figure()
ax = Axis3(fig[1, 1], zreversed = true)
colors = Makie.wong_colors()
for (c, layer) in enumerate(depths)
    surface!(ax, xrng, yrng, layer, color = fill(colors[c], nx, ny), shading = NoShading)
end
fig
Example block output

We generate a mesh based on these depth matrices for each horizon, creating two layers with three cells each, and turn the keyword Dict into a mesh:

grd = cpgrid_from_horizons(xrng, yrng, depths, layer_width = 3)
mesh = mesh_from_grid_section(grd)
fig, ax, plt = plot_cell_data(mesh, grd["LAYERNUM"][mesh.cell_map], colormap = :winter)
Jutul.plot_mesh_edges!(ax, mesh)
fig
Example block output

Let us say that the data was at a different resolution than what we want for our simulation mesh. We can increase or decrease the resolution by a third keyword argument, which will use linear interpolation to add additional points:

grd = cpgrid_from_horizons(xrng, yrng, depths, (100, 100), layer_width = [20, 25])
mesh2 = mesh_from_grid_section(grd)
fig, ax, plt = plot_cell_data(mesh2, grd["LAYERNUM"][mesh2.cell_map], colormap = :winter)
Jutul.plot_mesh_edges!(ax, mesh2)
fig
Example block output

We can also add various transforms to make the model complex. There are two types of supported transforms:

  1. Vertical transforms, which can be multiple transforms that change the depths of corner points based on the cell centroid and original corner points. These are typically used to create faults.
  2. Pillar transforms, which can alter the top and bottom points of the pillars that define the corner point mesh. A single transform is supported at the time.

We will now do the following:

  1. Introduce two faults, one sloping and one with fixed throw.
  2. Add a pillar transform that makes the mesh smaller for increasing depth.
fault1 = (x, y, z, x_c, y_c, i, j, k) -> ifelse(x_c/Lx + 0.25*y_c/Ly > 0.5, z + 50.0.*(y/Ly) + 15.0, z)
fault2 = (x, y, z, x_c, y_c, i, j, k) -> ifelse(y_c/Ly > 0.5, z + 25.0, z)
transforms = [fault1, fault2]
xy_transform = (x, y, i, j, zt, zb) -> (x, y, 0.9*x, 0.8*y)
grd = cpgrid_from_horizons(xrng, yrng, depths,
    layer_width = 3,
    transforms = transforms,
    xy_transform = xy_transform
)
mesh3 = mesh_from_grid_section(grd)
fig, ax, plt = plot_cell_data(mesh3, grd["LAYERNUM"][mesh3.cell_map], colormap = :winter)
ax.azimuth[] = 5.44
Jutul.plot_mesh_edges!(ax, mesh3)
fig
Example block output

We can also make cells inactive by setting NaN values in the depths. Note that as each entry in depths corresponds to the intersection between two layers, we set NaN in the top and bottom depths to impact the two layers separately. For more fine grained control, the "ACTNUM" array is also present and can be altered before mesh_from_grid_section is called.

for i in 1:nx
    for j in 1:ny
        center_dist = sqrt((xrng[i] - 500)^2 + (yrng[j] - 400)^2)
        if center_dist > 400
            depths[1][i, j] = NaN
        end
        if center_dist > 500
            depths[3][i, j] = NaN
        end
    end
end
grd = cpgrid_from_horizons(xrng, yrng, depths, layer_width = [20, 25])
mesh4 = mesh_from_grid_section(grd)
fig, ax, plt = plot_cell_data(mesh4, grd["LAYERNUM"][mesh4.cell_map], colormap = :winter)
Jutul.plot_mesh_edges!(ax, mesh4)
fig
Example block output

Utilities

GeoEnergyIO.InputParser.get_data_file_cell_regionFunction
region = get_data_file_cell_region(data, t::Symbol; active = nothing)
satnum = get_data_file_cell_region(data, :satnum)
pvtnum = get_data_file_cell_region(data, :pvtnum, active = 1:10)

Get the region indicator of some type for each cell of the domain stored in data (the output from parse_data_file). The optional keyword argument active can be used to extract the values for a subset of cells.

t should be one of the following:

  • :satnum (saturation function region)
  • :pvtnum (PVT function region)
  • :eqlnum (equilibriation region)
  • :eosnum (equation-of-state region)
source
GeoEnergyIO.InputParser.number_of_tablesFunction
number_of_tables(outer_data, t::Symbol)

Number of declared tables for given type t. Should be one of the following:

  • :satnum (saturation function region)
  • :pvtnum (PVT function region)
  • :eqlnum (equilibriation region)
  • :eosnum (equation-of-state region)
source