Running and Monitoring Simulations¶
After building your reservoir model and assembling your configuration, you are ready to run a simulation. BORES provides two closely related entry points for this: bores.run() executes the simulation and yields results, while bores.monitor() wraps that same execution with a live progress display and diagnostic statistics. Both functions produce the same ModelState output at the same cadence - the choice between them is purely about how much visibility you want during the run.
This separation is intentional. In batch or headless environments (HPC clusters, automated workflows, overnight runs), you want bores.run() with no terminal output overhead. During development and debugging, you want bores.monitor() with its live dashboard showing solver convergence, per-well rates, and pressure evolution. You can switch between them by changing a single function name - everything else stays the same.
Both functions are generator-based. They yield ModelState objects rather than returning a list of results. This means memory usage is bounded even for long simulations: BORES yields one state, you process it, and then the next step begins. If you want to retain history, you collect states into a list yourself. If you are running a 10,000-step simulation and only care about the final result, you iterate and discard intermediate states.
Quick Start¶
The minimum viable simulation run looks like this:
import bores
model = bores.ReservoirModel.from_file("reservoir.h5")
config = bores.Config.from_file("simulation.yaml")
for state in bores.run(model, config):
print(f"Step {state.step}: time = {state.time:.1f} s, "
f"avg pressure = {state.average_pressure:.1f} psi")
For interactive work where you want a live display in your terminal:
import bores
model = bores.ReservoirModel.from_file("reservoir.h5")
config = bores.Config.from_file("simulation.yaml")
for state in bores.monitor(model, config):
# Your per-step analysis here
pass
Both loops behave identically in terms of what state contains and when it is yielded. The only difference is the terminal output.
bores.run(): Core Simulation Execution¶
Function Signature¶
def run(
input: Union[ReservoirModel[ThreeDimensions], Run],
config: Optional[Config] = None,
*,
on_step_rejected: Optional[StepCallback] = None,
on_step_accepted: Optional[StepCallback] = None,
) -> Generator[ModelState[ThreeDimensions], None, None]:
Parameters¶
input accepts either:
- A
ReservoirModel[ThreeDimensions]- the static model you built. When you pass aReservoirModel, you must also supplyconfig. - A
Run- a pre-packaged specification that bundles model and config together. See the Run Specification section below.
config is the Config object containing your simulation parameters, scheme choice, wells, PVT tables, and output frequency.
- Required when
inputis aReservoirModel. - Optional when
inputis aRun- theRun's own config is used by default, but you can override it by passing a newconfighere.
on_step_rejected is an optional callback invoked whenever a proposed time step fails due to convergence problems or saturation change violations. BORES will automatically retry the step with a smaller time step size, but this callback gives you visibility into rejection events. It receives three arguments: the StepResult (containing residuals and failure messages), the rejected step size in seconds, and the total elapsed simulation time in seconds.
def handle_rejection(result, step_size, elapsed_time):
print(f"Step rejected at t={elapsed_time:.1f}s, "
f"step size {step_size:.3f}s: {result.message}")
for state in bores.run(model, config, on_step_rejected=handle_rejection):
pass
on_step_accepted is the mirror callback, invoked after each successfully completed step. It receives the same three arguments. Useful for collecting lightweight per-step metrics without storing full ModelState objects.
newton_counts = []
def on_accepted(result, step_size, elapsed_time):
newton_counts.append(result.timer_kwargs.get("newton_iterations", -1))
for state in bores.run(model, config, on_step_accepted=on_accepted):
pass
What ModelState Contains¶
Each yielded ModelState is a complete snapshot of the simulation at that point in time. The key fields you will use most often are:
state.step- The accepted step index (1-based).state.time- Total elapsed simulation time in seconds.state.step_size- The time step size used for this step in seconds.state.average_pressure,state.min_pressure,state.max_pressure- Grid-level pressure statistics in psi.state.average_water_saturation- Mean water saturation across all cells.state.model.fluid_properties- Full grid arrays for pressure, water saturation, oil saturation, and gas saturation.state.model.rock_properties- Porosity, absolute permeability, and residual saturations.state.wells- Well configuration with current schedule applied.state.production_ratesandstate.injection_rates-SparseTensorobjects holding surface-condition rates by well location (STB/day for oil and water, SCF/day for gas).state.production_bhpsandstate.injection_bhps- Bottom-hole pressures for each well in psi.state.timer_state- Serializable timer state for checkpointing and resumption.
See the ModelState Documentation for complete field access patterns.
Output Frequency¶
By default, bores.run() yields a ModelState after every accepted step (output_frequency=1). For long simulations, this produces a lot of data. You can reduce output by setting output_frequency in your Config:
# Yield every 100 accepted steps - good for 10,000+ step simulations
config = bores.Config(
...,
output_frequency=100,
)
BORES always yields the initial state (step 0) and the final state, regardless of output_frequency, so you never miss the start or end of the simulation.
Step Rejection and Retry¶
When a time step fails - because pressures became unphysical, saturation changes exceeded their limits, or Newton iteration did not converge - BORES does not abort. Instead, it:
- Calls
on_step_rejectedif you provided one. - Reduces the step size adaptively (the reduction strategy depends on how badly the step failed).
- Retries the same time interval with the smaller step.
This continues until the step succeeds, or until the step size cannot be reduced any further (at which point SimulationError is raised). The adaptive step control is transparent to you as the caller - from the outside, you simply see fewer states per unit time during difficult periods.
Example: Collecting Production History¶
import bores
model = bores.ReservoirModel.from_file("model.h5")
config = bores.Config.from_file("config.yaml")
production_history = {}
for state in bores.run(model, config):
for well_name, well in state.wells.items():
if well_name not in production_history:
production_history[well_name] = {"time": [], "oil": [], "water": [], "gas": []}
loc = well.location
production_history[well_name]["time"].append(state.time)
production_history[well_name]["oil"].append(
state.production_rates.oil.get(loc, 0.0)
)
production_history[well_name]["water"].append(
state.production_rates.water.get(loc, 0.0)
)
production_history[well_name]["gas"].append(
state.production_rates.gas.get(loc, 0.0)
)
for well_name, history in production_history.items():
final_oil = history["oil"][-1] if history["oil"] else 0.0
print(f"{well_name}: {final_oil:.1f} STB/day at end")
Run Specification¶
The Run class bundles a reservoir model and configuration into a single named object. This is useful for organizing multiple scenarios, serializing run definitions to disk, or packaging simulation inputs for distribution.
Creating a Run¶
from bores import ReservoirModel, Config, Run
model = ReservoirModel.from_file("path/to/3d_model.h5")
config = Config.from_file("path/to/simulation_config.yaml")
run_spec = Run(
model=model,
config=config,
name="Primary Depletion - Base Case",
description="30-year primary depletion from 3,000 psi with no injection support",
tags=("baseline", "primary-depletion"),
)
Executing a Run¶
The Run class is callable and iterable - you can execute it in several equivalent ways:
# Direct iteration
for state in run_spec:
process(state)
# Calling it as a function
for state in run_spec():
process(state)
# Passing to bores.run() - equivalent, and allows config override
for state in bores.run(run_spec):
process(state)
# Override the config for a sensitivity case
sensitivity_config = bores.Config(scheme="full-sequential-implicit", ...)
for state in bores.run(run_spec, config=sensitivity_config):
process(state)
Loading from Files¶
from bores import Run
run_spec = Run.from_files(
model_path="path/to/model.h5",
config_path="path/to/config.yaml",
pvt_tables_path="path/to/pvt_tables.h5", # Optional
)
for state in run_spec:
process(state)
The Run.from_files() method handles loading the PVT tables and attaching them to the config automatically, which simplifies the setup code when all your simulation inputs are stored as files.
bores.monitor(): Live Monitoring and Diagnostics¶
bores.monitor() wraps the simulation execution with a live terminal display and a RunStats accumulator. It yields the exact same ModelState objects as bores.run(), so you can switch to monitoring without changing any of your downstream processing code.
Function Signature¶
def monitor(
input: Union[ReservoirModel, Run, Iterable[ModelState]],
config: Optional[Config] = None,
*,
monitor: Optional[MonitorConfig] = None,
on_step_rejected: Optional[StepCallback] = None,
on_step_accepted: Optional[StepCallback] = None,
return_stats: bool = False,
) -> Generator[ModelState | Tuple[ModelState, RunStats], None, None]:
The input parameter accepts one additional type compared to bores.run(): an Iterable[ModelState]. This allows you to run the monitor over a stream of pre-loaded states from a saved run, getting diagnostics without re-running the simulation. See the Post-Processing Example below.
Enabling Diagnostic Statistics¶
Set return_stats=True to receive a (ModelState, RunStats) tuple at each yield. The RunStats object accumulates in-place throughout the loop and remains valid after the loop completes:
import bores
model = bores.ReservoirModel.from_file("reservoir.h5")
config = bores.Config.from_file("config.yaml")
for state, stats in bores.monitor(model, config, return_stats=True):
# stats is updated in-place at every step
pass
# Inspect after the loop
print(f"Accepted steps: {stats.accepted_steps}")
print(f"Rejected steps: {stats.rejected_steps}")
print(f"Total wall time: {stats.total_wall_time:.2f} s")
print(f"Avg step time: {stats.average_step_wall_ms:.2f} ms")
print(f"p95 step time: {stats.get_percentile_wall_time_ms(95):.2f} ms")
print(f"Avg Newton iters: {stats.average_newton_iterations:.2f}")
# Print the full summary table
print(stats.summary_table())
MonitorConfig: Controlling the Display¶
MonitorConfig controls which display backends are active and how they behave:
from bores import MonitorConfig
monitor_cfg = MonitorConfig(
use_rich=True, # Live Rich panel (default: True)
use_tqdm=True, # tqdm progress bar (default: False)
refresh_interval=1, # Update display every N accepted steps
extended_every=10, # Show p95 wall time and avg Newton every N steps
show_wells=True, # Include per-well rates table in display
color_theme="dark", # "dark" or "light"
)
for state in bores.monitor(model, config, monitor=monitor_cfg):
pass
use_rich enables the live Rich panel. This is a compact two-column display that shows reservoir physics on the left (pressure statistics, saturations) and solver diagnostics on the right (step size, wall time, Newton iterations, CFL number, rejected/accepted step counts). The panel updates in-place and is preserved in your terminal scroll-back history when the run ends.
use_tqdm adds a standard tqdm progress bar below the Rich panel. The bar tracks simulation-time progress from 0 to 100% and shows per-step timing in the postfix. This is useful when you want a clean progress summary in logs or CI output.
extended_every controls how often the Rich panel includes extended performance diagnostics (p95 step wall time and average Newton iterations). Setting it to 0 disables extended stats entirely, which gives a slightly cleaner display during fast-running simulations.
color_theme applies to the Rich panel. The "dark" theme uses charcoal background with amber accents. The "light" theme uses off-white with navy accents. Choose based on your terminal background.
RunStats: Diagnostic Accumulator¶
RunStats is the statistics object returned alongside states when return_stats=True. It accumulates data after every accepted output step and provides both live-readable properties and a post-run summary.
Key properties and methods:
stats.accepted_steps- Number of steps that succeeded.stats.rejected_steps- Number of step rejections (reduces when step size is reduced and retried).stats.total_wall_time- Cumulative wall clock time in seconds.stats.average_step_wall_ms- Mean time per accepted step in milliseconds.stats.get_percentile_wall_time_ms(95)- 95th percentile step wall time - useful for identifying slow outlier steps.stats.average_newton_iterations- Mean Newton iterations per step (only counts steps that used Newton iteration).stats.steps- A list ofStepDiagnosticsobjects, one per output step, containing the full scalar snapshot for each step.stats.summary_table()- Returns a formatted RichTablewith the complete run summary.stats.summary()- Returns a plain-text summary string suitable for logging.
The stats object is the same instance throughout the loop. If you want to inspect intermediate statistics (say, after the first 1000 steps), you can read from it inside the loop without any special handling.
Example: Full Monitoring Workflow¶
import bores
model = bores.ReservoirModel.from_file("reservoir.h5")
config = bores.Config.from_file("config.yaml")
monitor_cfg = bores.MonitorConfig(
use_rich=True,
use_tqdm=True,
show_wells=True,
refresh_interval=1,
)
pressure_history = []
saturation_history = []
for state, stats in bores.monitor(
model,
config,
monitor=monitor_cfg,
return_stats=True,
):
pressure_history.append(state.average_pressure)
saturation_history.append(state.average_water_saturation)
# Post-simulation analysis
print("\n" + "="*60)
print(stats.summary())
final = pressure_history[-1]
print(f"Final average pressure: {final:.2f} psi")
print(f"Rejection rate: {stats.rejected_steps}/{stats.accepted_steps + stats.rejected_steps}")
Example: Post-Processing Saved Runs¶
bores.monitor() also accepts an iterable of ModelState objects, which lets you run the diagnostic display over previously saved simulation states without re-running the simulation:
import bores
# Load states from disk - your loading function here
states = load_states_from_disk("simulation_states.h5")
# Collect diagnostics without re-running
for state, stats in bores.monitor(
states,
monitor=bores.MonitorConfig(use_rich=True),
return_stats=True,
):
pass
print(stats.summary_table())
Best Practices¶
Output Frequency vs. Memory Usage¶
Yielding every step generates large amounts of state data. For long simulations, increase output_frequency to match your actual analysis needs:
# For a 10,000-step simulation where you only need daily snapshots,
# set output_frequency to match your timestep-to-time ratio
config = bores.Config(
...,
output_frequency=100,
)
Alternatively, if you only need specific aggregates and not full grid snapshots, use the on_step_accepted callback to collect them without storing ModelState objects at all.
Use Callbacks for Lightweight Metrics¶
If your analysis requires per-step data (not just output-frequency data), callbacks are more efficient than storing states:
metrics = {"rejections": 0, "max_newton": 0, "step_times": []}
def on_rejected(result, step_size, elapsed):
metrics["rejections"] += 1
def on_accepted(result, step_size, elapsed):
ni = result.timer_kwargs.get("newton_iterations", 0)
metrics["max_newton"] = max(metrics["max_newton"], ni)
for state in bores.run(
model,
config,
on_step_rejected=on_rejected,
on_step_accepted=on_accepted
):
pass
Accessing Well Rates via SparseTensor¶
Production and injection rates are stored as SparseTensor objects. A SparseTensor is a dictionary-like container keyed by cell index tuples. Access it like this:
import numpy as np
for state in bores.run(model, config):
# Compute total production rates across all wells (sum all surface rates)
total_oil_production_ft3_day = state.production_rates.oil.sum()
total_water_production_ft3_day = state.production_rates.water.sum()
# Convert to surface conditions using formation volume factors
# Production rates are negative by convention, so take absolute value
total_oil_production_stb = (
abs(total_oil_production_ft3_day) * 5.615 /
state.production_formation_volume_factors.oil.mean()
)
# Compute statistics on production across all active cells
oil_prod_grid = state.production_rates.oil.array()
producing_cells = np.abs(oil_prod_grid[oil_prod_grid != 0])
if producing_cells.size > 0:
mean_rate_ft3_day = producing_cells.mean()
max_rate_ft3_day = producing_cells.max()
print(
f"Step {state.step} ({state.time_in_days:.1f} days): "
f"Oil production = {total_oil_production_stb:.1f} STB/day, "
f"Water production = {abs(total_water_production_ft3_day):.1f} ft³/day"
)
Resuming from a Checkpoint¶
BORES supports resuming a simulation from a previously yielded state. The timer_state field on each ModelState contains a serializable snapshot of the timer that you can use to create a new timer starting from that point:
import bores
# First run - stop after reaching a target time
states = []
for state in bores.run(model, config):
states.append(state)
if state.time > 1e6:
break
# Resume from the last yielded state
last_state = states[-1]
new_model = last_state.model
new_timer = bores.Timer.from_state(last_state.timer_state)
resumed_config = config.with_updates(timer=new_timer)
for state in bores.run(new_model, resumed_config):
process(state)
Troubleshooting¶
Simulation Runs Slowly¶
High step rejection rates are the most common cause of slow simulations. Check with a callback:
rejection_count = 0
def track(result, step_size, elapsed):
global rejection_count
rejection_count += 1
for state in bores.run(model, config, on_step_rejected=track):
pass
print(f"Total rejections: {rejection_count}")
If the rejection count is high relative to accepted steps, consider:
- Reducing
cfl_safety_marginif the scheme is too aggressive with step sizes. - Switching to a more stable scheme (Sequential Implicit or Full Sequential Implicit) for stiff problems with large mobility contrasts.
- Adding local grid refinement near wells if rejections are triggered by near-well pressure spikes.
Out of Memory During Long Simulations¶
Increase output_frequency to reduce the number of yielded states. For the most memory-efficient runs, process states in streaming fashion and do not store them:
config = bores.Config(..., output_frequency=1000)
# Stream without storing
for state in bores.run(model, config):
write_to_disk(state) # Write and discard
Convergence Failures¶
Use RunStats to diagnose:
for state, stats in bores.monitor(model, config, return_stats=True):
pass
print(f"Avg Newton iterations: {stats.average_newton_iterations:.2f}")
print(f"p95 step time: {stats.get_percentile_wall_time_ms(95):.1f} ms")
High average Newton iterations (above 8-10 per step) indicate that pressure-saturation coupling is tight and the solver is struggling. In this case, try switching from sequential-implicit to full-sequential-implicit, which adds outer iteration to enforce coupling consistency and typically converges in fewer total Newton iterations even though each step does more work.