"""Wellbore torque and drag calculations based on Johancsik et al. (SPE 11380-PA)."""
from copy import deepcopy
import numpy as np
try:
from plotly.subplots import make_subplots
import plotly.graph_objects as go
PLOTLY = True
except ImportError:
PLOTLY = False
from .survey import interpolate_md, Survey
from .units import ureg
[docs]
class TorqueDrag:
"""Torque and drag model for a string in a wellbore.
Computes axial tension and torsion profiles along a drillstring or
casing string for pickup, slackoff, rotating, and drilling scenarios
using the soft-string (Johancsik) method.
Methods
-------
add_survey_points_from_strings(strings)
Add string section boundaries as survey station points.
get_buoyancy_factors()
Calculate buoyancy factors for each survey interval.
get_inc_average()
Calculate average inclination per interval.
get_inc_delta()
Calculate inclination change per interval.
get_azi_delta()
Calculate azimuth change per interval.
get_characteristic_od(strings)
Determine effective OD for each survey interval from string data.
get_weight_buoyed_and_radius(strings)
Calculate buoyed weight and bend radius per interval.
get_coeff_friction_sliding(strings)
Get sliding friction coefficients per interval from string data.
get_forces_and_torsion(mode, friction)
Calculate axial forces and torque along the wellbore.
figure()
Create a plotly figure of string tension and torque.
"""
[docs]
def __init__(
self, survey, wellbore, string, fluid_density, name=None,
wob=None, tob=None, overpull=None,
):
"""
A class for calculating wellbore torque and drag, based on the
"Torque and Drag in Directional Wells--Prediction and Measurement
(SPE 11380-PA) by C.A. Johancsik et al.
Parameters
----------
survey: welleng.survey.Survey instance
The well trajectory of the scenario being modelled.
wellbore: welleng.architecture.WellBore instance
The well bore architecture of the scenario being modelled.
string: welleng.architecture.BHA or welleng.architecture.CasingString
instance
The string being run inside the well bore for the scenario being
modelled.
fluid_density: float
The density (in SG) of the fluid in the well bore.
name: str
The name of the scenario being modeled.
wob: float
The compressive force (weight on bit) applied at the bottom of the
string in N.
tob: float
The torque (torque on bit) applied at the bottom of the string in
N.m.
overpull: float
The tension applied at the bottom of the string in N.
"""
assert wellbore.complete, "Wellbore not complete"
assert string.complete, "String not complete"
self.survey_original = survey
self.wellbore = wellbore
self.string = string
self.fluid_density = fluid_density
self.name = name
self.add_survey_points_from_strings()
self.index = np.where(self.survey.md == self.string.bottom)[0][0] + 1
self.get_buoyancy_factors()
self.get_inc_average()
self.get_inc_delta()
self.get_azi_delta()
self.get_weight_buoyed_and_radius()
self.torque, self.tension = {}, {}
self.get_coeff_friction_sliding()
self.get_forces_and_torsion()
if any((wob, tob, overpull)):
self.get_forces_and_torsion(wob=wob, tob=tob, overpull=overpull)
[docs]
def add_survey_points_from_strings(self):
"""
Check that there's survey stations for the top and bottoms of
the string sections to ensure that the torque and drag is
calculated for these key locations.
"""
q = []
for k, v in self.wellbore.sections.items():
if v['bottom'] in self.survey_original.md:
continue
else:
q.append(interpolate_md(
self.survey_original, v['bottom']
))
for k, v in self.string.sections.items():
if v['bottom'] in self.survey_original.md:
continue
else:
q.append(interpolate_md(
self.survey_original, v['bottom']
))
md, inc, azi = (
self.survey_original.md.tolist(),
self.survey_original.inc_rad.tolist(),
self.survey_original.azi_grid_rad.tolist()
)
md.extend([station.md[-1] for station in q])
inc.extend([station.inc_rad[-1] for station in q])
azi.extend([station.azi_grid_rad[-1] for station in q])
md, inc, azi = zip(*sorted(zip(md, inc, azi)))
sh = self.survey_original.header
sh.azi_reference = 'grid'
self.survey = Survey(
md=md, inc=inc, azi=azi, header=sh, deg=False
)
[docs]
def get_buoyancy_factors(self):
"""
Determine the buoyancy factor for each string section and add it
to the string sections dict.
"""
for k, v in self.string.sections.items():
v['buoyancy_factor'] = buoyancy_factor(
self.fluid_density, v['density']
)
[docs]
def get_inc_average(self):
"""Calculate the average inclination between consecutive survey stations."""
self.inc_average = np.zeros_like(self.survey.inc_rad)
self.inc_average[1:] = np.average(
(self.survey.inc_rad[1:], self.survey.inc_rad[:-1]),
axis=0
)
[docs]
def get_inc_delta(self):
"""Calculate the inclination change between consecutive survey stations."""
self.inc_delta = np.zeros_like(self.survey.inc_rad)
self.inc_delta[1:] = self.survey.inc_rad[1:] - self.survey.inc_rad[:-1]
[docs]
def get_azi_delta(self):
"""Calculate the azimuth change between consecutive survey stations."""
self.azi_delta = np.zeros_like(self.survey.azi_grid_rad)
self.azi_delta[1:] = (
self.survey.azi_grid_rad[1:] - self.survey.azi_grid_rad[:-1]
)
[docs]
def get_characteristic_od(self, section):
"""Return the effective outer diameter for a string section.
Uses the tooljoint OD if available, otherwise the pipe body OD.
Parameters
----------
section : int
Index of the string section.
Returns
-------
float
The characteristic outer diameter in meters.
"""
if bool(self.string.sections[section].get('tooljoint_od')):
return self.string.sections[section]['tooljoint_od']
else:
return self.string.sections[section]['od']
[docs]
def get_weight_buoyed_and_radius(self):
"""Calculate buoyed weight and contact radius for each survey interval."""
sections = self.string.sections
bottoms = np.array([s['bottom'] for s in sections])
mds = self.survey.md[1:]
delta_mds = self.survey.delta_md[1:]
mask = mds <= self.string.bottom
mds = mds[mask]
delta_mds = delta_mds[mask]
idx = np.clip(np.searchsorted(bottoms, mds, side='left'), 0, len(sections) - 1)
unit_weights = np.array([sections[i]['unit_weight'] for i in idx])
buoyancy = np.array([sections[i]['buoyancy_factor'] for i in idx])
diameters = np.array([self.get_characteristic_od(i) for i in idx])
self.weight_buoyed = np.concatenate([[0], unit_weights * delta_mds * buoyancy])
self.radius = np.concatenate([[self.get_characteristic_od(0)], diameters]) / 2
[docs]
def get_coeff_friction_sliding(self):
"""Build an array of sliding friction coefficients mapped to survey stations."""
sections = self.wellbore.sections
bottoms = np.array([s['bottom'] for s in sections])
mds = self.survey.md[1:]
mask = mds <= self.wellbore.bottom
mds = mds[mask]
idx = np.clip(np.searchsorted(bottoms, mds, side='left'), 0, len(sections) - 1)
friction = np.array([sections[i]['coeff_friction_sliding'] for i in idx])
self.coeff_friction_sliding = np.concatenate(
[[sections[0]['coeff_friction_sliding']], friction]
)
[docs]
def get_forces_and_torsion(self, wob=False, tob=False, overpull=False):
"""Compute tension and torque profiles along the string.
Iterates from bit to surface, accumulating normal force, axial
tension, and torsion at each survey station. Results are stored
in ``self.tension`` and ``self.torque`` dicts keyed by load case.
Parameters
----------
wob : float, optional
Weight on bit in Newtons. Must be provided with tob.
tob : float, optional
Torque on bit in N*m. Must be provided with wob.
overpull : float, optional
Additional tension at the bit in Newtons.
"""
if any((wob, tob)):
assert tob, "Can't have WOB without TOB"
assert wob, "Can't have TOB wihtouh WOB"
ft = [np.array([0.0, -wob, -wob])]
tn = [tob]
else:
ft = [np.zeros(3)]
tn = [0]
if overpull:
ft[0][0] = overpull
fn = []
for row in zip(
self.inc_average[:self.index][::-1],
self.inc_delta[:self.index][::-1],
self.azi_delta[:self.index][::-1],
self.weight_buoyed[::-1],
self.coeff_friction_sliding[:self.index][::-1],
self.radius
):
(
inc_average, inc_delta, azi_delta, weight_buoyed,
coeff_friction_sliding, radius
) = row
fn.append(force_normal(
ft[-1], inc_average, inc_delta, azi_delta,
weight_buoyed
))
ft.append(ft[-1] + np.array(force_tension_delta(
weight_buoyed, inc_average, coeff_friction_sliding, fn[-1]
)))
tn.append(tn[-1] + np.array(torsion_delta(
coeff_friction_sliding, fn[-1][2], radius
)))
fn = np.array(fn)[::-1]
ft = np.array(ft)[::-1][1:]
tn = np.array(tn)[::-1][1:]
if wob:
self.tension["drilling"] = ft[:, 2]
self.tension["sliding"] = ft[:, 1]
else:
self.tension['slackoff'] = ft[:, 1]
self.tension['rotating'] = ft[:, 2]
if tob:
self.torque['drilling'] = tn
else:
self.torque['rotating'] = tn
if overpull:
self.tension['overpull'] = ft[:, 0]
else:
self.tension['pickup'] = ft[:, 0]
self.wob, self.tob, self.overpull = wob, tob, overpull
[docs]
def force_normal(
force_tension,
inc_average,
inc_delta,
azi_delta,
weight_buoyed,
):
"""Calculate the normal contact force between string and wellbore.
Parameters
----------
force_tension : numpy.ndarray
Axial tension array (pickup, slackoff, rotating) in N.
inc_average : float
Average inclination of the interval in radians.
inc_delta : float
Inclination change over the interval in radians.
azi_delta : float
Azimuth change over the interval in radians.
weight_buoyed : float
Buoyed weight of the string element in N.
Returns
-------
numpy.ndarray
Normal force array for each load case in N.
"""
result = np.sqrt(
(force_tension * azi_delta * np.sin(inc_average)) ** 2
+ (
force_tension * inc_delta
+ weight_buoyed * np.sin(inc_average)
) ** 2
)
return result
[docs]
def force_tension_delta(
weight_buoyed,
inc_average,
coeff_friction_sliding,
force_normal
):
"""Calculate the incremental tension change over one survey interval.
Parameters
----------
weight_buoyed : float
Buoyed weight of the string element in N.
inc_average : float
Average inclination of the interval in radians.
coeff_friction_sliding : float
Sliding friction coefficient for the interval.
force_normal : numpy.ndarray
Normal contact force for each load case in N.
Returns
-------
tuple of float
Tension increments for (pickup, slackoff, rotating) in N.
"""
A = weight_buoyed * np.cos(inc_average)
B = coeff_friction_sliding * force_normal
pickup, slackoff, rotating = A + B * np.array([1, -1, 0])
return (pickup, slackoff, rotating)
[docs]
def torsion_delta(
coeff_friction_sliding,
force_normal,
radius
):
"""Calculate the incremental torsion change over one survey interval.
Parameters
----------
coeff_friction_sliding : float
Sliding friction coefficient for the interval.
force_normal : float
Normal contact force for the rotating load case in N.
radius : float
Contact radius of the string element in meters.
Returns
-------
float
Torsion increment in N*m.
"""
result = coeff_friction_sliding * force_normal * radius
return result
[docs]
def buoyancy_factor(fluid_density, string_density=7.85):
"""
Parameters
----------
fluid_density: float
The density of the fluid in SG.
string_density: float
The density of the string, typically made from steel.
Returns
-------
result: float
The buoyancy factor when when multiplied against the string weight
yields the bouyed string weight.
"""
result = (string_density - fluid_density) / string_density
return result
[docs]
class HookLoad:
"""Hookload (broomstick) plot model for running or pulling a string.
Methods
-------
get_ff_range(ff_range)
Compute hookloads across a range of friction factors.
get_data()
Retrieve computed hookload data.
figure()
Create a plotly broomstick plot of hookload vs friction factor.
"""
[docs]
def __init__(
self, survey, wellbore, string, fluid_density, step=30, name=None,
ff_range=(0.1, 0.4, 0.1)
):
"""
A class for calculating the hookload or broomstick plot data for
running or pulling a string in a wellbore.
Parameters
----------
survey: welleng.survey.Survey instance
The well trajectory of the scenario being modelled.
wellbore: welleng.architecture.WellBore instance
The well bore architecture of the scenario being modelled.
string: welleng.architecture.BHA or welleng.architecture.CasingString
instance
The string being run inside the well bore for the scenario being
modelled.
fluid_density: float
The density (in SG) of the fluid in the well bore.
step: float
The measured depth step distance in meters to move the string.
name: str
The name of the scenario being modeled.
ff_range: (3) tuple
The start, stop and step for the range of friction factors to be
used in the hookload calculations.
"""
self.survey = survey
self.wellbore = wellbore
self.string = string
self.fluid_density = fluid_density
self.name = name
self.step = step
self.get_ff_range(ff_range)
self.get_data()
[docs]
def get_ff_range(self, ff_range):
"""Expand the friction factor range into a list of values.
Parameters
----------
ff_range : tuple of float
``(start, stop, step)`` for the friction factor range.
"""
self.ff_range = np.arange(*ff_range).tolist()
self.ff_range.append(ff_range[1])
[docs]
def get_data(self):
"""Run torque-drag calculations for each friction factor and depth step."""
self.data = {}
self.md_range = np.arange(
self.string.top + self.step, self.string.bottom, self.step
).tolist()
self.md_range.append(self.string.bottom)
for ff in self.ff_range:
wellbore_temp = deepcopy(self.wellbore)
for k in wellbore_temp.sections.keys():
wellbore_temp.sections[k]['coeff_friction_sliding'] = ff
self.data[ff] = {}
data_temp = []
for md in self.md_range:
bha_temp = self.string.depth(md)
data_temp.append(
TorqueDrag(
self.survey, wellbore_temp, bha_temp,
fluid_density=self.fluid_density,
name=self.name
)
)
for t in data_temp[0].tension.keys():
self.data[ff][t] = [
d.tension[t][0] for d in data_temp
]