Eikonal Solver with Drift (Advection)

This notebook demonstrates the anisotropic eikonal solver with drift added to wolfhece.eikonal, and compares it to the standard (no-drift) anisotropic solver.

Equation

The standard anisotropic eikonal equation is:

\[\sqrt{(\nabla T)^T \, D \, (\nabla T)} = 1\]

The drift (advection) variant adds a linear term:

\[\sqrt{(\nabla T)^T \, D \, (\nabla T)} \;+\; \mathbf{v} \cdot \nabla T = 1\]

where \(\mathbf{v} = (v_x, v_y)\) is a spatially-varying drift velocity.

Physical effect: the wavefront propagates faster downstream (in the direction of \(\mathbf{v}\)) and slower upstream (against \(\mathbf{v}\)). This breaks the symmetry of propagation.

Sub-critical condition: the FMM remains valid as long as the drift does not exceed the intrinsic wave speed in any direction.


Contents

  1. Point source with uniform drift — asymmetric wavefronts

  2. Varying drift magnitude — effect on arrival-time asymmetry

  3. River-like scenario — drift along a channel

  4. Inpainting with drift — data propagation comparison

[1]:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
from scipy.ndimage import binary_dilation

from wolfhece.eikonal import (
    _solve_eikonal_anisotropic,
    _solve_eikonal_aniso_drift,
    _solve_eikonal_aniso_drift_with_data,
)

1. Point Source with Uniform Drift

We place a single source at the centre of the domain and compare:

  • No drift (\(\mathbf{v} = 0\)): symmetric wavefronts

  • Drift along +x: wavefronts elongated downstream

[15]:
n = 81
dx = dy = 1.0
c = n // 2

# Isotropic metric: D = s² I  with s = 2
s = 2.0
Dxx = np.full((n, n), s**2)
Dxy = np.zeros((n, n))
Dyy = np.full((n, n), s**2)

sources = [(c, c)]

# --- No drift ---
wc0 = np.ones((n, n), dtype=np.float64)
wc0[c, c] = 0.0
T_no_drift = _solve_eikonal_anisotropic(sources, wc0, Dxx, Dxy, Dyy, dx, dy)

# --- Drift along +x  (vx = 0.5) ---
wc1 = np.ones((n, n), dtype=np.float64)
wc1[c, c] = 0.0
vx1 = np.full((n, n), 0.5)
vy1 = np.zeros((n, n))
T_drift_x = _solve_eikonal_aniso_drift(sources, wc1, Dxx, Dxy, Dyy, vx1, vy1, dx, dy)

# --- Drift at 45°  (vx = vy = 0.35) ---
wc2 = np.ones((n, n), dtype=np.float64)
wc2[c, c] = 0.0
vx2 = np.full((n, n), 0.35)
vy2 = np.full((n, n), 0.35)
T_drift_45 = _solve_eikonal_aniso_drift(sources, wc2, Dxx, Dxy, Dyy, vx2, vy2, dx, dy)

print(f"Domain: {n}×{n}, source at ({c},{c})")
print(f"Speed: s = {s}, isotropic")
print(f"Max T (no drift):  {T_no_drift[T_no_drift < 1e10].max():.2f}")
print(f"Max T (drift +x):  {T_drift_x[T_drift_x < 1e10].max():.2f}")
print(f"Max T (drift 45°): {T_drift_45[T_drift_45 < 1e10].max():.2f}")
Domain: 81×81, source at (40,40)
Speed: s = 2.0, isotropic
Max T (no drift):  27.58
Max T (drift +x):  33.50
Max T (drift 45°): 36.65
[16]:
fig, axes = plt.subplots(1, 3, figsize=(18, 5.5))
extent = [0, n, 0, n]

# Contour levels
levels = np.arange(1, 25, 1.5)

for ax, T, title in [
    (axes[0], T_no_drift, 'No drift ($\\mathbf{v} = 0$)'),
    (axes[1], T_drift_x, 'Drift along +x ($v_x = 0.5$)'),
    (axes[2], T_drift_45, 'Drift at 45° ($v_x = v_y = 0.35$)')]:

    T_plot = np.where(T < 1e10, T, np.nan)
    im = ax.imshow(T_plot, origin='lower', extent=extent,
                   cmap='inferno_r', vmin=0, vmax=22)
    cs = ax.contour(T_plot, levels=levels, colors='white',
                    linewidths=0.7, extent=extent, origin='lower')
    ax.plot(c, c, 'w*', ms=12, mec='black', mew=0.8)
    ax.set_title(title, fontsize=12)
    ax.set_xlabel('x (col)');  ax.set_ylabel('y (row)')
    plt.colorbar(im, ax=ax, shrink=0.8, label='T')

fig.suptitle('Arrival times — effect of drift on wavefront shape', fontsize=14, y=1.02)
fig.tight_layout()
plt.show()
../_images/tutorials_eikonal_drift_demo_4_0.png
[17]:
# --- Profiles along x and y through the source ---
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

x_range = np.arange(n) - c

# Along x (row = c)
ax = axes[0]
ax.plot(x_range, T_no_drift[c, :], 'k-', lw=2, label='No drift')
ax.plot(x_range, T_drift_x[c, :], 'r--', lw=2, label='Drift +x (0.5)')
ax.plot(x_range, T_drift_45[c, :], 'b-.', lw=2, label='Drift 45° (0.35, 0.35)')
ax.set_xlabel('Distance from source (x direction)', fontsize=11)
ax.set_ylabel('Arrival time T', fontsize=11)
ax.set_title('Profile along x (drift direction)', fontsize=12)
ax.legend(fontsize=9)
ax.set_ylim(bottom=0)
ax.grid(True, alpha=0.3)

