Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

petroplots

Well log plots, crossplots and lithology swatches for matplotlib.

CI PyPI Python Docs Licence

A well log plot with a depth track, gamma ray, a density-neutron crossover, formations and a lithology column


Contents


What it is

Plotting a well log in matplotlib takes roughly 150 lines, and they are much the same every time. Invert the depth axis once on the shared axis. Call tick_top() on every track. Build a ListedColormap and a BoundaryNorm so a facies colour means the same thing in every well. Add proxy Patch handles, because imshow produces no legend entries. Set gridspec_kw width ratios so a narrow facies column sits beside wide curve tracks.

petroplots does that part. It takes a pandas DataFrame and returns a matplotlib Figure, so what comes back is an ordinary figure that every matplotlib method still works on.

Several of those 150 lines are easy to get subtly wrong, and the result is a figure that renders without error and reports something incorrect:

  • A FaciesScheme fixes its colours from the class set you declare, not from the data in front of it. imshow scales its norm to whatever array it is handed, so a well containing no limestone recolours its sandstone.
  • The depth axis is inverted once, on the shared axis, after every track has drawn. Two tracks each inverting it leaves it the right way up.
  • Layer edges sit at sample midpoints, which avoids both a hairline at every contact and an overlap that hides thin beds.
  • Crossover shading is computed after both curves are normalised onto their own display limits. Density and neutron are recorded on incompatible scales, so shading computed without that step marks the wrong intervals.
  • Limits are applied after drawing. Matplotlib autoscales as artists are added, so a limit set mid-draw is overridden by the next artist on the same axes.

Install

pip install petroplots

Python 3.10 or newer. The core install pulls in matplotlib, numpy and pandas and nothing else. seaborn, lasio and PyYAML are optional and imported only when the feature that needs them is used.

pip install "petroplots[las]"     # lasio, for pp.io.from_las()
pip install "petroplots[yaml]"    # PyYAML, for templates
pip install "petroplots[docs]"    # sphinx, to build the documentation

A first plot

Ask the plot for a track, then add curves to that track:

import petroplots as pp

df = pp.datasets.load_example()

plot = pp.LogPlot(df, top=2000, bottom=2100)
plot.add_depth(interval=10)

gr = plot.add_track()
gr.add_curve("GR", limits=(0, 150), units="API")

nd = plot.add_track()
nd.add_curve("RHOB", limits=(1.9, 2.9),    units="g/cm3")
nd.add_curve("NPHI", limits=(0.45, -0.05), units="v/v")
nd.crossover()

plot.add_facies("FACIES", pp.LITHOLOGY_FGDC)

fig, axes = plot.render()

render() hands back the figure and its axes and stops there. Nothing calls show() or savefig() for you, so the figure is yours to carry on with:

axes["GR"].axvline(75, ls="--", color="0.4")   # by track label
axes[0].set_facecolor("#FAFAFA")               # by position
axes.depth.set_ylim(2080, 2020)                # the shared depth axis

Three ways to build one

The handle form above suits anything where the tracks are not known in advance, because gr and nd stay editable after you make them. That makes it the form to reach for when building from a loop, a config file or a user interface.

For a quick look at a frame you have just loaded there is a one-liner. A string becomes one track, a tuple becomes one track carrying several curves, and a column resolving to the facies family becomes a lithology column:

fig, axes = pp.logplot(df, ["GR", ("RHOB", "NPHI"), "FACIES"])

Each add_* method also builds a whole track in a single call and returns the plot, so calls chain:

fig, axes = (
    pp.LogPlot(df, top=2000, bottom=2100)
    .add_curves("GR", limits=(0, 200))
    .add_fill(["RHOB", "NPHI"],
              limits={"RHOB": (1.9, 2.9), "NPHI": (0.45, -0.05)},
              crossover=True)
    .add_facies("FACIES", pp.LITHOLOGY_FGDC)
    .render(figsize=(11, 9))
)

All three produce the same figure and serialise to the same template.

Tracks

Method Draws
add_track() an empty track; add curves to the handle it returns
add_curves() one or more curves in one call
add_fill() curves plus shading, including a density-neutron crossover
add_facies() a discrete class column, with or without ornament
add_zones() named depth intervals, labelled in place
add_flag() a boolean column as a presence strip
add_depth() a depth scale as a column of its own

