Assessment of CO2 Storage residual and dissolution trapping mechanism

Context

We consider the benchmark proposed in (Nordbotten et al., 2024) to showcase the effect of the convective mixing in addition to the usually considered residual trapping in a multi-facies resevoir. Also, by using a thermal formulation, the effect of injecting cold CO2 can be observed.

This example can serve as a guideline to set-up the input XML deck to reproduce the published results on SPE11 CSP. The input decks for other simulators can be found at decks.

Note

We refer to the official comparative website to see all submitted results.

Brief case description

As the detailed description is available in (Nordbotten et al.,2024), we will only briefly review the data set that will be used to in the following sections and let the interested reader read the full version.

The spe11-b is a reservoir-like 2D case rescaled from the FluidFlower experiment. It consists in a 8.4 km large and 1.2 km deep reservoir which top depth is at 2km from the surface. A global geothermal gradient of 25 °C/km is imposed from the bottom surface at 70 °C. The reservoir is composed of 7 facies with different properties:

../../../../../../_images/spe11b_presentation.png

Fig. 16 Schematic representation of SPE11B case and its reporting boxes. Image extracted from arxiv version

Here blue, red and orange boxes are representing the prescribed reporting boxes, respectively denoted box A, B and C from the description. These locations are used to observe the initial accumulation in the first anticline, the accumulation at the top of anticlines due to fluid migration through heterogeneous structures, and the development of convective mixing finger patterns.

Input base

This benchmark test is based on the XML file located below:

inputFiles/compositionalMultiphaseFlow/benchmarks/SPE11/b/spe11b_vti_source_00840x00120.xml

which includes

inputFiles/compositionalMultiphaseFlow/benchmarks/SPE11/b/spe11b_vti_source_base.xml

for all parameters not related to the mesh discretization. The full simulation involves 1000 years of initial thermal equilibration required to let the system stabilize under the geothermal gradient before starting the injection schedule.

The well schedule consists of 50 years of injection for the bottom injector and 25 years with a 25 year delayed start for the top injector. It is then followed by a 950 years of migration, dissolution and convection.

As we can see in the snippet below, SourceFlux was choosen to model the injection (rather than wellbores). It goes with a FieldSpecification setting for the temperature at the same place to define the imposed injected CO2 temperature. This also includes to object defined elsewhere as a set thermalSources1 (respectively thermalSources2) and a Function that varies over time to represent the flux schedule. These will be discussed in more details below.

    <FieldSpecification
      beginTime="0.01"
      endTime="1.5768e9"
      name="sourceTemperature1"
      setNames="{ thermalSource1 }"
      objectPath="ElementRegions"
      fieldName="temperature"
      scale="283.15" />
    <SourceFlux
      name="sourceTerm1"
      objectPath="ElementRegions"
      component="0"
      functionName="totalRateTable1"
      scale="1"
      setNames="{ thermalSource1 }"
      logLevel="0" />

    <FieldSpecification
      beginTime="7.8840000001e8"
      endTime="1.5768e9"
      name="sourceTemperature2"
      setNames="{ thermalSource2 }"
      objectPath="ElementRegions"
      fieldName="temperature"
      scale="283.15" />
    <SourceFlux
      name="sourceTerm2"
      objectPath="ElementRegions"
      component="0"
      functionName="totalRateTable2"
      scale="1"
      setNames="{ thermalSource2 }"
      logLevel="0" />
  </FieldSpecifications>

Constitutives and includes

The capillary and residual trapping in such a multi-facies reservoir occur due to the heterogeneous properties. For instance, a large entry pressure jump exists between anticline facies #5 and the upper layers. The box A is chosen such that it can monitor the expected trapped CO2 dissolution. The dissolved CO2 makes the brine heavier which results in its sinking and starts the convective mixing instabilites.

Below we show the sets of relative permeabilities (left) and capillary pressure (right) produced from their tabulated values. The facies #5 is highlighted as it plays an important role in the overall trapping.

(Source code)

../../../../../../_images/plotAllKrPc.png