# Along y (col = c)
ax = axes[1]
ax.plot(x_range, T_no_drift[:, c], 'k-', lw=2, label='No drift')
ax.plot(x_range, T_drift_x[:, c], 'r--', lw=2, label='Drift +x (0.5)')
ax.plot(x_range, T_drift_45[:, c], 'b-.', lw=2, label='Drift 45° (0.35, 0.35)')
ax.set_xlabel('Distance from source (y direction)', fontsize=11)
ax.set_ylabel('Arrival time T', fontsize=11)
ax.set_title('Profile along y (perpendicular)', fontsize=12)
ax.legend(fontsize=9)
ax.set_ylim(bottom=0)
ax.grid(True, alpha=0.3)

fig.tight_layout()
plt.show()

# Asymmetry metrics
d = 15
for label, T in [('No drift', T_no_drift), ('Drift +x', T_drift_x), ('Drift 45°', T_drift_45)]:
    t_east = T[c, c + d]
    t_west = T[c, c - d]
    t_north = T[c - d, c]
    t_south = T[c + d, c]
    print(f"{label:12s}  T_east={t_east:6.2f}  T_west={t_west:6.2f}  "
          f"T_north={t_north:6.2f}  T_south={t_south:6.2f}  "
          f"ratio(W/E)={t_west/t_east:.2f}")
../_images/tutorials_eikonal_drift_demo_5_0.png
No drift      T_east=  7.50  T_west=  7.50  T_north=  7.50  T_south=  7.50  ratio(W/E)=1.00
Drift +x      T_east=  6.00  T_west= 10.00  T_north=  7.50  T_south=  7.50  ratio(W/E)=1.67
Drift 45°     T_east=  6.38  T_west=  9.09  T_north=  9.09  T_south=  6.38  ratio(W/E)=1.42

Observations

  • No drift: perfectly circular iso-T contours, symmetric profiles.

  • Drift along +x: the wavefront is stretched downstream (+x direction). T is smaller east of the source (downstream) and larger west (upstream). The perpendicular (y) profile remains symmetric.

  • Drift at 45°: the stretching follows the diagonal. Both x and y profiles show asymmetry.

  • The W/E ratio quantifies the asymmetry: ratio > 1 means upstream takes longer than downstream.


2. Effect of Drift Magnitude

We sweep the drift magnitude \(|\mathbf{v}|\) from 0 to near-critical and measure the upstream/downstream time ratio at a fixed distance.

[18]:
drift_mags = [0.0, 0.1, 0.2, 0.3, 0.5, 0.7, 0.9, 1.0, 1.2, 1.5]
d_meas = 15  # measurement distance from source
ratios_we = []  # west / east ratio
t_east_list = []
t_west_list = []

for v_mag in drift_mags:
    wc_tmp = np.ones((n, n), dtype=np.float64)
    wc_tmp[c, c] = 0.0
    vx_tmp = np.full((n, n), v_mag)
    vy_tmp = np.zeros((n, n))
    T_tmp = _solve_eikonal_aniso_drift(
        sources, wc_tmp, Dxx, Dxy, Dyy, vx_tmp, vy_tmp, dx, dy)

    te = T_tmp[c, c + d_meas]
    tw = T_tmp[c, c - d_meas]
    t_east_list.append(te)
    t_west_list.append(tw)
    ratios_we.append(tw / te if te > 0 else np.inf)
    print(f"  |v| = {v_mag:4.1f}  T_east = {te:6.2f}  T_west = {tw:6.2f}  "
          f"ratio = {tw/te:.2f}" if te > 0 else f"  |v| = {v_mag:4.1f}  critical")

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

ax = axes[0]
ax.plot(drift_mags, t_east_list, 'rs-', lw=2, ms=7, label='T downstream (east)')
ax.plot(drift_mags, t_west_list, 'bo-', lw=2, ms=7, label='T upstream (west)')
ax.axhline(T_no_drift[c, c + d_meas], color='gray', ls=':', lw=1.5, label='No-drift reference')
ax.set_xlabel('Drift magnitude $|v|$', fontsize=11)
ax.set_ylabel(f'Arrival time at d={d_meas}', fontsize=11)
ax.set_title('Arrival time vs drift magnitude', fontsize=12)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)

ax = axes[1]
ax.plot(drift_mags, ratios_we, 'go-', lw=2, ms=7)
ax.axhline(1.0, color='gray', ls=':', lw=1.5)
ax.set_xlabel('Drift magnitude $|v|$', fontsize=11)
ax.set_ylabel('Upstream / Downstream ratio', fontsize=11)
ax.set_title('Asymmetry ratio vs drift magnitude', fontsize=12)
ax.grid(True, alpha=0.3)

