Skip to content

GuideDay-to-day use. Assumes you've run Ganymede once.

Did Oklahoma's earthquakes stop? ​

NOTE

A journal Claude Code kept with the ganymede-journal plugin, shown with the outputs of its last run. The notebook, its data script and its prompts are in examples/journals/.

What the person asked

In order, from the session's transcript.

  1. People keep saying Oklahoma's earthquakes "stopped" once regulators cut back wastewater injection. Did they? Use the USGS catalog and keep a journal.
  2. Before I believe a 99% drop: is 2015 measured the same way as 2025? Networks and magnitude scales change. Check what's actually in those rows.
  3. ComCat keeps every network's solution, not just the preferred one. Pull USGS's and OGS's own catalogs for the same period, match the events, and compare magnitudes directly.
  4. Flat at 55–70 a year: is that the background rate, or still swarms? A lot of 2015 was aftershock sequences. Strip out aftershocks and show me what's left.
  5. Where are they happening now? I've read the activity moved north toward Kansas and then came back. Map it.
  6. I added two cells at the end about one town. Use them.
  7. Here's what I actually care about: the big ones. Pawnee came after the count was already falling. Is the risk of a damaging quake falling as fast as the count?
  8. So did the injection cuts cause all this? Can you show that?
  9. Good. One thing bugs me: you said 'consistent with the published explanation'. Either cite it or take it out. The notebook should only claim what it shows.
  10. Good. Wrap it up: summary at the top, make sure it all re-runs, and have the reviewer read it cold.

Summary.

  1. The boom is over, but the quakes didn't stop. M2.5+ events fell from 2,990 in 2015 to about 56 in 2025 on a consistent magnitude scale (−98%), still about 14 times the 2000–2008 average.
  2. The published 2025 count (30) understates it. Since 2019 the Oklahoma Geological Survey sets most magnitudes, and by 2025 it reads 0.18 lower than USGS for the same quake. On one scale, the rate has been flat since 2023.
  3. Most of the boom was aftershocks (93% in 2015). Independent quakes peaked in 2017, two years after the raw count, and are now 6–10 times their pre-2009 rate.
  4. The activity moved north to the Kansas border in 2014–2018 and has mostly come back to central Oklahoma. In Stillwater, felt quakes went from one every two days (2014–2016) to 8 in six years.
  5. Large quakes fell about as fast as small ones: M4+ from 23 a year at the peak to 1.3 since 2020. They still happen (M5.06 near Prague, February 2024).
  6. The rise and fall follow Arbuckle disposal volumes in time and place, with mainshocks peaking three years after injection did; injection into other formations shows no such pattern. Mechanism is cited, not tested.

Open: the size of the OGS–USGS offset at M3+; 2025 injection is the latest year published; the 2024 OCC file is missing locations for about 1,700 wells.

Goal. Oklahoma went from a few felt earthquakes a decade to the most seismically active state in the lower 48 around 2015, an increase tied to wastewater injection. The common story is that the quakes "stopped" once regulators cut injection volumes in 2015–2016. Check that against the USGS catalog.

9 Nov 2026 · Thomas with Claude · kernel cdp-node (cdp-kernel 0.8.0, Node 22.23) · package versions pinned in the imports

Data fetched 9 Nov 2026: USGS ComCat (rows are revised over time, so a later fetch can differ slightly) and OCC Form 1012 injection volumes, 2011–2025.

Area: 33.6–37.5° N, 100–94.4° W (Oklahoma and the Kansas border). Events of magnitude 2.5 and up, January 2000 to October 2026.

Recorded: ran in the shell, not in this kernel.

sh
$ curl -sSfo data/ok-m25.csv "https://earthquake.usgs.gov/fdsnws/event/1/query?format=csv&starttime=2000-01-01&endtime=2026-11-01&minlatitude=33.6&maxlatitude=37.5&minlongitude=-100&maxlongitude=-94.4&minmagnitude=2.5&orderby=time-asc" && wc -l data/ok-m25.csv && head -2 data/ok-m25.csv
text
10189 data/ok-m25.csv
time,latitude,longitude,depth,mag,magType,nst,gap,dmin,rms,net,id,updated,place,type,horizontalError,depthError,magError,magNst,status,locationSource,magSource
2000-10-08T10:16:23.780Z,35.393,-97.98,5,2.6,mblg,7,,,0.38,us,usp000a1f3,2014-11-07T01:11:15.259Z,"3 km W of Union City, Oklahoma",earthquake,,,,,reviewed,us,us

1. Earthquakes per year ​

The raw count first: every event of magnitude 2.5 and up in the area, by year. Later sections test whether this count means the same thing in every year.

ts
import * as pl from "npm:nodejs-polars@0.18.0";
import * as Plot from "npm:@observablehq/plot@0.6.17";
import { parseHTML } from "npm:linkedom@0.18.9";
import { catalog, md } from "./quakes.ts";

const { document } = parseHTML("<html></html>");
const raw = catalog();
const counts = Object.fromEntries(raw.groupBy("year").agg(pl.col("id").count().alias("n")).toRecords().map((r) => [r.year, r.n]));
// Every year, including ones with no events (2001), so the axis is even.
const perYear = Array.from({ length: 27 }, (_, i) => ({ year: 2000 + i, events: counts[2000 + i] ?? 0 }));

