diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5b6afab..41d51ca 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -41,4 +41,4 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | - pytest + pytest test diff --git a/README.md b/README.md index 7640612..454fd24 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # welleng + [![Open Source Love svg2](https://badges.frapsoft.com/os/v2/open-source.svg?v=103)](https://github.com/pro-well-plan/pwptemp/blob/master/LICENSE.md) [![PyPI version](https://badge.fury.io/py/welleng.svg)](https://badge.fury.io/py/welleng) [![Downloads](https://static.pepy.tech/personalized-badge/welleng?period=total&units=international_system&left_color=grey&right_color=orange&left_text=Downloads)](https://pepy.tech/project/welleng) @@ -11,9 +12,11 @@ - Calculate well bore clearance and Separation Factors (SF) - standard [ISCWSA] method - new mesh based method using the [Flexible Collision Library] + ## New Features! + - **Import from Landmark .wbp files:** using the `exchange.wbp` module it's now possible to import .wbp files exported from Landmark's COMPASS or DecisionSpace software. - ``` + ```python import welleng as we wp = we.exchange.wbp.load(demo.wbp) # import file @@ -21,42 +24,53 @@ survey = we.exchange.wbp.wbp_to_survey(wp, step=30) # convert to survey mesh = we.mesh.WellMesh(survey, method='circle') # convert to mesh we.visual.plot(m.mesh) # plot the mesh ``` + - **Export to .wbp files *(experiemental)*:** using the `exchange.wbp` module, it's possible to convert a planned survey file into a list of turn points that can be exported to a .wbp file. - ``` + ```python import welleng as we wp = we.exchange.wbp.WellPlan(survey) # convert Survey to WellPlan object doc = we.exchange.wbp.export(wp) # create a .wbp document we.exchange.wbp.save_to_file(doc, f"demo.wbp") # save the document to file ``` + - **Well Path Creation:** the addition of the `connector` module enables drilling well paths to be created simply by providing start and end locations (with some vector data like inclination and azimuth). No need to indicate *how* to connect the points, the module will figure that out itself. - **Fast visualization of well trajectory meshes:** addition of the `visual` module for quick and simple viewing and QAQC of well meshes. - **Mesh Based Collision Detection:** the current method for determining the Separation Factor between wells is constrained by the frequency and location of survey stations or necessitates interpolation of survey stations in order to determine if Anti-Collision Rules have been violated. Meshing the well bore interpolates between survey stations and as such is a more reliable method for identifying potential well bore collisions, especially wth more sparse data sets. - More coming soon! + ## Tech + [welleng] uses a number of open source projects to work properly: * [trimesh] - awesome library for loading and using triangular meshes * [numpy] - the fundamental package for scientific computing with Python * [scipy] - a Python-based ecosystem of open-source software for mathematics, science, and engineering * [vedo] - a python module for scientific visualization, analysis of 3D objects and point clouds based on VTK. + ## Installation + [welleng] requires [trimesh], [numpy] and [scipy] to run. Other libraries are optional depending on usage and to get [python-fcl] running on which [trimesh] is built may require some additional installations. Other than that, it should be an easy pip install to get up and running with welleng and the minimum dependencies. -``` +```python pip install welleng ``` + For developers, the repository can be cloned and locally installed in your GitHub directory via your preferred Python env. -``` + +```python git clone https://github.com/jonnymaserati/welleng.git cd welleng pip install -e . ``` + Make sure you include that `.` in the final line (it's not a typo) as this ensures that any changes to your development version are immediately implemented on save. + ## Quick Start + Here's an example using `welleng` to construct a couple of simple well trajectories with the `connector` module, creating survey listings for the wells with well bore uncertainty data, using these surveys to create well bore meshes and finally printing the results and plotting the meshes with the closest lines and SF data. -``` +```python import welleng as we import numpy as np from tabulate import tabulate @@ -143,6 +157,7 @@ we.visual.plot( print("Done!") ``` + This results in a quick, interactive visualization of the well meshes that's great for QAQC. What's interesting about these results is that the ISCWSA method does not explicitly detect a collision in this scenario wheras the mesh method does. ![image](https://user-images.githubusercontent.com/41046859/102106351-c0dd1a00-3e30-11eb-82f0-a0454dfce1c6.png) @@ -154,6 +169,7 @@ For more examples, including how to build a well trajectory by joining up a seri Well trajectory generated by [build_a_well_from_sections.py] ## Todos + - Add a Target class to see what you're aiming for - **in progress** - Export to Landmark's .wbp format so survey listings can be modified in COMPASS - **in progress** - Documentation @@ -172,10 +188,9 @@ Equinor's Volve Wells The ISCWSA standard set of well paths for evaluating clearance scenarios and Equinor's [volve] wells have been rendered in [blender] above. See the [examples] for the code used to generate the [volve] scene, extracting the data from the [volve] EDM.xml file. -License ----- +## License -LGPL v3 +[Apache 2.0](LICENSE) Please note the terms of the license. Although this software endeavors to be accurate, it should not be used as is for real wells. If you want a production version or wish to develop this software for a particular application, then please get in touch with [jonnycorcutt], but the intent of this library is to assist development. diff --git a/requirements.txt b/requirements.txt index 4d765cb..e243c37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,5 @@ six==1.15.0 tabulate==0.8.7 trimesh==3.8.18 vedo==2020.4.2 -vtk==9.0.1 \ No newline at end of file +vtk==9.0.1 +PyYAML==5.3.1 \ No newline at end of file diff --git a/setup.py b/setup.py index 06429fb..e7c69de 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ ], author='Jonathan Corcutt', author_email='jonnycorcutt@gmail.com', - license='LGPL v3', + license='Apache 2.0', packages=find_packages(exclude=["tests"]), install_requires=[ 'python-fcl', diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_connector.py b/test/test_connector.py new file mode 100644 index 0000000..d5026b8 --- /dev/null +++ b/test/test_connector.py @@ -0,0 +1,121 @@ +from welleng.connector import Connector +from welleng.survey import Survey +import numpy as np + + +def test_md_hold(): + # test hold with only md provided + c = Connector( + vec1=[0, 0, 1], + md2=500, + ) + assert ( + c.inc_target == c.inc1 + and c.azi_target == c.azi1 + and c.pos_target[2] == c.md_target + ), "Failed c1" + assert c.method == 'hold', "Unexpected method" + + c.survey() + + +def test_md_and_vec(): + # test with md2 and vec2 provided (minimum curvature) + c = Connector( + vec1=[0, 0, 1], + md2=1000, + vec2=[0, 1, 0] + ) + assert c.method == 'min_curve' + + +def test_pos(): + # test with pos2 provided (minimum distance) + c = Connector( + vec1=[0, 0, 1], + pos2=[100, 100, 1000], + ) + assert c.md_target > c.pos1[2], "Failed c3" + + +def test_pos_and_dls(): + # test with pos2 needing more aggressive dls (minimum curvature) + c = Connector( + vec1=[0, 0, 1], + pos2=[200, 400, 200] + ) + assert c.method == 'min_curve_to_target' + + +def test_pos_and_vec(): + # test with pos2 and vec2 provided + vec1 = [-1, -1, 1] + vec2 = [1, -1, 0] + c = Connector( + pos1=[0., 0., 0], + vec1=vec1 / np.linalg.norm(vec1), + pos2=[0., 1000., 500.], + vec2=vec2 / np.linalg.norm(vec2), + ) + assert c.method == 'curve_hold_curve' + + # test if interpolator and survey functions are working + assert isinstance(c.survey(), Survey) + + +def test_pos_inc_azi(): + # test with pos2, inc1 and azi1 provided + c = Connector( + pos1=[0., 0., 0], + inc1=0., + azi1=90, + pos2=[1000., 1000., 1000.], + vec2=[0., 0., 1.], + ) + assert c.method == 'curve_hold_curve' + + +def test_dls2(): + # test with different dls for second curve section + c = Connector( + pos1=[0., 0., 0], + vec1=[0., 0., 1.], + pos2=[0., 100., 1000.], + vec2=[0., 0., 1.], + dls_design2=5 + ) + assert c.radius_design2 < c.radius_design + + +def test_radius_critical(): + # test with dls_critical requirement (actual dls < dls_design) + c = Connector( + pos1=[0., 0., 0], + vec1=[0., 0., 1.], + pos2=[0., 100., 100.], + vec2=[0., 0., 1.], + ) + assert c.radius_critical < c.radius_design + + +def test_min_curve(): + # test min_curve (inc2 provided) + c = Connector( + pos1=[0., 0., 0], + vec1=[0., 0., 1.], + inc2=30, + ) + assert c.method == 'min_curve' + + +def test_radius_critical_with_min_curve(): + # test min_curve with md less than required radius + c = Connector( + pos1=[0., 0., 0], + inc1=0, + azi1=0, + md2=500, + inc2=90, + azi2=0, + ) + assert c.radius_critical < c.radius_design diff --git a/welleng/clearance.py b/welleng/clearance.py index bc00c44..3cf406a 100644 --- a/welleng/clearance.py +++ b/welleng/clearance.py @@ -6,10 +6,11 @@ from scipy.spatial import KDTree from scipy.spatial.distance import cdist -from .survey import Survey, interpolate_survey, slice_survey, make_cov -from .utils import NEV_to_HLA, HLA_to_NEV +from .survey import Survey, interpolate_survey, slice_survey +from .utils import NEV_to_HLA from .mesh import WellMesh + class Clearance: def __init__( self, @@ -41,7 +42,7 @@ def __init__( self.sigma_pa = sigma_pa self.Sm = Sm # self.N_verts = N_verts - + self._get_kop_index(kop_depth) self._get_ref() self._get_radii(Rr, Ro) @@ -51,7 +52,9 @@ def __init__( def _get_kop_index(self, kop_depth): self.kop_depth = kop_depth - self.kop_index = np.searchsorted(self.reference.md, self.kop_depth, side="left") + self.kop_index = np.searchsorted( + self.reference.md, self.kop_depth, side="left" + ) def _get_nevs(self, survey): return np.array([ @@ -104,6 +107,7 @@ def _get_ref(self): unit=self.reference.unit ) + def get_ref_sigma(sigma1, sigma2, sigma3, kop_index): sigma = np.array([sigma1, sigma2, sigma3]).T sigma_diff = np.diff(sigma, axis=0) @@ -111,10 +115,11 @@ def get_ref_sigma(sigma1, sigma2, sigma3, kop_index): sigma_above = np.cumsum(sigma_diff[:kop_index][::-1], axis=0)[::-1] sigma_below = np.cumsum(sigma_diff[kop_index:], axis=0) - sigma_new = np.vstack((sigma_above, np.array([0,0,0]), sigma_below)) + sigma_new = np.vstack((sigma_above, np.array([0, 0, 0]), sigma_below)) return sigma_new + class ISCWSA: def __init__( self, @@ -126,7 +131,7 @@ def __init__( """ Clearance.__init__ self.c = clearance - # get closest survey station in offset well for each survey + # get closest survey station in offset well for each survey # station in the reference well self.idx = np.argmin( cdist( @@ -188,11 +193,15 @@ def get_lines(self): [(start_points.append(p[0]), end_points.append(p[1])) for p in points] - return np.array([np.vstack(start_points).tolist(), np.vstack(end_points).tolist()]) + return np.array([ + np.vstack(start_points).tolist(), np.vstack(end_points).tolist() + ]) def _get_closest_points(self): closest = [] - for j, (i, station) in enumerate(zip(self.idx, self.c.ref_nevs.tolist())): + for j, (i, station) in enumerate(zip( + self.idx, self.c.ref_nevs.tolist() + )): if i > 0: bnds = [(0, self.c.offset.md[i] - self.c.offset.md[i - 1])] res_1 = optimize.minimize( @@ -204,7 +213,8 @@ def _get_closest_points(self): ) mult = res_1.x[0] / (bnds[0][1] - bnds[0][0]) sigma_new_1 = self._interpolate_covs(i, mult) - else: res_1 = False + else: + res_1 = False if i < len(self.c.offset_nevs) - 1: bnds = [(0, self.c.offset.md[i + 1] - self.c.offset.md[i])] @@ -217,12 +227,22 @@ def _get_closest_points(self): ) mult = res_2.x[0] / (bnds[0][1] - bnds[0][0]) sigma_new_2 = self._interpolate_covs(i + 1, mult) - else: res_2 = False + else: + res_2 = False if res_1 and res_2 and res_1.fun < res_2.fun or not res_2: - closest.append((station, interpolate_survey(self.c.offset, res_1.x[0], i - 1), res_1, sigma_new_1)) + closest.append(( + station, + interpolate_survey(self.c.offset, res_1.x[0], i - 1), + res_1, sigma_new_1 + )) else: - closest.append((station, interpolate_survey(self.c.offset, res_2.x[0], i), res_2, sigma_new_2)) + closest.append(( + station, + interpolate_survey(self.c.offset, res_2.x[0], i), + res_2, + sigma_new_2 + )) self.closest = closest md, inc, azi, n, e, tvd, x, y, z, = np.array([ @@ -265,8 +285,8 @@ def _get_closest_points(self): error_model=None, cov_hla=cov_hla, cov_nev=cov_nev, - start_xyz=[x[0],y[0],z[0]], - start_nev=[n[0],e[0],tvd[0]], + start_xyz=[x[0], y[0], z[0]], + start_nev=[n[0], e[0], tvd[0]], deg=False, unit=self.c.offset.unit ) @@ -293,7 +313,7 @@ def _fun(self, x, survey, index, station): def _get_delta_nev_vectors(self): temp = self.off_nevs - self.c.ref_nevs - self.dist_CC_Clr = norm(temp, axis=-1).reshape(-1,1) + self.dist_CC_Clr = norm(temp, axis=-1).reshape(-1, 1) with np.errstate(divide='ignore', invalid='ignore'): self.ref_delta_nevs = np.nan_to_num( temp / self.dist_CC_Clr, @@ -310,7 +330,7 @@ def _get_delta_nev_vectors(self): ) self.hoz_bearing = np.arctan2( - self.ref_delta_nevs[:,1], self.ref_delta_nevs[:,0] + self.ref_delta_nevs[:, 1], self.ref_delta_nevs[:, 0] ) self.hoz_bearing_deg = (np.degrees(self.hoz_bearing) + 360) % 360 @@ -333,11 +353,9 @@ def _get_delta_hla_vectors(self): def _get_covs(self): self.ref_cov_hla = self.c.ref.cov_hla self.ref_cov_nev = self.c.ref.cov_nev - self.off_cov_hla = self.off.cov_hla self.off_cov_nev = self.off.cov_nev - def _get_PCRs(self): self.ref_PCR = np.hstack([ np.sqrt(np.dot(np.dot(vec, cov), vec.T)) @@ -351,6 +369,7 @@ def _get_PCRs(self): def _get_calc_hole(self): self.calc_hole = self.c.Rr + self.c.Ro[self.idx] + class MeshClearance: def __init__( self, @@ -418,8 +437,11 @@ def get_lines(self): [(start_points.append(p[0]), end_points.append(p[1])) for p in points] - return np.array([np.vstack(start_points).tolist(), np.vstack(end_points).tolist()]) - + return np.array([ + np.vstack(start_points).tolist(), + np.vstack(end_points).tolist() + ]) + def _get_mesh(self, survey, offset=False): """ Generates a mesh object from the survey object. @@ -449,7 +471,6 @@ def _process_well(self): off_nevs = self.c.offset_nevs for i in range(len(ref.md) - 1): - # slice a well section and create section survey s = slice_survey(ref, i) @@ -462,7 +483,9 @@ def _process_well(self): ) if self.return_data: - distance = self.cm.min_distance_single(m, return_name=True, return_data=True) + distance = self.cm.min_distance_single( + m, return_name=True, return_data=True + ) closest_point_reference = distance[2].point("__external") name_offset_absolute = distance[1] closest_point_offset = distance[2].point(name_offset_absolute) @@ -470,8 +493,8 @@ def _process_well(self): ref_nev = self._get_closest_nev(s, closest_point_reference) ref_md = ref.md[i-1] + ref_nev[1].x[0] - # find the closest point on the well trajectory to the closest points - # on the mesh surface + # find the closest point on the well trajectory to the closest + # points on the mesh surface off_index = KDTree(off_nevs).query(closest_point_offset)[1] if off_index < len(off.md) - 1: s = slice_survey(off, off_index) @@ -501,7 +524,9 @@ def _process_well(self): vec = off_nev[0] - ref_nev[0] distance_CC = norm(vec) - hoz_bearing_deg = (np.degrees(np.arctan2(vec[1], vec[0])) + 360) % 360 + hoz_bearing_deg = ( + np.degrees(np.arctan2(vec[1], vec[0])) + 360 + ) % 360 if collision[0] is True: depth = norm( @@ -523,8 +548,16 @@ def _process_well(self): self.SF.append(round(SF, 2)) self.nev.append((ref_nev, off_nev)) self.hoz_bearing_deg.append(hoz_bearing_deg) - self.ref_PCR.append((ref_nev[1].fun - self.c.sigma_pa / 2 - self.Rr[i]) / self.sigma) - self.off_PCR.append((off_nev[1].fun - self.c.sigma_pa / 2 - self.Ro[off_index] - self.c.Sm) / self.sigma) + self.ref_PCR.append( + (ref_nev[1].fun - self.c.sigma_pa / 2 - self.Rr[i]) + / self.sigma + ) + self.off_PCR.append( + ( + off_nev[1].fun - self.c.sigma_pa / 2 + - self.Ro[off_index] - self.c.Sm + ) / self.sigma + ) self.calc_hole.append(ref.radius[i] + off.radius[off_index]) self.ref_md.append(ref_md) self.off_md.append(off_md) @@ -546,7 +579,7 @@ def _fun(self, x, survey, pos): dist = norm(new_pos - pos, axis=-1) return dist - + def _get_closest_nev(self, survey, pos): """ Using an optimization function to determine the closest @@ -566,13 +599,3 @@ def _get_closest_nev(self, survey, pos): nev = np.array([s.n, s.e, s.tvd]).T[-1] return (nev, res) - - - - - - - - - - diff --git a/welleng/connector.py b/welleng/connector.py index a72f994..e87364b 100644 --- a/welleng/connector.py +++ b/welleng/connector.py @@ -456,7 +456,7 @@ def _min_curve(self): self.pos_target = self.pos2 + ( self.vec2 * (self.md_target - self.md2) ) - else: + else: self.dist_curve, self.func_dogleg = get_curve_hold_data( self.radius_critical, self.dogleg ) diff --git a/welleng/exchange/wbp.py b/welleng/exchange/wbp.py index ef274c3..bbc51ec 100644 --- a/welleng/exchange/wbp.py +++ b/welleng/exchange/wbp.py @@ -617,10 +617,14 @@ def wbp_to_survey(data, step=None, radius=10): # need to set dls_design relatively small to trigger adaption to # the dls used in the imported design, or to set it to the actual # dls used in the design. + # TODO: update the connector code so that None can be passed for + # dls_design, which will set np.inf for radius_design and force + # the dls to be set by radius_critical. if isinstance(s, welleng.exchange.wbp.TurnPoint): - dls_design = s.dls if s.dls > 0 else 0.1 + dls_design = s.dls if s.dls > 0 else 1e-5 else: - dls_design = data.dls if data.dls > 0 else None + # dls_design = data.dls if data.dls > 0 else None + dls_design = 1e-5 c = welleng.connector.Connector( pos1=pos[-1], diff --git a/welleng/io.py b/welleng/io.py index 7a9f611..65212ae 100644 --- a/welleng/io.py +++ b/welleng/io.py @@ -1,7 +1,10 @@ import numbers +import numpy as np +from .survey import Survey from openpyxl import load_workbook + def get_standard_data(filename): # import data from Excel workbook = load_workbook(filename, data_only=True) @@ -26,17 +29,18 @@ def get_standard_data(filename): return data + def get_data(well, sheet, data): temp = dict( - MD = [], - IncDeg = [], - AziDeg = [], - TVD = [], - N = [], - E = [], - sigmaH = [], - sigmaL = [], - sigmaA = [], + MD=[], + IncDeg=[], + AziDeg=[], + TVD=[], + N=[], + E=[], + sigmaH=[], + sigmaL=[], + sigmaA=[], ) for row in sheet.iter_rows( @@ -60,6 +64,7 @@ def get_data(well, sheet, data): return data + def acr_setup(sheet, data): data["acr"]["Sm"] = sheet["I4"].value data["acr"]["sigmapa"] = sheet["I5"].value @@ -69,8 +74,9 @@ def acr_setup(sheet, data): return data + def make_survey(data, well): - start_nev = data["wells"][offset]["mesh"].NEVs[0] + start_nev = data["wells"]["offset"]["mesh"].NEVs[0] y, x, z = start_nev start_xyz = np.array([x, y, z]) return Survey( @@ -81,11 +87,13 @@ def make_survey(data, well): start_xyz=start_xyz ) + def import_iscwsa_collision_data(filename): data = get_standard_data(filename) return data + if __name__ == "__main__": - filename = filename=f"reference/standard-set-of-wellpaths-for-evaluating-clearance-scenarios-r4-17-may-2017.xlsm" + filename = "reference/standard-set-of-wellpaths-for-evaluating-clearance-scenarios-r4-17-may-2017.xlsm" data = import_iscwsa_collision_data(filename) \ No newline at end of file diff --git a/welleng/version.py b/welleng/version.py index 47e46d9..13b7089 100644 --- a/welleng/version.py +++ b/welleng/version.py @@ -1 +1 @@ -__version__ = '0.1.10' \ No newline at end of file +__version__ = '0.1.11'