fig.tight_layout()
plt.show()
  |v| =  0.0  T_east =   7.50  T_west =   7.50  ratio = 1.00
  |v| =  0.1  T_east =   7.14  T_west =   7.89  ratio = 1.11
  |v| =  0.2  T_east =   6.82  T_west =   8.33  ratio = 1.22
  |v| =  0.3  T_east =   6.52  T_west =   8.82  ratio = 1.35
  |v| =  0.5  T_east =   6.00  T_west =  10.00  ratio = 1.67
  |v| =  0.7  T_east =   5.56  T_west =  11.54  ratio = 2.08
  |v| =  0.9  T_east =   5.17  T_west =  13.64  ratio = 2.64
  |v| =  1.0  T_east =   5.00  T_west =  15.00  ratio = 3.00
  |v| =  1.2  T_east =   4.69  T_west =  18.04  ratio = 3.85
  |v| =  1.5  T_east =   4.29  T_west =  22.58  ratio = 5.27
../_images/tutorials_eikonal_drift_demo_8_1.png

Observations

  • At \(|v| = 0\), upstream and downstream times are equal (ratio = 1).

  • As \(|v|\) increases, downstream T decreases and upstream T increases.

  • The ratio grows rapidly as the drift approaches the intrinsic wave speed (\(s = 2\) in metric units, but the critical drift depends on the mesh-relative formulation).

  • Beyond the critical drift, the FMM may produce unreliable results (the upstream direction effectively cannot be reached).


3. River Channel with Longitudinal Drift

A practical scenario: a pollution source in a river channel. The drift represents the flow velocity. We compare the arrival times of a contaminant plume with and without accounting for the current.

[19]:
# --- River domain ---
nx_r, ny_r = 201, 51
dx_r = dy_r = 1.0
c_r = ny_r // 2

# Anisotropic metric: faster along x (channel axis)
s_fast, s_slow = 3.0, 1.0
Dxx_r = np.full((ny_r, nx_r), s_fast**2)
Dxy_r = np.zeros((ny_r, nx_r))
Dyy_r = np.full((ny_r, nx_r), s_slow**2)

# Pollution source at left-centre
src_col = 30
sources_r = [(c_r, src_col)]

# --- No drift ---
wc_r0 = np.ones((ny_r, nx_r), dtype=np.float64)
wc_r0[c_r, src_col] = 0.0
T_r0 = _solve_eikonal_anisotropic(sources_r, wc_r0, Dxx_r, Dxy_r, Dyy_r, dx_r, dy_r)

# --- Drift along +x (downstream flow) ---
wc_r1 = np.ones((ny_r, nx_r), dtype=np.float64)
wc_r1[c_r, src_col] = 0.0
vx_r = np.full((ny_r, nx_r), 0.8)  # strong downstream current
vy_r = np.zeros((ny_r, nx_r))
T_r1 = _solve_eikonal_aniso_drift(sources_r, wc_r1, Dxx_r, Dxy_r, Dyy_r,
                                   vx_r, vy_r, dx_r, dy_r)

# --- Drift with lateral component (meandering) ---
wc_r2 = np.ones((ny_r, nx_r), dtype=np.float64)
wc_r2[c_r, src_col] = 0.0
# Sinusoidal lateral drift
jj = np.arange(nx_r)
vy_r2 = np.zeros((ny_r, nx_r))
for j in range(nx_r):
    vy_r2[:, j] = 0.3 * np.sin(2 * np.pi * j / 80)
vx_r2 = np.full((ny_r, nx_r), 0.8)
T_r2 = _solve_eikonal_aniso_drift(sources_r, wc_r2, Dxx_r, Dxy_r, Dyy_r,
                                   vx_r2, vy_r2, dx_r, dy_r)

print(f"Channel: {nx_r}×{ny_r}, source at (row={c_r}, col={src_col})")
Channel: 201×51, source at (row=25, col=30)
[20]:
fig, axes = plt.subplots(3, 1, figsize=(16, 10))
extent_r = [0, nx_r, 0, ny_r]
levels_r = np.arange(2, 60, 3)

for ax, T, title, drift_label in [
    (axes[0], T_r0, 'No drift', None),
    (axes[1], T_r1, 'Uniform drift along +x ($v_x = 0.8$)', None),
    (axes[2], T_r2, 'Meandering drift ($v_x = 0.8$, $v_y = 0.3 \\sin(2\\pi x/80)$)', None)]:

    T_plot = np.where(T < 1e10, T, np.nan)
    im = ax.imshow(T_plot, origin='lower', extent=extent_r, aspect='auto',
                   cmap='inferno_r', vmin=0, vmax=55)
    cs = ax.contour(T_plot, levels=levels_r, colors='white',
                    linewidths=0.6, extent=extent_r, origin='lower')
    ax.plot(src_col, c_r, 'w*', ms=14, mec='black', mew=1)
    ax.set_title(title, fontsize=12)
    ax.set_xlabel('x (downstream)');  ax.set_ylabel('y (cross-channel)')
    plt.colorbar(im, ax=ax, shrink=0.8, label='T')

fig.suptitle('Contaminant arrival times in a river channel', fontsize=14, y=1.01)
fig.tight_layout()
plt.show()
../_images/tutorials_eikonal_drift_demo_12_0.png
[21]:
# --- Longitudinal profiles ---
fig, ax = plt.subplots(figsize=(12, 5))