Plot.plot({
  document, width: 760, height: 260, marginLeft: 50,
  x: { label: null, tickFormat: "d", ticks: perYear.filter((d) => d.year % 2 === 0).map((d) => d.year) },
  y: { grid: true, label: "events, M2.5+" },
  marks: [Plot.barY(perYear, { x: "year", y: "events", fill: "#c2410c" }), Plot.ruleY([0])],
})

Output of the cell above (image/svg+xml)

ts
const peak = perYear.reduce((a, b) => (b.events > a.events ? b : a));
const at = (y: number) => perYear.find((d) => d.year === y)!.events;
jupyter.md`| | events |
|---|---|
| 2000–2008, per year | ${(perYear.filter((d) => d.year <= 2008).reduce((s, d) => s + d.events, 0) / 9).toFixed(1)} |
| peak (${peak.year}) | ${peak.events.toLocaleString()} |
| 2024 | ${at(2024)} |
| 2025 | ${at(2025)} |
| 2026 (Jan–Oct) | ${at(2026)} |
| 2025 vs peak | ${((at(2025) / peak.events - 1) * 100).toFixed(1)}% |`
events
2000–2008, per year4.1
peak (2015)2,990
202466
202530
2026 (Jan–Oct)37
2025 vs peak-99.0%

Finding (revised after section 3). The boom is over: 2,990 events of M2.5+ in 2015, about 56 in 2025 on a consistent magnitude scale (−98%). The published count of 30, which made 2025 look like the quietest year since 2009, is partly a change of network (section 3); on one scale the rate has been flat since 2023. It is not back to before the boom: 2000–2008 averaged 4.1 a year, so 2025 is still about 14 times that.

2. Is every year measured the same way? ​

The catalog merges solutions from several networks, each with its own magnitude scale. If the network or the scale changed over the years, the yearly counts compare different things. Two columns say: net (who located the event and set the preferred magnitude) and magType (the scale). Also check type, since the catalog holds more than earthquakes.

ts
const byYear = (col: string) =>
  raw.groupBy(["year", col]).agg(pl.col("id").count().alias("n"))
     .pivot("n", { index: "year", on: col }).fillNull("zero").sort("year");
// Pivot columns come out in hash order; fix it so the table is the same on every run.
const sorted = (df: pl.DataFrame) => df.select("year", ...df.columns.filter((c) => c !== "year").sort());

md(byYear("net").filter(pl.col("year").gtEq(2012)))
yearokusismpkansas
20120790
201302590
201401,98531
201502,94644
201602,0540
201701,0330
201806350
2019144910
202012870
202111560
20227860
20235310
20246510
20252640
20263610
ts
md(sorted(byYear("magType")).filter(pl.col("year").gtEq(2008)))
yearmbmb_lgmblgmdmlmwcmwrmww
200800550000
2009003300000
201000537601110
2011001757640101
2012109067020
20130101202270100
20140117001,76701311
20152202002,61901670
2016384001,86201014
20170131008570450
2018160005460262
2019075001470130
20200600126030
20210500112040
2022050078001
2023010052010
2024000062031
2025040026000
2026010035010
ts
// Earthquakes only, from here on.
const quakes = raw.filter(pl.col("type").eq(pl.lit("earthquake")));
md(raw.filter(pl.col("type").neq(pl.lit("earthquake"))).select("time", "mag", "type", "place"), 2)
timemagtypeplace
2020-01-11T13:00:56.700Z2.82other event13 km WNW of Wilson, Oklahoma
2022-07-20T16:43:10.470Z2.51quarry blast9 km WSW of Lebanon, Oklahoma

Finding. The rows are not measured the same way across the years.

  • Who sets the magnitude changed in 2019. Through 2018 almost every preferred solution is USGS's (us). From 2019 it is the Oklahoma Geological Survey's (ok): 144 of 235 events that year, and 26 of 30 in 2025.
  • Both use local magnitude (ml), but each network computes it from its own stations and calibration. The same label does not guarantee the same number for the same quake. These rows alone can't say whether the two agree.
  • mblg and mb_lg are one scale spelled two ways (the spelling changes in 2013); not a real change.
  • Two rows are not earthquakes (a quarry blast in 2022, an "other event" in 2020). Dropped from here on as quakes.

So the drop after 2019 mixes a real change with a change of network. The size of the second is still unknown.

Recorded: ran in the shell, not in this kernel.

sh
$ Q="format=csv&starttime=2019-01-01&endtime=2026-11-01&minlatitude=33.6&maxlatitude=37.5&minlongitude=-100&maxlongitude=-94.4&minmagnitude=1.5&orderby=time-asc"; for net in us ok; do curl -sSfo data/$net-m15.csv "https://earthquake.usgs.gov/fdsnws/event/1/query?$Q&catalog=$net"; done; wc -l data/us-m15.csv data/ok-m15.csv
text
   1884 data/us-m15.csv
   9144 data/ok-m15.csv
  11028 total

3. Do USGS and OGS give the same quake the same magnitude? ​

ComCat keeps each network's own solution, not only the preferred one. Both catalogs from 2019 on, down to M1.5 so events near the 2.5 cut still find their partner (recorded above). An event in both is the same quake when the origin times are within 8 seconds and the epicentres within 15 km. The difference that matters is USGS minus OGS for the same quake: positive means OGS reads lower.

ts
import { match } from "./quakes.ts";

