Well tie calculus

Synthetic seismograms can be created by doing basic calculus on travel-time functions. Integrating slowness (the reciprocal of velocity) can a yield a time-to-depth relationship for making a well tie. Differentiating an acoustic impedance (velocity times density) log yields a reflectivity function along the borehole, which can be convolved with a seismic wavelet. In effect, the integral tells us where a given interval of rock is in the time-domain, the derivative tells us what it should look like; its seismic character.

My goal is to demonstrate the robustness of simple mathematical operations of travel time,

The code and synthetic data required to reproduce the results and figures can be found here in an accompanying IPython Notebook. For this tutorial, we will make use of the open-source Python libraries NumPy and Matplotlib, and there is a handy script for parsing LAS files here: http://wiki.scipy.org/Cookbook/LASReader.

As an example, I will use the sonic and density log from the Penobscot L-30 well. This well sits within the same volume of 3D seismic data that Matt Hall used in his horizon smoothing tutorial, it's a vertical well, so we don't have to worry about deviated trajectories through time and space.

Tutorial

Make sure you are in the correct directory, or cd to the correct one. Then let's check the files.


In [1]:
ls


Figure_1.png                 how_to_make_a_synthetic.py
How_to_make_a_synthetic.pdf  how_to_make_synthetic.ipynb
L-30.las*                    las.py
PenobXL_1155.txt             las.pyc
README.md*                   tops.txt

Import NumPy, and the LASReader module from las.py. The las.py can be downloaded from the SciPy Cookbook (URL above), but we also give it here in the same directory. We create a well object called L30 by passing the 'L-30.las' file into the LASReader module.


In [2]:
%matplotlib inline
import matplotlib.pyplot as plt

In [3]:
import numpy as np
from las import LASReader
L30 = LASReader('L-30.las', null_subs=np.nan)

Let's take a look at which curves are in the file,


In [4]:
print L30.curves.names


['DEPTH', 'CALD', 'CALS', 'DEPT', 'DRHO', 'DT', 'GRD', 'GRS', 'ILD', 'ILM', 'LL8', 'NPHILS', 'NPHISS', 'RHOB', 'SP']

Let's take a look at the measured depth units for this log.


In [5]:
print L30.curves.DEPTH.units


FT

The units for this log are in feet (yuck). It turns out that we will have to convert to metres a number of times so let's write a function to do this,


In [6]:
def f2m(item_in_feet):
    "converts feet to meters"
    try:
        return item_in_feet / 3.28084
    
    except TypeError:
        return float(item_in_feet) / 3.28084
    
    return converted

For the curves that we will be accessing in the code, let's given them some shorter names. Also, some of the curves need to be converted into SI units,


In [7]:
z = f2m(L30.data['DEPTH'])      # convert feet to metres
GR = L30.data['GRS']
IL8 = L30.data['LL8']
ILM = L30.data['ILM']
ILD = L30.data['ILD']
NPHISS = L30.data['NPHISS']
NPHILS = L30.data['NPHILS']
ILD = L30.data['ILD']
DT = L30.data['DT']*3.28084     # convert usec/ft to usec/m
RHOB = L30.data['RHOB']*1000    # convert to SI units

Dealing with the shallow section

To deal with the shallow section, we first need to adjust the depths relative to sea level by subtracting the KB elevation (30.2 m) from the measured depths,

If we now integrate the sonic log, we see that time t = 0 corresponds to a depth of 347 m TVDss. To position the top of the log at the correct travel time on the seismic section, we need to use the thickness, and replacement velocities for both the water column, and the section above the log.

The replacement velocity for water = 1480 m/s

The replacement velocity for section above log = 1600 m/s

Update - missing variables now defined (Jan 5, 2014)


In [8]:
KB_elev = f2m(L30.well.KB.data)  # Kelly Bushing elevation(ft)
water_depth = f2m(L30.well.GL.data) # Depth to Sea Floor below sea level (ft)
top_of_log = f2m(L30.start)   # top of log (ft) relative to KB (actually 1150 ft)