x_axis = np.arange(nx_r) * dx_r
ax.plot(x_axis, T_r0[c_r, :], 'k-', lw=2, label='No drift')
ax.plot(x_axis, T_r1[c_r, :], 'r--', lw=2, label='Drift +x (0.8)')
ax.plot(x_axis, T_r2[c_r, :], 'b-.', lw=2, label='Drift meandering')
ax.axvline(src_col, color='gray', ls=':', lw=1, label='Source')
ax.set_xlabel('x (downstream distance)', fontsize=11)
ax.set_ylabel('Arrival time T', fontsize=11)
ax.set_title('Longitudinal profile along channel centreline', fontsize=12)
ax.legend(fontsize=10)
ax.set_ylim(bottom=0)
ax.grid(True, alpha=0.3)

fig.tight_layout()
plt.show()

# Downstream vs upstream times at d=25 cells
d_r = 25
for label, T in [('No drift', T_r0), ('Drift +x', T_r1), ('Drift meander', T_r2)]:
    te = T[c_r, src_col + d_r]
    tw = T[c_r, src_col - d_r] if src_col - d_r >= 0 else np.inf
    print(f"{label:15s}  T_downstream={te:6.2f}  T_upstream={tw:6.2f}  "
          f"ratio={tw/te:.2f}")
../_images/tutorials_eikonal_drift_demo_13_0.png
No drift         T_downstream=  8.33  T_upstream=  8.33  ratio=1.00
Drift +x         T_downstream=  6.58  T_upstream= 11.36  ratio=1.73
Drift meander    T_downstream=  6.58  T_upstream= 11.36  ratio=1.73

Observations — River Channel

  • No drift: the contaminant spreads symmetrically upstream and downstream. With the anisotropic metric (fast along x), the plume is elongated along the channel but equally in both directions.

  • Uniform drift: the downstream front arrives much sooner than upstream. This is the physically correct behaviour: the current carries the contaminant downstream.

  • Meandering drift: the lateral sinusoidal component bends the iso-T contours, simulating a winding channel where the plume follows the flow path rather than a straight line.

  • The longitudinal profile clearly shows the asymmetry: the drift curve has a gentler slope downstream (fast arrival) and steeper upstream (slow arrival or unreachable).


4. Inpainting with Drift

In this section, we compare data propagation (inpainting) with and without drift. The scenario: a river with a linear water-level gradient and a rectangular hole (e.g. a bridge). When inpainting, the drift biases propagation toward downstream values.

[22]:
# --- Reference water level ---
nx_i, ny_i = 151, 41
dx_i = dy_i = 1.0

# Linear slope along x
wl_ref = np.zeros((ny_i, nx_i))
slope = -0.05
for j in range(nx_i):
    wl_ref[:, j] = 100.0 + slope * j

# Slight parabolic cross-section
cy = ny_i // 2
for i in range(ny_i):
    wl_ref[i, :] += 0.003 * (i - cy)**2

# --- Hole (bridge) ---
hole = np.zeros((ny_i, nx_i), dtype=bool)
hole[3:-3, 65:95] = True  # 30-cell wide bridge

boundary = binary_dilation(hole, iterations=1) & ~hole
sources_i = list(zip(*np.where(boundary)))

# Metric: fast along x
s_f, s_s = 3.0, 1.0
Dxx_i = np.full((ny_i, nx_i), s_f**2)
Dxy_i = np.zeros((ny_i, nx_i))
Dyy_i = np.full((ny_i, nx_i), s_s**2)

print(f"Domain: {nx_i}×{ny_i}")
print(f"Hole: cols 65–94, rows 3–{ny_i-4}")
print(f"WL range: [{wl_ref.min():.2f}, {wl_ref.max():.2f}] m")
print(f"Boundary sources: {len(sources_i)}")
Domain: 151×41
Hole: cols 65–94, rows 3–37
WL range: [92.50, 101.20] m
Boundary sources: 130
[23]:
from wolfhece.eikonal import _solve_eikonal_anisotropic_with_data

# --- Inpainting WITHOUT drift (anisotropic only) ---
wc_i0 = np.zeros((ny_i, nx_i), dtype=np.float64)
wc_i0[hole] = 1.0
bd_i0 = wl_ref.copy()
bd_i0[hole] = 0.0
td_i0 = np.full((ny_i, nx_i), -np.inf)

T_i0 = _solve_eikonal_anisotropic_with_data(
    sources_i, wc_i0, bd_i0, td_i0, Dxx_i, Dxy_i, Dyy_i, dx_i, dy_i)

# --- Inpainting WITH drift along +x (downstream) ---
wc_i1 = np.zeros((ny_i, nx_i), dtype=np.float64)
wc_i1[hole] = 1.0
bd_i1 = wl_ref.copy()
bd_i1[hole] = 0.0
td_i1 = np.full((ny_i, nx_i), -np.inf)

vx_i = np.full((ny_i, nx_i), 0.5)
vy_i = np.zeros((ny_i, nx_i))

T_i1 = _solve_eikonal_aniso_drift_with_data(
    sources_i, wc_i1, bd_i1, td_i1,
    Dxx_i, Dxy_i, Dyy_i, vx_i, vy_i, dx_i, dy_i)

# --- Inpainting WITH drift along -x (upstream) ---
wc_i2 = np.zeros((ny_i, nx_i), dtype=np.float64)
wc_i2[hole] = 1.0
bd_i2 = wl_ref.copy()
bd_i2[hole] = 0.0
td_i2 = np.full((ny_i, nx_i), -np.inf)

vx_i2 = np.full((ny_i, nx_i), -0.5)  # upstream drift
vy_i2 = np.zeros((ny_i, nx_i))

