Dimitri Masson
  • Research
  • Publications
  • Projects
  • Blog
  • About
  • CV

On this page

  • Unknown Warming: Bayonne
  • Technical details
    • Getting the data
  • Computing the daily normal
  • Anomalies
  • Grouping the anomalies by year
  • Smoothing the data with a Gaussian blur
  • Colouring the years
    • Limits of a station series

Unknown Warming, Bayonne edition: daily temperature anomalies from a ground station

A Joy Division inspired plot of the daily temperature anomalies measured at Biarritz-Anglet (Bayonne), from Météo-France station data.

Author

Dimitri Masson

Published

October 18, 2026

Abstract

This is the local, ground-measured companion of Unknown Warming. Instead of the global ERA5 series, it uses the daily temperatures recorded at the Biarritz-Anglet weather station, the Météo-France station closest to Bayonne (the airport, about 5 km from the city centre).

Each line is one year. Its height shows how many days had a given temperature anomaly, that is the difference between the temperature of the day and the normal temperature of the same calendar day over a reference period. If the climate were stable, all the lines would pile up on the same hump; the drift of the whole distribution to the right is the local warming.

Two differences with the global plot: the anomalies are local (so each day is compared to the normal of its own calendar day, and the spread is much larger than for a global mean), and they are measured against 1991–2020 or 1961–1990, not against the 1850–1900 pre-industrial baseline, which cannot be derived from a single station.

Unknown Warming: Bayonne

md`**${placeName}** · ${sourceNote} · ${years.length} complete years (${minYear}–${maxYear}) · mean anomaly ${yearStats[0].mean.toFixed(2)} °C in ${minYear} → ${yearStats[yearStats.length - 1].mean.toFixed(2)} °C in ${maxYear}`
  • Plot
  • table
// One area per year; the height of each area is the share of days at a given anomaly,
// and every year is shifted up by one row so that the most recent year ends up at the bottom.
chart = Plot.plot({
  marks: [
    Plot.ruleX([0], {stroke: "currentColor", strokeOpacity: 0.35, strokeDasharray: "3,4"}),
    Plot.areaY(joyData,
      { x: "anomaly"
      , y1: fromBottom ? "reference" : 0
      , y2: "height"
      , z: "year"
      , fillOpacity: opacity
      , stroke: "white"
      , fill: colorMode === "Single color" ? color : "yearMean" }),
    showTrend
      ? Plot.line(yearStats, {x: "mean", y: "reference", stroke: "#d62728", strokeWidth: 1.6})
      : null,
    Plot.axisY(labelledYears.map(d => d.reference), {
      label: "Year",
      text: labelledYears.map(d => String(d.year)),
      grid: true
    }),
    Plot.axisX({
      label: "Daily temperature anomaly (°C)",
      ticks: xTicks,
      tickFormat: d => (d > 0 ? "+" : "") + d
    })
  ],
  x: {label: "Daily temperature anomaly (°C)", domain: [-limit, limit]},
  y: {label: "Year"},
  color: colorScale,
  width: 800,
  height: Math.round((years.length - 1 + ridgeHeight) * 12) + 70, // 12 px per year
  title: `${placeName}: daily temperature anomalies, ${minYear}–${maxYear}`,
  subtitle: `Anomaly relative to the ${baseline} daily normal · ${sourceNote}`
})
Figure 1: Joy Division plot of daily temperature anomalies
// Colour key, only needed when lines are coloured by the yearly mean
legend = colorMode === "Single color"
  ? html``
  : Plot.legend({color: colorScale, width: 320, marginLeft: 10})
viewof opacity = Inputs.range([0, 1], {step: 0.1, value: 0.9, label: "Opacity"})
viewof ridgeHeight = Inputs.range([1, 8], {step: 0.5, value: 4, label: "Ridge height"})
viewof smoothFactor = Inputs.range([0, 4], {step: 1, value: 2, label: "Smooth factor"})
viewof fromBottom = Inputs.toggle({label: "wave only", value: false})
viewof showTrend = Inputs.toggle({label: "yearly mean", value: true})
viewof colorMode = Inputs.select(["Single color", "RdYlBu by yearly mean"], {label: "Colour"})
viewof color = Inputs.color({label: "Single color", value: "#004055"})
viewof maxLightness = Inputs.range([40, 100], {step: 1, value: 65, label: "Max lightness"})
viewof baseline = Inputs.select(["1991-2020", "1961-1990"], {label: "Reference period"})
viewof normalWindow = Inputs.range([0, 15], {step: 1, value: 3, label: "Normal window (± days)"})