print "KB elevation [m]: ", f2m(L30.well.KB.data) # Kelly Bushing (ft)
print "Seafloor elevation [m]: ", f2m(L30.well.GL.data) # Depth to sea floor below sea level (ft)

repl_int = f2m(L30.start) - f2m(L30.well.KB.data) + f2m(L30.well.GL.data)
water_vel = 1480  # velocity of sea water [m/s]

EGL_time = 2.0 * np.abs(f2m(L30.well.KB.data)) / water_vel
print "Ground Level time above SRD :", EGL_time

water_twt = 2.0*abs(water_depth + EGL_time) / water_vel
print "water_time: ", water_twt

print "Top of sonic log [m]: ", f2m(L30.start)  # top of log (ft) (actually 1150 ft)
print "replacement interval [m]: ", repl_int

repl_vel = 1600 # m/s
repl_time = 2.0 * repl_int / repl_vel
print "two-way-replacement time: ", repl_time

log_start_time = water_twt + repl_time 
print 'log_start_time:', log_start_time

def tvdss(md):
    "assumes a vertical well"
    return md - f2m(L30.well.KB.data)

top_log_TVDss = f2m(L30.well.KB.data) - f2m(L30.well.GL.data)


KB elevation [m]:  30.1751990344
Seafloor elevation [m]:  -137.464795601
Ground Level time above SRD : 0.0407772959924
water_time:  0.185708132845
Top of sonic log [m]:  347.471988881
replacement interval [m]:  179.831994245
two-way-replacement time:  0.224789992807
log_start_time: 0.410498125651

The starting time for our log is :


In [9]:
print log_start_time, ' seconds'


0.410498125651  seconds

Fixing / editing the logs

We would like to clip spikes. But np.clip() clips absolute values. This won't work for us because there's a trend in the data. Instead, we'd like to compare to the neighbourhood, and clip locally spurious things.

This is going to be a bit like, but not exactly like, the conservative filter.


In [10]:
def rolling_window(a, window):
        shape = a.shape[:-1] + (a.shape[-1] - window + 1, window)
        strides = a.strides + (a.strides[-1],)
        rolled = np.lib.stride_tricks.as_strided(a, shape=shape, strides=strides)
        return rolled

Now we can smooth with a rolling median filter (preserves edges, only attacks spikes). Note we are not actually going to use the smoothed version, but we are going to use the diff to decide where the spikes are.


In [11]:
window = 13 # the length of filter is 13 samples or ~ 2 metres
rho_sm = np.median(rolling_window(RHOB,window), -1)
rho_sm = np.pad(rho_sm, window/2, mode='edge')

In [12]:
plt.figure(figsize=(18,4))
plt.plot(z,rho_sm,'b', z, RHOB, 'k', alpha=0.5)
plt.plot(z, rho_sm)
plt.show()


Now we will find spikes by comparing the original data with the median-smoothed data. If they differ by more than 100 (arbitrary), then it's a spike. Alternately, we could use the DRHO curve as a threshold for this:


In [13]:
def despike(curve, curve_sm, max_clip): 
    spikes = np.where(curve - curve_sm > max_clip)[0]
    spukes = np.where(curve_sm - curve > max_clip)[0]
    out = np.copy(curve)
    out[spikes] = curve_sm[spikes] + max_clip  # Clip at the max allowed diff
    out[spukes] = curve_sm[spukes] - max_clip  # Clip at the min allowed diff
    return out

Now we know the indices of the spiky data, we can replace with a clipped version of the same spike.


In [14]:
rho = despike(RHOB, rho_sm, max_clip = 100)


-c:2: RuntimeWarning: invalid value encountered in greater
-c:3: RuntimeWarning: invalid value encountered in greater

Let's examine a segment of the log to see what effect it had,


In [15]:
start = 13000
end = 14500

plt.figure(figsize=(18,4))
plt.plot(z[start:end], RHOB[start:end],'k')
plt.plot(z[start:end], rho_sm[start:end],'b')
plt.plot(z[start:end], rho[start:end],'r')
plt.title('de-spiked density')


Out[15]:
<matplotlib.text.Text at 0x10e540390>