T_i2 = _solve_eikonal_aniso_drift_with_data(
    sources_i, wc_i2, bd_i2, td_i2,
    Dxx_i, Dxy_i, Dyy_i, vx_i2, vy_i2, dx_i, dy_i)

print("Inpainting complete.")
Inpainting complete.
[24]:
err_i0 = bd_i0 - wl_ref
err_i1 = bd_i1 - wl_ref
err_i2 = bd_i2 - wl_ref

fig, axes = plt.subplots(2, 3, figsize=(18, 9))
extent_i = [0, nx_i, 0, ny_i]
vmin_w, vmax_w = wl_ref.min(), wl_ref.max()

# Row 1: reconstructed water levels
for ax, data, title in [
    (axes[0, 0], wl_ref, 'Reference'),
    (axes[0, 1], bd_i0, 'Aniso (no drift)'),
    (axes[0, 2], bd_i1, 'Aniso + drift downstream')]:
    im = ax.imshow(data, origin='lower', extent=extent_i, aspect='auto',
                   cmap='Blues_r', vmin=vmin_w, vmax=vmax_w)
    rect = plt.Rectangle((65, 3), 30, ny_i - 6,
                          edgecolor='red', facecolor='none', lw=2)
    ax.add_patch(rect)
    ax.set_title(title, fontsize=12)
    ax.set_xlabel('x');  ax.set_ylabel('y')
    plt.colorbar(im, ax=ax, shrink=0.75, label='WL (m)')

# Row 2: errors inside hole
err_i0_h = np.where(hole, err_i0, np.nan)
err_i1_h = np.where(hole, err_i1, np.nan)
err_i2_h = np.where(hole, err_i2, np.nan)
vabs = max(np.nanmax(np.abs(err_i0_h)), np.nanmax(np.abs(err_i1_h)),
           np.nanmax(np.abs(err_i2_h)))

rmse_0 = np.sqrt(np.nanmean(err_i0_h**2))
rmse_1 = np.sqrt(np.nanmean(err_i1_h**2))
rmse_2 = np.sqrt(np.nanmean(err_i2_h**2))

for ax, err, title in [
    (axes[1, 0], err_i0_h, f'No drift error\nRMSE = {rmse_0:.4f} m'),
    (axes[1, 1], err_i1_h, f'Drift downstream error\nRMSE = {rmse_1:.4f} m'),
    (axes[1, 2], err_i2_h, f'Drift upstream error\nRMSE = {rmse_2:.4f} m')]:
    im = ax.imshow(err, origin='lower', extent=extent_i, aspect='auto',
                   cmap='RdBu_r', vmin=-vabs, vmax=vabs)
    rect = plt.Rectangle((65, 3), 30, ny_i - 6,
                          edgecolor='black', facecolor='none', lw=1.5, ls='--')
    ax.add_patch(rect)
    ax.set_title(title, fontsize=12)
    ax.set_xlabel('x');  ax.set_ylabel('y')
    plt.colorbar(im, ax=ax, shrink=0.75, label='Error (m)')

fig.suptitle('Inpainting comparison — effect of drift direction', fontsize=14, y=1.01)
fig.tight_layout()
plt.show()

print(f"RMSE (no drift):         {rmse_0:.4f} m")
print(f"RMSE (drift downstream): {rmse_1:.4f} m")
print(f"RMSE (drift upstream):   {rmse_2:.4f} m")
../_images/tutorials_eikonal_drift_demo_18_0.png
RMSE (no drift):         0.4063 m
RMSE (drift downstream): 0.4115 m
RMSE (drift upstream):   0.3884 m
[25]:
# --- Longitudinal profile through hole centre ---
fig, ax = plt.subplots(figsize=(12, 5))

x_ax = np.arange(nx_i) * dx_i
mid_y = ny_i // 2

ax.plot(x_ax, wl_ref[mid_y, :], 'k-', lw=2.5, label='Reference')
ax.plot(x_ax, bd_i0[mid_y, :], 'g--', lw=1.8, label='Aniso (no drift)')
ax.plot(x_ax, bd_i1[mid_y, :], 'r-.', lw=1.8, label='Drift downstream (+x)')
ax.plot(x_ax, bd_i2[mid_y, :], 'b:', lw=1.8, label='Drift upstream (−x)')
ax.axvspan(65, 94, alpha=0.12, color='gray', label='Hole')
ax.set_xlabel('x (m)', fontsize=11)
ax.set_ylabel('Water level (m)', fontsize=11)
ax.set_title('Longitudinal profile (channel centreline) — inpainting', fontsize=12)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)

fig.tight_layout()
plt.show()
../_images/tutorials_eikonal_drift_demo_19_0.png

Observations — Inpainting with Drift

  • No drift: the inpainted water level is a compromise between upstream and downstream boundary values. The profile inside the hole is slightly flatter than the true gradient.

  • Drift downstream (+x): the inpainting preferentially uses data from the upstream boundary (which arrives first when propagation goes downstream). This shifts the reconstructed profile upward inside the hole.

  • Drift upstream (−x): conversely, data from the downstream boundary dominates, shifting the profile downward inside the hole.

  • The drift direction thus controls which boundary has more influence on the inpainted values. For a river, using the correct flow direction as drift yields a physically meaningful bias.


Summary

Function

Equation

Drift

Data

_solve_eikonal_anisotropic

