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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

petroplots-0.1.0rc1.tar.gz (2.5 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

petroplots-0.1.0rc1-py3-none-any.whl (122.0 kB view details)

Uploaded Python 3

File details

Details for the file petroplots-0.1.0rc1.tar.gz.

File metadata

  • Download URL: petroplots-0.1.0rc1.tar.gz
  • Upload date:
  • Size: 2.5 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for petroplots-0.1.0rc1.tar.gz
Algorithm Hash digest
SHA256 f15cf9ebc4bcdf093e3f0d41506938250642f7475cc65c638c2ec7d7ff75f471
MD5 f93efb91bdc069aebda8f14c81da6cad
BLAKE2b-256 f825920d3e4c4445be0994978e614901eab60cbac564fa3c44fee9d46a959205

See more details on using hashes here.

Provenance

The following attestation bundles were made for petroplots-0.1.0rc1.tar.gz:

Publisher: publish.yml on andymcdgeo/petroplots

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file petroplots-0.1.0rc1-py3-none-any.whl.

File metadata

  • Download URL: petroplots-0.1.0rc1-py3-none-any.whl
  • Upload date:
  • Size: 122.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for petroplots-0.1.0rc1-py3-none-any.whl
Algorithm Hash digest
SHA256 7ca2c878eca3ae7caec34f97e89de67f64afa743a3bf7f6c1908cf69e349589e
MD5 702afe2111a2938bf5f731f8644998ab
BLAKE2b-256 cd5163523a0f3abedb972ad5b431786365622741a4c2ce6ade7b01064e575030

See more details on using hashes here.

Provenance

The following attestation bundles were made for petroplots-0.1.0rc1-py3-none-any.whl:

Publisher: publish.yml on andymcdgeo/petroplots

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0rc1 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page