No topics yet. Start the conversation.

Summary

User supplied summary for the plot

Annual weather summary for Malmö in 2025. Source: Norwegian Meteorological Institute.

Description

The below description is supplied in free-text by the user

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 at met.no
  • NOVEM_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());