\((\nabla T)^T D (\nabla T) = 1\)

_solve_eikonal_anisotropic_with_data

\((\nabla T)^T D (\nabla T) = 1\)

_solve_eikonal_aniso_drift

\(\sqrt{(\nabla T)^T D (\nabla T)} + \mathbf{v} \cdot \nabla T = 1\)

_solve_eikonal_aniso_drift_with_data

\(\sqrt{(\nabla T)^T D (\nabla T)} + \mathbf{v} \cdot \nabla T = 1\)

Key parameters:

  • speed_xx, speed_xy, speed_yy: components of the metric tensor \(D\)

  • drift_vx, drift_vy: advection velocity (breaks symmetry)

  • Sub-critical (\(|\mathbf{v}| < s\)): all cells are reachable, wavefronts are asymmetric but closed

  • Super-critical (\(|\mathbf{v}| > s\)): a Mach cone appears — only downstream cells within \(\theta = \arcsin(s/|\mathbf{v}|)\) are reached, upstream cells remain at \(T = \infty\)


5. Supercritical Drift — Mach Cone

When the drift magnitude \(|\mathbf{v}|\) exceeds the intrinsic wave speed \(s\), the FMM solver already handles it: edges where the denominator \(\sqrt{\mathbf{e}^T D \mathbf{e}} - \mathbf{v} \cdot \mathbf{e} \leq 0\) are simply skipped (continue). Only the downstream directions within the Mach cone remain reachable.

The Mach half-angle is:

\[\theta = \arcsin\!\left(\frac{s}{|\mathbf{v}|}\right)\]

Below we set \(s = 2\) and sweep \(|\mathbf{v}|\) from subcritical (\(1.5\)) through critical (\(2.0\)) to strongly supercritical (\(5.0\)).

[26]:
n = 161
dx = dy = 1.0
c = n // 2
s = 2.0

Dxx = np.full((n, n), s**2)
Dxy = np.zeros((n, n))
Dyy = np.full((n, n), s**2)

drift_values = [1.5, 2.0, 3.0, 5.0]
labels = ['$|v|=1.5$ (sub-critical)',
          '$|v|=2.0$ (critical)',
          '$|v|=3.0$ (super-critical)',
          '$|v|=5.0$ (strongly super-critical)']

results = []
for v_mag in drift_values:
    wc = np.ones((n, n), dtype=np.float64)
    wc[c, c] = 0.0
    vx_sc = np.full((n, n), v_mag)
    vy_sc = np.zeros((n, n))
    T_sc = _solve_eikonal_aniso_drift(
        [(c, c)], wc, Dxx, Dxy, Dyy, vx_sc, vy_sc, dx, dy)
    results.append(T_sc)
    n_inf = np.sum(T_sc >= 1e10)
    pct_inf = 100 * n_inf / n**2
    print(f"|v| = {v_mag:.1f}:  {pct_inf:.0f}% unreachable  "
          f"(Mach angle = {np.degrees(np.arcsin(min(1.0, s / v_mag))):.1f}°)")

# Reference: no drift
wc0 = np.ones((n, n), dtype=np.float64)
wc0[c, c] = 0.0
T_ref = _solve_eikonal_anisotropic([(c, c)], wc0, Dxx, Dxy, Dyy, dx, dy)
|v| = 1.5:  0% unreachable  (Mach angle = 90.0°)
|v| = 2.0:  0% unreachable  (Mach angle = 90.0°)
|v| = 3.0:  48% unreachable  (Mach angle = 41.8°)
|v| = 5.0:  48% unreachable  (Mach angle = 23.6°)
[27]:
fig, axes = plt.subplots(2, 2, figsize=(14, 12))
extent = [0, n, 0, n]

for idx, (ax, T, label, v_mag) in enumerate(
        zip(axes.flat, results, labels, drift_values)):
    T_plot = np.where(T < 1e10, T, np.nan)
    vmax = np.nanpercentile(T_plot, 95) if np.any(~np.isnan(T_plot)) else 50

    im = ax.imshow(T_plot, origin='lower', extent=extent,
                   cmap='inferno_r', vmin=0, vmax=vmax)

    levels = np.linspace(1, vmax * 0.9, 12)
    cs = ax.contour(T_plot, levels=levels, colors='white',
                    linewidths=0.5, extent=extent, origin='lower')

    # Draw theoretical Mach cone
    if v_mag > s:
        theta = np.arcsin(s / v_mag)
        L = n * 0.45
        for sign in [1, -1]:
            x_end = c + L * np.cos(sign * theta)
            y_end = c + L * np.sin(sign * theta)
            ax.plot([c, x_end], [c, y_end], 'c--', lw=2, alpha=0.8)
        ax.text(c + 10, c - 15,
                f'$\\theta = {np.degrees(theta):.0f}°$',
                color='cyan', fontsize=12, fontweight='bold')

    ax.plot(c, c, 'w*', ms=14, mec='black', mew=1)
    ax.set_title(label, fontsize=12)
    ax.set_xlabel('x (col)');  ax.set_ylabel('y (row)')
    plt.colorbar(im, ax=ax, shrink=0.8, label='T')

    # Mark unreachable zone in gray
    mask_inf = T >= 1e10
    if np.any(mask_inf):
        ax.contourf(mask_inf.astype(float), levels=[0.5, 1.5],
                    colors=['gray'], alpha=0.3, extent=extent, origin='lower')

