Running Simulations¶
Python workflow for particle tracking simulations with mod-PATH3DU.
Workflow Overview¶
A complete simulation follows these steps:
- Build the grid from vertex and centroid data
- Hydrate cell properties (porosity, conductivity, dispersivity, etc.)
- Hydrate cell flows (heads, face flows, well rates, etc.)
- Hydrate Waterloo-specific inputs (geometry and face velocities)
- Fit the Waterloo velocity field
- Create a
SimulationConfigfrom JSON - Define particle starting positions
- Run the simulation
- Process the results
Loading Data¶
Building the Grid¶
The grid is constructed from cell vertices and centroids:
import mp3du
# vertices: list of cells, each cell is a list of (x, y) tuples
# centers: list of (x, y, z) tuples — one per cell
grid = mp3du.build_grid(vertices, centers)
print(f"Grid loaded: {grid.n_cells()} cells")
vertices— each cell's polygon boundary as ordered \((x, y)\) coordinate pairscenters— the \((x, y, z)\) centroid of each cell
Both lists must have the same length (one entry per cell).
Hydrating Cell Properties¶
Cell properties provide the physical parameters for each cell:
import numpy as np
cell_props = mp3du.hydrate_cell_properties(
top=top_array, # cell top elevations (n_cells,)
bot=bot_array, # cell bottom elevations (n_cells,)
porosity=porosity_array, # effective porosity (n_cells,)
retardation=retard_array, # retardation factor (n_cells,)
hhk=hhk_array, # horizontal hydraulic conductivity (n_cells,)
vhk=vhk_array, # vertical hydraulic conductivity (n_cells,)
disp_long=dl_array, # longitudinal dispersivity (n_cells,)
disp_trans_h=dth_array, # horizontal transverse dispersivity (n_cells,)
disp_trans_v=dtv_array, # vertical transverse dispersivity (n_cells,)
)
All arrays must be 1D numpy.ndarray[float64] with length equal to grid.n_cells().
Hydrating Cell Flows¶
Cell flows provide the hydraulic state and inter-cell fluxes:
cell_flows = mp3du.hydrate_cell_flows(
head=head_array, # hydraulic head (n_cells,)
water_table=wt_array, # water table elevation (n_cells,)
q_top=q_top_array, # top-face recharge flux (n_cells,)
q_bot=q_bot_array, # bottom-face flux (n_cells,)
q_vert=q_vert_array, # vertical flux (n_cells,)
q_well=q_well_array, # well pumping rate (n_cells,)
q_other=q_other_array, # other source/sink (n_cells,)
q_storage=q_storage_array, # storage flux (n_cells,)
has_well=has_well_array, # boolean well presence (n_cells,) bool
face_offset=face_offset, # CSR row pointers (n_cells + 1,) uint64
face_flow=face_flow_array, # face flow rates (n_faces,) float64
face_neighbor=face_nbr_array, # face neighbor cell IDs (n_faces,) int64
)
CSR face arrays
The face_offset, face_flow, and face_neighbor arrays use Compressed Sparse Row format. For cell \(i\), its face connections span indices face_offset[i] to face_offset[i+1]. See Units & Conventions for details.
Hydrating Boundary Conditions¶
For models with IFACE-assigned boundary conditions (CHD, WEL, RCH, etc.), pass boundary metadata to enable face-based particle capture:
import numpy as np
# Assemble parallel boundary condition arrays from MODFLOW CBC output.
# Each entry represents one BC record: (cell_index, iface, flow, type_index).
bc_cell_ids = []
bc_iface_arr = []
bc_flow_arr = []
bc_type_id_arr = []
bc_type_names = ["CONSTANT HEAD", "WELLS", "RECHARGE"]
# Example: CHD cells with IFACE = 2 (side face)
for node, flow in chd_flows.items():
bc_cell_ids.append(node - 1) # 0-based
bc_iface_arr.append(2)
bc_flow_arr.append(flow) # raw MODFLOW sign
bc_type_id_arr.append(0) # index into bc_type_names
cell_flows = mp3du.hydrate_cell_flows(
head=head_array,
water_table=wt_array,
q_top=q_top_array,
q_bot=q_bot_array,
q_vert=q_vert_array,
q_well=q_well_array,
q_other=q_other_array,
q_storage=q_storage_array,
has_well=has_well_array,
face_offset=face_offset,
face_flow=face_flow_array,
face_neighbor=face_nbr_array,
# Boundary condition metadata (optional — omit for legacy behaviour)
bc_cell_ids=np.array(bc_cell_ids, dtype=np.int64),
bc_iface=np.array(bc_iface_arr, dtype=np.int32),
bc_flow=np.array(bc_flow_arr, dtype=np.float64),
bc_type_id=np.array(bc_type_id_arr, dtype=np.int32),
bc_type_names=bc_type_names,
is_domain_boundary=domain_bdy_arr, # bool (n_cells,)
has_water_table=water_table_flags, # bool (n_cells,)
)
IFACE flow routing helper
The scripts/mp3du_iface_routing.py module provides route_iface_bc_flows(),
a vectorized helper that routes IFACE-tagged BC flows to the correct
q_well / q_other / q_top / q_bot arrays. See
IFACE Flow Routing for details.
All bc_* arrays are optional
If you omit all boundary arrays, hydrate_cell_flows() behaves exactly
as before — capture is controlled by has_well alone.
See IFACE & Boundary Capture
for the full conceptual guide.
Hydrating Waterloo Inputs¶
The Waterloo method requires additional geometric and velocity data per cell:
waterloo_inputs = mp3du.hydrate_waterloo_inputs(
centers_xy=centers_xy, # cell centroids (n_cells, 2) float64
radii=radii, # effective cell radii (n_cells,) float64
perimeters=perimeters, # cell perimeters (n_cells,) float64
areas=areas, # cell plan-view areas (n_cells,) float64
q_vert=q_vert, # vertical flux (n_cells,) float64
q_well=q_well, # well flux (n_cells,) float64
q_other=q_other, # other flux (n_cells,) float64
face_offset=face_offset, # CSR row pointers (n_cells + 1,) uint64
face_vx1=face_vx1, # face velocity x-component, node 1 (n_faces,) float64
face_vy1=face_vy1, # face velocity y-component, node 1 (n_faces,) float64
face_vx2=face_vx2, # face velocity x-component, node 2 (n_faces,) float64
face_vy2=face_vy2, # face velocity y-component, node 2 (n_faces,) float64
face_length=face_length, # face lengths (n_faces,) float64
face_flow=face_flow, # face flow rates (n_faces,) float64
noflow_mask=noflow_mask, # no-flow face flags (n_faces,) bool
)
Fitting the Velocity Field¶
Once all input data is hydrated, fit the Waterloo velocity field:
waterloo_cfg = mp3du.WaterlooConfig(order_of_approx=35, n_control_points=122)
field = mp3du.fit_waterloo(
waterloo_cfg, grid, waterloo_inputs, cell_props, cell_flows
)
print(f"Field: {field.method_name()}, {field.n_cells()} cells")
The fitting step computes polynomial velocity coefficients for every cell. This is the most computationally expensive setup step — it only needs to be done once per flow field configuration.
Creating the Configuration¶
Build the configuration as a Python dictionary and convert to SimulationConfig:
import json
config_dict = {
"velocity_method": "Waterloo",
"solver": "DormandPrince",
"direction": 1.0,
"initial_dt": 1.0,
"max_dt": 1000.0,
"retardation_enabled": False,
"adaptive": {
"tolerance": 1e-6,
"safety": 0.9,
"alpha": 0.2,
"min_scale": 0.2,
"max_scale": 5.0,
"max_rejects": 10,
"min_dt": 1e-10,
"euler_dt": 0.1,
},
"dispersion": {"method": "None"},
"capture": {
"max_time": 365000.0,
"max_steps": 1000000,
"stagnation_velocity": 1e-12,
"stagnation_limit": 100,
},
}
config = mp3du.SimulationConfig.from_json(json.dumps(config_dict))
config.validate()
See Building Configs for detailed field-by-field guidance.
Defining Particle Starting Positions¶
Each particle needs an ID, starting coordinates, containing cell, and initial time step:
particles = [
mp3du.ParticleStart(
id=0,
x=150.0,
y=250.0,
z=50.0,
cell_id=42,
initial_dt=1.0,
),
mp3du.ParticleStart(
id=1,
x=300.0,
y=400.0,
z=45.0,
cell_id=87,
initial_dt=1.0,
),
]
| Field | Type | Description |
|---|---|---|
id |
int | Unique particle identifier |
x, y, z |
float | Starting coordinates (must be inside the specified cell) |
cell_id |
int | 0-based index of the cell containing the start point |
initial_dt |
float | Initial time step for this particle |
Coordinates must be inside the specified cell
If the starting position is outside cell_id, the solver may produce incorrect results or raise an error. Verify your starting coordinates against the grid geometry.
Running the Simulation¶
Serial Execution¶
Parallel Execution¶
By default, particles are tracked in parallel using all available CPU cores (via Rayon):
Parallel execution provides near-linear speedup for large particle sets. The velocity field is shared read-only across threads.
Tip
Use parallel=True (the default) for production runs with many particles. Use parallel=False for debugging, where deterministic single-threaded execution simplifies diagnosis.
Interpreting Results¶
TrajectoryResult¶
Each particle produces a TrajectoryResult:
for result in results:
print(f"Particle {result.particle_id}")
print(f" Status: {result.final_status}")
print(f" Reason: {result.termination_reason}")
print(f" Steps: {len(result)}")
# Get trajectory as a list of record dicts
records = result.to_records()
if records:
first = records[0]
last = records[-1]
print(f" Start: ({first['x']:.2f}, {first['y']:.2f}, {first['z']:.2f}) t={first['time']:.2f}")
print(f" End: ({last['x']:.2f}, {last['y']:.2f}, {last['z']:.2f}) t={last['time']:.2f}")
| Attribute | Type | Description |
|---|---|---|
particle_id |
int | The particle's ID (matches ParticleStart.id) |
final_status |
str | Termination reason (see below) |
termination_reason |
str | Detailed capture description (e.g., "Internal sink/source: CONSTANT HEAD") |
to_records() |
list[dict] | Trajectory as a list of record dictionaries |
len(result) |
int | Number of trajectory points |
Trajectory Records¶
Each record dictionary from to_records() contains:
| Key | Type | Description |
|---|---|---|
step |
int | Integration step number |
time |
float | Simulation time |
x, y, z |
float | Particle position |
vx, vy, vz |
float | Velocity components at position |
cell_id |
int | Cell containing the particle |
dt |
float | Time step used |
Final Status Values¶
| Status | Meaning |
|---|---|
CapturedByWell |
Particle reached a well (IFACE 0) within capture radius |
CapturedByBoundary |
Particle terminated by an IFACE 2/5/6/7 boundary condition |
CapturedAtModelEdge |
Particle reached a domain-edge cell (is_domain_boundary = True) |
Exited |
Particle left the grid domain |
MaxTime |
capture.max_time was reached |
MaxSteps |
capture.max_steps was reached |
Stagnated |
Velocity below stagnation_velocity for stagnation_limit consecutive steps |
Error |
A solver error occurred (check error diagnostics) |
termination_reason
For CapturedByWell and CapturedByBoundary, termination_reason
includes the BC type name from bc_type_names, e.g.,
"Internal sink/source: CONSTANT HEAD". For other statuses it mirrors
final_status.
Error Handling¶
Simulation errors are reported via the final_status field or raised as Python exceptions during setup. Common errors:
- Invalid configuration — raised by
SimulationConfig.from_json()orvalidate() - Array shape mismatch — raised by
hydrate_*functions when array lengths don't match - Solver failure — reported as
final_status = "Error"in the trajectory result
See Error Diagnostics for the full error catalog and Troubleshooting for common problems and solutions.