const usCat = catalog("../data/us-m15.csv").filter(pl.col("type").eq(pl.lit("earthquake")));
const okCat = catalog("../data/ok-m15.csv").filter(pl.col("type").eq(pl.lit("earthquake")));
const pairs = pl.DataFrame(match(usCat.toRecords(), okCat.toRecords()))
  .withColumns(pl.col("magA").minus(pl.col("magB")).alias("diff"));

[usCat.height, okCat.height, pairs.height]
text
[ 1881, 8353, 1566 ]
ts
const offset = pairs.groupBy("year").agg(
  pl.col("diff").count().alias("pairs"),
  pl.col("diff").quantile(0.25).alias("q25"),
  pl.col("diff").median().alias("median"),
  pl.col("diff").quantile(0.75).alias("q75"),
).sort("year");
md(offset, 2)
yearpairsq25medianq75
2019293.00-0.23-0.090.06
2020266.00-0.16-0.050.10
2021250.00-0.130.010.19
2022183.00-0.070.060.19
2023169.00-0.040.120.24
2024170.00-0.110.040.19
2025137.000.050.180.32
202698.000.040.180.30
ts
Plot.plot({
  document, width: 760, height: 280, marginLeft: 50, marginRight: 50, inset: 10,
  x: { label: null, tickFormat: "d", ticks: offset.getColumn("year").toArray() },
  y: { grid: true, label: "USGS minus OGS magnitude, same quake", domain: [-0.4, 0.5] },
  marks: [
    Plot.ruleY([0], { stroke: "#888" }),
    Plot.ruleX(offset.toRecords(), { x: "year", y1: "q25", y2: "q75", stroke: "#2563eb", strokeWidth: 10, strokeOpacity: 0.35 }),
    Plot.dot(offset.toRecords(), { x: "year", y: "median", r: 5, fill: "#2563eb" }),
    Plot.text(offset.toRecords(), { x: "year", y: "median", text: (d) => (d.median >= 0 ? "+" : "") + d.median.toFixed(2), dx: 22, fontSize: 11 }),
  ],
})

Output of the cell above (image/svg+xml)

ts
// Events of M2.5+ per year as each network measures them, next to the preferred (merged) count.
const m25 = (df: pl.DataFrame, name: string) =>
  df.filter(pl.col("mag").gtEq(2.5)).groupBy("year").agg(pl.col("id").count().alias(name));
const scales = m25(quakes.filter(pl.col("year").gtEq(2019)), "preferred")
  .join(m25(usCat, "usgs"), { on: "year" })
  .join(m25(okCat, "ogs"), { on: "year" })
  .sort("year");
md(scales)
yearpreferredusgsogs
2019235227144
2020134134127
2021121122115
2022839377
2023547053
2024666865
2025305626
2026376436
ts
// When do OGS solutions start? Events per month in the OGS catalog, 2019.
md(okCat.filter(pl.col("year").eq(2019)).groupBy(pl.col("time").str.slice(0, 7).alias("month")).agg(pl.col("id").count().alias("ogs_events")).sort("month"))
monthogs_events
2019-03107
2019-0454
2019-0514
2019-06170
2019-07259
2019-08201
2019-09143
2019-10144
2019-11164
2019-12137
ts
// One series on USGS's own magnitudes for every year: the preferred count through 2018 (all but 75 of those events are USGS's), the USGS catalog from 2019.
const usgsByYear = Object.fromEntries(scales.toRecords().map((r) => [r.year, r.usgs]));
const series = perYear.filter((d) => d.year >= 2009).flatMap((d) => [
  { year: d.year, events: d.events, series: "preferred (as published)" },
  { year: d.year, events: d.year >= 2019 ? usgsByYear[d.year] : d.events, series: "USGS magnitudes throughout" },
]);
Plot.plot({
  document, width: 760, height: 300, marginLeft: 50, marginRight: 170,
  x: { label: null, tickFormat: "d" },
  y: { type: "log", grid: true, label: "events, M2.5+ (log scale)" },
  color: { domain: ["preferred (as published)", "USGS magnitudes throughout"], range: ["#c2410c", "#2563eb"] },
  marks: [
    Plot.ruleX([2019], { stroke: "#888", strokeDasharray: "3,3" }),
    Plot.text([{ x: 2019, y: 2500 }], { x: "x", y: "y", text: () => "OGS takes over, Mar 2019", textAnchor: "start", dx: 4, fill: "#666" }),
    Plot.line(series, { x: "year", y: "events", stroke: "series", strokeWidth: 2 }),
    Plot.dot(series, { x: "year", y: "events", fill: "series", r: 3 }),
    Plot.text(series.filter((d) => d.year === 2026), { x: "year", y: "events", text: "series", fill: "series", textAnchor: "start", dx: 8 }),
  ],
})

Output of the cell above (image/svg+xml)

Finding. OGS has been reading lower than USGS for the same quake, and the gap is growing. In 2019 OGS read 0.09 higher (median of 293 matched quakes); by 2025–2026 it reads 0.18 lower. Near the 2.5 cut, a few tenths of a magnitude decide whether a quake is counted at all.

On USGS's own magnitudes, 2025 had 56 events of M2.5+, not 30, and 2026 has 64 through October, not 37. The published series makes 2025 look like a new low; on one scale the rate has been flat at about 55–70 a year since 2023. The steep fall from 2015 to 2021 is real on either scale.

(2019 differs the other way only because OGS solutions start in March 2019.)

4. Mainshocks or aftershocks? ​