fig.suptitle('Supercritical drift — Mach cone emergence\n'
             f'$s = {s}$ (intrinsic wave speed), drift along +x',
             fontsize=14, y=1.01)
fig.tight_layout()
plt.show()
../_images/tutorials_eikonal_drift_demo_24_0.png
[28]:
# --- Profile along x through source ---
fig, ax = plt.subplots(figsize=(14, 6))
x_range = np.arange(n) - c

ax.plot(x_range, T_ref[c, :], 'k-', lw=2.5, label='No drift')
colors = ['blue', 'orange', 'red', 'darkred']
for T, label, col in zip(results, labels, colors):
    T_line = T[c, :].copy()
    T_line[T_line >= 1e10] = np.nan
    ax.plot(x_range, T_line, '--', lw=2, color=col, label=label)

ax.set_xlabel('Distance from source (x direction)', fontsize=12)
ax.set_ylabel('Arrival time T', fontsize=12)
ax.set_title('Longitudinal profile — sub-critical to super-critical', fontsize=13)
ax.legend(fontsize=10)
ax.set_ylim(bottom=0, top=60)
ax.grid(True, alpha=0.3)

fig.tight_layout()
plt.show()

# Show the cut-off for supercritical cases
for v_mag, T in zip(drift_values, results):
    T_line = T[c, :]
    reach_left = np.sum(T_line[:c] < 1e10)
    reach_right = np.sum(T_line[c+1:] < 1e10)
    print(f"|v| = {v_mag:.1f}:  reachable left (upstream) = {reach_left}  "
          f"right (downstream) = {reach_right}")
../_images/tutorials_eikonal_drift_demo_25_0.png
|v| = 1.5:  reachable left (upstream) = 80  right (downstream) = 80
|v| = 2.0:  reachable left (upstream) = 80  right (downstream) = 80
|v| = 3.0:  reachable left (upstream) = 1  right (downstream) = 80
|v| = 5.0:  reachable left (upstream) = 1  right (downstream) = 80

Observations — Supercritical Drift

  • Sub-critical (\(|v| < s\)): the wavefront is asymmetric but all cells are eventually reached. The iso-T contours are closed curves.

  • Critical (\(|v| = s\)): the upstream direction is barely reachable. The contours become infinitely elongated upstream.

  • Super-critical (\(|v| > s\)): a Mach cone appears. Only cells within the cone angle \(\theta = \arcsin(s/|v|)\) are reachable. Upstream cells have \(T = \infty\) (shown in gray).

  • The Mach cone boundary (cyan dashed lines) matches the theoretical prediction.

  • The code handles this without any modification: the existing guard denom 0 skip naturally produces the correct Mach cone pattern.

In summary: the solver already works for supercritical drift. No changes to the algorithm are needed — only the interpretation changes (some cells become unreachable).


6. Building Eikonal Inputs from a Hydrodynamic Velocity Field

In practice, the user has a velocity field \((U_x, U_y)\) from a hydrodynamic simulation and needs to derive \(D\) and \(\mathbf{v}\).

Two utility functions are provided:

  • ``diffusion_tensor_from_velocity(Ux, Uy, D_L, D_T)`` builds the anisotropic tensor \(D = D_L (\hat n \otimes \hat n) + D_T (\hat t \otimes \hat t)\) aligned with the local flow direction.

  • ``eikonal_params_from_velocity(Ux, Uy, D_L, D_T, drift_scale)`` returns the 5 arrays (Dxx, Dxy, Dyy, vx, vy) ready for the solver.

Parameters:

  • \(D_L\) (longitudinal) = propagation speed² along the flow

  • \(D_T\) (transverse) = propagation speed² across the flow

  • drift_scale = factor applied to \((U_x, U_y)\) for the drift term

[2]:
from wolfhece.eikonal import (
    diffusion_tensor_from_velocity,
    eikonal_params_from_velocity,
)

# --- Synthetic velocity field: vortex around a cylinder ---
n = 121
dx = dy = 1.0
cx, cy = n // 2, n // 2

# Free-stream + doublet (irrotational flow around cylinder)
U0 = 2.0   # free-stream speed along +x
R = 15.0   # cylinder radius

Y, X = np.mgrid[0:n, 0:n]
rx = (X - cx).astype(float)
ry = (Y - cy).astype(float)
r2 = rx**2 + ry**2
r2[r2 < 1e-10] = 1e-10

Ux_field = U0 * (1.0 - R**2 * (rx**2 - ry**2) / r2**2)
Uy_field = -U0 * R**2 * 2.0 * rx * ry / r2**2

# Mask inside cylinder
inside = (rx**2 + ry**2) < R**2
Ux_field[inside] = 0.0
Uy_field[inside] = 0.0

speed_mag = np.sqrt(Ux_field**2 + Uy_field**2)
print(f"Velocity field: {n}×{n}, max |U| = {speed_mag.max():.2f}")
print(f"Cylinder at ({cx},{cy}), R = {R}")

# --- Build eikonal parameters ---
D_L = 9.0   # fast along flow
D_T = 1.0   # slow across flow
drift_sc = 0.4  # drift = 40% of the flow velocity

Dxx, Dxy, Dyy, vx_f, vy_f = eikonal_params_from_velocity(
    Ux_field, Uy_field, D_longitudinal=D_L, D_transverse=D_T,
    drift_scale=drift_sc)