DOM.download(() => serialize(chart.querySelector("svg")), undefined, "Download the SVG")
Inputs.table(yearStats.map(d => ({year: d.year, days: d.n, "mean anomaly (°C)": Math.round(d.mean * 100) / 100})))

Technical details

The processing is roughly divided into six steps:

  1. extracting the station series from the Météo-France open data (a Deno script)
  2. computing the normal temperature of each calendar day over the reference period
  3. subtracting it from every day to get the anomalies
  4. counting the anomalies per year
  5. applying a Gaussian blur to smooth out the graph
  6. offsetting the curve of each year to produce the Joy Division effect

Getting the data

Météo-France publishes its climatological data under the Etalab Open Licence 2.0 on data.gouv.fr. The daily files are distributed by département and by period, as compressed CSV files with all the stations of the département and many parameters (precipitation, temperature, wind). The file names change every year, and each is a few hundred MB once unzipped, so the extraction is done once, offline, by fetch-bayonne.ts:

deno run --allow-net --allow-write fetch-bayonne.ts          # writes bayonne_daily_temperature.csv
deno run --allow-net fetch-bayonne.ts --list                 # check the candidate stations first

The script asks the data.gouv.fr API for the département 64 files, streams them, keeps the stations whose name matches Biarritz, Bayonne or Anglet, prints a summary of each (first and last day, number of complete years) and writes the chosen one to a small CSV. Use --station <NUM_POSTE> to force another station, and --variable tm to use the mean of the hourly temperatures instead of the default (TN+TX)/2.

NoteDaily mean definition

By default the daily mean is (TN+TX)/2, which exists whenever both extremes were recorded, and is therefore the most consistent definition across the whole record. The mean of the hourly temperatures (TM) is available for a shorter period, and the two differ slightly. Mixing them in the same series would create artificial steps in the anomalies.

The CSV starts with comment lines (starting with #) describing the source and the station, which are removed before parsing, as in the original plot.

rawText = FileAttachment("./bayonne_daily_temperature.csv").text()

stationMeta = {
  const meta = {};
  for (const line of rawText.split("\n")) {
    const m = /^#\s*(\w+):\s*(.*)$/.exec(line);
    if (m) meta[m[1]] = m[2];
  }
  return meta;
}

rawData = d3.csvParse(
  rawText.split("\n").filter(l => l.trim() && !l.startsWith("#")).join("\n"),
  d => ({
    year: +d.date.slice(0, 4),
    slot: slotOf(d.date),
    t: d.tmean === "" ? NaN : +d.tmean
  })
).filter(d => Number.isFinite(d.t))

placeName = "Bayonne"
sourceNote = `Météo-France, station ${stationMeta.station ?? "Biarritz-Anglet"}`

Inputs.table(rawData)
1
Filter out the comment lines from the CSV content.
2
The year is read from the ISO date string, to avoid time zone shifts when converting to a Date.
(a)
(b)
(c)
(d)
(e)
(f)
Figure 2: Raw data

Computing the daily normal

Unlike the global series, a single place has a strong seasonal cycle, so an “anomaly” must be measured against the normal temperature of that calendar day. For every day of the year we average the temperature over the reference period (1991–2020 by default). The window is a few days wide (±3 by default, adjustable in the sidebar) so that the normal is not a noisy single-day average, but narrow enough not to flatten the seasonal peaks.

Dates are mapped to a “slot” in a leap-year calendar, so that 29 February has a slot of its own and every later date keeps the same slot in all years.

CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]
slotOf = iso => CUM[+iso.slice(5, 7) - 1] + +iso.slice(8, 10) - 1

