Single-Cell Diagnostic¶
A visual sanity-check that builds one rectangular cell with known face flows, fits a Waterloo velocity field, tracks a particle, and plots everything together. Use it to verify that:
- face-flow signs and directions are consistent,
- the interpolated velocity field is physically reasonable,
- the particle trajectory follows the expected streamline.
This script is designed as a self-contained diagnostic that AI agents can run immediately to validate their understanding of mp3du conventions before attempting a full model integration.
Output¶
| Item | Value |
|---|---|
| Status | ExitedDomain |
| Steps | 174 |
| Start | (0.1000, 5.0000) |
| End | (9.9998, 9.9498) |
The particle enters through the left face (green arrow, Q = 1.0 m³/d IN), flows right and upward following the velocity field, and exits the domain near the top-right corner — exactly what the face-flow budget predicts (0.5 m³/d out the right face, 0.5 m³/d out the top face).
Script¶
"""
Single-Cell Diagnostic — visual sanity check for mp3du particle tracking.
==========================================================================
PURPOSE
-------
Creates one 10×10 rectangular cell with a known, balanced face-flow budget,
fits a Waterloo velocity field, tracks a single particle, and plots
everything together so you can confirm that:
1. Face-flow signs and directions are physically consistent.
2. The interpolated velocity field matches the prescribed flow budget.
3. The particle trajectory follows the expected streamline.
CONVENTIONS DEMONSTRATED (see Units & Conventions reference)
-------------------------------------------------------------
• Vertices wound clockwise (CW) — required by the Waterloo method.
(If your data uses CCW winding, negate all face_flow values.)
• Face index i corresponds to the edge from vertex[i] → vertex[(i+1) % n].
• z in ParticleStart is LOCAL [0, 1] (0 = cell bottom, 1 = cell top),
NOT a physical elevation.
• face_flow sign convention: positive = INTO the cell.
The same array is passed to both hydrate_cell_flows() and
hydrate_waterloo_inputs().
• q_well is passed in raw MODFLOW sign to BOTH functions — never negate it.
FLOW BUDGET
-----------
Left face : 1.0 m³/d IN (the only inflow)
Right face : 0.5 m³/d OUT
Top face : 0.5 m³/d OUT
Bottom face: 0.0 (no-flow)
ΣQ_in = 1.0, ΣQ_out = 0.5 + 0.5 = 1.0 ✓ balanced
In positive=INTO convention: inflow is +, outflow is −.
EXPECTED RESULT
---------------
Particle starts near the left face, tracks right and upward (following the
50/50 split), and exits the domain near the top-right corner.
Final status: ExitedDomain.
"""
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import mp3du
print(f"mp3du {mp3du.version()}")
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ Configuration — edit these to explore different scenarios ║
# ╚══════════════════════════════════════════════════════════════════════╝
CELL_SIZE = 10.0 # m — square cell side length
TOP, BOT = 10.0, 0.0 # m — cell top / bottom elevation
POROSITY = 0.25 # dimensionless
HHK = 1e-2 # m/d — horizontal hydraulic conductivity
VHK = 1e-3 # m/d — vertical hydraulic conductivity
CENTER_HEAD = 9.0 # m — hydraulic head at cell centre
# Physical flow budget (positive = INTO the cell)
# Faces are ordered by CW vertex index: Left(0), Top(1), Right(2), Bottom(3)
Q_LEFT = 1.0 # m³/d — 1.0 IN (positive = into cell)
Q_TOP = -0.5 # m³/d — 0.5 OUT (negative = out of cell)
Q_RIGHT = -0.5 # m³/d — 0.5 OUT (negative = out of cell)
Q_BOTTOM = 0.0 # m³/d — no flow through bottom face
# Particle start (x, y are physical coordinates; z is LOCAL [0, 1])
PX, PY, PZ = 0.1, 5.0, 0.5
# Waterloo fitting parameters
ORDER_OF_APPROX = 3
N_CONTROL_POINTS = 16
# ── Step 1: Grid (CW winding required) ─────────────────────────────────────────
# v0=(0,0) → v1=(0,L) → v2=(L,L) → v3=(L,0)
# Gives faces: 0=left, 1=top, 2=right, 3=bottom
L = CELL_SIZE
vertices = [[(0.0, 0.0), (0.0, L), (L, L), (L, 0.0)]]
centers = [(L / 2, L / 2, (TOP + BOT) / 2)]
grid = mp3du.build_grid(vertices, centers)
# ── Step 2: Cell properties ──────────────────────────────────────────
cell_props = mp3du.hydrate_cell_properties(
top=np.array([TOP]),
bot=np.array([BOT]),
porosity=np.array([POROSITY]),
retardation=np.array([1.0]),
hhk=np.array([HHK]),
vhk=np.array([VHK]),
disp_long=np.array([0.0]),
disp_trans_h=np.array([0.0]),
disp_trans_v=np.array([0.0]),
)
# ── Step 3: Face flows ───────────────────────────────────────────────
# Convention: positive = INTO cell, negative = OUT of cell.
# The SAME face_flow array is passed to both hydrate_cell_flows()
# and hydrate_waterloo_inputs().
#
# Budget: 1.0 IN (left) = 0.5 OUT (right) + 0.5 OUT (top)
# Face order follows CW vertex order: Left(0), Top(1), Right(2), Bottom(3)
face_flow = np.array([Q_LEFT, Q_TOP, Q_RIGHT, Q_BOTTOM], dtype=np.float64)
assert abs(face_flow.sum()) < 1e-12, "Flow budget not balanced"
face_offset = np.array([0, 4], dtype=np.uint64)
face_neighbor = np.array([-1, -1, -1, -1], dtype=np.int64) # all boundary
# ── Step 4: Hydrate cell flows ───────────────────────────────────────
# water_table rule: confined → top, unconfined → head, convertible → min(head, top)
cell_flows = mp3du.hydrate_cell_flows(
head=np.array([CENTER_HEAD]),
water_table=np.array([TOP]),
q_top=np.zeros(1), q_bot=np.zeros(1), q_vert=np.zeros(1),
q_well=np.zeros(1), q_other=np.zeros(1), q_storage=np.zeros(1),
has_well=np.zeros(1, dtype=bool),
face_offset=face_offset,
face_flow=face_flow, # ← positive = INTO
face_neighbor=face_neighbor,
)
# ── Step 5: Hydrate Waterloo inputs ──────────────────────────────────
waterloo_inputs = mp3du.hydrate_waterloo_inputs(
centers_xy=np.array([[L / 2, L / 2]]),
radii=np.array([L / 2]),
perimeters=np.array([4 * L]),
areas=np.array([L * L]),
q_vert=np.zeros(1), q_well=np.zeros(1), q_other=np.zeros(1),
face_offset=face_offset,
face_vx1=np.array([0.0, 0.0, L, L]),
face_vy1=np.array([0.0, L, L, 0.0]),
face_vx2=np.array([0.0, L, L, 0.0]),
face_vy2=np.array([L, L, 0.0, 0.0]),
face_length=np.array([L, L, L, L]),
face_flow=face_flow, # ← same array, positive = INTO
noflow_mask=np.zeros(4, dtype=bool),
)
# ── Step 6: Fit Waterloo velocity field ──────────────────────────────
waterloo_cfg = mp3du.WaterlooConfig(
order_of_approx=ORDER_OF_APPROX,
n_control_points=N_CONTROL_POINTS,
)
field = mp3du.fit_waterloo(waterloo_cfg, grid, waterloo_inputs, cell_props, cell_flows)
# ── Step 7: Simulation configuration ─────────────────────────────────
# direction: 1.0 = forward, -1.0 = backward (only valid values)
config = mp3du.SimulationConfig.from_json(json.dumps({
"velocity_method": "Waterloo",
"solver": "DormandPrince",
"direction": 1.0,
"initial_dt": 0.5,
"max_dt": 2.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": 500.0, "max_steps": 1000,
"stagnation_velocity": 1e-12, "stagnation_limit": 10,
},
}))
# ── Step 8: Track one particle ───────────────────────────────────────
# z is LOCAL [0, 1]: 0.5 = mid-layer. NOT a physical elevation!
particles = [
mp3du.ParticleStart(id=0, x=PX, y=PY, z=PZ, cell_id=0, initial_dt=0.5),
]
results = mp3du.run_simulation(config, field, particles, parallel=False)
result = results[0]
records = result.to_records()
print(f"Status : {result.final_status}")
print(f"Steps : {len(records)}")
if records:
print(f"Start : ({records[0]['x']:.4f}, {records[0]['y']:.4f})")
print(f"End : ({records[-1]['x']:.4f}, {records[-1]['y']:.4f})")
The plotting section (Step 9–10) samples the velocity field on a 30 × 30 grid, approximates head contours via Darcy's law, and draws the trajectory. See the full script for the complete matplotlib code.
What Each Layer Shows¶
| Layer | Description |
|---|---|
| Black rectangle | Cell boundary (10 × 10 m) |
| Labelled arrows | Face flows — green = inflow, red = outflow, with magnitude |
| Blue contours | Approximate head field derived from the fitted velocities |
| Grey quiver arrows | Waterloo interpolated velocity field (unit vectors, 30 × 30) |
| Orange line | Particle trajectory (174 steps, Dormand–Prince adaptive solver) |
| Green / red dots | Start (0.1000, 5.0000) and end (9.9998, 9.9498) positions |
Key Conventions Demonstrated¶
- CW winding — vertices are listed clockwise; this is required
by the Waterloo method.
(If your source uses CCW winding, reverse the vertex list and negate all
face_flowvalues.) - z is local [0, 1] — the particle starts at
z=0.5(mid-layer), not a physical elevation. - Face order matches vertex order — vertex pair (0→1) defines face 0 (Left), (1→2) defines face 1 (Top), etc.
- face_flow: positive = INTO — the same array (positive = into cell) is
passed to both
hydrate_cell_flows()andhydrate_waterloo_inputs(). - Low-order Waterloo fit —
order_of_approx=3with 16 control points works well for a simple rectangular cell with smooth flow. - Mass conservation — the script asserts that face flows sum to zero before running.
Adapting for Real MODFLOW Data¶
When loading actual MODFLOW output instead of synthetic flows:
# MODFLOW-USG / MF6 (FLOW-JA-FACE): raw positive = INTO cell
# → pass directly
face_flow = flowja_values
# MODFLOW-2005 / NWT (after directional → per-face assembly):
# → positive = OUT → negate once
face_flow = -assembled_face_flow
# In BOTH cases, pass the same face_flow to both hydrate functions.
See Units & Conventions for the full sign-convention reference.
See Also¶
- Units & Conventions — Full sign-convention and coordinate reference
- Minimal Python Script — Simplest possible simulation
- Troubleshooting — Common pitfalls