print(f"D_L = {D_L}, D_T = {D_T}, drift_scale = {drift_sc}")
print(f"Dxx range: [{Dxx.min():.2f}, {Dxx.max():.2f}]")
print(f"Dxy range: [{Dxy.min():.2f}, {Dxy.max():.2f}]")
Velocity field: 121×121, max |U| = 4.00
Cylinder at (60,60), R = 15.0
D_L = 9.0, D_T = 1.0, drift_scale = 0.4
Dxx range: [1.00, 9.00]
Dxy range: [-4.00, 4.00]
[3]:
# --- Solve: source upstream of cylinder ---
src_col = 15
sources_v = [(cy, src_col)]

# With drift
wc_v1 = np.ones((n, n), dtype=np.float64)
wc_v1[cy, src_col] = 0.0
wc_v1[inside] = 0.0  # cylinder is an obstacle (not computed)
T_v_drift = _solve_eikonal_aniso_drift(
    sources_v, wc_v1, Dxx, Dxy, Dyy, vx_f, vy_f, dx, dy)

# Without drift (same anisotropic tensor)
wc_v0 = np.ones((n, n), dtype=np.float64)
wc_v0[cy, src_col] = 0.0
wc_v0[inside] = 0.0
T_v_nodrift = _solve_eikonal_anisotropic(
    sources_v, wc_v0, Dxx, Dxy, Dyy, dx, dy)

# Mask cylinder for plotting
T_v_drift[inside] = np.nan
T_v_nodrift[inside] = np.nan

print("Solve complete.")
Solve complete.
[4]:
fig, axes = plt.subplots(1, 3, figsize=(20, 6))
extent_v = [0, n, 0, n]

# Panel 1: velocity field
ax = axes[0]
skip = 5
ax.quiver(X[::skip, ::skip], Y[::skip, ::skip],
          Ux_field[::skip, ::skip], Uy_field[::skip, ::skip],
          speed_mag[::skip, ::skip], cmap='viridis', scale=80, width=0.003)
circle = plt.Circle((cx, cy), R, color='gray', alpha=0.5)
ax.add_patch(circle)
ax.plot(src_col, cy, 'r*', ms=14, mec='black', mew=1)
ax.set_title('Velocity field (source = ★)', fontsize=12)
ax.set_xlabel('x');  ax.set_ylabel('y')
ax.set_aspect('equal')

# Panel 2: arrival time without drift
T_plot = np.where(T_v_nodrift < 1e10, T_v_nodrift, np.nan)
vmax_v = np.nanpercentile(T_plot, 95)
ax = axes[1]
im = ax.imshow(T_plot, origin='lower', extent=extent_v,
               cmap='inferno_r', vmin=0, vmax=vmax_v)
cs = ax.contour(T_plot, levels=np.arange(2, vmax_v, 3),
                colors='white', linewidths=0.5, extent=extent_v, origin='lower')
circle = plt.Circle((cx, cy), R, color='gray', alpha=0.6)
ax.add_patch(circle)
ax.plot(src_col, cy, 'w*', ms=12, mec='black', mew=0.8)
ax.set_title('Anisotropic (no drift)', fontsize=12)
ax.set_xlabel('x');  ax.set_ylabel('y')
plt.colorbar(im, ax=ax, shrink=0.8, label='T')

# Panel 3: arrival time with drift
T_plot2 = np.where(T_v_drift < 1e10, T_v_drift, np.nan)
ax = axes[2]
im = ax.imshow(T_plot2, origin='lower', extent=extent_v,
               cmap='inferno_r', vmin=0, vmax=vmax_v)
cs = ax.contour(T_plot2, levels=np.arange(2, vmax_v, 3),
                colors='white', linewidths=0.5, extent=extent_v, origin='lower')
circle = plt.Circle((cx, cy), R, color='gray', alpha=0.6)
ax.add_patch(circle)
ax.plot(src_col, cy, 'w*', ms=12, mec='black', mew=0.8)
ax.set_title(f'Anisotropic + drift (scale={drift_sc})', fontsize=12)
ax.set_xlabel('x');  ax.set_ylabel('y')
plt.colorbar(im, ax=ax, shrink=0.8, label='T')

fig.suptitle('Eikonal from hydrodynamic velocity field — cylinder flow',
             fontsize=14, y=1.02)
fig.tight_layout()
plt.show()
../_images/tutorials_eikonal_drift_demo_30_0.png

Usage Summary

from wolfhece.eikonal import (
    eikonal_params_from_velocity,
    _solve_eikonal_aniso_drift,
)

# From a 2D hydrodynamic simulation
Dxx, Dxy, Dyy, vx, vy = eikonal_params_from_velocity(
    Ux, Uy,
    D_longitudinal=9.0,  # speed² along flow
    D_transverse=1.0,    # speed² across flow
    drift_scale=0.4,     # fraction of flow used as drift
)

T = _solve_eikonal_aniso_drift(sources, wc, Dxx, Dxy, Dyy, vx, vy, dx, dy)
  • \(D_L > D_T\) makes propagation faster along the flow than across it (anisotropy from the tensor).

  • The drift then adds advective asymmetry: downstream is faster than upstream even along the same flow-aligned direction.

  • drift_scale controls how much of the flow velocity enters the eikonal equation. A value of 1.0 means the full velocity is used.