Do the same thing for the sonic log,


In [16]:
dt_sm = np.median(rolling_window(DT,window), -1)
dt_sm = np.pad(dt_sm, window/2, mode='edge')
dt = despike(DT, dt_sm, max_clip = 10)

In [17]:
start = 13000
end = 14500

plt.figure(figsize=(18,4))
plt.plot(z[start:end], DT[start:end],'k')
plt.plot(z[start:end], dt_sm[start:end],'b')
plt.plot(z[start:end], dt[start:end],'r')
plt.title('de-spiked sonic')


Out[17]:
<matplotlib.text.Text at 0x10e31b2d0>

Computing the time-to-depth relationship

The time-to-depth relationship is obtained by scaling the sonic log by the sample interval (6" or 0.1524 m) and by calling np.cumsum() on it.


In [18]:
# two-way-time to depth relationship
scaled_dt = 0.1524 * np.nan_to_num(dt) / 1e6

tcum = 2 * np.cumsum(scaled_dt)
tdr = tcum + log_start_time

Compute acoustic impedance


In [19]:
Z = (1e6/dt) * rho

Compute reflection coefficient series


In [20]:
RC = (Z[1:] - Z[:-1]) / (Z[1:] + Z[:-1])

Get well tops

Let's make a dictionary from the file 'tops.txt'


In [21]:
# Get well tops for plot annotation
tops = {}

with open('tops.txt') as f:
    for line in f.readlines():
        if not line.startswith('#'):
            temp = line.strip().split('\t')
            tops[temp[-1].replace('_',' ')] = float(temp[1])
            
tops


Out[21]:
{'Abenaki': 3404.311,
 'Base O-Marker': 2469.207,
 'Dawson Canyon': 984.504,
 'L Baccaro': 3964.534,
 'L Missisauga': 3190.646,
 'Logan Canyon': 1136.904,
 'Mid Baccaro': 3485.083,
 'U Missisauga': 2251.253,
 'Wyandot': 867.156}

In [22]:
tops.items() , tops.values()


Out[22]:
([('Logan Canyon', 1136.904),
  ('Wyandot', 867.156),
  ('L Baccaro', 3964.534),
  ('Abenaki', 3404.311),
  ('U Missisauga', 2251.253),
  ('L Missisauga', 3190.646),
  ('Base O-Marker', 2469.207),
  ('Dawson Canyon', 984.504),
  ('Mid Baccaro', 3485.083)],
 [1136.904,
  867.156,
  3964.534,
  3404.311,
  2251.253,
  3190.646,
  2469.207,
  984.504,
  3485.083])

Find top relative to nearest sample in time domain


In [23]:
def find_nearest(array, value):
    idx = (np.abs(array - value)).argmin()
    return idx

For plotting tops in the time-domain, make a second entry in the dictionary for the travel-time.


In [24]:
tops_twt = {}

for key, val in tops.iteritems():
    tops_twt[key] = tdr[find_nearest(z, val)]
    
tops_twt


Out[24]:
{'Abenaki': 2.4676720864136237,
 'Base O-Marker': 1.9861135739916733,
 'Dawson Canyon': 1.010277271650279,
 'L Baccaro': 2.7157311290554187,
 'L Missisauga': 2.3645146231084992,
 'Logan Canyon': 1.1354891476577422,
 'Mid Baccaro': 2.5014729234975923,
 'U Missisauga': 1.8730459013738003,
 'Wyandot': 0.92279460204849317}

Code to create figure

Plot the logs in the depth domain


In [25]:
f1 = plt.figure(figsize = (10,8))

ax1 = f1.add_axes([0.1, 0.1, 0.18, 0.8])
ax1.plot( DT, z,'b', alpha=0.5)
ax1.set_title('a) function', style = 'italic')
ax1.set_ylabel('measured depth ' + '$[m]$', fontsize = '12' )
ax1.set_xlabel('P-wave slowness '+ r'$[\mu s/m]$', fontsize = '12')
ax1.set_ylim(0, 4500)
ax1.set_xticklabels('')
ax1.invert_yaxis()
ax1.grid()

ax2 = f1.add_axes([0.3 , 0.1, 0.18, 0.8])
ax2.plot(tcum, z, 'b', alpha = 0.5)
ax2.set_title('integral', style = 'italic')
ax2.set_xlabel('two-way time ' + '$[s]$', fontsize = '12')
ax2.invert_yaxis()
ax2.set_yticklabels('')
ax2.set_xticklabels('')
ax2.set_ylim(4500, 0)
ax2.grid()

ax3 = f1.add_axes([0.5, 0.1, 0.18, 0.8])
ax3.plot( Z, z, 'k', alpha=0.5)
ax3.set_title('impedance', style = 'italic')
ax3.set_xlabel(r'$kg/m^2s^2$', fontsize = '12')
ax3.invert_yaxis()
ax3.set_yticklabels('')
ax3.set_xticklabels('')
ax3.set_ylim(4500, 0)
ax3.grid()

ax4 = f1.add_axes([0.7, 0.1, 0.18, 0.8])
ax4.plot( RC, z[:-1], 'k', alpha=0.5)
ax4.set_title('derivative', style = 'italic')
ax4.set_xlabel('reflectivity', fontsize = '12')
ax4.invert_yaxis()
ax4.set_yticklabels('')
ax4.set_xticklabels('')
ax4.set_ylim(4500, 0)
ax4.grid()

for i in range (4):
    for top in tops.values() :
        f1.axes[i].axhline( y = float(top), color = 'b', lw = 1, 
                            ls = ':',  
                            alpha = 0.5, xmin = 0.05, xmax = 0.95 )

for top, depth in tops.iteritems():
    ax4.text( x = max(ax4.xaxis.get_data_interval())*1.0,
              y = float(depth), s = top,
                         alpha=0.75, color='k',
                         fontsize = '10',
                         horizontalalignment = 'left',
                         verticalalignment = 'center',
                         bbox=dict(facecolor='white', alpha=1.0, lw = 0.25),
                         weight = 'light')

ax5 = f1.add_axes([0.8, 0.1, 0.18, 0.8])
ax5.invert_yaxis()
ax5.set_ylim(4500, 0)
ax5.set_axis_off()


Converting logs to two-way-travel time

But we need the functions represented by travel time


In [26]:
# RESAMPLING FUNCTION
dt = 0.004
maxt = 3.0

t = np.arange(0, maxt, dt) 

Z_t = np.interp(x = t, xp = tdr, fp = Z)

RC_t = (Z_t[1:] - Z_t[:-1]) / (Z_t[1:] + Z_t[:-1])

Creating the synthetic


In [27]:
# Define a wavelet.
def ricker(f, length, dt):
    t = np.linspace(-length / 2, (length-dt) / 2, length / dt)
    y = (1. - 2.*(np.pi**2)*(f**2)*(t**2))*np.exp(-(np.pi**2)*(f**2)*(t**2))
    return t, y

In [28]:
RC_t = np.nan_to_num(RC_t)
tw, w = ricker (f=25, length = 0.512, dt = 0.004)
synth = np.convolve(w, RC_t, mode='same')

Code to create figure


In [29]:
f2 = plt.figure(figsize=[10,12])

ax1 = f2.add_axes([0.05, 0.1, 0.2, 0.9])
ax1.plot(Z, z,'k', alpha=0.75)
ax1.set_title('impedance')
ax1.set_ylabel('measured depth ' + '$[m]$', fontsize = '12' )
ax1.set_xlabel(r'$kg/m^2s^2$ ', fontsize = '16')
ax1.set_ylim(0, 4500)
ax1.set_xticks( [0.0e7, 0.5e7, 1.0e7, 1.5e7, 2.0e7 ] )
ax1.invert_yaxis()
ax1.grid()

ax2 = f2.add_axes([0.325, 0.1, 0.2, 0.9])
ax2.plot(Z_t, t,'k', alpha=0.75)
ax2.set_title('impedance')
ax2.set_ylabel('two-way time ' + '$[s]$', fontsize = '12' )
ax2.set_xlabel(r'$kg/m^2s^2$ ', fontsize = '16')
ax2.set_ylim(0, 3)
ax2.set_xticks( [0.0e7, 0.5e7, 1.0e7, 1.5e7, 2.0e7 ] )
ax2.invert_yaxis()
ax2.grid()

ax3 = f2.add_axes([0.675, 0.1, 0.1, 0.9])
ax3.hlines(t[:-1], 0, RC_t, color='k', lw = 1)                    # Stems
ax3.plot([0, 0], [t.min(), t.max()], '-', c='k', alpha = 0.5)     # Middle bar
ax3.set_title('reflectivity')
ax3.set_xlabel('', fontsize = '10')
ax3.set_ylim(0, 3)
ax3.set_xlim(-0.5, 0.5)
ax3.invert_yaxis()
ax3.set_yticklabels('')
ax3.set_xticks([-0.3, 0, 0.3] )
ax3.grid()

ax4 = f2.add_axes([0.8, 0.1, 0.2, 0.9])
ax4.plot(synth, t[:-1],'k')
ax4.fill_betweenx(t[:-1], synth,  0,  synth > 0.0,  color='k', alpha = 1.0)
ax4.set_title('synthetic')
ax4.set_xlabel('', fontsize = '10')
ax4.set_ylim(0, 3)
ax4.set_xlim(-0.05, 0.05)
ax4.invert_yaxis()
ax4.set_yticklabels('')
ax4.set_xticks([-0.6, -0.3, 0, 0.3, 0.6 ] )
ax4.grid()

for i in range(1):
    for top, depth in tops.iteritems():
        f2.axes[i].axhline( y = float(depth), color = 'b', lw = 2, 
                            alpha = 0.5, xmin = 0.05, xmax = 0.95 )
        f2.axes[i].text( x = 1e7, y = float(depth)-0.015, s = top,
                         alpha=0.75, color='k',
                         fontsize = '12',
                         horizontalalignment = 'center',
                         verticalalignment = 'center',
                         bbox=dict(facecolor='white', alpha=0.5, lw = 0.5),
                         weight = 'light')
        

for i in range(1,4):
    for twt in tops_twt.values():
        f2.axes[i].axhline( y = float(twt), color = 'b', lw = 2, 
                    alpha = 0.5, xmin = 0.05, xmax = 0.95)
for i in range(1,2):
    for top, twt in tops_twt.iteritems():
        f2.axes[i].text( x = 2.75e7, y = float(twt), s = top,
                         alpha=0.75, color='k',
                         fontsize = '12',
                         horizontalalignment = 'center',
                         verticalalignment = 'center',
                         bbox=dict(facecolor='white', alpha=0.5, lw = 0.5),
                         weight = 'light'
                         )


Comparison with real data

real data is located at trace 77 of 500


In [30]:
traces = np.loadtxt('PenobXL_1155.txt')

# rearrange traces for plotting
traces = np.fliplr(np.transpose(traces))

Code to create figure


In [31]:
import matplotlib.gridspec as gridspec

f3 = plt.figure(figsize=[16,12])

gs = gridspec.GridSpec(1, 6, width_ratios = [1.25, 1.25, 0.5, 1.25, 0.75, 4])

# depth domain

axa = plt.subplot(gs[0])
axa.plot( DT, z,'k', alpha=0.75, lw=0.25)
axa.set_title('travel time')
axa.set_ylabel('measured depth ' + '$[m]$', fontsize = '12' )
axa.set_xlabel('slowness 'r'$[\mu s/m]$', fontsize = '12')
axa.set_ylim(0, 4500)
axa.set_xticklabels('')
axa.invert_yaxis()
axa.grid()
gs.update(wspace=0.1)

axb = plt.subplot(gs[1])
axb.plot(tcum, z, 'k', alpha = 0.75)
axb.set_title('integrated travel time')
axb.set_xlabel('two-way time ' + '$[s]$', fontsize = '12')
axb.invert_yaxis()
axb.set_yticklabels('')
axb.set_xticklabels('')
axb.set_ylim(4500, 0)
axb.grid()

for top, depth in tops.iteritems():
    axa.axhline( y = float(depth), color = 'b', lw = 2, 
                         alpha = 0.5, xmin = 0.05, xmax = 0.95 )
    axb.axhline( y = float(depth), color = 'b', lw = 2, 
                         alpha = 0.5, xmin = 0.05, xmax = 0.95 )
    axb.text( x = 1.25, y = float(depth)-0.015, s = top,
                         alpha=0.75, color='k',
                         fontsize = '10',
                         horizontalalignment = 'center',
                         verticalalignment = 'center',
                         bbox=dict(facecolor='white', alpha=1.0, lw = 0.5),
                         weight = 'light')

# time domain

#white space between depth and time plots
axoff = plt.subplot(gs[2])
axoff.set_axis_off()

axc = plt.subplot(gs[3])
axc.plot( Z_t, t, 'k', alpha=0.75)
axc.set_title('impedance')
axc.set_ylabel('two-way time '+ '$[s]$', fontsize = '12' )
axc.set_xlabel(r'$kg / m^2s^2$ ', fontsize = '12')
axc.set_ylim(0, 3)
axc.set_xlim(0, 2e7)
axc.invert_yaxis()
axc.grid()

for top, twt in tops_twt.iteritems():
    axc.axhline( y = float(twt), color = 'b', lw = 2, 
                    alpha = 0.5, xmin = 0.05, xmax = 0.95)
    axc.text(x = 1e7, y = float(twt), s = top,
                        alpha=0.75, color='k',
                        fontsize = '10',
                        horizontalalignment = 'center',                             
                        verticalalignment = 'center',
                        bbox=dict(facecolor='white', alpha=0.5, lw = 0.5),
                        weight = 'light')     

axd = plt.subplot(gs[4])
axd.hlines(t[:-1], 0, RC_t, color='k', lw = 1)                    # Stems
axd.plot([0, 0], [t.min(), t.max()], '-', c='k', alpha = 0.5)     # Middle bar
axd.set_title('reflectivity')
axd.set_xlabel('', fontsize = '12')
axd.set_ylim(0, 3)
axd.set_xlim(-0.5, 0.5)
axd.invert_yaxis()
axd.set_yticklabels('')
axd.set_axis_off()
axd.grid()
for depth in tops_twt.values():
    axd.axhline(y = float(depth), color = 'b', lw = 2, 
                alpha = 0.5, xmin = 0.25, xmax = 0.75)

axe = plt.subplot(gs[5])
axe.imshow( traces[:750,100:], cmap = 'Greys', 
           vmin = -7000, vmax = 7000, alpha = 0.8,
           aspect = 'auto'
           )
axe.set_axis_off()
axe.set_title('seismic')

# put synthetic on seismic
bottom = axe.get_position().get_points()[0][1]
top =  axe.get_position().get_points()[1][1]

axf = axe.figure.add_axes([0.79, bottom, 0.05, top-bottom])
gain_synth = 1
axf.plot(gain_synth * synth, t[:-1], 'k', alpha = 0.9)
axf.fill_betweenx(t[:-1], gain_synth * synth,  0, 
                  gain_synth * synth > 0.0,
                  color = 'k', alpha = 0.5)
axf.set_title('synthetic')
axf.set_xlabel('', fontsize = '12')
axf.set_ylim(0, 3)
axf.set_xlim(-0.5 ,0.5)
axf.invert_yaxis()
axf.set_yticklabels('')
axf.set_axis_off()
axf.grid()
for top, twt in tops_twt.iteritems():
    axf.axhline( y = float(twt), color = 'b', lw = 2, 
                alpha = 0.25, xmin = 0.25, xmax = 0.75)
    axf.text(x = -0.4, y = float(twt), s = top,
             alpha=0.75, color='k',
             fontsize = '10',
             horizontalalignment = 'right',
             verticalalignment = 'center',
             bbox=dict(facecolor='white', alpha=0.25, lw = 0.5),
             weight = 'light')

#f3.savefig('Figure_1.png', facecolor = 'white', dpi = 300)