Code
chart_hard_hit(batting)
Stats, refreshed the morning after every game
August 23, 2026
Last refreshed: August 23, 2026 at 6:26 AM PDT
This site treats a baseball season like a bioinformatics experiment: players are samples, their offensive stats are features, and we look for the hitters and pitchers behaving differently from the rest. The numbers come from FanGraphs and the MLB Stats API and are re-rendered automatically the morning after each Mariners game.
Read the full differential analysis →
New to the sabermetric alphabet soup? Here’s what every stat on this page means.
Hard contact vs results. Does hitting the ball hard translate into production? The dashed line is the league-wide trend; the grey band is its 95% confidence interval (how sure we are of that average relationship — not a per-player normal range). Players well above the line are out-producing their contact quality (lucky or crafty); well below, under-producing (unlucky or poor launch angles).
BABIP — luck on balls in play. League average is ~.300; sustained extremes are rare, so red bars tend to cool off and navy bars tend to warm up.
Pitching: ERA vs the peripherals. Negative bars mean a pitcher has allowed fewer runs than his strikeouts, walks, and contact quality would predict.
The biggest gaps between what hitters have actually produced (wOBA) and what their batted-ball quality says they should have produced (xwOBA).
glance <- batting |>
dplyr::transmute(
Player = player,
Pos = pos,
PA = pa,
wOBA = round(woba, 3),
xwOBA = round(xwoba, 3),
Diff = round(woba - xwoba, 3)
) |>
dplyr::arrange(dplyr::desc(Diff))
over <- dplyr::slice_max(glance, Diff, n = 5, with_ties = FALSE)
under <- dplyr::slice_min(glance, Diff, n = 5, with_ties = FALSE) |>
dplyr::arrange(Diff)Running hot — over-performing
| Player | Pos | PA | wOBA | xwOBA | Diff |
|---|---|---|---|---|---|
| Brendan Donovan | 3B | 159 | 0.349 | 0.314 | 0.034 |
| Miles Mastrobuoni | UNK | 30 | 0.202 | 0.184 | 0.018 |
| Randy Arozarena | LF | 526 | 0.366 | 0.350 | 0.016 |
| Colt Emerson | UNK | 241 | 0.256 | 0.248 | 0.008 |
| Victor Robles | RF | 141 | 0.285 | 0.281 | 0.004 |
Running cold — under-performing
| Player | Pos | PA | wOBA | xwOBA | Diff |
|---|---|---|---|---|---|
| Patrick Wisdom | UNK | 44 | 0.172 | 0.265 | -0.093 |
| Connor Joe | UNK | 45 | 0.274 | 0.337 | -0.063 |
| Rob Refsnyder | UNK | 129 | 0.207 | 0.254 | -0.047 |
| Julio Rodríguez | CF | 517 | 0.316 | 0.348 | -0.031 |
| Jhonny Pereda | C | 79 | 0.299 | 0.330 | -0.031 |
Pitching staff — ERA − xERA
| Player | Role | IP | ERA | xERA | ERA − xERA |
|---|---|---|---|---|---|
| Matt Brash | RP | 16.2 | 0.54 | 4.19 | -3.65 |
| Cole Wilcox | SP | 27.0 | 4.00 | 5.44 | -1.44 |
| Gabe Speier | SP | 34.0 | 2.65 | 4.01 | -1.37 |
| Eduard Bazardo | SP | 58.0 | 3.10 | 4.42 | -1.32 |
| Nick Davila | SP | 26.1 | 4.10 | 4.65 | -0.54 |
| Emerson Hancock | SP | 133.2 | 3.37 | 3.87 | -0.50 |
| Logan Gilbert | SP | 150.1 | 3.35 | 3.69 | -0.33 |
| José A. Ferrer | SP | 52.2 | 3.59 | 3.56 | 0.03 |
| Cooper Criswell | SP | 30.2 | 3.52 | 3.38 | 0.14 |
| George Kirby | SP | 140.0 | 4.18 | 4.00 | 0.18 |
| Bryan Woo | SP | 138.1 | 4.10 | 3.56 | 0.54 |
| Bryce Miller | SP | 89.2 | 3.71 | 3.04 | 0.68 |
| Michael Rucker | SP | 29.1 | 4.30 | 3.24 | 1.05 |
| Andrés Muñoz | SP | 47.1 | 4.37 | 3.28 | 1.09 |
Want the pitch-by-pitch Statcast detail behind these numbers? Each player links to their Baseball Savant page.
How this updates: a scheduled GitHub Actions workflow checks each morning whether the Mariners finished a game the night before. If so, it re-fetches the stats, re-renders this site, and deploys it to Vercel. Off-days are skipped, and the synthetic fallback is never published.
---
title: "Seattle Mariners 2026"
subtitle: "Stats, refreshed the morning after every game"
date: today
page-layout: full
code-fold: true
---
```{r setup, include=FALSE}
source(here::here("R", "00_setup.R"))
source(here::here("R", "charts.R"))
# In CI the data is fetched once before the render. When this page is rendered
# standalone the .rds files may not exist yet — fetch then.
if (!file.exists(here("data", "batting_2026.rds"))) {
source(here::here("R", "01_fetch_data.R"))
}
batting <- readRDS(here("data", "batting_2026.rds"))
pitching <- readRDS(here("data", "pitching_2026.rds"))
refreshed <- format(Sys.time(), tz = "America/Los_Angeles",
format = "%B %e, %Y at %l:%M %p %Z")
# Note when the underlying numbers came from the synthetic fallback rather than
# the live feed, so the page is never silently wrong.
src <- tryCatch(readLines(here("data", "data_source.txt"), n = 1),
error = function(e) "unknown")
```
::: {.refreshed-badge}
Last refreshed: `r refreshed`
:::
```{r live-warning, echo=FALSE, results='asis'}
if (!identical(src, "live")) {
cat("> ⚠️ **Heads up:** these numbers are from the synthetic fallback — the live",
"FanGraphs / MLB Stats API feed was unavailable at render time.\n")
}
```
This site treats a baseball season like a bioinformatics experiment: players are
*samples*, their offensive stats are *features*, and we look for the hitters and
pitchers behaving differently from the rest. The numbers come from FanGraphs and
the MLB Stats API and are re-rendered automatically the morning after each
Mariners game.
[**Read the full differential analysis →**](/analysis)
## Stat glossary
New to the sabermetric alphabet soup? Here's what every stat on this page means.
::: {.stat-glossary}
<dl>
<dt>wOBA — weighted on-base average</dt>
<dd>One number that captures a hitter's total offensive value. Instead of treating
every time on base equally (like OBP does), it weights each outcome — walk, single,
double, triple, home run — by how much that event is actually worth in run-scoring
terms. It's scaled to look like OBP, so league average is about **.320**, a good
hitter is around **.370**, and an elite one pushes **.400+**. Higher is better.</dd>
<dt>xwOBA — expected weighted on-base average</dt>
<dd>What a hitter <em>should</em> have produced based on <strong>how</strong> they hit
the ball, not whether it found a glove. Statcast feeds the exit velocity and launch
angle of every batted ball (plus sprint speed on grounders) into a model that returns
the wOBA a typical hitter earns on contact like that. Because it strips out luck,
defense, and ballpark, comparing it to actual wOBA is revealing: <strong>wOBA − xwOBA
above zero</strong> means a hitter is out-producing their contact quality (riding hot,
likely to cool off), while <strong>below zero</strong> flags strong contact that hasn't
paid off yet (an upside regression candidate).</dd>
<dt>BABIP — batting average on balls in play</dt>
<dd>Batting average counting only balls put in play — home runs and strikeouts are
excluded. League average sits near <strong>.300</strong>, and sustained extremes are
rare, so a sky-high BABIP usually signals good luck or favorable sequencing that
regresses, and a very low one signals the opposite.</dd>
<dt>ERA / xERA — earned run average vs. expected</dt>
<dd><strong>ERA</strong> is the earned runs a pitcher allows per nine innings — the
classic bottom-line number. <strong>xERA</strong> is the contact-quality-based
expectation, derived the same way as xwOBA. <strong>ERA − xERA</strong> separates skill
from luck: a negative gap means a pitcher has allowed fewer runs than their stuff
predicts (possibly lucky), a positive gap the reverse.</dd>
<dt>Hard-hit %, exit velocity & launch angle — the batted-ball inputs</dt>
<dd><strong>Exit velocity</strong> is how fast the ball leaves the bat (mph);
<strong>hard-hit %</strong> is the share of batted balls hit at 95 mph or more — a proxy
for raw contact quality. <strong>Launch angle</strong> is the vertical angle off the
bat: too low is a grounder, too high is a pop-up, and the productive line-drive window
sits in between. Together these are the ingredients xwOBA is built from.</dd>
<dt>PA / IP — sample size</dt>
<dd><strong>Plate appearances</strong> (hitters) and <strong>innings pitched</strong>
(pitchers). They aren't performance stats — they tell you how much to trust the rest.
Early in the season small samples swing wildly, so a gaudy line on 30 PA means far
less than the same line on 300.</dd>
</dl>
:::
## Who's hot, who's not
**Hard contact vs results.** Does hitting the ball hard translate into production?
The dashed line is the league-wide trend; the grey band is its 95% confidence
interval (how sure we are of that *average* relationship — not a per-player normal
range). Players well *above* the line are out-producing their contact quality
(lucky or crafty); well *below*, under-producing (unlucky or poor launch angles).
```{r hard-hit, fig.width = 9, fig.height = 6}
chart_hard_hit(batting)
```
**BABIP — luck on balls in play.** League average is ~.300; sustained extremes are
rare, so red bars tend to cool off and navy bars tend to warm up.
```{r babip, fig.width = 9, fig.height = 5}
chart_babip(batting)
```
**Pitching: ERA vs the peripherals.** Negative bars mean a pitcher has allowed
fewer runs than his strikeouts, walks, and contact quality would predict.
```{r pitching-luck, fig.width = 9, fig.height = 5}
chart_pitching_luck(pitching)
```
## The numbers
The biggest gaps between what hitters have actually produced (**wOBA**) and what
their batted-ball quality says they *should* have produced (**xwOBA**).
```{r glance}
glance <- batting |>
dplyr::transmute(
Player = player,
Pos = pos,
PA = pa,
wOBA = round(woba, 3),
xwOBA = round(xwoba, 3),
Diff = round(woba - xwoba, 3)
) |>
dplyr::arrange(dplyr::desc(Diff))
over <- dplyr::slice_max(glance, Diff, n = 5, with_ties = FALSE)
under <- dplyr::slice_min(glance, Diff, n = 5, with_ties = FALSE) |>
dplyr::arrange(Diff)
```
::: {layout-ncol="2"}
::: {}
**Running hot — over-performing**
```{r over}
knitr::kable(over, row.names = FALSE)
```
:::
::: {}
**Running cold — under-performing**
```{r under}
knitr::kable(under, row.names = FALSE)
```
:::
:::
**Pitching staff — ERA − xERA**
```{r pitching-glance}
pitching |>
dplyr::transmute(
Player = player,
Role = role,
IP = ip,
ERA = round(era, 2),
xERA = round(xera, 2),
`ERA − xERA` = round(era_minus_xera, 2)
) |>
dplyr::arrange(`ERA − xERA`) |>
knitr::kable(row.names = FALSE)
```
## Player pages on Baseball Savant
Want the pitch-by-pitch Statcast detail behind these numbers? Each player links to
their [Baseball Savant](https://baseballsavant.mlb.com) page.
```{r savant-helper, include=FALSE}
# Emit a markdown bullet list of Savant links for a data frame that has
# `player` + `mlbam_id`. Players without an id are skipped.
savant_bullets <- function(df, kind) {
if (!"mlbam_id" %in% names(df)) return(invisible())
urls <- savant_url(df$player, df$mlbam_id, kind)
ok <- !is.na(urls)
cat(paste0("- [", df$player[ok], "](", urls[ok], ")"), sep = "\n")
cat("\n")
}
```
::: {layout-ncol="2"}
::: {.savant-list}
### Hitters
```{r savant-hitters, results='asis'}
batting |>
dplyr::arrange(dplyr::desc(pa)) |>
savant_bullets("hitting")
```
:::
::: {.savant-list}
### Pitchers
```{r savant-pitchers, results='asis'}
pitching |>
dplyr::arrange(dplyr::desc(ip)) |>
savant_bullets("pitching")
```
:::
:::
---
*How this updates: a scheduled GitHub Actions workflow checks each morning whether
the Mariners finished a game the night before. If so, it re-fetches the stats,
re-renders this site, and deploys it to Vercel. Off-days are skipped, and the
synthetic fallback is never published.*