A swarm year counts every aftershock. Separate them with the standard Gardner–Knopoff (1974) windows: a quake of magnitude m claims smaller quakes within a distance and time that grow with m (M3: 22 km, 12 days; M4: 30 km, 41 days; M5.8 Pawnee: 49 km, 391 days). What is left, the mainshocks, is the rate of independent quakes: the background the question is really about. decluster() in quakes.ts.

ts
import { decluster } from "./quakes.ts";

const dep = decluster(quakes.toRecords());
const labelled = quakes.withColumns(pl.Series("kind", dep.map((d) => (d ? "aftershock" : "mainshock"))));
const kinds = labelled.groupBy("year").agg(
  pl.col("id").count().alias("all"),
  pl.col("kind").eq(pl.lit("mainshock")).sum().alias("mainshocks"),
).withColumns(pl.col("mainshocks").cast(pl.Float64).div(pl.col("all")).mul(100).alias("mainshock_pct")).sort("year");
md(kinds.filter(pl.col("year").gtEq(2009)))
yearallmainshocksmainshock_pct
2009331752
20101413827
20111492215
2012791924
20132596224
20142,0161749
20152,9902017
20162,0541658
20171,03321721
201863520733
201923511750
20201347556
20211216453
2022834757
2023543463
2024663452
2025302377
2026372362
ts
// Each series as a share of its own peak year, so their shapes can be compared.
const k = kinds.filter(pl.col("year").gtEq(2009)).toRecords();
const peakAll = Math.max(...k.map((d) => d.all)), peakMain = Math.max(...k.map((d) => d.mainshocks));
const shapes = k.flatMap((d) => [
  { year: d.year, share: d.all / peakAll, n: d.all, series: "all events" },
  { year: d.year, share: d.mainshocks / peakMain, n: d.mainshocks, series: "mainshocks only" },
]);
Plot.plot({
  document, width: 760, height: 300, marginLeft: 50, marginRight: 120, marginTop: 30,
  x: { label: null, tickFormat: "d" },
  y: { grid: true, percent: true, label: "% of the series' own peak year" },
  color: { domain: ["all events", "mainshocks only"], range: ["#c2410c", "#7c3aed"] },
  marks: [
    Plot.areaY(shapes.filter((d) => d.series === "all events"), { x: "year", y: "share", fill: "#c2410c", fillOpacity: 0.12 }),
    Plot.line(shapes, { x: "year", y: "share", stroke: "series", strokeWidth: 2.5 }),
    Plot.dot(shapes.filter((d) => d.share === 1), { x: "year", y: "share", fill: "series", r: 5 }),
    Plot.text(shapes.filter((d) => d.share === 1 && d.series === "all events"), { x: "year", y: "share", text: (d) => `peak ${d.year}: ${d.n.toLocaleString()}`, dx: -8, textAnchor: "end", fill: "series" }),
    Plot.text(shapes.filter((d) => d.share === 1 && d.series === "mainshocks only"), { x: "year", y: "share", text: (d) => `peak ${d.year}: ${d.n}`, dx: 8, textAnchor: "start", fill: "series" }),
    Plot.text(shapes.filter((d) => d.year === 2026), { x: "year", y: "share", text: "series", fill: "series", textAnchor: "start", dx: 8 }),
  ],
})

Output of the cell above (image/svg+xml)

ts
// Baseline and recent mainshock rates, for the finding below.
const ks = kinds.toRecords();
jupyter.md`| | mainshocks per year |
|---|---|
| 2000–2008 average | ${(ks.filter((d) => d.year <= 2008).reduce((s, d) => s + d.mainshocks, 0) / 9).toFixed(1)} |
${ks.filter((d) => d.year >= 2023).map((d) => `| ${d.year} | ${d.mainshocks} |`).join("\n")}`
mainshocks per year
2000–2008 average3.6
202334
202434
202523
202623

Finding. Most of the boom's count was aftershocks, and independent quakes peaked two years later than the count did.

  • In 2015, only 201 of 2,990 events (7%) were mainshocks. The rest belonged to sequences.
  • Mainshocks peaked in 2017 (217), not 2015. The fall in the raw count from 2015 to 2017 (−65%) was fewer aftershocks; independent quakes were still rising, after the 2015–2016 injection cuts had begun.
  • From 2017 to 2025 mainshocks fell 89% (217 to 23). Since 2023 they run at 23–34 a year, about 6–10 times the 2000–2008 average of 3.6. These use the published magnitudes, so the recent years are slightly low (section 3).

So the background rate came down, but more slowly than the headline count, and it hasn't gone back to where it was.

5. Where are they? ​

Mainshocks only (section 4), so a single aftershock sequence doesn't paint a region, in four eras: before the boom took off, the peak, the fall, and since. Dot size is magnitude. State lines from the US Census atlas (us-atlas).

ts
import * as topo from "npm:topojson-client@3.1.0";
import us from "npm:us-atlas@3.0.1/states-10m.json" with { type: "json" };

const states = topo.feature(us, us.objects.states);
const near = { type: "FeatureCollection", features: states.features.filter((f) => ["Oklahoma", "Kansas", "Texas", "Arkansas", "Missouri"].includes(f.properties.name)) };
const box = { type: "MultiPoint", coordinates: [[-100, 33.6], [-94.4, 37.5], [-100, 37.5], [-94.4, 33.6]] };
const era = (y: number) => (y < 2014 ? "2009–2013" : y < 2017 ? "2014–2016" : y < 2020 ? "2017–2019" : "2020–2026");
const mains = labelled.filter(pl.col("kind").eq(pl.lit("mainshock"))).filter(pl.col("year").gtEq(2009))
  .toRecords().map((d) => ({ ...d, era: era(d.year) }));