Limits, scale and units come from the curve family when you do not give them, so resistivity lands on a logarithmic axis and neutron runs right to left without being told. Column names resolve through an alias table, which is why a template written against GR finds a column called SGR.

Header text is fitted to the track it belongs to. On a narrow figure a track name shrinks and then stands upright, tick labels drop from three values to two before they shrink, and keys too wide for their own track move to the figure legend.

Shading

A fill has two sides. Each is either a curve on the track or a number, and you name them the way you see them on the plot:

gr.fill(baseline=0, cmap="YlOrBr")     # graded, from zero
phi.fill("PHIE", "PHIT")               # between two curves
sw.fill(left="SW", right=1.0)          # a curve across to a value

Gamma ray graded from zero, the gap between total and effective porosity, and water saturation shaded across to one

Leave a side out and it falls back to the track's first curve, so phi.fill(right="PHIE") shades from the curve already there. A number is read against the limits of the curve on the other side, so left=0 on a (0, 200) gamma ray track means zero API.

Between two curves the shading is computed in normalised space, which is what lets the two sit on different scales.

Facies schemes and lithology swatches

A FaciesScheme maps class names to colours, and optionally to ornament. It declares its class set up front, so a class keeps its colour whether or not the well in front of you contains it.

scheme = pp.FaciesScheme.from_lithologies(
    ["Shale", "Sandstone", "Chalk", "Limestone", "Dolomite"]
)

scheme = pp.FaciesScheme.from_lithologies(
    ["Shale", "Sandstone", "Sandstone/Shale"],
    swatches={"Sandstone/Shale": pp.swatches.INTERBEDDED},
)

Two schemes ship with the same nine classes, so a figure can be switched between them without touching the data. pp.LITHOLOGY is colour only and fast. pp.LITHOLOGY_FGDC adds ornament, which keeps a facies column readable in greyscale, where a lot of well reviews still happen.

The shipped lithology swatch set

Behind those are thirty parametric patterns and thirty-two named lithologies, modelled on the FGDC Digital Cartographic Standard for Geologic Map Symbolization. The geometry is computed rather than loaded from artwork, so the ornament stays vector in PDF and SVG output and scales with figure size instead of pixel density.

pp.swatches.available()            # 30 patterns
pp.swatches.LITHOLOGY_SWATCHES     # 32 named lithologies

Register your own with pp.swatches.register().

Crossplots and histograms

The same schemes and the same curve conventions carry across the other plot types, so a colour, a range and a formation mean one thing everywhere.

pp.crossplot(df, "NPHI", "RHOB", color="GR", color_steps=6)
pp.crossplot(df, "PHIT", "PERM", color="FACIES", scheme=pp.LITHOLOGY)
pp.histogram(df, "PHIT", color="FACIES", scheme=pp.LITHOLOGY)

Three crossplots of the same well: coloured by gamma ray, banded into six colours, and coloured by facies

Permeability lands on a logarithmic axis without being told, and a histogram of it gets logarithmic bins. A gamma ray colour axis runs 0 to 150 in every figure, so wells drawn side by side are comparable. color_steps cuts the colour map into bands, which lets a reader assign a point to a class rather than only rank it against its neighbours.

Formation tops

A tops list rarely arrives in the same shape twice, so read_tops takes a mapping, a list of pairs, a frame, or a project export in CSV, Excel, JSON or Parquet. Headers are matched loosely, CSV separators are sniffed, and well= picks one well out of a field export.

df = pp.add_tops(df, "field_tops.csv", well="15/9-19 A")

The column comes back as an ordered categorical in depth order, so legends, facies tracks and panel grids come out in stratigraphic order. Depths outside the tops list, or inside a gap between two units, are left empty rather than filled from a neighbour.

The same tops drive a formation track on the log plot, where each name is written inside its band:

plot.add_zones()

Small multiples

by= splits a crossplot or a histogram into one panel per class:

pp.crossplot(df, "NPHI", "RHOB", by="FORMATION", color="GR")

A density-neutron crossplot split into one panel per formation, on shared axes

Limits, colour ranges and bins are computed over the whole frame before the split. Panels that autoscaled independently would draw a tight cluster and a broad scatter at the same size on the page, which reverses the comparison the grid exists to make.

Templates

A template names curves, never data. Applied to a well that lacks one it degrades predictably instead of raising, so one definition runs over a field:

