Skip to content

Commit a0e6e7d

Browse files
authored
Merge pull request #13 from Loop3D/fix/add-downhole-plot
Fix:add downhole plot
2 parents b3a909d + 368bfcb commit a0e6e7d

File tree

4 files changed

+682
-0
lines changed

4 files changed

+682
-0
lines changed

examples/plot_downhole.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
Downhole plotting
3+
================
4+
5+
Example showing downhole line, categorical, and image plots.
6+
"""
7+
8+
import matplotlib.pyplot as plt
9+
import pandas as pd
10+
11+
from loopresources.drillhole.dhconfig import DhConfig
12+
from loopresources.drillhole.drillhole_database import DrillholeDatabase
13+
14+
collar = pd.DataFrame(
15+
{
16+
DhConfig.holeid: ["DH001", "DH002", "DH003"],
17+
DhConfig.x: [100.0, 200.0, 300.0],
18+
DhConfig.y: [1000.0, 2000.0, 3000.0],
19+
DhConfig.z: [50.0, 60.0, 70.0],
20+
DhConfig.total_depth: [150.0, 200.0, 180.0],
21+
}
22+
)
23+
24+
survey = pd.DataFrame(
25+
{
26+
DhConfig.holeid: ["DH001", "DH001", "DH002", "DH002", "DH003"],
27+
DhConfig.depth: [0.0, 100.0, 0.0, 120.0, 0.0],
28+
DhConfig.azimuth: [0.0, 0.0, 45.0, 45.0, 90.0],
29+
DhConfig.dip: [-90.0, -90.0, -85.0, -80.0, -90.0],
30+
}
31+
)
32+
33+
db = DrillholeDatabase(collar, survey)
34+
35+
lithology = pd.DataFrame(
36+
{
37+
DhConfig.holeid: ["DH001", "DH001", "DH002", "DH002", "DH003"],
38+
DhConfig.sample_from: [0.0, 50.0, 0.0, 80.0, 0.0],
39+
DhConfig.sample_to: [50.0, 150.0, 80.0, 200.0, 180.0],
40+
"LITHO": ["Granite", "Schist", "Sandstone", "Shale", "Limestone"],
41+
}
42+
)
43+
44+
assays = pd.DataFrame(
45+
{
46+
DhConfig.holeid: ["DH001", "DH001", "DH002", "DH002", "DH003"],
47+
DhConfig.sample_from: [0.0, 75.0, 0.0, 100.0, 0.0],
48+
DhConfig.sample_to: [75.0, 150.0, 100.0, 200.0, 180.0],
49+
"AU_ppm": [0.1, 2.5, 0.05, 1.2, 0.4],
50+
}
51+
)
52+
53+
db.add_interval_table("lithology", lithology)
54+
db.add_interval_table("assays", assays)
55+
56+
# Line plot (numeric values)
57+
db.plot_downhole("assays", "AU_ppm", kind="line", layout="grid", ncols=2)
58+
plt.tight_layout()
59+
plt.show()
60+
61+
# Categorical plot with shared legend on the right
62+
db.plot_downhole("lithology", "LITHO", kind="categorical", step=2.0, layout="grid", ncols=2)
63+
plt.tight_layout()
64+
plt.show()
65+
66+
# Categorical plot with legend at bottom
67+
db.plot_downhole("lithology", "LITHO", kind="categorical", step=2.0, layout="grid", ncols=2, legend_loc="bottom")
68+
plt.tight_layout()
69+
plt.show()
70+
71+
# Categorical plot without legend
72+
db.plot_downhole("lithology", "LITHO", kind="categorical", step=2.0, layout="grid", ncols=2, show_legend=False)
73+
plt.tight_layout()
74+
plt.show()
75+
76+
# Create standalone legend
77+
categories = lithology["LITHO"].unique()
78+
DrillholeDatabase.create_categorical_legend(categories, cmap="tab20", title="Lithology")
79+
plt.tight_layout()
80+
plt.show()
81+
82+
# Image plot (numeric heatmap)
83+
db.plot_downhole("assays", "AU_ppm", kind="image", step=2.0, layout="grid", ncols=2)
84+
plt.tight_layout()
85+
plt.show()

loopresources/drillhole/drillhole.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)