@@ -387,6 +387,192 @@ def trace(self, step: float = 1.0) -> DrillHoleTrace:
387387 """
388388 return DrillHoleTrace (self , interval = step )
389389
390+ def _downhole_depth_grid (
391+ self , step : float , max_depth : Optional [float ] = None
392+ ) -> np .ndarray :
393+ if step <= 0 :
394+ raise ValueError ("step must be > 0" )
395+ if max_depth is None :
396+ max_depth = float (self .collar [DhConfig .total_depth ].values [0 ])
397+ if max_depth <= 0 :
398+ return np .array ([], dtype = float )
399+ return np .arange (0.0 , max_depth + step , step )
400+
401+ def _sample_downhole_values (
402+ self ,
403+ table_name : str ,
404+ column : str ,
405+ step : float ,
406+ kind : str ,
407+ depth_grid : Optional [np .ndarray ] = None ,
408+ ):
409+ if table_name in self .database .intervals :
410+ table = self [table_name ]
411+ if table .empty :
412+ return np .array ([], dtype = float ), np .array ([], dtype = float )
413+ if column not in table .columns :
414+ raise KeyError (f"Column '{ column } ' not found in interval table '{ table_name } '" )
415+ grid = depth_grid if depth_grid is not None else self ._downhole_depth_grid (step )
416+ from .resample import resample_interval
417+
418+ sampled = resample_interval (
419+ pd .DataFrame ({DhConfig .depth : grid }), table , [column ], method = "direct"
420+ )
421+ return sampled [DhConfig .depth ].to_numpy (), sampled [column ].to_numpy ()
422+
423+ if table_name in self .database .points :
424+ table = self [table_name ]
425+ if table .empty :
426+ return np .array ([], dtype = float ), np .array ([], dtype = float )
427+ if column not in table .columns :
428+ raise KeyError (f"Column '{ column } ' not found in point table '{ table_name } '" )
429+ if kind == "line" :
430+ return table [DhConfig .depth ].to_numpy (), table [column ].to_numpy ()
431+
432+ grid = depth_grid if depth_grid is not None else self ._downhole_depth_grid (step )
433+ values = np .array ([None ] * len (grid ), dtype = object )
434+ depths = table [DhConfig .depth ].to_numpy ()
435+ for depth , value in zip (depths , table [column ].to_numpy ()):
436+ if step <= 0 :
437+ continue
438+ idx = int (np .round (depth / step ))
439+ if 0 <= idx < len (values ):
440+ values [idx ] = value
441+ return grid , values
442+
443+ raise KeyError (f"Table '{ table_name } ' not found in intervals or points" )
444+
445+ def plot_downhole (
446+ self ,
447+ table_name : str ,
448+ column : str ,
449+ kind : str = "line" ,
450+ step : float = 1.0 ,
451+ ax = None ,
452+ cmap : str = "tab20" ,
453+ show_legend : bool = True ,
454+ ** kwargs ,
455+ ):
456+ """Plot a downhole variable as a line or categorical image.
457+
458+ Parameters
459+ ----------
460+ table_name : str
461+ Interval or point table name.
462+ column : str
463+ Column to plot.
464+ kind : {"line", "categorical", "image"}
465+ Plot style. Use "categorical" for discrete values and "image" for numeric heatmaps.
466+ step : float, default 1.0
467+ Sampling step (meters) for interval or categorical plots.
468+ ax : matplotlib.axes.Axes, optional
469+ Axes to plot on.
470+ cmap : str, default "tab20"
471+ Colormap name for categorical plots.
472+ show_legend : bool, default True
473+ Whether to show a legend.
474+ **kwargs
475+ Passed through to matplotlib plot functions.
476+ """
477+ import matplotlib .pyplot as plt
478+ import matplotlib .patches as mpatches
479+
480+ kind = kind .lower ()
481+ if kind not in {"line" , "categorical" , "image" }:
482+ raise ValueError ("kind must be 'line', 'categorical', or 'image'" )
483+
484+ if ax is None :
485+ _ , ax = plt .subplots (figsize = (4 , 8 ))
486+
487+ depths , values = self ._sample_downhole_values (table_name , column , step , kind )
488+ if len (depths ) == 0 :
489+ return ax
490+
491+ if kind == "line" :
492+ series = pd .to_numeric (pd .Series (values ), errors = "coerce" )
493+ mask = ~ np .isnan (series .to_numpy ())
494+ if not mask .any ():
495+ return ax
496+ ax .plot (series [mask ], np .asarray (depths )[mask ], label = self .hole_id , ** kwargs )
497+ ax .set_xlabel (column )
498+ ax .set_ylabel ("Depth" )
499+ ax .set_title (f"{ self .hole_id } { column } " )
500+ ax .invert_yaxis ()
501+ if show_legend :
502+ ax .legend ()
503+ return ax
504+
505+ if kind == "image" :
506+ series = pd .to_numeric (pd .Series (values ), errors = "coerce" )
507+ if series .isna ().all ():
508+ return ax
509+ data = np .ma .masked_invalid (series .to_numpy ())[:, None ]
510+ max_depth = float (np .nanmax (depths )) if len (depths ) else 0.0
511+ im = ax .imshow (
512+ data ,
513+ aspect = "auto" ,
514+ interpolation = "nearest" ,
515+ origin = "upper" ,
516+ extent = (0.0 , 1.0 , max_depth , 0.0 ),
517+ cmap = cmap ,
518+ )
519+ ax .set_xticks ([0.5 ])
520+ ax .set_xticklabels ([self .hole_id ])
521+ ax .set_xlabel ("Hole" )
522+ ax .set_ylabel ("Depth" )
523+ ax .set_title (f"{ column } " )
524+ if show_legend :
525+ ax .figure .colorbar (im , ax = ax , label = column )
526+ return ax
527+
528+ depth_values = np .asarray (values , dtype = object )
529+ category_values = pd .Series (depth_values )
530+ categories = [c for c in category_values .unique () if pd .notna (c )]
531+ if not categories :
532+ return ax
533+ category_to_code = {cat : idx for idx , cat in enumerate (categories )}
534+
535+ codes = np .full (len (depth_values ), - 1.0 )
536+ for idx , value in enumerate (depth_values ):
537+ if pd .notna (value ):
538+ codes [idx ] = category_to_code [value ]
539+
540+ masked = np .ma .masked_where (codes < 0 , codes )
541+ cmap_obj = plt .get_cmap (cmap , len (categories ))
542+ try :
543+ cmap_obj = cmap_obj .copy ()
544+ except Exception :
545+ pass
546+ try :
547+ cmap_obj .set_bad (color = "lightgray" )
548+ except Exception :
549+ pass
550+
551+ max_depth = float (np .nanmax (depths )) if len (depths ) else 0.0
552+ ax .imshow (
553+ masked [:, None ],
554+ aspect = "auto" ,
555+ interpolation = "nearest" ,
556+ origin = "upper" ,
557+ extent = (0.0 , 1.0 , max_depth , 0.0 ),
558+ cmap = cmap_obj ,
559+ vmin = 0 ,
560+ vmax = max (0 , len (categories ) - 1 ),
561+ )
562+ ax .set_xticks ([0.5 ])
563+ ax .set_xticklabels ([self .hole_id ])
564+ ax .set_xlabel ("Hole" )
565+ ax .set_ylabel ("Depth" )
566+ ax .set_title (f"{ column } " )
567+
568+ if show_legend :
569+ handles = [
570+ mpatches .Patch (color = cmap_obj (i ), label = str (cat ))
571+ for i , cat in enumerate (categories )
572+ ]
573+ ax .legend (handles = handles , title = column , bbox_to_anchor = (1.02 , 1 ), loc = "upper left" )
574+ return ax
575+
390576 def find_implicit_function_intersection (
391577 self , function : Callable [[ArrayLike ], ArrayLike ], step : float = 1.0 , intersection_value : float = 0.0
392578 ) -> pd .DataFrame :
0 commit comments