Plot.plot({
  document, width: 760, height: 290, marginTop: 24,
  projection: { type: "conic-equal-area", parallels: [34, 37], rotate: [97.2, 0], domain: box, inset: 4 },
  fx: { label: null, domain: ["2009–2013", "2014–2016", "2017–2019", "2020–2026"] },
  r: { range: [1, 9], domain: [2.5, 5.8] },
  marks: [
    Plot.geo(near, { stroke: "#999", strokeWidth: 0.8 }),
    Plot.dot(mains, { x: "lon", y: "lat", r: "mag", fx: "era", fill: "#c2410c", fillOpacity: 0.35, stroke: "#c2410c", strokeWidth: 0.5 }),
    Plot.text(mains, Plot.groupZ({ text: "count" }, { fx: "era", frameAnchor: "bottom-right", dy: -4, dx: -4, text: (d) => d, fill: "#666" })),
  ],
})

Output of the cell above (image/svg+xml)

ts
// How far north: share of mainshocks above 36.5° N (north-central Oklahoma and the Kansas border), by year.
const north = pl.DataFrame(mains).groupBy("year").agg(
  pl.col("id").count().alias("mainshocks"),
  pl.col("lat").mean().alias("mean_lat"),
  pl.col("lat").gtEq(36.5).cast(pl.Float64).mean().mul(100).alias("pct_north_of_36_5"),
).sort("year");
md(north, 1)
yearmainshocksmean_latpct_north_of_36_5
200917.035.50.0
201038.035.12.6
201122.035.10.0
201219.035.45.3
201362.035.621.0
2014174.036.239.7
2015201.036.340.8
2016165.036.238.2
2017217.036.335.9
2018207.036.233.3
2019117.035.927.4
202075.036.232.0
202164.035.825.0
202247.035.931.9
202334.035.817.6
202434.035.811.8
202523.035.921.7
202623.035.30.0

Finding. The activity did move north and come back, partly.

  • Before 2013, almost no mainshocks were north of 36.5° N. From 2014 to 2018, 33–41% were, along the Kansas border (the dense clusters in the 2014–2016 and 2017–2019 maps).
  • Since 2023 that share is 12–22% (0 of 23 so far in 2026), and the average latitude is 35.8–35.9°, south of the 36.2–36.3° of 2014–2018 but still north of 2009–2013 (35.1–35.6°).
  • What remains is a band from north-central Oklahoma down through the center of the state, overlapping the area of the 2009–2013 activity but reaching further north.

6. One town: Stillwater ​

Thomas added the two cells below: the question, and the town with a 40 km radius. Using them as given: every quake within town.radiusKm of the town, and those of M3+, which people in town would usually feel. All events here, aftershocks included, since an aftershock shakes the house just the same.

Thomas: what has this been like in one town? Stillwater is between the Pawnee and Cushing quakes. Count quakes within 40 km of town by year, and the ones people would have felt (M3+).

ts
const town = { name: "Stillwater", lat: 36.1156, lon: -97.0584, radiusKm: 40 };
ts
import { km } from "./quakes.ts";

const local = quakes.toRecords()
  .map((d) => ({ ...d, dist: km(town.lat, town.lon, d.lat, d.lon) }))
  .filter((d) => d.dist <= town.radiusKm);
const localYears = Array.from({ length: 2026 - 2009 + 1 }, (_, i) => 2009 + i).map((year) => {
  const ys = local.filter((d) => d.year === year);
  return { year, events: ys.length, felt_m3: ys.filter((d) => d.mag >= 3).length, largest: ys.length ? Math.max(...ys.map((d) => d.mag)).toFixed(1) : "–" };
});
md(pl.DataFrame(localYears))
yeareventsfelt_m3largest
2009213.0
2010202.6
2011202.8
2012423.3
20132073.7
20146311804.2
20156492094.3
20164641315.8
2017154454.1
201855183.5
20191233.3
20202064.2
2021613.7
2022102.6
20231354.0
2024413.0
2025413.2
2026202.9
ts
// Every quake within the radius, 2009 on: when, how big, how far from town (darker = closer).
const notable = local.filter((d) => d.mag >= 4.5);
Plot.plot({
  document, width: 820, height: 300, marginLeft: 44, marginRight: 30, marginTop: 30,
  x: { type: "utc", label: null },
  y: { grid: true, label: "magnitude", domain: [2.4, 6.1] },
  color: { type: "linear", domain: [0, 40], range: ["#7f1d1d", "#fca5a5"], legend: false },
  marks: [
    Plot.ruleY([3], { stroke: "#2563eb", strokeDasharray: "4,3" }),
    Plot.text(["felt in town (M3+)"], { frameAnchor: "top-left", y: 3, dy: -8, dx: 4, fill: "#2563eb", textAnchor: "start" }),
    Plot.dot(local.filter((d) => d.year >= 2009), { x: (d) => new Date(d.time), y: "mag", r: 2.5, fill: "dist", fillOpacity: 0.7 }),
    Plot.text(notable, { x: (d) => new Date(d.time), y: "mag", text: (d) => `M${d.mag} ${d.place.replace(/, Oklahoma$/, "")}`, dy: -9, fontSize: 11 }),
  ],
})