template = pp.load_template("triple_combo")

for name, well in wells.items():
    fig, axes = template.apply(well).title(name).render()

The same template applied to a well recorded with vendor mnemonics

Curves spelled differently are resolved through the alias table. Curves missing entirely have their track dropped. A crossover missing one of its two curves degrades to a plain curve rather than shading everything under the survivor.

Save one from a plot you have built with pp.save_template(plot, "mine.yaml").

Loading data

df = pp.io.from_las("15_9-19A.las")      # needs petroplots[las]
df = pp.io.normalise(df, depth="DEPT")   # any frame you already have

normalise moves a depth index into a column, replaces null sentinels such as -999.25 with NaN, and checks that depth is monotonic. pp.io.coverage(df) reports which curves are present and how complete each one is.

What it does not do

No file parsing, no petrophysical calculations, and no well or project data model. lasio, welly and dlisio cover those, and pp.io.from_las() is a convenience that delegates to lasio rather than a parser of its own.

Documentation

petroplots.readthedocs.io

Versioned, so a page matches the release you have installed. /en/stable/ is the latest release and /en/latest/ tracks main.

To build it yourself:

pip install "petroplots[docs]"
python -m sphinx -b html docs docs/_build/html

Development

git clone https://github.com/andymcdgeo/petroplots
cd petroplots
pip install -e ".[test,yaml,las,docs]"

python -m pytest -q                                     # tests
python examples/gallery.py examples/figures             # render every example
python -m sphinx -b html docs docs/_build/html -W       # docs, warnings fatal

CI runs the tests on Python 3.10 to 3.14, builds the documentation with warnings treated as errors, and renders the gallery so a figure that raises is caught even where no test covers it.

CONTRIBUTING.md covers branching, versioning and how a release is cut.

Citing

If petroplots contributes to work you publish, please cite it. A CITATION.cff file is included, so GitHub can generate APA and BibTeX through the Cite this repository link.

@software{mcdonald_petroplots,
  author  = {McDonald, Andy},
  title   = {petroplots: well log plots and lithology swatches for matplotlib},
  version = {0.1.0},
  year    = {2026},
  url     = {https://github.com/andymcdgeo/petroplots}
}

Licence

MIT.

The lithology ornament follows the FGDC Digital Cartographic Standard for Geologic Map Symbolization, published by the USGS. As a US Government work it is in the public domain, and the geometry here is reimplemented parametrically rather than copied from any artwork file. The colours are this library's own: the FGDC 600-series patterns are monochrome and specify no fills.

The British Geological Survey ornament sets were considered and deliberately not used, since BGS sells its map symbol products and its Open Government Licence position covers data rather than cartographic symbols.

Bundled example data is synthetic and represents no real well.

Metadata

Release files for petroplots 0.1.0rc1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for petroplots 0.1.0rc1
File Size Uploaded Provenance Actions
petroplots-0.1.0rc1.tar.gz 2.5 MB With provenance Details for petroplots-0.1.0rc1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for petroplots 0.1.0rc1
File Interpreter ABI Platform Actions
petroplots-0.1.0rc1-py3-none-any.whl Python 3 none any Details for petroplots-0.1.0rc1-py3-none-any.whl

Total release size: 2.6 MB

Release files / petroplots-0.1.0rc1.tar.gz

Download URL petroplots-0.1.0rc1.tar.gz
Size 2.5 MB
Tags Source
SHA-256 checksum
How to use checksums
f15cf9ebc4bcdf093e3f0d41506938250642f7475cc65c638c2ec7d7ff75f471
BLAKE2b-256 checksum
How to use checksums
f825920d3e4c4445be0994978e614901eab60cbac564fa3c44fee9d46a959205
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 10, 2026.

Transparency log

Release files / petroplots-0.1.0rc1-py3-none-any.whl

Download URL petroplots-0.1.0rc1-py3-none-any.whl
Size 122.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7ca2c878eca3ae7caec34f97e89de67f64afa743a3bf7f6c1908cf69e349589e
BLAKE2b-256 checksum
How to use checksums
cd5163523a0f3abedb972ad5b431786365622741a4c2ce6ade7b01064e575030
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 10, 2026.

Transparency log

Security for petroplots 0.1.0rc1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for petroplots-0.1.0rc1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 10, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page