The tables import in the simulation deck is done as the following showing the relative permeability and capillary pressure tables for facies #5:

    <TableFunction
      name="waterRelativePermeabilityTable5"
      coordinateFiles="{ ../tables/waterSaturation_spe11b_5.txt }"
      voxelFile="../tables/waterRelperm_spe11b_5.txt"/>
    <TableFunction
      name="gasRelativePermeabilityTable5"
      coordinateFiles="{ ../tables/gasSaturation_spe11b_5.txt }"
      voxelFile="../tables/gasRelperm_spe11b_5.txt"/>
    <TableFunction
      name="cappresTable5"
      coordinateFiles="{ ../tables/waterPCSaturation_spe11b_5.txt}"
      voxelFile="../tables/waterCapPres_spe11b_5.txt"/>

Then, relative permeabilities and capillary pressure are defined under the Constitutive tag. For example, the relative permeability for the facies #1 is defined as

    <TableRelativePermeability
      name="relperm1"
      phaseNames="{ gas, water }"
      wettingNonWettingRelPermTableNames="{ waterRelativePermeabilityTable1, gasRelativePermeabilityTable1 }" />

Other properties such as permeabilities, diffusivity, and rock thermal conductivity are modeled as constant per facies (thermal conductivity definition is shown as an example):

    <MultiPhaseVolumeWeightedThermalConductivity
      name="thermalCond"
      phaseNames="{ gas, water }"
      phaseThermalConductivity="{ 0.09, 0.65 }"
      rockThermalConductivityComponents="{ 1.25, 1.25, 1.25 }" />

In addition, the scaling of the Constitutive values is defined to represent the facies:

    <FieldSpecification
      name="condx1"
      initialCondition="1"
      component="0"
      setNames="{ all }"
      objectPath="ElementRegions/reservoir1"
      fieldName="thermalCond_rockThermalConductivity"
      scale="1.9" />
    <FieldSpecification
      name="condy1"
      initialCondition="1"
      component="1"
      setNames="{ all }"
      objectPath="ElementRegions/reservoir1"
      fieldName="thermalCond_rockThermalConductivity"
      scale="1.9" />

Note

Their FieldSpecification scaling definition is linked to the Constitutive definition through the generated field name, for instance thermalCond_rockThermalConductivity. The prefix being extracted from the Constitutive name attributes.

The permeabilities and porosities are defined similarly to the rock thermal conductivities using the scaling approach. The diffusivity being non-heterogeneous in the specifications of the case, it is solely defined in its Constitutive model.

Eventually, one has to pick a model for both brine and CO2 densities and viscosities with respect to varying pressure, temperature and composition. It is done using the CO2BrinePhillipsThermalFluid tag. It lists files that include the related parameters. For instance, CO2 density is modeled through Span Wagner model, while the viscosities are obtained via Fenghour model (see CO2-brine model for more details).

    <CO2BrinePhillipsThermalFluid
      name="fluid"
      phaseNames="{ gas, water }"
      logLevel="0"
      componentNames="{ co2, water }"
      componentMolarWeight="{ 44e-3, 18e-3 }"
      phasePVTParaFiles="{ tables/pvtgas_thermal.txt, tables/pvtliquid_thermal.txt }"
      flashModelParaFile="tables/co2flash_thermal.txt" />

The model conformity to NIST values is assessed in the following plots:

(Source code)

../../../../../../_images/plotNIST.png

Fluid model comparison with NIST data. The dotted lines represent GEOS values while the solid-and-stars are the values from NIST database.

The plots demonstrate the validity of the selected models with respect to the provided NIST tables over the range of pressures and temperatures.

Flow solver

The CompositionalMultiphaseFVM solver uses the finite volume discretization on the targetRegions. The discretization will be TPFA

  <NumericalMethods>
    <FiniteVolume>
      <TwoPointFluxApproximation
        name="fluidTPFA" />
    </FiniteVolume>
  </NumericalMethods>

and here is linked-by-name to the TwoPointFluxApproximation XML block. The Phase Potential Upwind (PPU) scheme is used.

  <Solvers>
    <CompositionalMultiphaseFVM
      name="compositionalMultiphaseFlow"
      targetRegions="{ reservoir1, reservoir2, reservoir3, reservoir4, reservoir5, reservoir6, reservoir7 }"
      discretization="fluidTPFA"
      temperature="333.15"
      isThermal="1"
      initialDt="1e2"
      targetPhaseVolFractionChangeInTimeStep="0.2"      
      maxCompFractionChange="0.2"
      logLevel="1"
      useMass="1">
      <NonlinearSolverParameters
        newtonTol="1.0e-4"
        maxTimeStepCuts="100"
        maxSubSteps="1000"
        lineSearchAction="Attempt"
        lineSearchStartingIteration="5"
        timeStepIncreaseIterLimit="0.3"
        timeStepDecreaseIterLimit="0.6"
        newtonMaxIter="15" />
      <LinearSolverParameters
        solverType="fgmres"
        preconditionerType="mgr"
        krylovTol="1e-5"
        krylovMaxIter="100"
        logLevel="1" />        
    </CompositionalMultiphaseFVM>
  </Solvers>