Output of the cell above (image/svg+xml)

ts
// The felt quakes since 2021, for the finding below.
md(pl.DataFrame(local.filter((d) => d.year >= 2021 && d.mag >= 3).map((d) => ({ date: d.time.slice(0, 10), mag: d.mag, place: d.place, km_from_town: d.dist }))), 1)
datemagplacekm_from_town
2021-06-163.73 km SE of Carney, Oklahoma36.6
2023-01-253.55 km ENE of Meridian, Oklahoma31.7
2023-04-053.24 km ESE of Carney, Oklahoma36.6
2023-04-064.04 km ESE of Carney, Oklahoma36.7
2023-04-063.2Oklahoma36.7
2023-04-073.43 km ESE of Carney, Oklahoma36.8
2024-06-193.011 km E of Red Rock, Oklahoma36.7
2025-05-233.211 km NW of Pawnee, Oklahoma37.3

Finding. In Stillwater the boom was a daily experience and is now an occasional one.

  • 2014–2016: 1,744 quakes within 40 km, 520 of them M3+: a quake people would feel about every two days for three years, plus the two largest in the record, M5.8 Pawnee (Sep 2016, 37 km away) and M5.0 Cushing (Nov 2016).
  • 2021 to October 2026: 8 felt quakes in almost six years, 4 of them in a three-day sequence near Carney in April 2023 (largest M4.0, 37 km away).
  • 2009–2012: three felt quakes in four years.

The town is close to its pre-boom experience, though not at it, and on USGS's scale (section 3) the recent counts would be a little higher.

7. Are the big ones falling as fast as the count? ​

Damage comes from the rare large quakes, so the question is whether the share of large ones changed. Two ways to look:

  1. M4+ per year, next to all events. Few, so read them by era, not by year.
  2. The Gutenberg–Richter slope (b-value). Counts fall about tenfold per magnitude unit; b is that slope. A lower b means a larger share of big quakes. Maximum-likelihood estimate (Aki 1965) above M2.5, with its standard error b/√n. Eras as in section 5.
ts
const eraOf = (y: number) => (y < 2009 ? null : era(y));
const eraStats = ["2009–2013", "2014–2016", "2017–2019", "2020–2026"].map((name) => {
  const m = quakes.toRecords().filter((d) => eraOf(d.year) === name).map((d) => d.mag);
  const mean = m.reduce((s, v) => s + v, 0) / m.length;
  const b = Math.LOG10E / (mean - (2.5 - 0.05));
  return {
    era: name, events: m.length, m4: m.filter((v) => v >= 4).length,
    per_1000: (1000 * m.filter((v) => v >= 4).length) / m.length,
    b, b_se: b / Math.sqrt(m.length),
  };
});
md(pl.DataFrame(eraStats), 2)
eraeventsm4per_1000bb_se
2009–2013661.0010.0015.130.930.04
2014–20167,060.0070.009.921.070.01
2017–20191,903.0016.008.411.160.03
2020–2026525.009.0017.141.100.05
ts
// Gutenberg–Richter: quakes per year at or above each magnitude, one line per era.
const eraYears = { "2009–2013": 5, "2014–2016": 3, "2017–2019": 3, "2020–2026": 6 + 10 / 12 };
const grid = Array.from({ length: 34 }, (_, i) => +(2.5 + i * 0.1).toFixed(1));
const gr = Object.entries(eraYears).flatMap(([name, years]) => {
  const m = quakes.toRecords().filter((d) => eraOf(d.year) === name).map((d) => d.mag);
  return grid.map((M) => ({ era: name, M, perYear: m.filter((v) => v >= M - 1e-9).length / years })).filter((d) => d.perYear > 0);
});
Plot.plot({
  document, width: 760, height: 340, marginLeft: 56, marginRight: 90,
  x: { label: "magnitude M", domain: [2.5, 6] },
  y: { type: "log", grid: true, label: "quakes per year at or above M", tickFormat: "~g", domain: [0.1, 3000] },
  color: { domain: Object.keys(eraYears), range: ["#94a3b8", "#c2410c", "#d97706", "#2563eb"] },
  marks: [
    Plot.line(gr, { x: "M", y: "perYear", stroke: "era", strokeWidth: 2, curve: "step-after" }),
    Plot.text(gr, Plot.selectLast({ x: "M", y: "perYear", z: "era", text: "era", fill: "era", textAnchor: "start", dx: 6, dy: -6 })),
    Plot.ruleX([4, 5], { stroke: "#aaa", strokeDasharray: "3,3" }),
  ],
})

Output of the cell above (image/svg+xml)

ts
// Every M4.5+ quake, for the finding below.
md(quakes.filter(pl.col("mag").gtEq(4.5)).select(pl.col("time").str.slice(0, 10).alias("date"), "mag", "place").sort("date"), 2)
datemagplace
2011-11-054.808 km SE of Sparks, Oklahoma
2011-11-065.708 km NW of Prague, Oklahoma
2011-11-084.809 km SSE of Sparks, Oklahoma
2013-12-074.508 km SW of Arcadia, Oklahoma
2014-11-124.904 km ENE of Milan, Kansas
2015-07-274.504 km NNE of Crescent, Oklahoma
2015-11-194.704 km SW of Lambert, Oklahoma
2015-11-304.708 km E of Nescatunga, Oklahoma
2016-01-074.7016 km SE of Waynoka, Oklahoma
2016-02-135.1018 km SE of Waynoka, Oklahoma
2016-09-035.8014 km NW of Pawnee, Oklahoma
2016-11-075.003 km W of Cushing, Oklahoma
2018-04-074.605 km WNW of Lucien, Oklahoma
2018-04-094.608 km NNE of Marshall, Oklahoma
2022-01-314.507 km NW of Medford, Oklahoma
2024-02-035.068 km NW of Prague, Oklahoma

