Simulation Workflow¶
From Model to Results¶
Running a reservoir simulation in BORES follows a clear pipeline: you build a reservoir model, configure the simulation parameters, call bores.run(), and iterate over the results. Under the hood, the simulator marches forward in time, solving for pressure and saturation at each timestep, checking convergence, and yielding snapshots of the model state at intervals you control.
This page walks you through what happens inside that loop - how BORES solves the pressure equation, updates fluid properties, computes phase velocities, evolves saturations, and manages timestep size. Understanding this workflow will help you interpret simulation results, diagnose convergence issues, and choose appropriate solver settings for your problem.
BORES supports three evolution schemes: IMPES (Implicit Pressure, Explicit Saturation), explicit (both pressure and saturation explicit), and implicit (fully implicit, currently in development). IMPES is the default and recommended scheme for most problems. The explicit scheme is available as an alternative for problems where you want simpler numerics at the cost of smaller timesteps.
The IMPES Method¶
IMPES (Implicit Pressure, Explicit Saturation) is the primary solution scheme in BORES and the one you should use unless you have a specific reason not to. It splits the coupled pressure-saturation system into two sequential steps: first solve pressure implicitly (by assembling and solving a linear system), then update saturations explicitly (by computing fluxes from the new pressure field). This splitting is the key design choice that makes IMPES both efficient and practical.
The implicit pressure step means that pressure changes of any magnitude can be handled stably in a single timestep - there is no CFL-like stability limit on the pressure update. The explicit saturation step means that saturation updates are cheap (no linear system to solve), but the timestep size must be kept small enough that saturation fronts do not jump more than one cell per step. In practice, this is rarely a binding constraint for field-scale simulations where fronts move slowly relative to the grid spacing.
Here is the sequence of operations that BORES executes at each IMPES timestep:
-
Solve pressure implicitly - Assemble the pressure coefficient matrix and right-hand side from current fluid properties, transmissibilities, compressibilities, and well terms. Solve the resulting sparse linear system using an iterative solver (BiCGSTAB by default) with a preconditioner (ILU by default).
-
Update PVT properties - Recompute all pressure-dependent fluid properties (formation volume factors, viscosities, solution GOR, compressibilities, densities) at the new pressure. This is critical because the saturation update that follows needs accurate fluid properties at the new pressure.
-
Recompute relative mobilities - Rebuild phase mobilities (\(\lambda_\alpha = k_{r\alpha} / \mu_\alpha\)) using the updated viscosities from step 2 and the current relative permeabilities.
-
Update saturations explicitly - Compute inter-cell fluxes using Darcy's law with the new pressure gradient and current mobilities. Update oil, water, and gas saturations in each cell based on the net flux balance and well source/sink terms. Enforce the saturation constraint \(S_o + S_w + S_g = 1\).
-
Check convergence and adjust timestep - Verify that saturation changes and pressure changes are within configured limits. If they are, accept the step and advance time. If not, reject the step, reduce the timestep, and retry.
The following diagram illustrates this loop:
flowchart TD
A["Initialize Model"] --> B["Start Timestep"]
B --> C["Solve Pressure Implicitly"]
C --> D["Update PVT Properties"]
D --> E["Recompute Relative Mobilities"]
E --> F["Update Saturations Explicitly"]
F --> G{"Convergence Check"}
G -- Pass --> H["Accept Step & Advance Time"]
G -- Fail --> I["Reduce Timestep"]
I --> B
H --> J{"End Time?"}
J -- No --> B
J -- Yes --> K["End Simulation"] Why PVT Updates Happen Between Pressure and Saturation
In the IMPES scheme, PVT properties are updated after the implicit pressure solve but before the explicit saturation update. This is intentional: pressure changes affect viscosity, density, and compressibility, which in turn affect phase mobility and transport. By updating PVT between the two steps, the saturation transport uses fluid properties that are consistent with the new pressure field. In contrast, the explicit scheme updates PVT after both pressure and saturation have been evolved, using old-time values for transport coefficients. This difference is one reason IMPES is generally more accurate at larger timesteps.
The Explicit Scheme¶
The explicit scheme solves both pressure and saturation using explicit (forward-in-time) finite difference updates. No linear system is assembled or solved - each cell's new pressure and saturation are computed directly from the old-time values and the fluxes through its faces.
The main advantage of the explicit scheme is simplicity and per-step speed. There is no matrix assembly, no iterative solver, and no preconditioner. Each timestep is just a sequence of array operations. The main disadvantage is stability: the CFL condition constrains the timestep to be small enough that information does not travel more than one cell width per step. For fine grids or high-permeability zones, this can force very small timesteps, making the overall simulation slower than IMPES despite the cheaper per-step cost.
You select the explicit scheme by setting scheme="explicit" in your Config:
import bores
config = bores.Config(
timer=bores.Timer(
initial_step_size=bores.Time(hours=1),
max_step_size=bores.Time(days=1),
min_step_size=bores.Time(seconds=10),
simulation_time=bores.Time(days=365),
),
rock_fluid_tables=rock_fluid_tables,
wells=wells,
scheme="explicit", # Use explicit scheme instead of IMPES
)
Stability with the Explicit Scheme
The explicit scheme requires smaller timesteps than IMPES to remain stable. BORES monitors the CFL number at each step and will reject steps that exceed the configured pressure_cfl_threshold (default 0.9) or saturation_cfl_threshold (default 0.6). If your simulation is rejecting many steps, try reducing max_step_size in the Timer or switching to scheme="impes".
Timestep Control¶
Choosing the right timestep size is critical for balancing accuracy, stability, and computational speed. Too large a timestep causes convergence failures and inaccurate results. Too small a timestep wastes computation on unnecessarily fine time resolution. BORES uses an adaptive timestep controller (the Timer class) that automatically adjusts the step size based on what is happening in the simulation.
The Timer manages timestep size through several mechanisms working together:
Ramp-up at simulation start. The initial timestep is typically small (specified by initial_step_size) and grows gradually over the first several steps. This protects against the pressure transient that often occurs at the very start of a simulation when wells first begin operating.
Growth on success. After each successful timestep, the Timer may increase the next step size, subject to max_growth_per_step (default 1.3, meaning at most 30% growth per step) and max_step_size. Growth is also controlled by a cooldown period (growth_cooldown_steps) that prevents aggressive growth immediately after a failure.
Backoff on failure. When a timestep is rejected (because pressure or saturation changes exceeded their limits, or the solver failed to converge), the Timer reduces the step size by backoff_factor (default 0.5) and retries. If multiple consecutive failures occur, it uses aggressive_backoff_factor (default 0.25) for a more dramatic reduction.
CFL-based adjustment. For the explicit scheme, the Timer monitors the CFL number and adjusts the step size to keep it below the safety margin. This is the primary stability control for explicit simulations.
Change-based limits. The Config specifies maximum allowable changes per timestep for pressure (max_pressure_change, default 100 psi) and for each phase saturation (max_oil_saturation_change, max_water_saturation_change, max_gas_saturation_change). If any of these limits are exceeded, the step is rejected and retried with a smaller timestep.
import bores
Time = bores.Time
timer = bores.Timer(
initial_step_size=Time(days=0.5), # Start with half-day steps
max_step_size=Time(days=30), # Allow up to 30-day steps
min_step_size=Time(minutes=30), # Never go below 30 minutes
simulation_time=Time(years=10), # Run for 10 years
max_growth_per_step=1.3, # Grow at most 30% per step
backoff_factor=0.5, # Halve step size on failure
)
Timer Settings for Different Problems
- Waterflooding (slow fronts):
initial_step_size=Time(days=1),max_step_size=Time(days=30) - Gas injection (fast fronts):
initial_step_size=Time(hours=6),max_step_size=Time(days=5) - Early-time transient (well startup):
initial_step_size=Time(minutes=30), allow ramp-up - Depletion (slow, uniform):
initial_step_size=Time(days=5),max_step_size=Time(days=60)
What Happens Each Timestep¶
Let us walk through a single IMPES timestep in more detail to give you a concrete picture of what the simulator is doing.
1. Propose a timestep size. The Timer proposes a step size \(\Delta t\) based on the history of previous steps - whether they succeeded or failed, how large the CFL number was, and how much the pressure and saturation changed. This proposed size is a "trial" - it may be rejected later.
2. Apply boundary conditions. Before solving anything, BORES updates the ghost cells (the padding layer around the grid) to reflect the current boundary conditions. For constant-pressure boundaries, the ghost cells are set to the boundary pressure. For no-flow boundaries (the default), ghost cells mirror their interior neighbors. For time-dependent boundaries like Carter-Tracy aquifers, the influx is computed based on the current pressure history.
3. Build rock-fluid property grids. Relative permeabilities are computed from the current saturations using the rock-fluid tables you configured (Corey, Brooks-Corey, LET, etc.). Relative mobilities are then computed as \(\lambda_\alpha = k_{r\alpha} / \mu_\alpha\). Capillary pressures are computed if enabled.
4. Solve the pressure equation. The pressure coefficient matrix \(\mathbf{A}\) and right-hand side vector \(\mathbf{b}\) are assembled from transmissibilities, compressibilities, well terms, and gravity terms. The system \(\mathbf{A} \mathbf{p}^{n+1} = \mathbf{b}\) is solved using the configured iterative solver and preconditioner. The solution gives the new pressure field.
5. Validate the pressure solution. BORES checks that no cell has an unphysical pressure (negative or extremely high). If unphysical pressures are detected, the step is rejected with a diagnostic message.
6. Update PVT properties. All pressure-dependent fluid properties are recomputed at the new pressure: formation volume factors (\(B_o\), \(B_g\), \(B_w\)), viscosities (\(\mu_o\), \(\mu_g\), \(\mu_w\)), solution GOR (\(R_s\)), gas solubility in water (\(R_{sw}\)), compressibilities, and densities. If you provided PVT tables, they are used for interpolation; otherwise, correlations are evaluated.
7. Update saturations. Inter-cell Darcy fluxes are computed from the new pressure gradient and current mobilities. Each cell's saturation is updated based on the net flux balance:
where \(V_b\) is the bulk cell volume, \(q_\alpha\) is the flux of phase \(\alpha\) through a cell face, and \(Q_\alpha^{\text{well}}\) is the well source/sink term.
8. Check changes. The maximum pressure change and maximum saturation change across all cells are compared against the configured limits. If either exceeds its limit, the step is rejected, the timestep is reduced, and the simulator returns to step 1 with the smaller timestep. The old pressure and saturation fields are restored.
9. Record state. If the step is accepted and it falls on an output interval (controlled by output_frequency in Config), a ModelState snapshot is captured and yielded to the caller. This snapshot contains the full reservoir model state, well data, relative permeabilities, rate grids, and timer state.
Running a Simulation in BORES¶
With all the theory covered, here is what the actual code looks like. The bores.run() function is a Python generator that yields ModelState objects at each output interval.
import bores
# Assume model and config have been set up
# (see the Quick Example on the home page for full setup)
# bores.run() returns a generator - it computes one step at a time
simulation = bores.run(model, config)
# Iterate over the results
for state in simulation:
pressure = state.model.fluid_properties.pressure_grid
oil_sat = state.model.fluid_properties.oil_saturation_grid
print(
f"Step {state.step:4d} | "
f"Time: {state.time_in_days:8.1f} days | "
f"Avg P: {pressure.mean():8.1f} psi | "
f"Avg So: {oil_sat.mean():.4f}"
)
The generator pattern is important: BORES does not compute all timesteps upfront and store them in memory. It computes one step at a time and yields the result. This means you can process results as they arrive, stop early if a condition is met, or stream results to disk without holding the entire simulation history in memory.
You can also use the Run class for a more structured approach, especially when loading models and configs from files:
import bores
# Create a Run from files
sim_run = bores.Run.from_files(
model_path="model.h5",
config_path="config.yaml",
pvt_data_path="pvt.h5", # Optional
)
# Iterate directly over the Run object
for state in sim_run:
process(state)
Collecting All States
If your model is small enough to fit in memory, you can collect all states into a list for post-processing:
states = list(bores.run(model, config))
final_state = states[-1]
print(f"Simulation completed: {final_state.step} steps, {final_state.time_in_days:.1f} days")
For large models, consider using BORES streaming utilities (bores.streams) to pipe results to disk (HDF5, Zarr, or pickle) instead of holding them all in memory.
The ModelState Object¶
Each yielded ModelState contains a complete snapshot of the simulation at that point in time. Here are the key attributes you will use most often:
| Attribute | Type | Description |
|---|---|---|
state.step | int | Timestep index |
state.step_size | float | Timestep size in seconds |
state.time | float | Elapsed simulation time in seconds |
state.time_in_days | float | Elapsed simulation time in days |
state.model | ReservoirModel | Full reservoir model with updated properties |
state.model.fluid_properties.pressure_grid | ndarray | Pressure at each cell (psi) |
state.model.fluid_properties.oil_saturation_grid | ndarray | Oil saturation at each cell |
state.model.fluid_properties.water_saturation_grid | ndarray | Water saturation at each cell |
state.model.fluid_properties.gas_saturation_grid | ndarray | Gas saturation at each cell |
state.injection | RateGrids | Injection rates (oil, water, gas) in ft³/day |
state.production | RateGrids | Production rates (oil, water, gas) in ft³/day |
state.wells | Wells | Well configuration at this state |
Because every property is a NumPy array, you can perform any NumPy operation on the results - compute field averages, extract well-block values, calculate material balance, or feed the data into your own analysis pipeline.
Solver and Preconditioner Selection¶
The pressure equation at each IMPES timestep requires solving a sparse linear system. The choice of solver and preconditioner affects both the speed and robustness of the simulation. BORES provides several options that you configure through the Config class.
config = bores.Config(
timer=timer,
rock_fluid_tables=rock_fluid_tables,
pressure_solver="bicgstab",
pressure_preconditioner="ilu",
# ... other parameters
)
The default combination of BiCGSTAB solver with ILU preconditioner works well for most problems and is the recommended starting point.
config = bores.Config(
timer=timer,
rock_fluid_tables=rock_fluid_tables,
pressure_solver="gmres",
pressure_preconditioner="cpr",
# ... other parameters
)
For problems with strong coupling, high permeability contrasts, or convergence difficulties, try GMRES with the CPR (Constrained Pressure Residual) preconditioner.
config = bores.Config(
timer=timer,
rock_fluid_tables=rock_fluid_tables,
pressure_solver="cg",
pressure_preconditioner="diagonal",
# ... other parameters
)
For well-conditioned problems (homogeneous reservoirs, mild contrasts), the conjugate gradient solver with a diagonal preconditioner is the cheapest option.
Direct Solver Warning
Avoid using pressure_solver="direct" for grids larger than about 50,000 cells. The direct solver (sparse LU factorization) has \(O(n^{1.5})\) to \(O(n^2)\) memory scaling, and BORES will warn you about estimated memory usage for large systems. Iterative solvers with good preconditioners are almost always faster and more memory-efficient for reservoir simulation problems.
For a comprehensive guide to solver and preconditioner selection, including performance benchmarks and tuning advice, see the Solvers and Preconditioners guide.