baselode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
baselode/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ # Copyright (C) 2026 Darkmine Pty Ltd
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ from importlib.metadata import version, PackageNotFoundError
5
+
6
+ try:
7
+ __version__ = version("baselode")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0"
baselode/datamodel.py ADDED
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ # Copyright (C) 2026 Darkmine Pty Ltd
4
+
5
+ # This file is part of baselode.
6
+
7
+ # baselode is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+
12
+ # baselode is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU General Public License for more details.
16
+
17
+ # You should have received a copy of the GNU General Public License
18
+ # along with baselode. If not, see <https://www.gnu.org/licenses/>.
19
+
20
+ """
21
+ Baselode Open Data Model
22
+
23
+ Provides a consistent schema for data handling throughout the library.
24
+
25
+ Individual data loaders apply common column mapping, but also accept user-provided column maps to handle variations in source data.
26
+ """
27
+
28
+ HOLE_ID = "hole_id"
29
+ LATITUDE = "latitude"
30
+ LONGITUDE = "longitude"
31
+ ELEVATION = "elevation"
32
+ AZIMUTH = "azimuth"
33
+ DIP = "dip"
34
+ FROM = "from"
35
+ TO = "to"
36
+ MID = "mid"
37
+ PROJECT_ID = "project_id"
38
+ EASTING = "easting"
39
+ NORTHING = "northing"
40
+ CRS = "crs"
41
+ DEPTH = "depth"
@@ -0,0 +1,2 @@
1
+ # Copyright (C) 2026 Darkmine Pty Ltd
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ # Copyright (C) 2026 Darkmine Pty Ltd
4
+
5
+ # This file is part of baselode.
6
+
7
+ # baselode is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+
12
+ # baselode is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU General Public License for more details.
16
+
17
+ # You should have received a copy of the GNU General Public License
18
+ # along with baselode. If not, see <https://www.gnu.org/licenses/>.
19
+
20
+ """Compositing and resampling helpers."""
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from baselode.datamodel import EASTING, NORTHING, ELEVATION
26
+
27
+
28
+ def composite_intervals(df, value_col, from_col="from", to_col="to", length=1.0, method="average"):
29
+ if df.empty:
30
+ return df.copy()
31
+ df_sorted = df.sort_values(["hole_id", from_col])
32
+ composites = []
33
+ for hole_id, group in df_sorted.groupby("hole_id"):
34
+ start = group[from_col].min()
35
+ end = group[to_col].max()
36
+ bins = np.arange(start, end + length, length)
37
+ for i in range(len(bins) - 1):
38
+ c_from = bins[i]
39
+ c_to = bins[i + 1]
40
+ window = group[(group[from_col] < c_to) & (group[to_col] > c_from)]
41
+ if window.empty:
42
+ continue
43
+ overlap_len = (np.minimum(window[to_col], c_to) - np.maximum(window[from_col], c_from)).clip(lower=0)
44
+ weights = overlap_len / overlap_len.sum()
45
+ if method == "sum":
46
+ val = (window[value_col] * overlap_len).sum()
47
+ else:
48
+ val = (window[value_col] * weights).sum()
49
+ composites.append({"hole_id": hole_id, from_col: c_from, to_col: c_to, value_col: val})
50
+ return pd.DataFrame(composites)
51
+
52
+
53
+ def resample_trace(trace_df, step=1.0):
54
+ if trace_df.empty:
55
+ return trace_df.copy()
56
+ resampled = []
57
+ for hole_id, group in trace_df.groupby("hole_id"):
58
+ group_sorted = group.sort_values("md")
59
+ mds = group_sorted["md"].values
60
+ start = mds.min()
61
+ end = mds.max()
62
+ sample_mds = np.arange(start, end + step, step)
63
+ res_e = np.interp(sample_mds, mds, group_sorted[EASTING].values)
64
+ res_n = np.interp(sample_mds, mds, group_sorted[NORTHING].values)
65
+ res_z = np.interp(sample_mds, mds, group_sorted[ELEVATION].values)
66
+ for md_val, easting, northing, elevation in zip(sample_mds, res_e, res_n, res_z):
67
+ resampled.append({"hole_id": hole_id, "md": md_val, EASTING: easting, NORTHING: northing, ELEVATION: elevation})
68
+ return pd.DataFrame(resampled)
69
+
70
+
71
+ def merge_numeric_categorical(numeric_df, categorical_df, on_cols=("hole_id", "from", "to")):
72
+ if numeric_df.empty and categorical_df.empty:
73
+ return pd.DataFrame()
74
+ if numeric_df.empty:
75
+ return categorical_df.copy()
76
+ if categorical_df.empty:
77
+ return numeric_df.copy()
78
+ return numeric_df.merge(categorical_df, on=list(on_cols), how="outer")
baselode/drill/data.py ADDED
@@ -0,0 +1,276 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ # Copyright (C) 2026 Darkmine Pty Ltd
4
+
5
+ # This file is part of baselode.
6
+
7
+ # baselode is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+
12
+ # baselode is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU General Public License for more details.
16
+
17
+ # You should have received a copy of the GNU General Public License
18
+ # along with baselode. If not, see <https://www.gnu.org/licenses/>.
19
+
20
+ """Data loading and table normalization helpers for drillhole datasets.
21
+
22
+ Supports CSV, Parquet, or SQL sources and applies column
23
+ standardization towards the baselode open data model,
24
+ so downstream functions can expect consistent keys.
25
+ """
26
+
27
+ import pandas as pd
28
+ import geopandas as gpd
29
+
30
+ from baselode.datamodel import ( HOLE_ID, LATITUDE, LONGITUDE, ELEVATION, AZIMUTH, DIP, FROM, TO, MID, PROJECT_ID, EASTING, NORTHING, CRS, DEPTH )
31
+
32
+
33
+ """
34
+ Baselode Open Data Model
35
+
36
+ Provides a consistent schema for data handling throughout the library.
37
+
38
+ Individual data loaders apply common column mapping, but also accept user-provided column maps to handle variations in source data.
39
+ """
40
+
41
+ # Minimum expected columns for drillhole collars
42
+ # The collar forms the basis for hole_id and spatial location, so it is expected to exist in all datasets and be standardized as much as possible.
43
+ BASELODE_DATA_MODEL_DRILL_COLLAR = {
44
+ # A unique hole identifier across the entire dataset and all future data sets
45
+ HOLE_ID: str,
46
+ # The hole ID from the original collar source
47
+ "datasource_hole_id": str,
48
+ # The project ID or project code from the original collar source, if available
49
+ PROJECT_ID: str,
50
+ # The latitude of the collar, in decimal degrees (WGS84)
51
+ LATITUDE: float,
52
+ # The longitude of the collar, in decimal degrees (WGS84)
53
+ LONGITUDE: float,
54
+ # The elevation of the collar, in meters above sea level (WGS84)
55
+ ELEVATION: float,
56
+ # The easting coordinate of the collar, in meters (projected CRS)
57
+ EASTING: float,
58
+ # The northing coordinate of the collar, in meters (projected CRS)
59
+ NORTHING: float,
60
+ # The coordinate reference system of the collar coordinates for easting/northing, as an EPSG code or proj string
61
+ CRS: str
62
+ }
63
+
64
+ BASELODE_DATA_MODEL_DRILL_SURVEY = {
65
+ # The unique hole id that maps to the collar and any other data tables
66
+ HOLE_ID: str,
67
+ # The depth along the hole where the survey measurement was taken / started
68
+ DEPTH: float,
69
+ # The depth along the hole where the survey measurement ended, if applicable (some surveys are point measurements and may not have a 'to' depth)
70
+ TO: float,
71
+ # The azimuth of the hole at the survey depth, in degrees from north
72
+ AZIMUTH: float,
73
+ # The dip of the hole at the survey depth, in degrees from horizontal (negative values indicate downward inclination)
74
+ DIP: float
75
+ }
76
+
77
+ BASELODE_DATA_MODEL_DRILL_ASSAY = {
78
+ # The unique hole id that maps to the collar and any other data tables
79
+ HOLE_ID: str,
80
+ # The depth along the hole where the assay interval starts
81
+ FROM: float,
82
+ # The depth along the hole where the assay interval ends
83
+ TO: float,
84
+ # The midpoint depth of the assay interval
85
+ MID: float,
86
+ # assay value columns are variable and not standardized here.
87
+ # Assays may be flattened (one column per assay type) or long (one row per assay type with an additional 'assay_type' column)
88
+ }
89
+
90
+
91
+ # This column map is used to make a 'best guess' for mapping common variations in source column names to the baselode data model.
92
+ # It is applied in the standardize_columns function, but users can also provide their own column map to override or extend this mapping as needed.
93
+ # The keys from the input source are normalized to lowercase and stripped of whitespace for more robust matching.
94
+ # this dictionary is stored for human readability,then pivoted to make lookup quicker in code.
95
+ # Be cautious of not mapping a source column to multiple baselode columns, as this can lead to unpredictable results.
96
+ DEFAULT_COLUMN_MAP = {
97
+ HOLE_ID: ["hole_id", "holeid", "hole id", "hole-id"],
98
+ "datasource_hole_id": ["datasource_hole_id", "datasourceholeid", "datasource hole id", "datasource-hole-id", "company_hole_id", "companyholeid", "company hole id", "company-hole-id"],
99
+ PROJECT_ID: ["project_id", "projectid", "project id", "project-id", "project_code", "projectcode", "project code", "project-code", "companyId", "company_id", "companyid", "company id", "company-id", "dataset", "project"],
100
+ LATITUDE: ["latitude", "lat"],
101
+ LONGITUDE: ["longitude", "lon"],
102
+ ELEVATION: ["elevation", "rl", "elev", "z"],
103
+ EASTING: ["easting", "x"],
104
+ NORTHING: ["northing", "y"],
105
+ CRS: ["crs", "epsg", "projection"],
106
+ FROM: ["from", "depth_from", "from_depth", "samp_from", "sample_from", "sampfrom", "fromdepth"],
107
+ TO: ["to", "depth_to", "to_depth", "samp_to", "sample_to", "sampto", "todepth"],
108
+ AZIMUTH: ["azimuth", "az", "dipdir", "dip_direction"],
109
+ DIP: ["dip"],
110
+ "declination": ["declination", "dec"],
111
+ DEPTH: ["depth", "survey_depth", "surveydepth"]
112
+ }
113
+
114
+ # Pivot the DEFAULT_COLUMN_MAP for efficient reverse lookup
115
+ # Maps normalized column names -> standardized baselode column names
116
+ _COLUMN_LOOKUP = {}
117
+ for standard_col, variations in DEFAULT_COLUMN_MAP.items():
118
+ for variation in variations:
119
+ normalized = variation.lower().strip()
120
+ _COLUMN_LOOKUP[normalized] = standard_col
121
+
122
+
123
+ def _frame(df):
124
+ if df is None:
125
+ return pd.DataFrame()
126
+ if isinstance(df, pd.DataFrame):
127
+ return df.copy()
128
+ return pd.DataFrame(df)
129
+
130
+
131
+ def standardize_columns(df, column_map=None, source_column_map=None):
132
+ column_map = column_map or DEFAULT_COLUMN_MAP
133
+
134
+ lookup = dict(_COLUMN_LOOKUP)
135
+ if source_column_map:
136
+ normalized_map = {
137
+ str(raw_name).lower().strip(): str(expected_name).lower().strip()
138
+ for raw_name, expected_name in source_column_map.items()
139
+ if raw_name is not None and expected_name is not None
140
+ }
141
+ lookup.update(normalized_map)
142
+
143
+ renamed = {}
144
+ for col in df.columns:
145
+ key = col.lower().strip()
146
+ mapped = lookup.get(key, key)
147
+ renamed[col] = mapped
148
+ out = df.rename(columns=renamed)
149
+ if not out.columns.is_unique:
150
+ out = out.T.groupby(level=0, sort=False).first().T
151
+ return out
152
+
153
+
154
+ def load_table(source, kind="csv", connection=None, query=None, table=None, column_map=None, source_column_map=None, **kwargs):
155
+ if isinstance(source, pd.DataFrame):
156
+ df = source.copy()
157
+ elif kind == "csv":
158
+ df = pd.read_csv(source, **kwargs)
159
+ elif kind == "parquet":
160
+ df = pd.read_parquet(source, **kwargs)
161
+ elif kind == "sql":
162
+ if query is None and table is None:
163
+ raise ValueError("For SQL sources, provide query or table")
164
+ if query is not None:
165
+ df = pd.read_sql_query(query, connection, **kwargs)
166
+ else:
167
+ df = pd.read_sql_table(table, connection, **kwargs)
168
+ else:
169
+ raise ValueError(f"Unsupported kind: {kind}")
170
+ return standardize_columns(df, column_map=column_map, source_column_map=source_column_map)
171
+
172
+
173
+ def load_collars(source, crs=None, source_column_map=None, keep_all=True, **kwargs):
174
+ df = load_table(source, source_column_map=source_column_map, **kwargs)
175
+
176
+ if HOLE_ID not in df.columns:
177
+ raise ValueError(f"Collar table missing column: {HOLE_ID}")
178
+
179
+ required_cols = set(BASELODE_DATA_MODEL_DRILL_COLLAR.keys())
180
+
181
+ has_xy = EASTING in df.columns and NORTHING in df.columns
182
+ has_latlon = LATITUDE in df.columns and LONGITUDE in df.columns
183
+ if not has_xy and has_latlon:
184
+ required_cols -= {EASTING, NORTHING, CRS}
185
+ elif has_xy and not has_latlon:
186
+ required_cols -= {LATITUDE, LONGITUDE}
187
+
188
+ if has_latlon:
189
+ geom = gpd.points_from_xy(df[LONGITUDE], df[LATITUDE])
190
+ resolved_crs = crs or "EPSG:4326"
191
+ else:
192
+ geom = gpd.points_from_xy(df[EASTING], df[NORTHING])
193
+ resolved_crs = crs
194
+
195
+ # if dataset_hole_id was not populated, copy it from hole_id
196
+ if "datasource_hole_id" not in df.columns:
197
+ hole_series = df[HOLE_ID]
198
+ if isinstance(hole_series, pd.DataFrame):
199
+ hole_series = hole_series.bfill(axis=1).iloc[:, 0]
200
+ df["datasource_hole_id"] = hole_series
201
+
202
+ for col in sorted(required_cols):
203
+ if col not in df.columns:
204
+ raise ValueError(f"Collar table missing column: {col}")
205
+
206
+ if not keep_all:
207
+ df = df[[col for col in BASELODE_DATA_MODEL_DRILL_COLLAR.keys() if col in required_cols]]
208
+
209
+ return gpd.GeoDataFrame(df, geometry=geom, crs=resolved_crs)
210
+
211
+
212
+ def load_surveys(source, source_column_map=None, keep_all=True, **kwargs):
213
+ df = load_table(source, source_column_map=source_column_map, **kwargs)
214
+ required_cols = set(BASELODE_DATA_MODEL_DRILL_SURVEY.keys())
215
+
216
+ if TO not in df.columns:
217
+ required_cols -= {TO}
218
+
219
+ required = [HOLE_ID, DEPTH, AZIMUTH, DIP]
220
+ for col in required:
221
+ if col not in df.columns:
222
+ raise ValueError(f"Survey table missing column: {col}")
223
+
224
+ if not keep_all:
225
+ df = df[[col for col in BASELODE_DATA_MODEL_DRILL_SURVEY.keys() if col in required_cols]]
226
+
227
+ return df.sort_values([HOLE_ID, DEPTH])
228
+
229
+
230
+ def load_assays(source, source_column_map=None, keep_all=True, **kwargs):
231
+ df = load_table(source, source_column_map=source_column_map, **kwargs)
232
+ required_cols = set(BASELODE_DATA_MODEL_DRILL_ASSAY.keys())
233
+
234
+ required = [HOLE_ID, FROM, TO]
235
+ for col in required:
236
+ if col not in df.columns:
237
+ raise ValueError(f"Assay table missing column: {col}")
238
+
239
+ # Calculate midpoint depth
240
+ df[MID] = 0.5 * (df[FROM] + df[TO])
241
+
242
+ if not keep_all:
243
+ df = df[[col for col in BASELODE_DATA_MODEL_DRILL_ASSAY.keys() if col in required_cols]]
244
+
245
+ return df.sort_values([HOLE_ID, FROM, TO])
246
+
247
+
248
+ def join_assays_to_traces(assays, traces, on_cols=(HOLE_ID,)):
249
+ if traces.empty:
250
+ return assays.copy()
251
+ merged = assays.merge(traces, on=list(on_cols), how="left", suffixes=("", "_trace"))
252
+ return merged
253
+
254
+
255
+ def filter_by_project(df, project_id=None):
256
+ if project_id is None or df.empty or PROJECT_ID not in df.columns:
257
+ return df.copy()
258
+ return df.loc[df[PROJECT_ID] == project_id].copy()
259
+
260
+
261
+ def coerce_numeric(df, columns):
262
+ out = df.copy()
263
+ for col in columns:
264
+ if col in out.columns:
265
+ out[col] = pd.to_numeric(out[col], errors="coerce")
266
+ return out
267
+
268
+
269
+ def assemble_dataset(collars=None, surveys=None, assays=None, structures=None, metadata=None):
270
+ return {
271
+ "collars": _frame(collars),
272
+ "surveys": _frame(surveys),
273
+ "assays": _frame(assays),
274
+ "structures": _frame(structures),
275
+ "metadata": metadata or {},
276
+ }
@@ -0,0 +1,191 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ # Copyright (C) 2026 Darkmine Pty Ltd
4
+
5
+ # This file is part of baselode.
6
+
7
+ # baselode is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+
12
+ # baselode is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU General Public License for more details.
16
+
17
+ # You should have received a copy of the GNU General Public License
18
+ # along with baselode. If not, see <https://www.gnu.org/licenses/>.
19
+
20
+ """Desurveying utilities.
21
+
22
+ Supports multiple methods that trade simplicity for accuracy:
23
+ - minimum_curvature (default): standard industry approach.
24
+ - tangential: keeps the initial station orientation through the segment.
25
+ - balanced_tangential: averages start/end orientations per segment.
26
+
27
+ All methods output a trace table with x, y, z coordinates at chosen step size,
28
+ plus measured depth and azimuth/dip per vertex. Dependencies are limited to
29
+ pandas and numpy for portability.
30
+ """
31
+
32
+ import math
33
+ import pandas as pd
34
+
35
+ from baselode.datamodel import HOLE_ID, AZIMUTH, DIP, FROM, TO, EASTING, NORTHING, ELEVATION, DEPTH, MID
36
+
37
+
38
+ def _direction_cosines(azimuth, dip):
39
+ az_rad = math.radians(azimuth)
40
+ dip_rad = math.radians(dip)
41
+ ca = math.cos(dip_rad) * math.sin(az_rad)
42
+ cb = math.cos(dip_rad) * math.cos(az_rad)
43
+ cc = math.sin(dip_rad) * -1
44
+ return ca, cb, cc
45
+
46
+
47
+ def _segment_displacement(delta_md, az0, dip0, az1, dip1, method="minimum_curvature"):
48
+ ca0, cb0, cc0 = _direction_cosines(az0, dip0)
49
+ ca1, cb1, cc1 = _direction_cosines(az1, dip1)
50
+ if method == "tangential":
51
+ return delta_md * ca0, delta_md * cb0, delta_md * cc0, az0, dip0
52
+ if method == "balanced_tangential":
53
+ az_avg = 0.5 * (az0 + az1)
54
+ dip_avg = 0.5 * (dip0 + dip1)
55
+ ca_avg, cb_avg, cc_avg = _direction_cosines(az_avg, dip_avg)
56
+ return delta_md * ca_avg, delta_md * cb_avg, delta_md * cc_avg, az_avg, dip_avg
57
+
58
+ # Minimum curvature (default)
59
+ dogleg = math.acos(max(-1.0, min(1.0, ca0 * ca1 + cb0 * cb1 + cc0 * cc1)))
60
+ rf = 1.0
61
+ if dogleg > 1e-6:
62
+ rf = 2 * math.tan(dogleg / 2) / dogleg
63
+ dx = 0.5 * delta_md * (ca0 + ca1) * rf
64
+ dy = 0.5 * delta_md * (cb0 + cb1) * rf
65
+ dz = 0.5 * delta_md * (cc0 + cc1) * rf
66
+ return dx, dy, dz, az1, dip1
67
+
68
+
69
+ def _desurvey(collars, surveys, step=1.0, method="minimum_curvature"):
70
+ if collars.empty or surveys.empty:
71
+ return pd.DataFrame(columns=[HOLE_ID, "md", EASTING, NORTHING, ELEVATION, AZIMUTH, DIP])
72
+
73
+ traces = []
74
+ for hole_id, collar in collars.groupby(HOLE_ID):
75
+ collar_row = collar.iloc[0]
76
+ hole_surveys = surveys[surveys[HOLE_ID] == hole_id].sort_values(DEPTH)
77
+ if hole_surveys.empty:
78
+ continue
79
+ x, y, z = float(collar_row.get(EASTING, 0)), float(collar_row.get(NORTHING, 0)), float(collar_row.get(ELEVATION, 0))
80
+ md_cursor = float(hole_surveys.iloc[0][DEPTH])
81
+ az_prev = float(hole_surveys.iloc[0][AZIMUTH])
82
+ dip_prev = float(hole_surveys.iloc[0][DIP])
83
+ first_record = {HOLE_ID: hole_id, "md": md_cursor, EASTING: x, NORTHING: y, ELEVATION: z, AZIMUTH: az_prev, DIP: dip_prev}
84
+ traces.append(first_record)
85
+
86
+ for idx in range(len(hole_surveys) - 1):
87
+ s0 = hole_surveys.iloc[idx]
88
+ s1 = hole_surveys.iloc[idx + 1]
89
+ md0 = float(s0[DEPTH])
90
+ md1 = float(s1[DEPTH])
91
+ delta_md = md1 - md0
92
+ if delta_md <= 0:
93
+ continue
94
+ az0, dip0 = float(s0[AZIMUTH]), float(s0[DIP])
95
+ az1, dip1 = float(s1[AZIMUTH]), float(s1[DIP])
96
+
97
+ segment_steps = max(1, int(math.ceil(delta_md / step)))
98
+ md_increment = delta_md / segment_steps
99
+ for step_idx in range(segment_steps):
100
+ md_cursor += md_increment
101
+ weight = (md_cursor - md0) / delta_md
102
+ az_interp = az0 + weight * (az1 - az0)
103
+ dip_interp = dip0 + weight * (dip1 - dip0)
104
+ dx, dy, dz, az_for_record, dip_for_record = _segment_displacement(
105
+ md_increment,
106
+ az0=az0,
107
+ dip0=dip0,
108
+ az1=az1,
109
+ dip1=dip1,
110
+ method=method,
111
+ )
112
+ x += dx
113
+ y += dy
114
+ z += dz
115
+ record = {
116
+ HOLE_ID: hole_id,
117
+ "md": md_cursor,
118
+ EASTING: x,
119
+ NORTHING: y,
120
+ ELEVATION: z,
121
+ AZIMUTH: az_interp if method == "minimum_curvature" else az_for_record,
122
+ DIP: dip_interp if method == "minimum_curvature" else dip_for_record,
123
+ }
124
+ traces.append(record)
125
+ out = pd.DataFrame(traces)
126
+ return out
127
+
128
+
129
+ def minimum_curvature_desurvey(collars, surveys, step=1.0):
130
+ return _desurvey(collars=collars, surveys=surveys, step=step, method="minimum_curvature")
131
+
132
+
133
+ def tangential_desurvey(collars, surveys, step=1.0,):
134
+ """Simpler desurvey: uses the starting station orientation for each segment."""
135
+ return _desurvey(collars=collars, surveys=surveys, step=step, method="tangential")
136
+
137
+
138
+ def balanced_tangential_desurvey(collars, surveys, step=1.0):
139
+ """Balanced tangential desurvey using the average of start/end orientations per segment."""
140
+ return _desurvey(collars=collars, surveys=surveys, step=step, method="balanced_tangential")
141
+
142
+
143
+ def attach_assay_positions(assays, traces):
144
+
145
+ if assays.empty or traces.empty:
146
+ return assays.copy()
147
+
148
+ traces_sorted = traces.copy()
149
+ traces_sorted["md"] = pd.to_numeric(traces_sorted["md"], errors="coerce")
150
+ traces_sorted = traces_sorted[traces_sorted[HOLE_ID].notna() & traces_sorted["md"].notna()]
151
+ traces_sorted = traces_sorted.sort_values([HOLE_ID, "md"], kind="mergesort").reset_index(drop=True)
152
+
153
+ assays_sorted = assays.copy()
154
+ assays_sorted["from"] = pd.to_numeric(assays_sorted[FROM], errors="coerce")
155
+ assays_sorted["to"] = pd.to_numeric(assays_sorted[TO], errors="coerce")
156
+ assays_sorted = assays_sorted[assays_sorted[HOLE_ID].notna()]
157
+ assays_sorted = assays_sorted.sort_values([HOLE_ID, FROM, TO], kind="mergesort")
158
+ # Calculate midpoint if not already present (typically added by load_assays)
159
+ if MID not in assays_sorted.columns:
160
+ assays_sorted[MID] = 0.5 * (assays_sorted[FROM] + assays_sorted[TO])
161
+ assays_sorted = assays_sorted[assays_sorted[MID].notna()]
162
+
163
+ merged_groups = []
164
+ for hid, group in assays_sorted.groupby(HOLE_ID, sort=False):
165
+ tgroup = traces_sorted[traces_sorted[HOLE_ID] == hid]
166
+ if tgroup.empty:
167
+ merged_groups.append(group)
168
+ continue
169
+ pos_cols = [c for c in ["md", EASTING, NORTHING, ELEVATION, AZIMUTH, DIP] if c in tgroup.columns]
170
+ tgroup_use = tgroup[[HOLE_ID] + pos_cols].sort_values("md", kind="mergesort")
171
+ merged = pd.merge_asof(
172
+ group.sort_values(MID, kind="mergesort"),
173
+ tgroup_use,
174
+ left_on=MID,
175
+ right_on="md",
176
+ by=HOLE_ID,
177
+ direction="nearest",
178
+ suffixes=("", "_trace"),
179
+ )
180
+ drop_cols = [col for col in [f"{HOLE_ID}_trace", "hole_id_trace"] if col in merged.columns]
181
+ if drop_cols:
182
+ merged = merged.drop(columns=drop_cols)
183
+ merged_groups.append(merged)
184
+
185
+ if not merged_groups:
186
+ return assays_sorted
187
+ return pd.concat(merged_groups, ignore_index=True)
188
+
189
+
190
+ def build_traces(collars, surveys, step=1.0):
191
+ return minimum_curvature_desurvey(collars=collars, surveys=surveys, step=step)