Chris Parmer — home

borehole striplog / composite well log

Geology — Kennetcook #2, P-129

Example from the compendium of canonical charts

Geology — borehole striplog / composite well log (Kennetcook #2, P-129)

Python Code

"""Geology — borehole striplog / composite well log (Kennetcook #2, P-129).

A composite downhole log for one real well: gamma-ray, bulk density, neutron
porosity and deep resistivity curves beside a USGS-patterned lithology column,
all hung on a shared reversed depth axis — plus a PROGRESSIVE ZOOM CASCADE:
three sub-views that each magnify a window *inside* the previous one
(overview → zoom → deeper zoom → deepest), each on its OWN depth axis.
Correlation lines chain between adjacent panels using plotly.js 3.4.0
multi-axis shape references (`yref: ['y', 'y2']`, then `['y2','y3']`, …,
PR #7666): pan any panel's depth axis and its links track, the way a geologist
nudges one log against another to line a marker up.

Data: the Kennetcook #2 (P-129) well, Windsor Block, Nova Scotia — the
canonical teaching well shipped with Agile's `striplog` library. Wireline
curves come from the PETREL LAS export; the lithology column is the digitized
striplog. The multi-axis connectors live only in the interactive JSON; the
static PNG (kaleido bundles an older plotly.js) shows the tracks without them.
"""
import json
import os
import tempfile


import numpy as np
import pandas as pd
import plotly.graph_objects as go

BASE = "https://raw.githubusercontent.com/agilescientific/striplog/main/docs/tutorial/"
LAS_URL = BASE + "P-129_out.LAS"
LITH_URL = BASE + "P-129_striplog_from_image.las"
NULL_VAL = -999.25
DECIMATE = 1600

# Depth span (m) of each zoom level — each nested inside the previous one.
ZOOM_WIDTHS = (330.0, 110.0, 38.0)
ZOOM_ACCENTS = [VIOLET, PINK, TEAL]   # one accent per zoom level

# Lithology classes → (fill colour, plotly pattern shape, pattern line colour).
# Colours/patterns approximate the FGDC/USGS lithology convention within the
# limited set of fills plotly offers (dots ≈ sandstone, brick-grid ≈ limestone,
# dashes ≈ mudstone/siltstone, hatch ≈ anhydrite).
LITHOLOGY = {
    "Siltstone":    ("#c0673e", "-",  "#7d3f23"),
    "Sandstone":    ("#edc949", ".",  "#b8901f"),
    "Limestone":    ("#52b3d0", "+",  "#2c7d96"),
    "Mudstone":     ("#8a8d93", "",   "#5a5d63"),
    "Heterolithic": ("#b07c9e", "x",  "#754a63"),
    "Anhydrite":    ("#b39ddb", "|",  "#7e63b0"),
}
LEGEND_ORDER = ["Sandstone", "Siltstone", "Mudstone",
                "Heterolithic", "Limestone", "Anhydrite"]


def classify(desc: str) -> str:
    d = desc.lower()
    if "anhydrite" in d:
        return "Anhydrite"
    if "limestone" in d:
        return "Limestone"
    if "heterolithic" in d:
        return "Heterolithic"
    if "mudstone" in d:
        return "Mudstone"
    if "sandstone" in d:
        return "Sandstone"
    return "Siltstone"


def load_curves() -> pd.DataFrame:
    import requests
    import lasio
    r = requests.get(LAS_URL, verify=False, timeout=90)
    r.raise_for_status()
    with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as f:
        f.write(r.content)
        tmp = f.name
    try:
        las = lasio.read(tmp, engine="normal")  # the export is WRAP=YES
        df = las.df().reset_index()
        df.columns = [c.strip().upper() for c in df.columns]
        df.replace(NULL_VAL, np.nan, inplace=True)
        return df
    finally:
        os.unlink(tmp)


def load_lithology() -> pd.DataFrame:
    import requests
    r = requests.get(LITH_URL, verify=False, timeout=60)
    r.raise_for_status()
    lines = r.text.replace("\r", "\n").split("\n")
    start = next(i for i, l in enumerate(lines) if l.startswith("~Lithology_Data"))
    rows = []
    for line in lines[start + 1:]:
        line = line.strip()
        if not line or line.startswith("~") or line.startswith("#"):
            continue
        top, base, desc = (x.strip() for x in line.split(",", 2))
        rows.append((float(top), float(base), desc.strip().strip('"')))
    return pd.DataFrame(rows, columns=["top", "base", "description"])


def pick_nested(lith, d_top, d_base, widths=ZOOM_WIDTHS):
    """Choose progressively nested windows: each level scans *within* the
    previous window for the placement with the most lithologic variety, so
    window 1 ⊃ window 2 ⊃ window 3 (a telescoping zoom)."""
    windows = []
    lo, hi = d_top, d_base
    for w in widths:
        if hi - lo <= w:
            best = (round(lo), round(hi))
        else:
            best, best_score = (lo, lo + w), (-1, -1)
            step = max(4.0, (hi - lo - w) / 40)
            for start in np.arange(lo, hi - w + 0.1, step):
                end = start + w
                sel = lith[(lith["base"] > start) & (lith["top"] < end)]
                score = (sel["litho"].nunique(), len(sel))
                if score > best_score:
                    best_score, best = score, (round(start), round(end))
        windows.append(best)
        lo, hi = best          # next level nests inside this window
    return windows


def generate():
    try:
        print("fetching P-129 wireline LAS …")
        cv = load_curves()
        dcol = next(c for c in cv.columns if c in ("DEPT", "DEPTH", "MD"))
        step = max(1, len(cv) // DECIMATE)
        cv = cv.iloc[::step].copy()
        print(f"  curves: {len(cv)} rows, {cv[dcol].min():.0f}–{cv[dcol].max():.0f} m")
        print("fetching P-129 lithology striplog …")
        lith = load_lithology()
        lith["litho"] = lith["description"].map(classify)
        print(f"  lithology: {len(lith)} beds, classes {sorted(lith.litho.unique())}")
    except Exception as e:
        print(f"  fetch failed ({e}); aborting (no synthetic fallback for composite log)")
        raise

    depth = cv[dcol]
    d_top, d_base = 280.0, 1935.0
    windows = pick_nested(lith, d_top, d_base)
    print(f"  nested zoom windows: {windows}")

    def col(name):
        return cv[name] if name in cv.columns else pd.Series(np.nan, index=cv.index)

    fig = go.Figure()

    # ── Curve tracks (overview), all anchored to the shared depth axis y ────
    def curve(values, axis, color, name):
        fig.add_trace(go.Scatter(
            x=values, y=depth, mode="lines", line=dict(color=color, width=1.0),
            name=name, xaxis=axis, yaxis="y", showlegend=False,
            hovertemplate="%{x:.2f}<br>%{y:.1f} m<extra>" + name + "</extra>",
        ))

    curve(col("GR"), "x", GREEN, "GR (gAPI)")
    curve(col("RHOB"), "x2", "#c0392b", "RHOB (g/cc)")
    curve(col("NPHI_SAN"), "x3", "#2980b9", "NPHI (v/v)")
    curve(col("RT_HRLT"), "x4", MUTED, "Resistivity (Ω·m)")

    # ── Lithology column — reused for the overview and each zoom panel ──────
    def lith_bars(xaxis, yaxis, showlegend):
        for name in LEGEND_ORDER:
            sub = lith[lith["litho"] == name]
            if sub.empty:
                continue
            color, shape, fg = LITHOLOGY[name]
            mids = (sub["top"] + sub["base"]) / 2
            fig.add_trace(go.Bar(
                x=[1.0] * len(sub), y=mids, width=(sub["base"] - sub["top"]),
                base=0, orientation="h",
                marker=dict(
                    color=color, line=dict(color="#ffffff", width=0.4),
                    pattern=dict(shape=shape, fgcolor=fg, size=5, solidity=0.35),
                ),
                name=name, legendgroup=name, showlegend=showlegend,
                xaxis=xaxis, yaxis=yaxis,
                customdata=np.stack([sub["top"], sub["base"], sub["description"]], axis=-1),
                hovertemplate=("<b>%{customdata[2]}</b><br>"
                               "%{customdata[0]:.1f}–%{customdata[1]:.1f} m"
                               "<extra>" + name + "</extra>"),
            ))

    lith_bars("x5", "y", True)

    # ── Layout scaffolding ──────────────────────────────────────────────────
    def xax(domain, anchor, **kw):
        return dict(domain=domain, anchor=anchor, showgrid=False, **kw)

    OV_R = 0.40   # right edge of the overview lithology strip (connector source)
    axes = dict(
        xaxis=xax([0.035, 0.105], "y"),
        xaxis2=xax([0.115, 0.185], "y"),
        xaxis3=xax([0.195, 0.265], "y"),
        xaxis4=xax([0.275, 0.345], "y", type="log"),
        xaxis5=xax([0.355, OV_R], "y", range=[0, 1], showticklabels=False, zeroline=False),
        yaxis=dict(domain=[0.10, 0.93], range=[d_base, d_top], autorange=False,
                   title="Depth (m)", gridcolor=GRID, anchor="x"),
    )

    annotations = [
        dict(text=t, x=x, y=0.965, xref="paper", yref="paper", showarrow=False,
             font=dict(size=11, color=TEXT), xanchor="center")
        for t, x in [("GR", 0.07), ("RHOB", 0.15), ("NPHI", 0.23),
                     ("Res", 0.31), ("Lith", 0.378)]
    ]
    annotations.append(dict(
        text="Kennetcook #2 (P-129) · Windsor Block, Nova Scotia",
        x=0.0, y=1.045, xref="paper", yref="paper", showarrow=False,
        xanchor="left", font=dict(size=12, color=MUTED)))

    shapes = []          # single-axis (render in PNG)
    connectors = []      # multi-axis (injected into JSON only)

    # Panel x-domains: three slots across [0.44, 1.0], each GR + lithology.
    Z0, ZSPAN = 0.44, 0.56
    slot = ZSPAN / 3
    panels = []
    for i in range(3):
        s = Z0 + i * slot
        panels.append(([round(s + 0.035, 3), round(s + 0.080, 3)],    # GR domain
                       [round(s + 0.084, 3), round(s + 0.169, 3)]))    # Lith domain

    # ── Progressive zoom cascade: panel i magnifies window i, which is nested
    #    inside window i-1 (the overview is the parent of panel 0) ───────────
    for i, (wt, wb) in enumerate(windows):
        accent = ZOOM_ACCENTS[i]
        yax = f"y{i + 2}"                 # trace refs: y2, y3, y4
        gr_ax = f"x{6 + 2 * i}"           # trace refs: x6, x8, x10
        li_ax = f"x{7 + 2 * i}"           # trace refs: x7, x9, x11
        gr_key = f"xaxis{6 + 2 * i}"      # layout keys: xaxis6, xaxis8, xaxis10
        li_key = f"xaxis{7 + 2 * i}"      # layout keys: xaxis7, xaxis9, xaxis11
        gr_dom, li_dom = panels[i]

        # Parent panel (where this window is highlighted + where its connector
        # starts): the overview for panel 0, otherwise the previous panel.
        parent_y = "y" if i == 0 else f"y{i + 1}"
        parent_right = OV_R if i == 0 else panels[i - 1][1][1]
        parent_xspan = (0.035, OV_R) if i == 0 else (panels[i - 1][0][0], panels[i - 1][1][1])

        # Highlight band marking this window, drawn on the PARENT's depth axis
        # (single y-axis → shows in the static PNG too).
        shapes.append(dict(
            type="rect", xref="paper", yref=parent_y,
            x0=parent_xspan[0], x1=parent_xspan[1], y0=wt, y1=wb,
            fillcolor=_rgba(accent, 0.12), line=dict(color=accent, width=1, dash="dot"),
            layer="below"))

        # This panel's own independent (reversed) depth axis.
        axes[f"yaxis{i + 2}"] = dict(
            domain=[0.10, 0.93], range=[wb, wt], autorange=False,
            gridcolor=GRID, anchor=gr_ax, side="left",
            tickfont=dict(size=9), color=accent)
        axes[gr_key] = xax(gr_dom, yax, tickfont=dict(size=8))
        axes[li_key] = xax(li_dom, yax, range=[0, 1], showticklabels=False, zeroline=False)

        # Zoom traces.
        zmask = (depth >= wt) & (depth <= wb)
        fig.add_trace(go.Scatter(
            x=col("GR")[zmask], y=depth[zmask], mode="lines",
            line=dict(color=GREEN, width=1.2), name=f"GR zoom {i+1}",
            xaxis=gr_ax, yaxis=yax, showlegend=False,
            hovertemplate="%{x:.1f} gAPI<br>%{y:.1f} m<extra>GR</extra>"))
        lith_bars(li_ax, yax, False)

        # Panel heading.
        annotations.append(dict(
            text=f"{wt:.0f}–{wb:.0f} m", x=(gr_dom[0] + li_dom[1]) / 2, y=0.965,
            xref="paper", yref="paper", showarrow=False, xanchor="center",
            font=dict(size=11, color=accent)))

        # Multi-axis correlation lines chaining parent → this panel: window
        # edges on the parent axis to the top/base of this panel's axis.
        for d in (wt, wb):
            connectors.append(dict(
                type="line", xref="paper", yref=[parent_y, yax],
                x0=parent_right, x1=gr_dom[0], y0=d, y1=d,
                line=dict(color=accent, width=1.1, dash="dot")))

    fig.update_layout(
        barmode="overlay", bargap=0,
        annotations=annotations, shapes=shapes,
        legend=dict(orientation="h", x=0.5, xanchor="center", y=0.045,
                    yanchor="top", title=dict(text="Lithology  ")),
        margin=dict(t=60, b=60, l=70, r=15),
        height=800, **axes,
    )

    apply_theme(fig)
    fig.update_layout(title_text=None)

    # Static PNG — kaleido's bundled plotly.js predates multi-axis shapes, so
    # it gets the tracks + highlight bands only.
    (OUT / f"chart-{CHART_NUM:02d}.png").write_bytes(
        fig.to_image(format="png", width=1280, height=800, scale=1))

    # Interactive JSON — inject the multi-axis correlation lines.
    doc = json.loads(fig.to_json())
    doc["layout"].setdefault("shapes", [])
    doc["layout"]["shapes"].extend(connectors)
    (OUT / f"chart-{CHART_NUM:02d}.json").write_text(json.dumps(doc))

    print(f"  saved chart-{CHART_NUM:02d}.json + .png "
          f"({len(doc['data'])} traces, {len(doc['layout']['shapes'])} shapes, "
          f"{len(connectors)} multi-axis connectors)")


def _rgba(hex_color, alpha):
    h = hex_color.lstrip("#")
    r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
    return f"rgba({r},{g},{b},{alpha})"


if __name__ == "__main__":
    generate()

Made with Plotly