climatology = {
  const [b0, b1] = baseline.split("-").map(Number);
  const sum = new Float64Array(366), n = new Float64Array(366);
  for (const d of rawData) {
    if (d.year >= b0 && d.year <= b1) { sum[d.slot] += d.t; n[d.slot]++; }
  }
  const raw = Array.from(sum, (s, i) => n[i] ? s / n[i] : NaN);
  return raw.map((_, i) => {                       // circular moving average, ±normalWindow days
    let a = 0, c = 0;
    for (let k = -normalWindow; k <= normalWindow; k++) {
      const v = raw[(i + k + 366) % 366];
      if (!Number.isNaN(v)) { a += v; c++; }
    }
    return c ? a / c : NaN;
  });
}
1
Offsets of the first day of each month in a leap year.

Anomalies

The anomaly of a day is its temperature minus the normal of its slot. Only complete years (at least 330 days with data) are kept, which also drops the current, unfinished year.

anomalies = rawData
  .map(d => ({year: d.year, slot: d.slot, anomaly: d.t - climatology[d.slot]}))
  .filter(d => Number.isFinite(d.anomaly))

byYear = d3.group(anomalies, d => d.year)
years = [...byYear].filter(([y, v]) => v.length >= 330).map(([y]) => y).sort(d3.ascending)
minYear = years[0]
maxYear = years[years.length - 1]

yearStats = years.map(y => ({
  year: y,
  n: byYear.get(y).length,
  mean: d3.mean(byYear.get(y), d => d.anomaly),
  reference: maxYear - y
}))
yearStatsMap = new Map(yearStats.map(d => [d.year, d]))
labelledYears = yearStats.filter(d => d.year % 5 === 0 || d.year === maxYear)
1
The most recent complete year is drawn at the bottom.
2
Vertical position (in rows) of the baseline of each year.

Grouping the anomalies by year

For each year we count the days falling in each bin of the anomaly. The bins are 0.5 °C wide, and the horizontal range adapts to the climate of the place: a continental winter swings much more than the Basque coast, so the range is set from the 99.9th percentile of the absolute anomalies, rounded up to a multiple of 4 °C (between ±8 and ±32 °C).

binWidth = 0.5
limit = {
  const keep = new Set(years);
  const a = anomalies.filter(d => keep.has(d.year)).map(d => Math.abs(d.anomaly)).sort(d3.ascending);
  return Math.min(32, Math.max(8, Math.ceil(d3.quantileSorted(a, 0.999) / 4) * 4));
}
length = Math.round(2 * limit / binWidth)
indexF = x => Math.max(0, Math.min(length - 1, Math.floor((x + limit) / binWidth)))
indexF_1 = i => -limit + (i + 0.5) * binWidth

counts = new Map(years.map(y => {
  const a = new Float64Array(length);
  for (const d of byYear.get(y)) a[indexF(d.anomaly)]++;
  return [y, a];
}))

xStep = limit <= 12 ? 2 : limit <= 20 ? 4 : 8
xTicks = d3.range(-Math.floor(limit / xStep) * xStep, limit + 1, xStep)
1
Anomaly to bin index (values beyond the range land in the outermost bins).
2
Bin index to the anomaly at the centre of the bin.

Smoothing the data with a Gaussian blur

As in the original plot, the counts are smoothed with small binomial kernels (a discrete approximation of a Gaussian), keeping only the positive side of each kernel. With 0.5 °C bins, the default factor 2 corresponds to a standard deviation of about 0.5 °C.

kernels = [
      [1],
      [2, 1],
      [6, 4, 1],
      [20, 15, 6, 1],
      [70, 56, 28, 8, 1],
    ];
kernelVisu = kernels.flatMap( (kernel, i) =>
      kernel.flatMap( (e, j) => (j == 0)? Array(e).fill( {i, j } ) : Array(e).fill( [{i, j }, {i, j: -j} ]) ) )
      .flat()

Plot.plot({
  marks: [
    Plot.barY(kernelVisu,
      Plot.binX({y: "proportion-facet"},
        { x: "j", fx: "i", stroke: "black", fill: "black"})),
    Plot.axisY({label: "Weight", tickFormat: "%"})
  ],
  grid: true,
  x: { tickFormat: (d) => d == 0 ? "0" : Math.floor(+d), label: "Kernel Offset" },
  fx: { label: "Kernel Size", anchor: "bottom" },
  y: { inset: 5, label: "Weight", tickFormat: "%" }
})
(a)
(b)
Figure 3: Kernel Visualization

