@@ -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