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 +9 -0
- baselode/datamodel.py +41 -0
- baselode/drill/__init__.py +2 -0
- baselode/drill/composite.py +78 -0
- baselode/drill/data.py +276 -0
- baselode/drill/desurvey.py +191 -0
- baselode/drill/model.py +126 -0
- baselode/drill/structural.py +65 -0
- baselode/drill/validate.py +54 -0
- baselode/drill/view.py +534 -0
- baselode/drill/view_2d.py +72 -0
- baselode/drill/view_3d.py +75 -0
- baselode/extent.py +74 -0
- baselode/map.py +53 -0
- baselode-0.1.0.dist-info/METADATA +104 -0
- baselode-0.1.0.dist-info/RECORD +19 -0
- baselode-0.1.0.dist-info/WHEEL +5 -0
- baselode-0.1.0.dist-info/licenses/LICENSE +674 -0
- baselode-0.1.0.dist-info/top_level.txt +1 -0
baselode/__init__.py
ADDED
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,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)
|