The smoothed counts are turned into a share of the days of the year, then scaled so that the tallest ridge of the whole chart is ridgeHeight rows high. This keeps the plot readable whatever the climate (a narrow distribution gives a high, thin hump, a broad one a low, wide hump), and each ridge is finally offset by its year.

joyData = {
  const kernel = kernels[smoothFactor];
  const smooth = y => {
    const c = counts.get(y), n = byYear.get(y).length;
    return Float64Array.from(c, (_, i) => {
      let result = 0, weight = 0;
      for (let j = 1 - kernel.length; j < kernel.length; j++) {
        const m = i + j;
        if (m >= 0 && m < length) {
          const k = kernel[Math.abs(j)];
          result += c[m] * k;
          weight += k;
        }
      }
      return result / weight / n;   // share of the days of the year
    });
  };
  const dens = years.map(y => ({y, s: smooth(y)}));
  const peak = d3.max(dens, d => d3.max(d.s));
  return dens.flatMap(({y, s}) => {
    const st = yearStatsMap.get(y);
    return Array.from(s, (v, i) => ({
      year: y,
      anomaly: indexF_1(i),
      count: v,
      height: st.reference + v / peak * ridgeHeight,
      reference: st.reference,
      yearMean: st.mean
    }));
  });
}
1
Offset by the year (reversed so that the latest year is at the bottom) to obtain the Joy Division effect.

Colouring the years

Colouring each year by its mean anomaly shows the warming at a glance. A standard diverging scale such as RdYlBu is pale yellow in the middle, which is nearly invisible on a white page (its contrast ratio against white is about 1.1:1). The interpolator below therefore caps the lightness of every colour in the perceptual Lab space, keeping hue and chroma: the ends of the scale are untouched, the middle turns into a darker khaki that stays visible (about 2.7:1 at the default maximum lightness of 65). Lower the “Max lightness” slider for more contrast, raise it to 100 to get the plain RdYlBu back.

divergingInterpolator = t => {
  const c = d3.lab(d3.interpolateRdYlBu(1 - t));   // reversed: warm = red
  c.l = Math.min(c.l, maxLightness);
  return c.formatHex();
}
colorLimit = Math.max(0.5, Math.ceil(d3.max(yearStats, d => Math.abs(d.mean)) * 2) / 2)
colorScale = ({
  type: "diverging",
  pivot: 0,
  domain: [-colorLimit, colorLimit],
  interpolate: divergingInterpolator,
  label: "Yearly mean anomaly (°C)"
})
serialize = {
  const xmlns = "http://www.w3.org/2000/xmlns/";
  const xlinkns = "http://www.w3.org/1999/xlink";
  const svgns = "http://www.w3.org/2000/svg";
  return function serialize(svg) {
    svg = svg.cloneNode(true);
    const fragment = window.location.href + "#";
    const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
    while (walker.nextNode()) {
      for (const attr of walker.currentNode.attributes) {
        if (attr.value.includes(fragment)) {
          attr.value = attr.value.replace(fragment, "#");
        }
      }
    }
    svg.setAttributeNS(xmlns, "xmlns", svgns);
    svg.setAttributeNS(xmlns, "xmlns:xlink", xlinkns);
    const serializer = new window.XMLSerializer;
    const string = serializer.serializeToString(svg);
    return new Blob([string], {type: "image/svg+xml"});
  };
}

Limits of a station series

A station is a real measurement, but not a perfect one: the instruments, the shelter and the surroundings of the airport changed over the decades, and the definition of the daily extremes was modified over time, so part of the long-term drift can come from these changes rather than from the climate. Météo-France applies a climatological quality control, but the series is not homogenised in this visualisation. Years with fewer than 330 days of data are left out, which leaves visible gaps in the vertical axis.

  • © 2022–2026 Dimitri Masson
 
  • Built with Quarto,
  • Source code available on GitHub