Note the important isThermal and useMass flags are enabled. The targetPhaseVolFractionChangeInTimeStep parameter controls the time step selection and the maxCompFractionChange parameter controls the solution chopping strategy in the solver. The NonlinearSolverParameters contains the nonlinear tolerance, line search parameters, and the parameters for the time step increase and decrease. The LinearSolverParameters contains parameters for the linear solver: the standard pick here is Flexible GMRES iterative solve with Multi Grid Reduction preconditioner.

Initial and boundary conditions

In the following, we will provide more details about the initialization and describe how to set both the injections and the left and right buffers used to mimic large connected aquifers.

As mentioned above, the injection description refers to a cell set to be applied on and a Function defining incoming flux over time.

The cell sets are defined using boxes in the discretization-specific file (also root file):

  <Geometry>
    <Box
      name="top"
      xMin="{   -0.01, -0.01, 1189.99 }"
      xMax="{ 8400.01,  1.01, 1200.01 }" />
    <Box
      name="bottom"
      xMin="{   -0.01, -0.01, -0.01 }"
      xMax="{ 8400.01,  1.01, 10.01 }" />
      <Box
       name="thermalSource1"
       xMin="{ 2699.99, -0.01, 299.99 }"
       xMax="{ 2710.01,  1.01, 310.01 }" />
      <Box
       name="thermalSource2"
       xMin="{ 5099.99, -0.01, 699.99 }"
       xMax="{ 5110.01,  1.01, 710.01 }" />
  </Geometry>

The lower-interpolated 1D function over time serve as a scaler for the incoming flux:

  <Functions>
    <TableFunction
      name="initCO2CompFracTable"
      coordinates="{ 0, 1200 }"
      values="{ 0.0, 0.0 }" />
    <TableFunction
      name="initWaterCompFracTable"
      coordinates="{ 0, 1200 }"
      values="{ 1.0, 1.0 }" />
    <TableFunction
      name="initTempTable"
      coordinates="{ 0, 1200 }"
      values="{ 343.15, 313.15 }" />
   
    <TableFunction
      name="totalRateTable1"
      inputVarNames="{ time }"
      coordinates="{ -1e11, 0, 7.884e8, 1.5768e9, 1.576800001e9, 3.153600e10 }"
      values="{ 0, -0.035, -0.035, -0.035, 0, 0}"
      interpolation="lower" />
    <TableFunction
      name="totalRateTable2"
      inputVarNames="{ time }"
      coordinates="{ -1e11, 0, 7.8839999e8, 7.884e8, 1.5768e9, 1.576800001e9, 3.153600e10 }"
      values="{ 0., 0., 0., -0.035, -0.035, 0, 0}"
      interpolation="lower" />
  </Functions>

Note

Note that the units here are in mass as the useMass=1 attribute is set in Solvers.

Using Functions we also imposed the initial geothermal gradient as well as initial compositions. Then we need to define the HydrostaticEquilibrium to be computed, as shown below:

  <FieldSpecifications>
    <HydrostaticEquilibrium
      name="equil"
      objectPath="ElementRegions"
      datumElevation="300"
      datumPressure="3e7"
      initialPhaseName="water"
      componentNames="{ co2, water }"
      componentFractionVsElevationTableNames="{ initCO2CompFracTable, initWaterCompFracTable }"
      temperatureVsElevationTableName="initTempTable" />

Finally, the the equilibration stage is defined in the Events by setting the simulation to start at -1000 years:

  <Events
    minTime="-31536000000"
    maxTime="31536000000">

    <PeriodicEvent
      name="solverApplications0"
      beginTime="-31536000000"
      maxEventDt="157680000"
      endTime="0"
      target="/Solvers/compositionalMultiphaseFlow" />
    <PeriodicEvent
      name="solverApplications1"
      beginTime="0"
      maxEventDt="2.6e5"
      endTime="7.884e5"
      target="/Solvers/compositionalMultiphaseFlow" />
    <PeriodicEvent
      name="solverApplications2"
      beginTime="7.884e5"
      maxEventDt="1.3e6"
      target="/Solvers/compositionalMultiphaseFlow" />