Finding. The risk of a large quake fell about as fast as the count: not faster, and not measurably slower.

  • The whole curve moved down. The mix of sizes did shift some: b rose from 1.07 at the peak to 1.16 in 2017–2019 (fewer large quakes per event as the rate fell) and is 1.10 since 2020, the same as the peak within its error. 2009–2013 had the largest share of big quakes (b 0.93). The b estimates assume magnitudes rounded to 0.1; OGS reports two decimals, which would lower the 2020–2026 b by up to 0.1 (to about 1.0), so the recent mix may hold slightly more large quakes than the peak's. M4+ ran at 23 a year in 2014–2016, 5.3 in 2017–2019 and 1.3 since 2020, below 2009–2013 (2 a year).
  • 2020–2026 has more M4+ per 1,000 events (17) than the peak (10), but that is 9 quakes. A Poisson 95% range for 9 is 4 to 17, or 8 to 33 per 1,000, which includes the peak's rate. OGS's lower magnitudes (section 3) also shrink the denominator.
  • Pawnee (M5.8, Sep 2016) came when the raw count was falling but mainshocks were still near their peak (section 4). That is the series to watch for large-quake risk, not the headline count.
  • Large quakes have not stopped: M5.06 near Prague in February 2024 (table above).

Recorded: ran in the shell, not in this kernel.

sh
$ mkdir -p data/occ && for y in $(seq 2011 2025); do curl -sSfLo data/occ/$y.xlsx "https://oklahoma.gov/content/dam/ok/en/occ/documents/og/ogdatafiles/$y-uic-injection-volumes.xlsx"; done; ls data/occ | wc -l; du -sh data/occ
text
15
51M	data/occ

8. Does injection line up with the quakes? ​

The catalog alone can't show cause. The Oklahoma Corporation Commission publishes every disposal well's monthly injection volume (Form 1012), 2011–2025 (recorded above). Disposal into the deep Arbuckle group, close to the basement faults, is the injection linked to the quakes; the OCC's 2015–2016 directives cut Arbuckle volumes in the quake areas. Wells inside the study area only. This can show whether timing and place line up, not prove cause.

ts
import { injection } from "./quakes.ts";

const inj = (await injection(Array.from({ length: 15 }, (_, i) => 2011 + i)))
  .filter(pl.col("lat").gtEq(33.6).and(pl.col("lat").ltEq(37.5)).and(pl.col("lon").gtEq(-100)).and(pl.col("lon").ltEq(-94.4)));
[inj.height, inj.select(pl.col("api").nUnique()).row(0)[0]]
text
[ 1284694, 11378 ]
ts
// Sanity check before summing: the largest single well-month volumes.
md(inj.sort("barrels", true).head(8).select("year", "month", "api", "formation", "barrels"))
yearmonthapiformationbarrels
202023507324320OSWEGO10,009,670
202083507324320OSWEGO8,935,046
202423504322287LOWER MORROW SAND8,300,091
202013513325025ARBUCKLE5,158,248
2020123501520306PONTOTOC4,876,145
201813513726796LOCO SANDLOCO SD
201823513726796LOCO SANDLOCO SD
201833513726796LOCO SANDLOCO SD
ts
// The numbers behind the cut-off below.
const q999 = inj.filter(pl.col("arbuckle")).select(pl.col("barrels").quantile(0.999)).row(0)[0];
jupyter.md`| | |
|---|---|
| well-month rows in the area | ${inj.height.toLocaleString()} |
| Arbuckle well-months, 99.9th percentile | ${Math.round(q999).toLocaleString()} barrels |
| well-months over 2 million barrels | ${inj.filter(pl.col("barrels").gt(2_000_000)).height} |`
well-month rows in the area1,284,694
Arbuckle well-months, 99.9th percentile1,107,043 barrels
well-months over 2 million barrels43

Data check. Some reports are not believable: one shallow Oswego well reports 10 million barrels in a month (330,000 a day), and another repeats 4.0 million every month of 2018. The busiest Arbuckle wells top out near 1.1 million a month (99.9th percentile). Well-months over 2 million barrels are dropped as entry errors: 43 of 1.28 million rows.

