---
title: "Visualising SPT N-values from AGS4 data in Python"
description: "Read SPT N-values from AGS4 ISPT, handle refusals, assign each test to a stratum, and plot N by unit, across a landslide base, in 3D and on a map."
url: https://www.technicalgis.com/tutorials/spt-n-values-ags-python/
author: TechnicalGIS Team
publisher: TechnicalGIS (https://www.technicalgis.com/about/)
published: 2026-11-15
updated: 2026-11-15
language: en-GB
---

# Visualising SPT N-values from AGS4 data in Python

> Read SPT N-values from AGS4 ISPT, handle refusals, assign each test to a stratum, and plot N by unit, across a landslide base, in 3D and on a map.

- Level: Intermediate
- Tested with: Python 3.12
- Source code: [Download scripts](https://www.technicalgis.com/code/spt-n-values-ags-python/)

Series · Part 6 of 7

## AGS data in GIS

1.  [Read and validate AGS4 ground investigation data with Python](https://www.technicalgis.com/tutorials/read-validate-ags4-python/)
2.  [Boreholes on a map: AGS4 locations in British National Grid with QGIS](https://www.technicalgis.com/tutorials/ags4-boreholes-map-qgis/)
3.  [3D borehole logs from AGS4 data in PyVista and QGIS](https://www.technicalgis.com/tutorials/3d-borehole-logs-pyvista-qgis/)
4.  [A geological cross-section from AGS4 boreholes and LIDAR in Python and QGIS](https://www.technicalgis.com/tutorials/geological-cross-section-ags4-python/)
5.  [A 3D ground model from AGS4 boreholes: kriged stratum surfaces in Python](https://www.technicalgis.com/tutorials/geological-ground-model-surfaces-python/)
6.  Visualising SPT N-values from AGS4 data in Python
7.  [3D-print a geological ground model: one watertight STL per stratum](https://www.technicalgis.com/tutorials/3d-print-geological-ground-model/)

## Key takeaways

1.  The AGS4 ISPT group gives each test's depth and reported N. Tests that hit 50 blows before the full 300 mm are refusals, and this file reports them as N scaled up to 300 mm, so values run to 1,200. Treat anything above 50 as a refusal, not as a measurement.
2.  Joining each test to the GEOL interval that contains it turns a list of blow counts into evidence about the ground. On the A417, median N rises from 19 in the landslide deposits to over 100 in every bedrock unit below them.
3.  Plotting N against height above or below the logged base of the landslide deposits shows how sharp that contact is: 5% of tests refuse above it and 84% below it.
4.  The depth to the first refusal maps the thickness of soft ground. It is 2.2 m (median) on the limestone plateau and 13.0 m on the escarpment, where the landslide deposits are thickest.

## Before you start

-   Part 1 of this series, or at least its helper module a417\_ags.py
-   Python 3.10+ with python-ags4, pandas, GeoPandas, rasterio, PyVista, requests and matplotlib
-   Part 3 is useful background for the 3D view

**A borehole log tells you what the ground is; the SPTs tell you how hard it is.** In [Part 3](https://www.technicalgis.com/tutorials/3d-borehole-logs-pyvista-qgis/) we built the A417 Missing Link logs in 3D, and in [Part 5](https://www.technicalgis.com/tutorials/geological-ground-model-surfaces-python/) we interpolated them into a ground model. This part adds the 415 Standard Penetration Tests in the same file and asks a simple question of them: where does the soft ground stop?

On Crickley Hill the answer is mostly at the base of the landslide deposits that cover the escarpment. The figures below show that from four directions, and every number in them comes straight from the AGS file.

The steps are:

1.  read the SPTs and pick out the refusals;
2.  find the GEOL unit each test was made in;
3.  plot N against elevation for each unit;
4.  plot N across the base of the landslide deposits;
5.  put the N-values on the 3D logs over LIDAR terrain;
6.  map the depth to the first refusal;
7.  write the tests to a GeoPackage for QGIS.

The code is [`spt_n_values.py`](https://www.technicalgis.com/code/spt-n-values-ags-python/). It uses `a417_ags.py` from Part 1, which downloads and repairs the AGS file the first time it runs.

![3D view looking north-east up Crickley Hill over semi-transparent LIDAR terrain with 10 m contours. Thin borehole logs coloured by unit carry stacks of coloured discs, one per SPT: yellow and green discs for low N-values near the top of the holes on the escarpment, changing to dark purple refusal discs below black rings that mark the base of the landslide deposits. On the plateau at the top right, the holes turn purple within a few metres of the surface.](https://www.technicalgis.com/_astro/01-spt-n-3d.wDJHWlwF_5MlT.webp)

_Every SPT on Crickley Hill as a disc on its borehole log, coloured by N, with a black ring at the logged base of the landslide deposits. Looking north-east up the escarpment with three times vertical exaggeration._

## Step 1: read the SPTs and pick out the refusals

AGS4 keeps SPT results in the ISPT group. In this file it has four columns: `LOCA_ID`, the depth to the top of the test (`ISPT_TOP`), the reported N (`ISPT_REP`) and an empty hammer reference (`ISPT_HAM`). There is no record of the seating drive, the blows for each 75 mm, or the hammer’s energy ratio, so we work with N as reported.

`spt_n_values.py`

```python
spt = ags["ISPT"][["LOCA_ID", "ISPT_TOP", "ISPT_REP"]].copy()
spt["ISPT_TOP"] = pd.to_numeric(spt.ISPT_TOP)
spt["N"] = pd.to_numeric(spt.ISPT_REP)
spt["refusal"] = spt.N > 50
spt["pen_mm"] = np.where(spt.refusal, 50 * 300 / spt.N, 300.0)    # penetration implied by 50 blows
spt = spt.join(loca[["LOCA_NATE", "LOCA_NATN", "LOCA_GL"]], on="LOCA_ID")
spt["mOD"] = spt.LOCA_GL - spt.ISPT_TOP
```

`Output`

```text
=== Step 1: SPT N-values and refusals ===
415 tests in 52 holes, 0.6 to 105.0 m deep
N from 2 to 1200; 183 tests (44%) above 50
penetration implied by those values for 50 blows: 12.5 to 294.1 mm, e.g. 12, 47, 80, 123, 200, 294
```

**N runs to 1,200, which no test can actually reach.** A test stops after 50 blows in the main drive, so any N above 50 is a refusal that the contractor has scaled up to 300 mm. Turning it back into the penetration it implies, 50 × 300 / N, gives 12.5 to 294 mm: an N of 1,200 means the sampler moved 12.5 mm in 50 blows. That is useful as a ranking of how hard the ground was, but not as a measurement, so every chart below shades N above 50 grey and draws refusals as triangles.

## Step 2: find the GEOL unit each test was made in

An N-value means little without the ground it was measured in. Join each test to its hole’s GEOL intervals and keep the interval whose top and base bracket the test depth:

`spt_n_values.py`

```python
g = geol[["LOCA_ID", "GEOL_TOP", "GEOL_BASE", "UNIT"]]
m = spt.reset_index().merge(g, on="LOCA_ID", how="left")
m = m[(m.GEOL_TOP <= m.ISPT_TOP) & (m.ISPT_TOP < m.GEOL_BASE)].drop_duplicates("index").set_index("index")
spt["UNIT"] = m.UNIT.reindex(spt.index).fillna("NR")
```

`Output`

```text
=== Step 2: the GEOL unit at each test ===
      tests  holes  median_N refusals
UNIT
MGR       7      3       5.0       0%
HEAD     13      9      18.0       8%
SLIP    189     31      19.0       5%
WHL       3      2     150.0     100%
HMB       1      1     171.0     100%
FE       18      6      23.0      28%
ASLS      1      1     600.0     100%
BLPL     38     11     106.0      68%
BDS      31      6     231.0      94%
WHM      73     10     316.0      97%
MRB       1      1     316.0     100%
DYS      26      7     131.5      96%
CHAM      2      1      24.0       0%
NR       12     11     218.0      92%
```

The `UNIT` codes are the ones `a417_ags.py` gives each GEOL description in Part 1. Nearly half of all the tests, 189, were made in the landslide deposits (SLIP). Their median N of 19 is close to that of the made ground, head and Fuller’s Earth clay. Every bedrock unit below them has a median above 100, and most of their tests refused. `NR` is where the log records no recovery, typically because the ground was too hard to sample, and 92% of those tests refused too.

## Step 3: N against elevation, by unit

A chart for each unit, with elevation on the vertical axis and N on a log scale, shows both how hard each unit is and where it sits in the hill:

`spt_n_values.py`

```python
for ax, u in zip(axes, facets + ["other"]):
    d = spt[spt.UNIT == u] if u != "other" else spt[~spt.UNIT.isin(facets)]
    ax.axvspan(50, 2000, color="#eeeeee", zorder=0)
    for refused, marker in [(False, "o"), (True, ">")]:
        e = d[d.refusal == refused]
        ax.scatter(e.N, e.mOD, c=[A.UNITS.colour[x] for x in e.UNIT], marker=marker, s=22 if refused else 16,
                   edgecolor="#333333", lw=0.4, zorder=3)
    ax.set_xscale("log")
```

`Output`

```text
=== Step 3: N against elevation, by unit ===
Made ground               7 tests  median N    5  168 to 192 m OD
Head                     13 tests  median N   18  229 to 278 m OD
Landslide deposits      189 tests  median N   19  120 to 254 m OD
Fuller's Earth           18 tests  median N   23  243 to 285 m OD
Birdlip Limestone        38 tests  median N  106  212 to 267 m OD
Bridport Sand            31 tests  median N  231  189 to 212 m OD
Whitby Mudstone          73 tests  median N  316  129 to 194 m OD
Dyrham                   26 tests  median N  132  139 to 179 m OD
Other units              20 tests  median N  214  128 to 281 m OD
```

![Nine narrow scatter plots side by side sharing an elevation axis from about 120 to 290 m OD, one per unit, with SPT N on a log scale and the region above N = 50 shaded grey. Made ground, head, landslide deposits and Fuller's Earth plot mostly as circles left of the grey band. Birdlip Limestone straddles it. Bridport Sand, Whitby Mudstone and Dyrham plot almost entirely as triangles inside the grey band. The landslide deposits span the widest range of elevation, from about 120 to 255 m OD.](https://www.technicalgis.com/_astro/02-n-vs-elevation-by-unit.CKILd1Kv_1Xgi2r.webp)

_SPT N against the elevation of each test, one panel per unit. Circles are full tests, triangles are refusals, and the grey band is N above 50. The landslide deposits stand out: soft, and found from 120 to 254 m OD, all the way down the escarpment._

Two things stand out. The first is the split between the soft units on the left and the bedrock units on the right. The second is the landslide deposits’ spread of elevation. Every other unit sits in a narrow band of height, as layers in a hill should, but the landslide deposits are found over 134 m of it, because they lie on the slope rather than in the sequence.

**What the N-values mean depends on the ground.** BS EN ISO 14688-2 and BS 5930 use N to describe the density of sands and gravels: under 4 is very loose, 4 to 10 loose, 10 to 30 medium dense, 30 to 50 dense and over 50 very dense. The bands in the 3D view and the map follow those limits. Here they apply strictly only to the Bridport Sand, which is very dense. The clays and weak rocks are described by strength instead, and N is one guide to that among others, so treat the bands there as a scale of hardness.

## Step 4: N across the base of the landslide deposits

If the landslide deposits are soft and the bedrock is hard, the change should happen at the contact between them. To test that, take every hole that logs landslide deposits directly on bedrock and measure each test’s height above or below that hole’s contact:

`spt_n_values.py`

```python
rows = []
for lid, h in geol.groupby("LOCA_ID"):
    s = h[h.UNIT == "SLIP"]
    if s.empty:
        continue
    under = h[(h.GEOL_TOP >= s.GEOL_BASE.max() - 0.01) & h.UNIT.isin(bedrock)]
    if len(under):
        rows.append((lid, s.GEOL_BASE.max(), under.UNIT.iloc[0]))
base = pd.DataFrame(rows, columns=["LOCA_ID", "slip_base", "below"]).set_index("LOCA_ID")
b = spt.join(base, on="LOCA_ID", how="inner")
b["rel"] = b.slip_base - b.ISPT_TOP                      # metres above (+) or below (-) the logged base
```

`Output`

```text
=== Step 4: across the base of the landslide deposits ===
31 holes log landslide deposits directly on bedrock; 28 of them have SPTs (235 tests)
base of the deposits: 1.3 to 20.7 m deep, median 10.4 m
above the base: 178 tests, median N 19, refusals 5%
below the base: 57 tests, median N 128, refusals 84%
first test below the base refused in 7 of 12 holes; bedrock beneath: Whitby Mudstone Formation 15, Dyrham Formation 5, Charmouth Mudstone Formation 3, Salperton Limestone Formation 3, Fuller's Earth Formation 3, Marlstone Rock Formation 1, Aston Limestone Formation 1
```

![Scatter plot of SPT N on a log scale against metres above or below the logged base of the landslide deposits, from +20 to −23 m, with a black horizontal line at zero labelled base of landslide deposits. Above the line, orange circles for landslide deposits cluster between N = 5 and 50. Below it, blue-grey triangles for Whitby Mudstone and Dyrham refusals fill the grey band above N = 50. A red line through the median N in 2 m bands stays near 15 to 20 above the base and climbs steeply to over 300 below it.](https://www.technicalgis.com/_astro/03-n-across-slip-base.COh4xAK2_FkFza.webp)

_235 SPTs in 28 holes, plotted against height above (+) or below (−) the logged base of the landslide deposits in each hole. The red line is the median N in 2 m bands._

**The base of the landslide deposits is where the ground gets hard.** Above it, the median N is between 13 and 24 in every 2 m band up to 18 m above the contact. Only 9 of the 178 tests refuse, and 5 of those are within 1.5 m of the base. Below it, the median jumps to 128 and 84% refuse. The first test below the contact refused in 7 of the 12 holes that have one.

That is good evidence that the loggers placed the contact well. It also shows that the landslide deposits are soft right down to their base, rather than stiffening gradually with depth. Most of them, 15 of 31 holes, rest on Whitby Mudstone.

## Step 5: N on the 3D logs over LIDAR

The lead figure puts the same tests where they were made. It reuses Part 3’s approach: thin cylinders for the GEOL intervals, coloured by unit, under semi-transparent LIDAR terrain at three times vertical exaggeration. Each SPT becomes a wider, flat disc at its elevation, coloured by its N band, and each hole from Step 4 gets a black ring at the base of its landslide deposits:

`spt_n_values.py`

```python
discs = [coloured(pv.Cylinder(center=(r.LOCA_NATE - X0, r.LOCA_NATN - Y0, r.mOD * VE), direction=(0, 0, 1),
                              radius=11, height=1.4 * VE, resolution=32), BAND_COLOURS[int(r.band)])
         for r in spt[spt.LOCA_ID.isin(holes.index)].itertuples()]
rings = [pv.Disc(center=(loca.at[lid, "LOCA_NATE"] - X0, loca.at[lid, "LOCA_NATN"] - Y0,
                         (loca.at[lid, "LOCA_GL"] - d) * VE + 0.5), inner=13, outer=22, normal=(0, 0, 1), c_res=48)
         for lid, d in base.slip_base.items() if lid in holes.index]
```

`Output`

```text
=== Step 5: N on the 3D logs ===
39 holes with SPTs on Crickley Hill: 880 log intervals, 378 SPT discs, 24 rings at the base of the landslide deposits
```

The colours come from viridis, from yellow for very low N to dark purple for refusals, so the change from soft to hard reads as a change from light to dark even in greyscale. In the view at the top of the page, the holes on the escarpment are green above their black rings and purple below them, and the holes on the plateau at the top right turn purple almost at the surface.

## Step 6: map the depth to the first refusal

The 3D view shows a pattern; a map measures it. For each hole, find the shallowest test that refused. Holes that never refused are shown as white circles labelled with the depth of their deepest test, because that is as far as we know the ground stayed soft:

`spt_n_values.py`

```python
first = spt[spt.refusal].groupby("LOCA_ID").ISPT_TOP.min()
none = spt.groupby("LOCA_ID").ISPT_TOP.max()[~spt.groupby("LOCA_ID").refusal.any()]
```

`Output`

```text
=== Step 6: depth to the first refusal ===
32 holes reached refusal, at 0.6 to 60.0 m (median 4.1 m); 20 holes never did (deepest test 1.0 to 13.7 m)
  plateau, east of 393,100 E: 9 holes, median depth to refusal 2.2 m
  escarpment, west of 393,100 E: 11 holes, median depth to refusal 13.0 m
```

![Map of Crickley Hill on a grey LIDAR hillshade with 10 m contours, showing the curving escarpment. Circles coloured from yellow (shallow) to dark purple (25 m) show the depth of the first SPT refusal. Holes on the plateau in the north-east are yellow and orange, at a few metres. Holes on the escarpment face in the centre are red and purple, at 12 to 25 m. White circles with labels such as >7 and >14 mark holes in the vale to the south-west that never refused.](https://www.technicalgis.com/_astro/04-depth-to-refusal-map.C4DKDykd_Z1bqBGz.webp)

_Depth to the first SPT refusal in each hole on Crickley Hill. White circles never refused, labelled with the depth of their deepest test. Plateau holes refuse at a median of 2.2 m and escarpment holes at 13.0 m._

**The map divides the hill into three.** On the plateau, the Birdlip Limestone is within a few metres of the surface under a metre or two of head or made ground, and 8 of the 9 plateau holes first refused in it, at a median depth of 2.2 m. On the escarpment face the median is 13.0 m: 5 of those 11 holes first refused in the landslide deposits and 6 in the Lias mudstones beneath them. Most of the holes that never refused, 14 of 20, are on the lower slopes and in the vale to the south-west, and their deepest test was still in the landslide deposits.

## Step 7: write the tests to a GeoPackage for QGIS

The same table, with a PointZ geometry at each test’s position and elevation, goes into a GeoPackage. You can add it to the QGIS project from [Part 2](https://www.technicalgis.com/tutorials/ags4-boreholes-map-qgis/), style it by `band`, or show it in the 3D map view from Part 3:

`spt_n_values.py`

```python
pts = gpd.GeoDataFrame(
    spt[["LOCA_ID", "ISPT_TOP", "mOD", "N", "refusal", "pen_mm", "UNIT"]]
    .assign(unit_name=spt.UNIT.map(A.UNITS.name), band=[BAND_LABELS[int(i)] for i in spt.band]),
    geometry=gpd.points_from_xy(spt.LOCA_NATE, spt.LOCA_NATN, spt.mOD), crs="EPSG:27700")
pts.to_file(OUT / "a417_spt.gpkg", layer="spt", driver="GPKG")
```

`Output`

```text
=== Step 7: write the tests to a GeoPackage ===
a417_spt.gpkg: 415 PointZ features in EPSG:27700, fields LOCA_ID, ISPT_TOP, mOD, N, refusal, pen_mm, UNIT, unit_name, band
figures: 01-spt-n-3d.png, 02-n-vs-elevation-by-unit.png, 03-n-across-slip-base.png, 04-depth-to-refusal-map.png
```

## Frequently asked questions

### Where are SPT results in an AGS4 file?

In the ISPT group, one row for each test, keyed by `LOCA_ID` and the depth to the top of the test, `ISPT_TOP`. The N-value is in `ISPT_NVAL`, and `ISPT_REP` holds the result as the contractor reported it, which may be text such as “50/75” or, as here, an extrapolated N. Penetration (`ISPT_NPEN`), seating and main-drive blows, the 75 mm increments and the hammer energy ratio have their own headings if the contractor supplied them.

### How should I plot SPT refusals?

Keep them, but mark them differently. Refusals are often the most informative tests because they show where hard ground starts. Plot them with their own symbol and shade the region above N = 50, rather than mixing extrapolated values with measured ones or dropping them.

### Is this N60?

No. N60 needs the energy ratio of the hammer used, and this file has neither `ISPT_ERAT` nor `ISPT_N60`, and an empty hammer reference. The values here are N as reported. For design you would correct for hammer energy, and for overburden pressure in sands, using the contractor’s hammer calibration records.

### Why use a log scale for N?

Measured N on this site runs from 2 to 50, and extrapolated refusals go to 1,200. On a linear scale everything below 50 is squeezed against the axis. A log scale gives the soft ground room and still shows the refusals.

### Can I do this with any AGS file?

Yes. Steps 1, 2, 3, 6 and 7 need only LOCA, GEOL and ISPT. Step 4 needs a contact that many holes pass through, so swap `SLIP` for the base of made ground, alluvium or glacial till on your site.

## Next steps

-   [Part 7: 3D-print the ground model](https://www.technicalgis.com/tutorials/3d-print-geological-ground-model/) turns the kriged surfaces from Part 5 into a multi-material print of Crickley Hill.
-   [Part 4: a geological cross-section](https://www.technicalgis.com/tutorials/geological-cross-section-ags4-python/) is a good place to add the N-values from Step 7 along the section line.
-   [Kriging spatial interpolation in Python](https://www.technicalgis.com/tutorials/kriging-spatial-interpolation-python/) shows how you could turn the depth to first refusal into a continuous surface.
-   Back to [Part 1: Reading and validating AGS4 files](https://www.technicalgis.com/tutorials/read-validate-ags4-python/), [Part 2: Boreholes on a map](https://www.technicalgis.com/tutorials/ags4-boreholes-map-qgis/), [Part 3: 3D borehole logs](https://www.technicalgis.com/tutorials/3d-borehole-logs-pyvista-qgis/) or [Part 5: the ground model](https://www.technicalgis.com/tutorials/geological-ground-model-surfaces-python/).

## Data and licences

-   **A417 Missing Link ground investigation** (BGS deposited data item 173984; client Highways England, now National Highways; contractor Geotechnical Engineering Ltd), downloaded through the BGS AGS borehole index and export APIs. Contains British Geological Survey materials © UKRI 2026, licensed under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/). Every figure carries the credit.
-   **Environment Agency LIDAR Composite DTM 1 m**, requested at 5 m from the Defra Data Services Platform WCS. © Environment Agency copyright and/or database right, licensed under the Open Government Licence v3.0. It is the terrain in the 3D view and the hillshade and contours in the map, and both figures carry the credit. No other basemap is used.
-   Code: [`spt_n_values.py`](https://www.technicalgis.com/code/spt-n-values-ags-python/) and `a417_ags.py`, tested with Python 3.12.0, python-ags4 1.2.0, pandas 2.3.3, GeoPandas 1.2.0, rasterio 1.5.2, PyVista 0.49.2, VTK 9.7.2 and matplotlib 3.11.2.

## Key terms

- **Standard Penetration Test (SPT)**: An in-situ test in which a 63.5 kg hammer falling 760 mm drives a split-spoon sampler or solid cone into the bottom of a borehole. After a 150 mm seating drive, the blows for the next 300 mm are the N-value.
- **N-value**: The number of blows to drive the sampler 300 mm after the seating drive. Low N means loose or soft ground and high N means dense or hard ground.
- **Refusal**: A test stopped after 50 blows in the main drive before the sampler has gone the full 300 mm. The penetration reached is recorded, and some contractors report an N extrapolated to 300 mm.
- **N60**: N corrected to 60% of the hammer's theoretical energy, so tests with different hammers can be compared. It needs the hammer's measured energy ratio, which this file does not hold.
- **Landslide deposits**: Ground that has slid down a slope, here mainly Lias clay and broken limestone that has moved off the Cotswold escarpment. Its base is often a polished slip surface on undisturbed bedrock.

## Data for this tutorial

-   [BGS AGS borehole data](https://www.technicalgis.com/data-sources/united-kingdom/#src-bgs-ags-borehole-data) · Donated AGS ground investigation data, Open Government Licence

Licences and formats for each source are listed in the [GIS data sources catalogue](https://www.technicalgis.com/data-sources/).

[← Part 5 A 3D ground model from AGS4 boreholes: kriged stratum surfaces in Python](https://www.technicalgis.com/tutorials/geological-ground-model-surfaces-python/) [Part 7 → 3D-print a geological ground model: one watertight STL per stratum](https://www.technicalgis.com/tutorials/3d-print-geological-ground-model/)

---

Source: https://www.technicalgis.com/tutorials/spt-n-values-ags-python/