Note

The time tags in GEOS are in seconds. Here a split between equilibration, start of the injection and post-injection is chosen as the maximum time steps in these stages are quite different. Specifically, separate solverApplication1 definition is ensuring stability as the injection starts.

The boundary conditions for the domain are imposed as follows. The top and bottom temperatures are set using the FieldSpecification on top and bottom geometrical sets defined above:

<Problem>
  <FieldSpecifications>
    <FieldSpecification
      name="topTemperature"
      objectPath="ElementRegions"
      fieldName="temperature"
      scale="313.15"
      setNames="{ top }"
      logLevel="0" />
    <FieldSpecification
      name="bottomTemperature"
      objectPath="ElementRegions"
      fieldName="temperature"
      scale="343.15"
      setNames="{ bottom }"
      logLevel="0" />
</FieldSpecifications>

</Problem>

Fictive aquifers are used as buffers to damp the pressure buildup (see (Nordbotten et al.,2024), for details about these buffers definition). They are set similar to the sources definition but instead of using the boxes for cell sets, we use tags created during the construction of the mesh, namely 12_hexahedra to 15_hexahedra. It is then easy to rescale the volumes in those regions:

    <FieldSpecification
      name="vol12"
      initialCondition="1"
      setNames="{ all }"
      objectPath="ElementRegions/reservoir2/12_hexahedra"
      fieldName="elementVolume"
      functionName="bufferVolume"
      scale="1.0" />
    <FieldSpecification
      name="vol13"
      initialCondition="1"
      setNames="{ all }"
      objectPath="ElementRegions/reservoir3/13_hexahedra"
      fieldName="elementVolume"
      functionName="bufferVolume"
      scale="1.0" />
    <FieldSpecification
      name="vol14"
      initialCondition="1"
      setNames="{ all }"
      objectPath="ElementRegions/reservoir4/14_hexahedra"
      fieldName="elementVolume"
      functionName="bufferVolume"
      scale="1.0" />
    <FieldSpecification
      name="vol15"
      initialCondition="1"
      setNames="{ all }"
      objectPath="ElementRegions/reservoir5/15_hexahedra"
      fieldName="elementVolume"
      functionName="bufferVolume"
      scale="1.0" />

The scaling factor depends on the discretization and hence that is defined in the discretization-specific file:

  <Functions>
    <TableFunction
      name="bufferVolume"
      inputVarNames="{ time }"
      coordinates="{ -1e11 }"
      values="{ 500100 }"
      interpolation="lower" />
  </Functions>

Post-processing and reporting

First, inspection of the results is done using Paraview as in the usual GEOS workflow. Here, we report temperature, saturation and dissolved CO2 fraction for 500 years and 1000 years:

../../../../../../_images/spe11b_thermal_T.0100.png

Fig. 17 Thermal state after 500 years

../../../../../../_images/spe11b_thermal_T.0200.png

Fig. 18 Thermal state after 1000 years

The saturation distribution is showing that, for this mesh resolution, almost all gaseous CO2 is trapped and then dissolved after 1000 years of the injection scenario. The reporting of the dissolved fraction clearly shows evidence of on-set of convective mixing fingers, with heavier CO2-saturated brine sinking. In particular, the dissolved CO2 sinks and accumulates in the lower left part of the domain, perpendicular to the first injector. This behavior is similar for the temperature distributions.

GEOS run can be post-treated leveraging Paraview (as mentioned in other tutorials) or pyvtk. Here is an example of the latter. It is built around two main driver write() and plot() functions which respectively write the integrated mass balance in the reporting boxes A and B and re-read the generated file and plot it to png. It requires usual dependencies such as re, os, pandas, numpy, scipy (for interpolation) and pyplot

import os, logging
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

in addition to pyvtk. The main drivers hereafter,