ts
const injOk = inj.filter(pl.col("barrels").ltEq(2_000_000));
const injYear = injOk.groupBy("year").agg(
  pl.col("barrels").filter(pl.col("arbuckle")).sum().div(1e6).alias("arbuckle_mbbl"),
  pl.col("barrels").filter(pl.col("arbuckle").not()).sum().div(1e6).alias("other_mbbl"),
  pl.col("api").filter(pl.col("arbuckle")).nUnique().alias("arbuckle_wells"),
).sort("year");
md(injYear)
yeararbuckle_mbblother_mbblarbuckle_wells
20112521,604290
20124481,646424
20136231,703531
20148291,778614
20157711,590583
20165981,515587
20175011,609556
20184401,677538
20193961,565519
20202971,387459
20212821,410423
20222931,556408
20232721,573402
20242291,028368
20252161,563371
ts
// Arbuckle injection, injection into everything else, and mainshocks, each as % of its own peak year.
const mainsByYear = Object.fromEntries(kinds.toRecords().map((r) => [r.year, r.mainshocks]));
const iy = injYear.toRecords();
const pk = (k: string) => Math.max(...iy.map((d) => d[k]));
const peakM = Math.max(...iy.map((d) => mainsByYear[d.year]));
const timing = iy.flatMap((d) => [
  { year: d.year, share: d.arbuckle_mbbl / pk("arbuckle_mbbl"), series: "Arbuckle injection" },
  { year: d.year, share: d.other_mbbl / pk("other_mbbl"), series: "other formations" },
  { year: d.year, share: mainsByYear[d.year] / peakM, series: "mainshocks" },
]);
Plot.plot({
  document, width: 760, height: 320, marginLeft: 50, marginRight: 140, marginTop: 24,
  x: { label: null, tickFormat: "d" },
  y: { grid: true, percent: true, label: "% of own peak year", domain: [0, 100] },
  color: { domain: ["Arbuckle injection", "other formations", "mainshocks"], range: ["#0f766e", "#94a3b8", "#7c3aed"] },
  marks: [
    Plot.rectY([{ x1: 2015.3, x2: 2016.2 }], { x1: "x1", x2: "x2", y1: 0, y2: 1, fill: "#fde68a", fillOpacity: 0.5 }),
    Plot.text(["OCC Arbuckle directives"], { x: 2015.75, y: 1, dy: -8, fill: "#92400e", fontSize: 11 }),
    Plot.line(timing, { x: "year", y: "share", stroke: "series", strokeWidth: 2.5 }),
    Plot.dot(timing, { x: "year", y: "share", fill: "series", r: 3 }),
    Plot.text(timing.filter((d) => d.year === 2025), { x: "year", y: "share", text: "series", fill: "series", textAnchor: "start", dx: 8 }),
  ],
})

Output of the cell above (image/svg+xml)

ts
// Same comparison by place: north of 36.5° N (the Kansas-border cluster in section 5) and south of it.
const side = (lat: number) => (lat >= 36.5 ? "north" : "south");
const arbBySide = injOk.filter(pl.col("arbuckle")).withColumns(pl.col("lat").gtEq(36.5).alias("north"))
  .groupBy(["year", "north"]).agg(pl.col("barrels").sum().div(1e6).alias("mbbl")).toRecords();
const placeRows = [2013, 2014, 2015, 2016, 2017, 2018, 2019, 2021, 2023, 2025].map((year) => ({
  year,
  north_arbuckle_mbbl: arbBySide.find((d) => d.year === year && d.north)?.mbbl ?? 0,
  north_mainshocks: mains.filter((d) => d.year === year && side(d.lat) === "north").length,
  south_arbuckle_mbbl: arbBySide.find((d) => d.year === year && !d.north)?.mbbl ?? 0,
  south_mainshocks: mains.filter((d) => d.year === year && side(d.lat) === "south").length,
}));
md(pl.DataFrame(placeRows))
yearnorth_arbuckle_mbblnorth_mainshockssouth_arbuckle_mbblsouth_mainshocks
20134091321449
201449869332105
201547982292119
201634763251102
201727678225139
201822169219138
20191873220985
20211191616348
2023105616728
202578513918
ts
// Wells with a location reporting non-Arbuckle volumes, by year: checks the 2024 dip.
md(injOk.filter(pl.col("arbuckle").not()).groupBy("year").agg(pl.col("api").nUnique().alias("located_wells"), pl.col("barrels").sum().div(1e6).alias("mbbl")).sort("year").filter(pl.col("year").gtEq(2020)))
yearlocated_wellsmbbl
20206,9721,387
20216,8271,410
20226,8671,556
20236,7611,573
20245,0301,028
20256,4071,563

Finding. Timing and place line up with Arbuckle injection, and not with injection in general.

  • Arbuckle volumes in the area tripled from 2011 to 2014 (252 to 829 million barrels), then fell every year to 216 million in 2025 (−74%). Injection into other formations stayed at 1.4–1.7 billion barrels most years, with no trend; quakes did not follow it.
  • Mainshocks rose behind the Arbuckle rise and peaked in 2017, three years after Arbuckle volumes peaked (2014). They have fallen with them since 2019, and a little faster.
  • By place, 2015 to 2025: north of 36.5° N, where the cuts were deepest, Arbuckle fell 84% (479 to 78 million barrels) and mainshocks 94% (82 to 5). South of it, Arbuckle fell 52% (292 to 139) and mainshocks 85% (119 to 18). North's share of quakes shrank as its injection did (section 5).

What this does not show: mechanism. This notebook shows coincidence in time and place. The physical case, that pressure from Arbuckle disposal spreads to basement faults with a delay, is made elsewhere, not tested here: Keranen et al. 2014, Science 345:448; Langenbruch & Zoback 2016, Science Advances 2:e1601542.

Open: the 2024 dip in other-formation volume (1.03 billion) is a reporting gap: about 1,700 fewer wells in the 2024 file have a location (5,030, against 6,400–6,900 in the years around it). 2024 Arbuckle may be slightly low for the same reason.

MIT license.