No topics yet. Start the conversation.
Summary
Annual weather summary for Malmö in 2025. Source: Norwegian Meteorological Institute.
Description
Weather Wheel
This wheel displays a full year of weather data, showing daily and monthly temperature ranges and precipitation patterns. Below is a detailed description of how the weather wheel is built and kept up to date with fresh data daily in a fully automated workflow.
The main idea is to give a quick visual overview of the weather patterns throughout the year, while having the ability to zoom in for more detailed information. The goal is to have this insight fresh and up to date every day, with no manual work for me.
Overview
This description covers the implementation of the weather wheel and how to keep it updated with fresh data. The main components are:
- Novem job Sets up a VM and triggers a python script to run on a schdule
- Python fetching and processing Collects data from METs Frost API and pushes this to a novem plot
- Novem plot Custom plot that renders the weather wheel using D3.js
Novem schduled job
The novem job makes sure a python script is run on a regular basis with the right access rights and environment. Normally, a user would not need to modify this part but it is includeded here for understanding the platform.
Novem uses Docker to set up a custom python environment, a lightweight alpine python image where dependencies from requirements.txt are installed before opening a bash script called run.sh.
# Standard python, newest version
FROM python:alpine
WORKDIR /app
COPY ./requirements.txt requirements.txt
RUN apk add --no-cache gcc python3-dev musl-dev linux-headers
RUN pip install -r requirements.txt
# Copy all other source code to the image
COPY . ./
ENTRYPOINT ["ash", "run.sh"]%
This bash script triggers the actual python code.
#!/bin/bash
if [ -f "$FILENAME" ]; then
python "$FILENAME"
else
python -m "$FILENAME"
fi%
Notice $FILENAME is an environment variable. These are set in `config/env'. For this job we have specified three environment variables:
FILENAME: The main python script to run. Used by 'run.sh' above and points to a file stored in the same repo as the dockerfile.FROST_CLIENT_ID: The API token used to identify me towards the Frost API. You can sign up for your own free account atmet.noNOVEM_TOKEN: The API token used to authenticate the novem job towards the novem platform
We keep these as environment variables to avoid hardcoding sensitive information in the codebase and to be able to inject information from outside the codebase to the python code.
The full job runs daily at 3:55 AM UTC which is set via a cron expression config/schedule = "55 3 * * *". At the specified time, the job spins up a VM, builds the docker image, and runs the python code. After the job is done, the VM is terminated to save costs.
Data Collection Code
The script fetches daily weather observations:
import os
import requests
FROST_CLIENT_ID = os.environ.get("FROST_CLIENT_ID")
BASE_URL = "https://frost.met.no/observations/v0.jsonld"
SOURCE_ID = "SN18700" # Oslo Blindern
DATE_RANGE = "2025-01-01/2025-12-31" # start/end
def fetch_location(source_id, date_range):
r = requests.get(
BASE_URL,
auth=(FROST_CLIENT_ID, ""),
params={
"sources": source_id,
"referencetime": date_range,
"elements": "min(air_temperature P1D),max(air_temperature P1D),sum(precipitation_amount P1D)",
"timeresolutions": "P1D",
},
)
return r.json()
data = fetch_location(SOURCE_ID, DATE_RANGE)
print(data) # raw Frost JSON
The fetched data is converted to a dataframe, slightly massaged and pasted into a novem plot.
from novem import Plot
plot_id = 'yr-oslo-annual'
plt = Plot(plot_id)
plt.data = df
The final datastructure looks like this where doy is day of year.
doy,date,t_min,t_max,precip_mm
32,2025-02-01,-7.1,0.5,0.0
33,2025-02-02,-7.1,-2.6,0.0
34,2025-02-03,-4.2,-0.3,2.5
Novem plot
The weather wheel is built using D3.js. This is defined by setting type = custom and the custom.js of the plot to the code below. Note this is a complex (!) plot and the code is complex as well. I aim
to achieve a zooming effect where one quick glance on the plot gives high level information, hot in the summer, a few really cold winter days and a rainy autumn. Zooming in gives more detailed information and
helps you see things like the days after Cristmas saw a sudden drop in temparature and there were indeed some really hot and dry days towards the end of July.
Key features:
- Outer Ring: Daily temperature ranges (min to max) with color-coded categories
- Inner Ring: Daily precipitation with logarithmic scale
- Monthly: Coldest/hottest temperatures and total rainfall per month
- Responsive: Auto-scales fonts and spacing based on viewport size
- Legends: Temperature categories (left) and precipitation categories (right)
node.innerHTML = "";
const headers = info.metadata.header;
const rows = info.data;
// Convert to objects
const data = rows.map(row => {
const obj = {};
headers.forEach((h, i) => obj[h] = row[i]);
return obj;
});
data.forEach(d => {
d.date = new Date(d.date);
d.t_min = +d.t_min;
d.t_max = +d.t_max;
d.precip_mm = +d.precip_mm;
});
// Responsive scaling
const xScale = width < 400 ? 1 : width < 800 ? 2 : 3;
const yScale = width < 400 ? 1 : width < 800 ? 2 : 3;
const fontSize = 10 + 3 * xScale;
const tinyFont = fontSize - 10;
const outerRadius = Math.min(width, height)/2 * 0.95;
const tempInnerRadius = outerRadius * 0.55;
const tempOuterRadius = outerRadius * 1;
const precipInnerRadius = outerRadius * 0.34;
const precipOuterRadius = outerRadius * 0.5;
const radialInnerRadius = outerRadius * 0.32;
const radialOuterRadius = outerRadius * 1;
const year = data[0].date.getFullYear();
const YEAR_DAYS = 365;
// Fixed angular scale for full year (Jan 1 at top)
const angle = d3.scaleLinear()
.domain([0, YEAR_DAYS])
.range([0, Math.PI * 2]);
const barAngle = (2 * Math.PI) / YEAR_DAYS * .98;
const tempExtent = [-15, 35];
// Temperature categorization and colors
const tempCategory = ({ Tmin, Tmax }) => {
if (Tmin > 20) return "Tropenatt (Tmin > 20°C)";
if (Tmax > 20) return "Sommerdag (Tmax > 20°C)";
if (Tmin < -10) return "Kaldt (Tmin < -10°C)";
if (Tmin < 0) return "Frost (Tmin < 0°C)";
return "Mildt";
};
const tempColor = d3.scaleOrdinal()
.domain(["Tropenatt (Tmin > 20°C)", "Sommerdag (Tmax > 20°C)", "Mildt",
"Frost (Tmin < 0°C)", "Kaldt (Tmin < -10°C)"])
.range(["#a50f15", "#fb6a4a", "#fcae91", "#4292c6", "#08306b"]);
// Precipitation categorization and colors
const rainCategory = ({precip_mm}) => {
if (precip_mm >= 20) return "Kraftig regn (> 20 mm)";
if (precip_mm >= 5) return "Regndag (5-20 mm)";
if (precip_mm >= 0.1) return "Lett regn (< 5 mm)";
return "Opphold";
};
const rainColor = d3.scaleOrdinal()
.domain(["Kraftig regn (> 20 mm)", "Regndag (5-20 mm)",
"Lett regn (< 5 mm)", "Opphold"])
.range(["#08306b", "#4292c6", "#9ecae1", "#deebf7"]);
const tempScale = d3.scaleLinear()
.domain(tempExtent)
.range([tempInnerRadius, outerRadius]);
const precipScale = d3.scaleSymlog()
.domain([0, 20])
.range([precipInnerRadius, precipOuterRadius])
.constant(1);
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width / 2, -height / 2, width, height])
.style("font", "10px system-ui, -apple-system, sans-serif");
const arc = d3.arc();
// Temperature range bars (outer ring)
svg.append("g")
.selectAll("path")
.data(data)
.join("path")
.attr("d", (d, i) =>
arc({
innerRadius: tempScale(d.t_min),
outerRadius: tempScale(d.t_max),
startAngle: angle(d.doy),
endAngle: angle(d.doy) + barAngle
})
)
.attr("fill", d => tempColor(tempCategory({ Tmin: d.t_min, Tmax: d.t_max })));
// Rain bars (inner ring)
svg.append("g")
.selectAll("path")
.data(data)
.join("path")
.attr("d", (d, i) =>
arc({
innerRadius: precipInnerRadius * 0.98,
outerRadius: precipScale(d.precip_mm),
startAngle: angle(d.doy),
endAngle: angle(d.doy) + barAngle
})
)
.attr("fill", d => rainColor(rainCategory(d)));
// Month boundaries
const monthStarts = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
const monthNames = ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Des"];
const rad2deg = 180 / Math.PI;
// Radial lines per month
svg.append("g")
.selectAll("line")
.data(monthStarts)
.join("line")
.attr("x1", 0)
.attr("y1", radialInnerRadius)
.attr("x2", 0)
.attr("y2", radialOuterRadius)
.attr("transform", d => `rotate(${angle(d)*rad2deg-180})`)
.attr("stroke", "#242424ff")
.attr("stroke-opacity", 0.15);
// Temperature grid circles
const tempGrid = d3.range(-10, 40, 10);
svg.append("g")
.selectAll("circle")
.data(tempGrid)
.join("circle")
.attr("r", d => tempScale(d))
.attr("fill", "none")
.attr("stroke", "#242424ff")
.attr("stroke-opacity", 0.15);
// Precipitation grid circles
const precipGrid = [2, 10];
svg.append("g")
.selectAll(".precip-grid")
.data(precipGrid)
.join("circle")
.attr("r", d => precipScale(d))
.attr("fill", "none")
.attr("stroke", "#7e7e7eff")
.attr("stroke-opacity", 0.25)
.attr("stroke-dasharray", "2,3");
// Center labels
svg.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.2em")
.style("font-size", fontSize - 4)
.style("fill", "#242424ff")
.text(year);
svg.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-0.1em")
.style("font-size", fontSize-2)
.style("fill", "#242424ff")
.style("font-weight", 600)
.text("[[LOCATION]]");
// Monthly aggregates
const monthly = d3.groups(data, d => d.date.getMonth()).map(([m, v]) => {
const coldDay = d3.least(v, d => d.t_min);
const hotDay = d3.greatest(v, d => d.t_max);
return {
month: m,
tMin: coldDay.t_min,
tMinDOY: coldDay.doy,
tMax: hotDay.t_max,
tMaxDOY: hotDay.doy,
rainSum: d3.sum(v, d => d.precip_mm)
};
});
// Coldest/hottest day labels
svg.append("g")
.selectAll(".cold-label")
.data(monthly)
.join("text")
.attr("x", d => Math.cos(angle(d.tMinDOY) - Math.PI / 2) * (tempScale(d.tMin) - 12))
.attr("y", d => Math.sin(angle(d.tMinDOY) - Math.PI / 2) * (tempScale(d.tMin) - 12))
.attr("text-anchor", "middle")
.style("font-size", tinyFont)
.style("fill", "#7e7e7eff")
.text(d => `${d.tMin.toFixed(1)}°`);
svg.append("g")
.selectAll(".hot-label")
.data(monthly)
.join("text")
.attr("x", d => Math.cos(angle(d.tMaxDOY) - Math.PI / 2) * (tempScale(d.tMax) + 12))
.attr("y", d => Math.sin(angle(d.tMaxDOY) - Math.PI / 2) * (tempScale(d.tMax) + 12))
.attr("text-anchor", "middle")
.style("font-size", tinyFont)
.style("fill", "#7e7e7eff")
.text(d => `${d.tMax.toFixed(1)}°`);
// Monthly precipitation
svg.append("g")
.selectAll(".rain-label")
.data(monthly)
.join("text")
.attr("x", d => Math.cos(angle(monthStarts[d.month] + 15) - Math.PI / 2) * precipInnerRadius * 0.77)
.attr("y", d => Math.sin(angle(monthStarts[d.month] + 15) - Math.PI / 2) * precipInnerRadius * 0.77)
.attr("text-anchor", "middle")
.style("font-size", tinyFont)
.style("fill", "#4292c6")
.each(function (d) {
const el = d3.select(this);
el.append("tspan")
.attr("x", el.attr("x"))
.attr("dy", "-0.3em")
.style("font-weight", 600)
.text(monthNames[d.month]);
el.append("tspan")
.attr("x", el.attr("x"))
.attr("dy", "1.1em")
.text(`${Math.round(d.rainSum)} mm`);
});
// Legends
const legendDistance = outerRadius - 3*fontSize;
const legendY = outerRadius*0.6;
const legendLeft = svg.append("g")
.attr("transform", `translate(${-legendDistance}, ${legendY})`);
legendLeft.selectAll("rect")
.data(tempColor.domain())
.join("rect")
.attr("x", -14)
.attr("y", (d,i) => i * fontSize)
.attr("width", 14)
.attr("height", 14)
.attr("fill", d => tempColor(d));
legendLeft.selectAll("text")
.data(tempColor.domain())
.join("text")
.attr("x", -fontSize)
.attr("y", (d,i) => (i + 0.45) * fontSize)
.text(d => d)
.attr("font-size", tinyFont)
.attr("text-anchor", "end");
const legendRight = svg.append("g")
.attr("transform", `translate(${legendDistance}, ${legendY})`);
legendRight.selectAll("rect")
.data(rainColor.domain())
.join("rect")
.attr("y", (d,i) => i * fontSize)
.attr("width", 14)
.attr("height", 14)
.attr("fill", d => rainColor(d));
legendRight.selectAll("text")
.data(rainColor.domain())
.join("text")
.attr("x", fontSize)
.attr("y", (d,i) => (i + 0.45) * fontSize)
.text(d => d)
.attr("font-size", tinyFont);
node.appendChild(svg.node());