def write(ifile):

    #utils
    def read_pvd(ifile):

        logging.info(f'Reading pvd file {ifile}')
        import xml.etree.ElementTree as ET
        tree = ET.parse(ifile)
        root = tree.getroot()
        for ds in root.find('Collection').findall('DataSet'):
            data.data_sets[float(ds.attrib['timestep'])] = ds.attrib['file']


    # note that solubility is in KgCO2/KgBrine
    read_pvd(ifile)
    define_boxes()
    # preprocess input list from desired output
    import re
    olist_ = set(
        [item for name in name_indirection.keys() for item in re.findall(r'(\w+)_\d|(\w+)', name)[0] if
         len(item) > 0])

    ddf = pd.DataFrame()
    for time in data.schedule:
        field_and_pts = process_time(ifile, time, olist_)
        ddf = pd.concat([ddf, intergrate_fields(field_and_pts)])
    ddf.to_csv( './spe11b_time_series.csv',
                       index_label=False)



def plot():
    df = pd.read_csv('./spe11b_time_series.csv')
    fig, axs = plt.subplots(2, 2)
    (time_name, time_unit), (mass_name, mass_unit), (pressure_name, pressure_unit) \
        = ('Y', SEC2YEAR), ('T', 1000), ('bar', 1e5)
    # pressures
    axs[0][0].plot(df['t[s]'].to_numpy() / time_unit, df['p1[Pa]'].to_numpy() / pressure_unit,
                   label=f'pressure 1 [{pressure_name}]')
    axs[0][0].plot(df['t[s]'].to_numpy() / time_unit, df['p2[Pa]'].to_numpy() / pressure_unit,
                   label=f'pressure 2 [{pressure_name}]')
    axs[0][0].legend()
    # box A
    axs[0][1].plot(df['t[s]'].to_numpy() / time_unit, df['mobA[kg]'].to_numpy() / mass_unit,
                   label=f'mobile CO2 [{mass_name}]')
    axs[0][1].plot(df['t[s]'].to_numpy() / time_unit, df['immA[kg]'].to_numpy() / mass_unit,
                   label=f'immobile CO2 [{mass_name}]')
    axs[0][1].plot(df['t[s]'].to_numpy() / time_unit, df['dissA[kg]'].to_numpy() / mass_unit,
                   label=f'dissolved CO2 [{mass_name}]')
    axs[0][1].legend()
    axs[0][1].set_title('boxA')
    # box B
    axs[1][0].plot(df['t[s]'].to_numpy() / time_unit, df['mobB[kg]'].to_numpy() / mass_unit,
                   label=f'mobile CO2 [{mass_name}]')
    axs[1][0].plot(df['t[s]'].to_numpy() / time_unit, df['immB[kg]'].to_numpy() / mass_unit,
                   label=f'immobile CO2 [{mass_name}]')
    axs[1][0].plot(df['t[s]'].to_numpy() / time_unit, df['dissB[kg]'].to_numpy() / mass_unit,
                   label=f'dissolved CO2 [{mass_name}]')
    axs[1][0].legend()
    axs[1][0].set_title('boxB')

    fig.tight_layout()
    fig.savefig('./spe11b_timeseries.png', bbox_inches='tight')


if __name__ == "__main__":

    write('vtkOutput.pvd')
    # plot
    plot()

are using simple data structure to gather info and to link GEOS’ name to name used in formulas,

# for geos
name_indirection = {'pressure': 'pres',
                         'phaseVolumeFraction_0': 'satg',
                         'phaseVolumeFraction_1': 'satw',
                         'fluid_phaseCompFraction_2': 'mCO2',
                         'fluid_phaseCompFraction_1': 'mH2O',
                         'fluid_phaseDensity_0': 'rG',
                         'fluid_phaseDensity_1': 'rL',
                         'rockPorosity_porosity': 'poro',
                         'elementVolume': 'vol',
                         'phaseMobility_0': 'krg',
                         'phaseMobility_1': 'krw'}

logging.basicConfig(filename='spe11-pt.log', encoding='utf-8', level=logging.INFO)


SEC2YEAR =  365*24*3600

#main containers
class Data:

    data_sets = {}
    schedule = []
    PO1 = []
    PO2 = []
    boxes = {}

data = Data()
def define_boxes():
    #define and rescale
    data.boxes = {'Whole': [(0.0, 0.0, 0.0), (8400, 1., 1200.)]}

    data.boxes['A'] = [(3300.,0.,0.), (8300.,1.,600.)]
    data.boxes['B'] = [(100.,0.,600.), (3300.,1.,1200.)]
    data.PO1 = [ 4500, 500]
    data.PO2 = [ 5100, 1100]
    data.schedule = np.arange(0., 1000 * SEC2YEAR, 1000/200 * SEC2YEAR)

