Units and Conventions¶
Coordinate system, unit expectations, and sign conventions for mod-PATH3DU.
Coordinate System¶
mod-PATH3DU uses a right-handed Cartesian coordinate system consistent with MODFLOW-USG:
| Axis | Direction | Description |
|---|---|---|
| X | Horizontal (east) | Model easting coordinate |
| Y | Horizontal (north) | Model northing coordinate |
| Z | Vertical (up) | Elevation above datum |
Cell geometry is defined by 2D polygonal footprints in the XY plane plus top/bottom elevations in Z. The grid supports unstructured (arbitrary polygon) cells as used in MODFLOW-USG.
Units¶
Unit Consistency
mod-PATH3DU does not perform unit conversions. All input data must use consistent units matching the MODFLOW model.
| Quantity | Expected Unit | Notes |
|---|---|---|
| Length | Model length units (m or ft) | Coordinates, elevations, dispersivities |
| Time | Model time units (d or s) | Step sizes, max_time, velocities |
| Velocity | Length / Time | Derived from flow and geometry |
| Dispersivity | Length | Same length unit as model — alpha_l, alpha_th, alpha_tv |
| Flow rate | Length³ / Time | Face flows, well rates, top/bottom fluxes |
| Hydraulic head | Length | Same length unit as model |
| Porosity | Dimensionless | 0 < n ≤ 1 |
| Retardation | Dimensionless | ≥ 1.0 |
| Hydraulic conductivity | Length / Time | hhk (horizontal), vhk (vertical) |
Direction Convention¶
The direction field in SimulationConfig controls tracking direction:
| Value | Meaning |
|---|---|
1.0 |
Forward tracking (in the direction of flow) |
-1.0 |
Backward tracking (reverse flow direction) |
Schema Constraint
The direction field accepts only 1.0 or -1.0 (enum constraint in JSON Schema). No other values are valid.
Forward tracking computes where particles will go from their starting positions. Backward tracking computes where particles came from — useful for delineating capture zones.
Sign Conventions¶
Critical — Read This Before Writing Data-Loading Code
The sign conventions for flow rates differ between MODFLOW output and the mp3du Waterloo fitting inputs. Getting these wrong produces silently incorrect velocity fields — particles will follow plausible-looking but physically wrong trajectories. The table below is the authoritative reference.
MODFLOW Version Matters — Different Versions Use Different Raw Signs
Different MODFLOW versions store cell-by-cell (CBC) face flows with fundamentally different sign conventions. You must know which MODFLOW variant produced your flow data before passing it to mp3du:
| MODFLOW Version | CBC Record Type | Raw Sign Convention |
|---|---|---|
| MODFLOW-2005 / NWT (structured) | FLOW RIGHT FACE, FLOW FRONT FACE, FLOW LOWER FACE |
Positive = flow in direction of increasing row/column/layer index (directional, not in/out) |
| MODFLOW-USG (unstructured) | FLOW JA FACE / FLOW-JA-FACE |
Positive = flow INTO the cell from the neighbour |
| MODFLOW 6 (unstructured) | FLOW-JA-FACE (or API FLOWJA) |
Positive = flow INTO the cell from the neighbour |
The mp3du API defines its own consistent convention that is independent of the MODFLOW version. Your data-loading code must transform whatever raw MODFLOW output you have into these mp3du conventions — see the conversion table below.
mp3du API Convention (Target)¶
These are the conventions mp3du expects after you have done any necessary sign transformation:
| Function | face_flow Convention |
q_well Convention |
|---|---|---|
hydrate_cell_flows() |
positive = INTO cell | raw MODFLOW sign (negative = extraction) |
hydrate_waterloo_inputs() |
positive = INTO cell | raw MODFLOW sign (negative = extraction) |
Both hydration functions accept the same face_flow array — convert
once to positive = INTO, then pass the result to both.
Flow rates¶
| Quantity | Convention | Description |
|---|---|---|
Face flow (hydrate_cell_flows) |
positive = into the cell | mp3du convention; transform your MODFLOW output to match |
Face flow (hydrate_waterloo_inputs) |
positive = into the cell | same array as hydrate_cell_flows — no separate transformation |
q_top positive |
Flow entering through the top face (from above) | |
q_bot positive |
Flow entering through the bottom face (from below) | |
q_well (everywhere) |
negative = pumping (extraction), positive = injection | Raw MODFLOW sign — never negate |
Sign Conversion by MODFLOW Version¶
The transformation from raw MODFLOW output to mp3du convention depends on which MODFLOW version produced the data.
MODFLOW-USG / MODFLOW 6 (FLOW-JA-FACE or API FLOWJA)¶
Raw sign: positive = INTO cell. mp3du also uses positive = INTO, so pass directly to both functions — no negation needed.
# MODFLOW-USG/MF6 FLOW-JA-FACE: positive = INTO cell
face_flow = flowja_face_flow # pass directly: already positive = INTO
# Pass face_flow to BOTH hydrate_cell_flows() and hydrate_waterloo_inputs()
MODFLOW-2005 / NWT (Structured: FLOW RIGHT FACE etc.)¶
Structured MODFLOW stores directional inter-cell flows — not per-cell in/out flows. The raw convention is positive in the direction of increasing row or column number:
FLOW RIGHT FACEat (row, col): positive = flow from cell (row, col) → cell (row, col+1) (in the +column direction).FLOW FRONT FACEat (row, col): positive = flow from cell (row, col) → cell (row+1, col) (in the +row direction).FLOW LOWER FACEat (row, col): positive = flow downward from cell (row, col, lay) → cell (row, col, lay+1).
A single stored value describes flow between two cells: it is simultaneously an outflow for one cell and an inflow for the other. You must convert these directional flows to per-cell per-face flows during CSR assembly. The assembly step looks at each face from the perspective of the current cell and flips the sign when the face's stored direction points into the cell:
# FLOW RIGHT FACE at (row, col) = positive in +column direction
# When assembling faces for cell (r, c):
if neighbor is to the right: flow = +frf[r, c] # same direction as stored → OUT
if neighbor is to the left: flow = -frf[r, c - 1] # reverse direction → OUT
# FLOW FRONT FACE at (row, col) = positive in +row direction
if neighbor is below (front): flow = +fff[r, c] # same direction → OUT
if neighbor is above (back): flow = -fff[r - 1, c] # reverse direction → OUT
After this directional-to-per-face conversion, the resulting values follow positive = OUT of cell convention — negate once before passing to both functions:
face_flow = -assembled_face_flow # negate: positive = INTO cell
# Pass face_flow to BOTH hydrate_cell_flows() and hydrate_waterloo_inputs()
mp3du adopts the MODFLOW-USG / MF6 convention
The mp3du Python API uses the same sign convention as MODFLOW-USG and
MODFLOW 6 FLOW-JA-FACE: positive = flow entering the cell through
that face. MODFLOW-2005 / NWT users must negate face_flow once
after directional-to-per-face assembly to match this convention.
Quick-Reference Cheat Sheet¶
# ── MODFLOW-USG / MF6 (FLOW-JA-FACE / FLOWJA) ──
face_flow = flowja # pass directly: already positive = INTO
# ── MODFLOW-2005 / NWT (after directional → per-face assembly) ──
face_flow = -assembled # negate once: positive = INTO
# Pass the SAME face_flow to BOTH hydrate_cell_flows() and
# hydrate_waterloo_inputs(). No per-function negation needed.
# ── Well Q: NEVER negate — pass raw MODFLOW sign to BOTH functions ──
q_well = modflow_q_well # raw sign (negative = extraction)
Why q_well is NOT negated
The Waterloo method uses singularity subtraction: during fitting, the analytic well contribution is subtracted from boundary flux data using \(Q_\text{well}\). During evaluation, the same analytic term is added back. Both operations must use the same \(Q_\text{well}\) value. The C++ implementation passes the raw MODFLOW sign (negative for extraction) to both fitting and evaluation. If you negate \(Q_\text{well}\) for fitting but not evaluation (or vice versa), the subtraction/addition will not cancel and the velocity field will be asymmetrically distorted around the well.
Direct q_well arrays vs. IFACE-routed bc_flow
The raw-sign rule above applies to direct per-cell q_well arrays passed to
hydrate_cell_flows() or hydrate_waterloo_inputs(). If you instead start
from IFACE-tagged boundary records, keep bc_flow in raw MODFLOW sign and
let the IFACE routing step apply the documented per-IFACE transformation
before accumulating into q_well, q_top, or q_bot.
Why both functions accept the same face_flow array
The face_flow array uses a single unified sign convention
(positive = INTO cell) for both hydrate_cell_flows() and
hydrate_waterloo_inputs(). Convert your MODFLOW output once and
pass the same array to both. No per-function negation is needed.
Velocity¶
Velocity components (vx, vy, vz) represent the average linear velocity (seepage velocity) — the Darcy flux divided by porosity. The direction follows the Cartesian axes: positive vx is in the +X direction.
Elevations and Saturated Thickness¶
top= top elevation of the cellbot= bottom elevation of the cellwater_table= water table elevation (layer-type dependent — see table below)- Saturated thickness =
water_table - bot(clamped to ≥ 0)
CRITICAL — Layer-Type-Dependent Water Table
The water_table array passed to hydrate_cell_flows() directly controls
the saturated thickness used to convert the Waterloo stream function into
velocity: vx = Re(dΩ) / sat_thick / porosity. Getting this wrong produces
silently incorrect velocities — typically 2×–17× too fast for confined
layers. There is no runtime warning; the particles simply follow wrong
trajectories that look plausible.
The correct value depends on the MODFLOW layer type (LAYTYP / LAYCON from the LPF, UPW, or NPF package):
| LAYTYP | Layer Type | water_table |
sat_thick |
|---|---|---|---|
| 0 | Confined | top |
top - bot (full layer thickness) |
| 1 | Unconfined | head |
head - bot |
| > 0 | Convertible | min(head, top) |
min(head, top) - bot |
The C++ implementation applies this automatically in cls_cell.cpp when
reading heads (cell->wt = cell->top for confined; cell->wt = cell->head
for unconfined/convertible with head < top). Python scripts using the Rust
API must replicate this logic explicitly — see Example 4a.
Diagnostic check: If your Rust velocities are a constant multiple of
the C++ reference, compare water_table values. For confined models,
water_table should equal top everywhere, NOT head.
# Correct water_table assignment (MODFLOW LPF/UPW)
laytyp = m.lpf.laytyp.array # per-layer array
for ci in range(n_cells):
layer = cell_layer[ci] # 0-based layer index
lt = int(laytyp[layer])
if lt == 0: # confined
wt_arr[ci] = cell_top[ci]
elif lt == 1: # unconfined
wt_arr[ci] = head_arr[ci]
else: # convertible
wt_arr[ci] = min(head_arr[ci], cell_top[ci])
IFACE-Based Boundary Flow Routing¶
When a MODFLOW boundary condition package assigns an IFACE value to a stress
entry, the BC flow must be routed to the correct mp3du flow-term array. See
IFACE Flow Routing for the complete specification,
sign conventions, and the route_iface_bc_flows() Python helper.
IFACE Sign Conventions¶
All bc_flow values passed to hydrate_cell_flows() use raw MODFLOW sign
(negative = extraction/out of cell, positive = injection/into cell). The Rust
hydration layer applies any internal negation as needed:
| IFACE | Target Array | Internal Negation? | Notes |
|---|---|---|---|
| 0 | q_well |
Yes (negate) | Well at cell centre |
| 2 | q_other |
No (raw) | Distributed side-face BC |
| 5 | q_bot |
No (raw) | Bottom-face BC |
| 6 | q_top |
Yes (negate) | Top-face BC (e.g., recharge) |
| 7 | q_top |
Yes (negate) | Internal BC, no well |
Aggregation Rule¶
Multiple BC entries for the same cell are summed per flow bucket, matching the
C++ bcs[] += semantics. The route_iface_bc_flows() helper uses
np.add.at() for this purpose.
Backward Compatibility¶
When bc_* arrays are provided to hydrate_cell_flows(), boundary-info-based
capture replaces the legacy has_well-only mechanism for cells that have
boundary entries. For cells with no boundary entries, has_well still controls
well capture.
Deprecation Notice
Setting has_well=True as a workaround for non-well BCs (e.g., CHD cells)
is deprecated. Use the bc_* arrays with proper IFACE values instead.
The has_well field remains for backward compatibility with scripts that
do not use IFACE-based boundary data.
Array Ordering¶
All per-cell arrays are 0-indexed and ordered by cell index (matching the order cells are passed to build_grid()):
| Array | Length | Indexing |
|---|---|---|
top, bot, porosity, etc. |
n_cells |
arr[i] = value for cell i |
head, water_table, q_* |
n_cells |
arr[i] = value for cell i |
has_well |
n_cells |
arr[i] = True if cell i has a well |
Cell Vertex Winding Order (CW REQUIRED)¶
Silent geometry error — no runtime validation
All cell polygons passed to build_grid() must be wound
Clockwise (CW) when viewed from above (looking down the −Z
axis). The code does not validate winding order. Counter-clockwise
(CCW) vertices silently flip face normals, producing a 180° reversed
velocity field.
Diagnostic: Compute the signed area with the shoelace formula. Negative area = CW (correct). Positive area = CCW (wrong).
If your source data is CCW: reverse the vertex list and negate all
face_flow values.
Face-to-Vertex Index Mapping¶
For a cell with N vertices [v0, v1, ..., v_{N-1}] in CW order:
- Face 0 = edge
v0 → v1 - Face 1 = edge
v1 → v2 - ...
- Face N-1 = edge
v_{N-1} → v0
All face-level arrays (face_flow, face_vx1/vy1/vx2/vy2, face_length,
face_neighbor, noflow_mask) follow this indexing. For example,
face_vx1[i], face_vy1[i] are the coordinates of vertex i (the start of
face i), and face_vx2[i], face_vy2[i] are the coordinates of vertex
(i+1) % N (the end of face i).
Boundary condition arrays (parallel, variable-length)¶
The optional bc_* arrays passed to hydrate_cell_flows() are parallel arrays
indexed by boundary condition entry (not by cell). All must have the same
length (n_bc_entries):
| Array | Dtype | Description |
|---|---|---|
bc_cell_ids |
int64 |
0-based cell index for each BC entry |
bc_iface |
int32 |
IFACE value (0, 2, 5, 6, or 7) |
bc_flow |
float64 |
Flow rate (raw MODFLOW sign) |
bc_type_id |
int32 |
Index into bc_type_names |
bc_type_names |
List[str] |
Human-readable BC type names (e.g., ["CONSTANT HEAD", "WELLS"]) |
is_domain_boundary |
bool (n_cells) |
True if cell is at the model domain edge |
has_water_table |
bool (n_cells) |
True if cell is unconfined/convertible |
is_domain_boundary and has_water_table
These two arrays are per-cell (n_cells), not per-BC-entry.
Face connectivity (CSR layout)¶
Face-level data uses a Compressed Sparse Row (CSR) layout:
| Array | Length | Description |
|---|---|---|
face_offset |
n_cells + 1 |
face_offset[i] to face_offset[i+1] spans face entries for cell i |
face_flow |
total faces | Flow rate across each face |
face_neighbor |
total faces | Neighbor cell ID for each face (-1 for boundary/no-flow) |
# Example: get face flows for cell i
start = face_offset[i]
end = face_offset[i + 1]
cell_face_flows = face_flow[start:end]
cell_face_neighbors = face_neighbor[start:end]
The face_offset array must:
- Start with
0 - Be non-decreasing
- End with the total number of face entries
Particle Coordinates¶
Particle starting positions (ParticleStart) use XY model coordinates but local normalized Z:
| Field | Description |
|---|---|
x, y |
Position in model (global) coordinates |
z |
Local vertical coordinate in [0, 1]: 0.0 = cell bottom, 1.0 = cell top, 0.5 = layer midpoint |
cell_id |
0-based index of the containing cell |
initial_dt |
Initial time step magnitude (model time units) |
CRITICAL — z is LOCAL, not a physical elevation
z is a normalized local coordinate in the range [0, 1], NOT a physical
elevation in model units. If you pass a physical elevation (e.g. z=5.0
when top=10.0, bot=0.0), the particle will immediately exit the cell
because 5.0 > 1.0.
To convert a physical elevation to local z:
This follows the MODPATH convention (Dave Pollock) where all vertical tracking is done in local cell coordinates.
The cell_id must correspond to a valid cell index in the grid. The particle's (x, y) position should be within the polygon footprint of the specified cell.
Trajectory Output¶
TrajectoryResult.to_records() returns a list of dictionaries with these fields:
| Field | Type | Description |
|---|---|---|
step |
int | Step number (0-based) |
time |
float | Accumulated simulation time |
x, y |
float | Particle position in model (global) coordinates |
z |
float | Local vertical coordinate in [0, 1] (NOT physical elevation) |
vx, vy, vz |
float | Velocity components at this position |
cell_id |
int | Cell containing the particle at this step |
dt |
float | Time step size used for this step |
Converting output z to physical elevation
The final_status field on TrajectoryResult is a string describing why tracking ended:
| 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 threshold for N consecutive steps |
Error |
A solver error occurred |
The termination_reason field provides additional detail for capture events.
For boundary captures it includes the BC type name, e.g.,
"Internal sink/source: CONSTANT HEAD". For non-capture terminations it
mirrors the final_status string.