Metadata-Version: 2.4
Name: petroplots
Version: 0.1.0rc1
Summary: Publication-quality well log plots and lithology swatches for matplotlib
Project-URL: Homepage, https://github.com/andymcdgeo/petroplots
Project-URL: Documentation, https://petroplots.readthedocs.io
Project-URL: Repository, https://github.com/andymcdgeo/petroplots
Project-URL: Issues, https://github.com/andymcdgeo/petroplots/issues
Author: Andy McDonald
License-Expression: MIT
License-File: LICENSE
Keywords: geoscience,matplotlib,petrophysics,plotting,well logs
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Matplotlib
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: Visualization
Requires-Python: >=3.10
Requires-Dist: matplotlib>=3.7
Requires-Dist: numpy>=1.23
Requires-Dist: pandas>=1.5
Provides-Extra: dist
Requires-Dist: seaborn>=0.12; extra == 'dist'
Provides-Extra: docs
Requires-Dist: furo>=2024.1; extra == 'docs'
Requires-Dist: sphinx-copybutton>=0.5; extra == 'docs'
Requires-Dist: sphinx>=7.0; extra == 'docs'
Provides-Extra: las
Requires-Dist: lasio>=0.30; extra == 'las'
Provides-Extra: test
Requires-Dist: pytest-mpl>=0.16; extra == 'test'
Requires-Dist: pytest>=7.0; extra == 'test'
Requires-Dist: pyyaml>=6.0; extra == 'test'
Provides-Extra: welly
Requires-Dist: welly>=0.5; extra == 'welly'
Provides-Extra: yaml
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
Description-Content-Type: text/markdown

# petroplots

Well log plots, crossplots and lithology swatches for matplotlib.

[![CI](https://github.com/andymcdgeo/petroplots/actions/workflows/ci.yml/badge.svg)](https://github.com/andymcdgeo/petroplots/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/petroplots.svg)](https://pypi.org/project/petroplots/)
[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue.svg)](https://pypi.org/project/petroplots/)
[![Docs](https://readthedocs.org/projects/petroplots/badge/?version=latest)](https://petroplots.readthedocs.io/en/latest/)
[![Licence](https://img.shields.io/badge/licence-MIT-green.svg)](LICENSE)

![A well log plot with a depth track, gamma ray, a density-neutron crossover, formations and a lithology column](docs/images/09_header_and_depth.png)

---

## Contents

- [What it is](#what-it-is)
- [Install](#install)
- [A first plot](#a-first-plot)
  - [Three ways to build one](#three-ways-to-build-one)
- [Tracks](#tracks)
- [Shading](#shading)
- [Facies schemes and lithology swatches](#facies-schemes-and-lithology-swatches)
- [Crossplots and histograms](#crossplots-and-histograms)
- [Formation tops](#formation-tops)
- [Small multiples](#small-multiples)
- [Templates](#templates)
- [Loading data](#loading-data)
- [What it does not do](#what-it-does-not-do)
- [Documentation](#documentation)
- [Development](#development)
- [Citing](#citing)
- [Licence](#licence)

---

## 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

```bash
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.

```bash
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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](docs/images/13_fill_edges.png)

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.

```python
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](docs/images/05_swatch_set.png)

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.

```python
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.

```python
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](docs/images/10_crossplots.png)

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.

```python
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:

```python
plot.add_zones()
```

## Small multiples

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

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

![A density-neutron crossplot split into one panel per formation, on shared axes](docs/images/11_small_multiples.png)

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:

```python
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](docs/images/07b_template.png)

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

```python
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](https://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:

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

## Development

```bash
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](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.

```bibtex
@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.