It uses a string interpreted function process_keys(formula, fielddict) to build the observable from the fields loaded from GEOS :

formula = {'mImmobile': 'if(krg<0.001, rG*satg*poro*vol)',
           'mMobile': 'if(krg>0.001,rG*satg*poro*vol)',
           'mDissolved': 'rL*mCO2*poro*vol*satw' }
def process_keys(formula, fielddict):
    """
    Basic formula interface, reading from multiplication and division (no parenthesis)
    :param formula: string of the formula
    :param fielddict: all-containing dict with base-variables
    :return: Array of the formula value
    """
    import re
    if re.findall(r'if', formula):
        bits = formula.split(',')
        assert (len(bits) == 2)
        cdt = bits[0][3:]
        if re.findall(r'>', cdt):
            inner_bits = cdt.split('>')
            cdt_field = inner_bits[0].strip()
            cdt_lim = float(inner_bits[1])
            return np.where(fielddict[cdt_field] > cdt_lim, process_keys(bits[1][:-1], fielddict), 0)
        elif re.findall(r'<', cdt):
            inner_bits = cdt.split('<')
            cdt_field = inner_bits[0].strip()
            cdt_lim = float(inner_bits[1])
            return np.where(fielddict[cdt_field] < cdt_lim, process_keys(bits[1][:-1], fielddict), 0)
        else:
            raise NotImplemented('Not a valid condition')
    elif re.findall(r'\+', formula):
        bits = formula.split('+')
        bits = [item.strip() for item in bits]
        return process_keys(bits[0], fielddict) + process_keys('+'.join(bits[1:]), fielddict)
    elif re.findall(r'-', formula):
        bits = formula.split('-')
        bits = [item.strip() for item in bits]
        return process_keys(bits[0], fielddict) - process_keys('+'.join(bits[1:]), fielddict)
    elif re.findall(r'\*', formula):
        bits = formula.split('*')
        bits = [item.strip() for item in bits]
        return process_keys(bits[0], fielddict) * process_keys('*'.join(bits[1:]), fielddict)
    elif re.findall(r'/', formula):
        bits = formula.split('/')
        bits = [item.strip() for item in bits]
        return process_keys(bits[0], fielddict) / process_keys('/'.join(bits[1:]), fielddict)
    return fielddict[formula]

and a tool set to interpolate and integrate them over the correct boxes,

def get_interpolate(points_from_vtk, fields: dict, nskip=1):
    """ getting dict of proper interpolation for fields """
    def get_lambda_2(xpts, disc_func):
        from scipy.interpolate import LinearNDInterpolator
        x, z = xpts
        return LinearNDInterpolator(np.asarray([x, z]).transpose(), disc_func, fill_value=0.0)

    fn = {}
    for key, val in fields.items():
        if key in name_indirection.values() or key in formula.keys():
            logging.info(f'Building interpolation for {key}')
            # do not interpolate if not interested by (and only brought as field is another component of
            # a field of interest
            T = points_from_vtk
            fn[key] = get_lambda_2((T[::nskip, 0], T[::nskip, 2]), val[::nskip])

    return fn

def integrate_2(pts, fields, box):
    """ Simplistic integration equivalent to Paraview integrate variables """
    xmin, ymin, zmin = box[0]
    xmax, ymax, zmax = box[1]
    ii = np.intersect1d(np.intersect1d(np.argwhere((pts[:, 0] > xmin)), np.argwhere((pts[:, 0] < xmax))),
                        np.intersect1d(np.argwhere((pts[:, 2] > zmin)), np.argwhere((pts[:, 2] < zmax))))
    return fields[ii].sum()

def intergrate_fields(pft_tuple: list):

    logging.info(f'Interpolating reported fields')
    lines = list()
    pts_from_vtk, fields, time = pft_tuple

    # some lines for MC magic number
    for key, form in formula.items():
        fields[key] = process_keys(form, fields)

    fn = get_interpolate(pts_from_vtk,
                                    {'pres': fields['pres'], 'vol': fields['vol']},
                                    nskip=1)

    line = [time]
    logging.info(f'Writing sparse time {time:2}')
    # deal with P1 and P2
    p1 = fn['pres'](data.PO1)
    p2 = fn['pres'](data.PO2)
    line.extend([p1[0], p2[0]])
    # deal box A & B
    logging.info(f'Integrating for boxes A and B')
    for box_name, box in data.boxes.items():
            if box_name in ['A', 'B']:
                line.extend([
                    integrate_2(pts_from_vtk, fields['mMobile'], box),
                    integrate_2(pts_from_vtk, fields['mImmobile'], box),
                    integrate_2(pts_from_vtk, fields['mDissolved'], box)
                ])
        # #deal sealTot
    lines.append(line)

    return pd.DataFrame(data=lines,
                        columns=['t[s]', 'p1[Pa]', 'p2[Pa]', 'mobA[kg]', 'immA[kg]', 'dissA[kg]',
                                 'mobB[kg]', 'immB[kg]', 'dissB[kg]'])

