Skip to content

Commit 6455a65

Browse files
committed
fix: allow missing depth and infer from survey.
add option to force uppercase holeid for all tables
1 parent a0e6e7d commit 6455a65

3 files changed

Lines changed: 116 additions & 34 deletions

File tree

‎loopresources/drillhole/desurvey.py‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,23 @@ def desurvey(
5454
raise ValueError(f"Unknown method: {method}")
5555

5656

57+
def _infer_max_depth(collar: pd.DataFrame, survey: pd.DataFrame) -> float:
58+
"""Infer total depth from collar or survey if collar total depth missing."""
59+
if DhConfig.total_depth in collar.columns and collar[DhConfig.total_depth].notna().any():
60+
return float(collar[DhConfig.total_depth].max())
61+
if DhConfig.depth in survey.columns and survey[DhConfig.depth].notna().any():
62+
return float(survey[DhConfig.depth].max())
63+
raise ValueError("Cannot determine total depth from collar or survey")
64+
65+
5766
def tangent_method(
5867
collar: pd.DataFrame, survey: pd.DataFrame, newinterval=10, drop_intermediate=True
5968
) -> pd.DataFrame:
6069
"""Compute well path using tangent method at regular intervals."""
6170
# Implementation goes here
6271
if not hasattr(newinterval, "__len__"): # is it an array?
63-
newdepth = np.arange(
64-
0,
65-
collar[DhConfig.total_depth].max(),
66-
newinterval,
67-
)
72+
max_depth = _infer_max_depth(collar, survey)
73+
newdepth = np.arange(0, max_depth, newinterval)
6874
else:
6975
newdepth = newinterval
7076
depth = survey[DhConfig.depth].values
@@ -101,11 +107,8 @@ def straight_path_from_single_survey(collar, survey, newinterval=10):
101107
depth = survey[DhConfig.depth]
102108

103109
if not hasattr(newinterval, "__len__"): # is it an array?
104-
newdepth = np.arange(
105-
0,
106-
collar[DhConfig.total_depth].max(),
107-
newinterval,
108-
)
110+
max_depth = _infer_max_depth(collar, survey)
111+
newdepth = np.arange(0, max_depth, newinterval)
109112
else:
110113
newdepth = newinterval
111114
new_trend = [survey[DhConfig.azimuth].to_list()[0]] * len(newdepth)
@@ -155,11 +158,8 @@ def minimum_curvature(
155158
plunge = survey[DhConfig.dip].values # + #.apply(math.radians)
156159

157160
if not hasattr(newinterval, "__len__"): # is it an array?
158-
newdepth = np.arange(
159-
0,
160-
collar[DhConfig.total_depth].max(),
161-
newinterval,
162-
)
161+
max_depth = _infer_max_depth(collar, survey)
162+
newdepth = np.arange(0, max_depth, newinterval)
163163
else:
164164
newdepth = newinterval
165165
unit_vectors = trendandplunge2vector(trend, plunge)

‎loopresources/drillhole/drillhole.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,19 @@ def __init__(self, database: "DrillholeDatabase", hole_id: str):
216216

217217
def __repr__(self) -> str:
218218
"""Return a concise representation of the DrillHole."""
219-
total_depth = self.collar[DhConfig.total_depth].values[0]
219+
try:
220+
total_depth = self.database.get_hole_total_depth(self.hole_id)
221+
except Exception:
222+
total_depth = float("nan")
220223
return f"DrillHole(hole_id='{self.hole_id}', depth={total_depth:.2f}m)"
221224

222225
def __str__(self) -> str:
223226
"""Return a detailed string representation of the DrillHole."""
224227
# Get basic hole information
225-
total_depth = self.collar[DhConfig.total_depth].values[0]
228+
try:
229+
total_depth = self.database.get_hole_total_depth(self.hole_id)
230+
except Exception:
231+
total_depth = float("nan")
226232
collar_x = self.collar[DhConfig.x].values[0]
227233
collar_y = self.collar[DhConfig.y].values[0]
228234
collar_z = self.collar[DhConfig.z].values[0]
@@ -393,7 +399,10 @@ def _downhole_depth_grid(
393399
if step <= 0:
394400
raise ValueError("step must be > 0")
395401
if max_depth is None:
396-
max_depth = float(self.collar[DhConfig.total_depth].values[0])
402+
try:
403+
max_depth = float(self.database.get_hole_total_depth(self.hole_id))
404+
except Exception:
405+
return np.array([], dtype=float)
397406
if max_depth <= 0:
398407
return np.array([], dtype=float)
399408
return np.arange(0.0, max_depth + step, step)

‎loopresources/drillhole/drillhole_database.py‎

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,17 @@ class DrillholeDatabase:
3131
"""
3232

3333
def __init__(
34-
self, collar: pd.DataFrame, survey: pd.DataFrame, db_config: Optional[DbConfig] = None
34+
self, collar: pd.DataFrame, survey: pd.DataFrame, db_config: Optional[DbConfig] = None, force_uppercase_holeid: bool = False
3535
):
3636
"""Initialize DrillholeDatabase.
3737
3838
Parameters
3939
----------
4040
collar : pd.DataFrame
4141
Collar data with one row per drillhole
42-
Required columns: HOLE_ID, X, Y, Z, TOTAL_DEPTH
42+
Required columns: HOLE_ID, X, Y, Z. `TOTAL_DEPTH` is optional —
43+
when absent the DB will attempt to infer a hole's total depth
44+
from survey/interval/point data.
4345
survey : pd.DataFrame
4446
Survey data with one row per survey station
4547
Required columns: HOLE_ID, DEPTH, AZIMUTH, DIP
@@ -48,7 +50,10 @@ def __init__(
4850
"""
4951
self.db_config = db_config if db_config is not None else DbConfig(backend="memory")
5052
self._conn = None
51-
53+
self.force_uppercase_holeid = force_uppercase_holeid
54+
if force_uppercase_holeid:
55+
collar[DhConfig.holeid] = collar[DhConfig.holeid].str.upper()
56+
survey[DhConfig.holeid] = survey[DhConfig.holeid].str.upper()
5257
# Store data based on backend configuration
5358
if self.db_config.backend == "memory":
5459
self.collar = collar.copy()
@@ -275,7 +280,11 @@ def plot_downhole(
275280
raise ValueError("step must be > 0")
276281
for hole_id, hole_ax in zip(valid_holes, axes_list):
277282
hole = DrillHole(self, hole_id)
278-
max_depth = float(hole.collar[DhConfig.total_depth].values[0])
283+
try:
284+
max_depth = float(self.get_hole_total_depth(hole_id))
285+
except Exception:
286+
logger.warning("Could not determine total depth for hole %s; skipping", hole_id)
287+
continue
279288
if max_depth <= 0:
280289
continue
281290
depth_grid = np.arange(0.0, max_depth + step, step)
@@ -312,7 +321,12 @@ def plot_downhole(
312321
all_values = []
313322
for hole_id in valid_holes:
314323
hole = DrillHole(self, hole_id)
315-
max_depth = float(hole.collar[DhConfig.total_depth].values[0])
324+
try:
325+
max_depth = float(self.get_hole_total_depth(hole_id))
326+
except Exception:
327+
logger.warning("Could not determine total depth for hole %s; treating as empty", hole_id)
328+
sampled_values.append((hole_id, 0.0, np.array([])))
329+
continue
316330
if max_depth <= 0:
317331
sampled_values.append((hole_id, max_depth, np.array([])))
318332
continue
@@ -447,6 +461,50 @@ def get_collar_for_hole(self, hole_id: str) -> pd.DataFrame:
447461
else:
448462
return self._load_table_from_db("collar", hole_id=hole_id)
449463

464+
def get_hole_total_depth(self, hole_id: str) -> float:
465+
"""Return reported or inferred total depth for a hole.
466+
467+
Strategy:
468+
- Use `TOTAL_DEPTH` from the collar if present and not NaN.
469+
- Otherwise infer the max depth from survey, interval `TO` columns, or point depths.
470+
- If nothing is available, raise `ValueError`.
471+
"""
472+
# Try collar first
473+
collar_row = self.get_collar_for_hole(hole_id)
474+
if not collar_row.empty and DhConfig.total_depth in collar_row.columns:
475+
val = collar_row[DhConfig.total_depth].values[0]
476+
if pd.notna(val):
477+
return float(val)
478+
479+
# Infer from survey
480+
survey_df = self.get_survey_for_hole(hole_id)
481+
survey_max = None
482+
if not survey_df.empty and DhConfig.depth in survey_df.columns:
483+
if survey_df[DhConfig.depth].notna().any():
484+
survey_max = float(survey_df[DhConfig.depth].max())
485+
486+
# Infer from intervals
487+
interval_maxes = []
488+
for tbl in self.intervals.values():
489+
if DhConfig.sample_to in tbl.columns and DhConfig.holeid in tbl.columns:
490+
hole_rows = tbl[tbl[DhConfig.holeid] == hole_id]
491+
if not hole_rows.empty and hole_rows[DhConfig.sample_to].notna().any():
492+
interval_maxes.append(float(hole_rows[DhConfig.sample_to].max()))
493+
494+
# Infer from points
495+
point_maxes = []
496+
for tbl in self.points.values():
497+
if DhConfig.depth in tbl.columns and DhConfig.holeid in tbl.columns:
498+
hole_rows = tbl[tbl[DhConfig.holeid] == hole_id]
499+
if not hole_rows.empty and hole_rows[DhConfig.depth].notna().any():
500+
point_maxes.append(float(hole_rows[DhConfig.depth].max()))
501+
502+
candidates = [v for v in [survey_max] + interval_maxes + point_maxes if v is not None]
503+
if candidates:
504+
return float(max(candidates))
505+
506+
raise ValueError(f"Unable to determine total depth for hole '{hole_id}': no reported or inferable depth values")
507+
450508
def get_survey_for_hole(self, hole_id: str) -> pd.DataFrame:
451509
"""Get survey data for a specific hole.
452510
@@ -811,6 +869,8 @@ def from_csv(
811869
survey_file: str,
812870
collar_columns: Dict[str, str] = {},
813871
survey_columns: Dict[str, str] = {},
872+
*,
873+
force_uppercase_holeid: bool = False,
814874
**kwargs,
815875
) -> "DrillholeDatabase":
816876
"""Create a DrillholeDatabase from CSV files with column mapping.
@@ -893,18 +953,19 @@ def from_csv(
893953
DhConfig.x,
894954
DhConfig.y,
895955
DhConfig.z,
896-
DhConfig.total_depth,
897956
]
898957
for col in required_collar_cols:
899958
if col not in collar_df.columns:
900959
raise KeyError(f"Required collar column '{col}' not found in CSV file")
960+
# total_depth is optional during CSV import; only drop rows missing
961+
# strictly required columns here
901962
collar_df = collar_df.dropna(subset=required_collar_cols)
902963

903964
required_survey_cols = [DhConfig.holeid, DhConfig.depth, DhConfig.azimuth, DhConfig.dip]
904965
survey_df = survey_df.dropna(subset=required_survey_cols)
905-
966+
906967
# Create and return DrillholeDatabase instance
907-
return cls(collar=collar_df, survey=survey_df)
968+
return cls(collar=collar_df, survey=survey_df, force_uppercase_holeid=force_uppercase_holeid)
908969

909970
def _validate_collar(self):
910971
"""Validate collar DataFrame structure."""
@@ -920,6 +981,7 @@ def _validate_survey(self):
920981
"""Validate survey DataFrame structure."""
921982
required_cols = [DhConfig.holeid, DhConfig.depth, DhConfig.azimuth, DhConfig.dip]
922983
missing_cols = [col for col in required_cols if col not in self.survey.columns]
984+
923985
if missing_cols:
924986
raise ValueError(f"Missing required survey columns: {missing_cols}")
925987

@@ -1176,7 +1238,8 @@ def add_interval_table(
11761238
if type(df) is not pd.DataFrame:
11771239
raise TypeError("df must be a pandas DataFrame, or a path to a CSV file")
11781240
df = df.rename(columns=column_mapping)
1179-
1241+
if self.force_uppercase_holeid:
1242+
df[DhConfig.holeid] = df[DhConfig.holeid].str.upper()
11801243
# Validate required columns
11811244
required_cols = [DhConfig.holeid, DhConfig.sample_from, DhConfig.sample_to]
11821245
missing_cols = [col for col in required_cols if col not in df.columns]
@@ -1216,7 +1279,8 @@ def add_point_table(
12161279
if type(df) is not pd.DataFrame:
12171280
raise TypeError("df must be a pandas DataFrame, or a path to a CSV file")
12181281
df = df.rename(columns=column_mapping)
1219-
1282+
if self.force_uppercase_holeid:
1283+
df[DhConfig.holeid] = df[DhConfig.holeid].str.upper()
12201284
# Validate required columns
12211285
required_cols = [DhConfig.holeid, DhConfig.depth]
12221286
missing_cols = [col for col in required_cols if col not in df.columns]
@@ -1615,10 +1679,14 @@ def validate(self) -> bool:
16151679
f"Interval table '{name}' has holes not in collar: {missing_holes}"
16161680
)
16171681

1618-
# Check depths don't exceed total depth
1682+
# Check depths don't exceed total depth (reported or inferred)
16191683
for hole_id in interval_holes:
1620-
collar_row = self.collar[self.collar[DhConfig.holeid] == hole_id].iloc[0]
1621-
total_depth = collar_row[DhConfig.total_depth]
1684+
try:
1685+
total_depth = self.get_hole_total_depth(hole_id)
1686+
except Exception:
1687+
raise ValueError(
1688+
f"Unable to determine total depth for hole '{hole_id}' when validating intervals"
1689+
)
16221690

16231691
hole_intervals = table[table[DhConfig.holeid] == hole_id]
16241692
max_to = hole_intervals[DhConfig.sample_to].max()
@@ -1633,14 +1701,19 @@ def validate(self) -> bool:
16331701
# Check holes exist
16341702
point_holes = set(table[DhConfig.holeid])
16351703
collar_holes = set(self.collar[DhConfig.holeid])
1704+
16361705
missing_holes = point_holes - collar_holes
16371706
if missing_holes:
16381707
raise ValueError(f"Point table '{name}' has holes not in collar: {missing_holes}")
16391708

1640-
# Check depths don't exceed total depth
1709+
# Check depths don't exceed total depth (reported or inferred)
16411710
for hole_id in point_holes:
1642-
collar_row = self.collar[self.collar[DhConfig.holeid] == hole_id].iloc[0]
1643-
total_depth = collar_row[DhConfig.total_depth]
1711+
try:
1712+
total_depth = self.get_hole_total_depth(hole_id)
1713+
except Exception:
1714+
raise ValueError(
1715+
f"Unable to determine total depth for hole '{hole_id}' when validating points"
1716+
)
16441717

16451718
hole_points = table[table[DhConfig.holeid] == hole_id]
16461719
max_depth = hole_points[DhConfig.depth].max()

0 commit comments

Comments
 (0)