Eventually, the per-time driver, load usefull specific fields from the correct time in the multi-block VTK format GEOS is using,

def process_time(pvdfile, time, olist):

    import vtk
    import numpy as np
    from vtk.util import numpy_support

    reader = vtk.vtkXMLMultiBlockDataReader()
    reader.SetFileName( './' + os.path.dirname(pvdfile) + '/' + data.data_sets[time] )
    loadlist = list(olist)

    for fieldname in loadlist:
        if fieldname in olist or fieldname in ['ghostRank']:
            reader.SetCellArrayStatus(fieldname, 1)
        else:
            reader.SetCellArrayStatus(fieldname, 0)

    logging.info('Updating multiblocks')
    reader.Update()
    logging.info('Processing multiblocks')
    # remove well blocks - in hierarchy mesh.lvl0.CellElementRegion/WellElementRegion
    if reader.GetOutput().GetBlock(0).GetBlock(0).GetNumberOfBlocks() > 1:
        reader.GetOutput().GetBlock(0).GetBlock(0).RemoveBlock(1)

    # browse blocks - assemble size
    # counting cells and blocks/rank
    nv = reader.GetOutput().GetNumberOfCells()
    it = reader.GetOutput().NewIterator()
    it.InitTraversal()

    ncnf = 0
    it = reader.GetOutput().NewIterator()
    it.InitTraversal()
    for ifields in olist:
        ncc = reader.GetOutput().GetDataSet(it).GetCellData().GetArray(ifields).GetNumberOfComponents()
        ncnf += ncc
        it.GoToNextItem()

    # form data container
    f = np.zeros(shape=(nv, ncnf), dtype='float')
    pts = np.zeros(shape=(nv, 3), dtype='float')

    # get seal flagged
    mesh = reader.GetOutput().GetBlock(0).GetBlock(0).GetBlock(0)
    fielddict = {}
    for i in range(0, mesh.GetNumberOfBlocks()):
        start = 0
        it = mesh.GetBlock(i).NewIterator()
        it.InitTraversal()
        info = mesh.GetMetaData(i)
        while not it.IsDoneWithTraversal():
            field = mesh.GetBlock(i).GetDataSet(it).GetCellData().GetArray("ghostRank")
            it.GoToNextItem()

        nc = 0
        j = 0  # for field loop
        for ifields in olist:
            start = 0
            it = reader.GetOutput().NewIterator()
            it.InitTraversal()
            while not it.IsDoneWithTraversal():
                field = reader.GetOutput().GetDataSet(it).GetCellData().GetArray(ifields)
                nt = field.GetNumberOfValues()
                nc = field.GetNumberOfComponents()
                f[start:(start + int(nt / nc)), j:j + nc] = numpy_support.vtk_to_numpy(field).reshape(int(nt / nc),
                                                                                                      nc)

                cc = vtk.vtkCellCenters()
                cc.SetInputData(reader.GetOutput().GetDataSet(it))
                cc.Update()
                if nt > 0:
                    pts[start:(start + int(nt / nc)), :] = numpy_support.vtk_to_numpy(
                        cc.GetOutput().GetPoints().GetData())
                it.GoToNextItem()
                start += int(nt / nc)

            if nc > 1:
                for k in range(0, nc):
                    if ifields + "_" + str(k) in name_indirection:
                        fielddict[name_indirection[ifields + "_" + str(k)]] = f[:, j + k]
            else:
                if ifields in name_indirection:
                    fielddict[name_indirection[ifields]] = f[:, j]
            j = j + nc

    return pts, fielddict, time

and this function is mapped over the schedule of the simulation.

To go further

Feedback on this example

For any feedback on this example, please submit a GitHub issue on the project’s GitHub page.