Turn a Year of Weather Into Digital Art
Fetch real weather data and transform it into a radial portrait with Python.
Introduction
30 Second Summary
You check the weather every day, but have you ever seen what an entire year of weather looks like? Imagine 365 days of temperature, rain, and wind compressed into a single image you could hang on your wall.
In this project, you will build a Python script that pulls 365 days of real weather data for any city and transforms it into a radial artwork. Cold days glow blue, hot days burn red, rainy days appear thick, and windy days stretch long.
What You'll Build
Run your script and watch it turn a year of real weather data into a glowing ring of colored bars that reveals seasons, storms, and heatwaves in a single image.
By the end of this project, you'll have:
- A working data-to-art pipeline that fetches live weather data from the Open-Meteo API and renders a polished PNG artwork with a single command.
- A radial portrait where temperature drives color, precipitation drives thickness, and wind speed drives length. You can spot seasons, storms, and heatwaves at a glance.
- A reusable script you can point at any city on Earth. Paste in new coordinates for Tokyo, Cairo, or your hometown and get a completely different portrait.
- Secret Mission: Generate side-by-side portraits for two cities and see how their climates compare as art.
Do I need to know Python?
This project uses basic Python: variables, loops, and functions. All libraries are installed during setup, so if you have written a few Python scripts before, you are ready to go.
Before We Start
Before you dive in, take a moment to lock in what you're about to build and why it matters.
Set Up Your Project
Your weather portrait needs two libraries that don't come with Python. Pillow handles all the image creation, drawing shapes and rendering text onto a canvas. The requests library handles calling the Open-Meteo API to fetch real weather data.
In this step, you'll create your project folder, set up your script file, and install both libraries.
In this step, get ready to:
- Create a project folder with an empty weather_art.py file.
- Install Pillow and requests via pip.
- Verify both libraries are ready to use.
Create your project folder and script file
- Press the Windows key to open your search bar.
- Type VS Code and press Enter to open it.
- Click Terminal in the top menu bar.
- Click New Terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir weather-portrait
cd weather-portrait
- Open this folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- In the folder picker, navigate to your Desktop.
- Select the weather-portrait folder and click Select Folder.
Seeing a trust prompt?
VS Code may ask if you trust the authors of files in this folder. Click Yes, I trust the authors to continue.
- In the Explorer sidebar on the left, click the New File icon (it looks like a page with a small plus sign).
- Type weather_art.py and press Enter.
The file opens in the editor as an empty tab. You'll start writing code in it in the next step.
Don't see the Explorer sidebar?
Press Ctrl+Shift+E to toggle the Explorer panel. If you still don't see WEATHER-PORTRAIT at the top of the sidebar, repeat the File > Open Folder steps to make sure you selected the right folder.
help me open the Explorer sidebar in VS Code
Install Pillow and requests
Python's standard library doesn't include tools for image creation or HTTP requests. Pillow gives you a drawing canvas, and requests gives you a clean way to call web APIs.
- Open a new terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
- Install both libraries by running this command:
py -m pip install Pillow==12.3.0 requests==2.34.2
What does this command do?
- py -m pip runs Python's package installer through the py launcher on Windows.
- Pillow==12.3.0 installs version 12.3.0 of Pillow, the image creation library.
- requests==2.34.2 installs version 2.34.2 of requests, the HTTP library for calling APIs.
You should see output scrolling in the terminal, ending with a line that says Successfully installed followed by the package names.
Seeing 'py' is not recognized?
Make sure you're running the command in the VS Code terminal, not in a browser or file explorer. If the error persists, try replacing py with python or python3 in the command.
help me fix py not recognized in VS Code terminal
Verify both libraries work
Before you run this next command, what do you expect to see if both libraries installed correctly?
- Test that both libraries import correctly by running:
py -c "from PIL import Image; import requests; print('Ready')"
You should see Ready printed in the terminal. This confirms both Pillow and requests are installed and importable.
Seeing ModuleNotFoundError?
If the error mentions PIL, Pillow didn't install correctly. Re-run the install command from the previous substep. If the error mentions requests, do the same.
Make sure you used py -m pip install (not just pip install) so the packages install into the same Python that py points to.
help me fix ModuleNotFoundError for Pillow or requests
Your project is ready to go. Next up, you'll connect to the Open-Meteo API and pull a full year of real weather data for London.
Fetch a Year of Weather Data
Your libraries are installed and your file is ready. Now you need the raw material for your artwork.
Every visual property of the final portrait is driven by real weather numbers: temperature controls color, precipitation controls thickness, and wind speed controls length. In this step, you'll connect to a free weather API and pull 365 days of data for London.
In this step, get ready to:
- Write a function that fetches a year of daily weather data from the Open-Meteo API.
- Call the function for London and print the results to your terminal.
- Confirm the API returns a full year of real weather numbers.
Write the weather data function
The Open-Meteo Historical Weather API provides daily weather data for any location on Earth. It requires no API key, no sign-up, and no credit card.
You'll send it a latitude, longitude, and date range, and it returns daily values for temperature, precipitation, and wind speed as JSON.
- In VS Code, open weather_art.py and paste the following code:
import requests
def fetch_weather_data(latitude, longitude, year):
"""Fetch a year of daily weather data from Open-Meteo."""
# The Open-Meteo Historical Weather API endpoint
url = "https://archive-api.open-meteo.com/v1/archive"
# Build query parameters for the request
params = {
"latitude": latitude,
"longitude": longitude,
"start_date": f"{year}-01-01",
"end_date": f"{year}-12-31",
# Request four daily weather variables
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max",
"timezone": "auto",
}
response = requests.get(url, params=params)
# Raise an error if the request failed
response.raise_for_status()
return response.json()
What does this code do?
- requests.get() sends an HTTP GET request to the Open-Meteo API with your coordinates and date range.
- The daily parameter tells the API which weather variables to return: max and min temperature, total precipitation, and max wind speed.
- response.raise_for_status() stops the script immediately if the request fails, so you get a clear error instead of broken data.
- response.json() converts the API response into a Python dictionary you can work with.
Why is the timezone parameter required?
Open-Meteo requires a timezone parameter whenever you request daily weather variables. Setting it to "auto" tells the API to figure out the local timezone from the coordinates you provided.
- Save weather_art.py. The function is ready. In the next chunk, you'll call it and see real weather data printed to your terminal.
Call the function and print the results
The function exists, but nothing calls it yet. You need a main section at the bottom of the script that passes in coordinates for a city and prints what comes back.
- Add the following code below your fetch_weather_data function in weather_art.py:
# Set the city coordinates and year
city_name = "London"
latitude = 51.51
longitude = -0.13
year = 2025
print(f"Fetching weather data for {city_name} ({year})...")
weather_data = fetch_weather_data(latitude, longitude, year)
# Extract the daily data from the response
daily = weather_data["daily"]
num_days = len(daily["time"])
print(f"Received {num_days} days of data")
# Filter out None values and print the temperature range
temp_maxes = [t for t in daily["temperature_2m_max"] if t is not None]
temp_mins = [t for t in daily["temperature_2m_min"] if t is not None]
print(f"Temperature range: {min(temp_mins):.1f}C to {max(temp_maxes):.1f}C")
What does this code do?
- latitude and longitude pinpoint London on the map. These are the coordinates the API uses to find weather data.
- weather_data["daily"] pulls out the daily data from the JSON response. Each weather variable is a list of 365 values, one per day.
- The list comprehensions filter out any None values before calculating the temperature range. Some days may have missing data.
- Save weather_art.py.
Run the script and check the data
Before you run this, take a guess. How many days of data do you think the API will return for a full year?
- Run the script in your VS Code terminal by running this command:
py weather_art.py
You should see three lines of output. The first confirms the fetch started. The second reports 365 days of data. The third prints the temperature range for London in 2025.
What should I see?
Your output will look something like this (the exact temperature numbers depend on real weather data):
Fetching weather data for London (2025)...
Received 365 days of data
Temperature range: -2.1C to 33.4C
The temperature values will differ from this example because they reflect real recorded weather. The important thing is that you see 365 days and a realistic temperature range.
Seeing an error instead of weather data?
- If you see ModuleNotFoundError: No module named 'requests', the library is not installed. Run py -m pip install requests==2.34.2 and try again.
- If you see a connection error, check that you have an internet connection. The script needs to reach the Open-Meteo API.
- If you see a SyntaxError, make sure the params dictionary has a comma after each line inside the curly braces.
- Still stuck? Help me debug my weather data fetch
✔️ Awesome, I've got everything!
Great. Double check you've saved weather_art.py before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm your weather_art.py contains these two sections, in order:
- The import requests line at the top, followed by the fetch_weather_data function definition.
- The main section at the bottom that sets city_name, latitude, longitude, and year, calls the function, and prints the results.
Make sure the main section is below the function, not inside it. The city_name = "London" line should have no indentation.
You have 365 real data points sitting in memory. Next up, you'll turn those numbers into something you can actually see by drawing your first image.
Draw Your First Image
Your script can pull 365 days of weather data from the internet. But right now, all you see is text in a terminal. Numbers alone can't show you the shape of a year.
Pillow lets you create images from scratch using pure Python. In this step, you will draw a simple horizontal strip with one gray bar for every day of the year. It will not look like much yet, and that is the point.
In this step, get ready to:
- Add Pillow imports for image creation.
- Draw a strip of 365 identical gray bars and save it as a PNG.
Add the Pillow imports
Pillow's Image module creates blank canvases, and ImageDraw lets you paint shapes onto them. You need both before you can draw anything.
- In weather_art.py, find this line at the very top of the file:
import requests
- Add this new import line directly below it:
from PIL import Image, ImageDraw
- The top of your file should now look like this:
import requests
from PIL import Image, ImageDraw
Create a canvas and draw the strip
Every Pillow image starts as a blank canvas with a fixed size and background color. You will create a white canvas wide enough to fit one bar per day, then loop through all 365 days and stamp a gray rectangle for each one.
- Scroll to the very bottom of weather_art.py, below the print(f"Temperature range:... line.
- Add the following code to create the image, draw the bars, and save the result:
# Create a blank white canvas
image = Image.new("RGB", (num_days * 3, 200), (255, 255, 255))
draw = ImageDraw.Draw(image)
# Draw one gray bar for each day of the year
for i in range(num_days):
x = i * 3
draw.rectangle([x, 0, x + 2, 199], fill=(128, 128, 128))
# Save the image and open it in the default viewer
image.save("weather_strip.png", "PNG")
image.show()
What does this code do?
- Image.new("RGB", (num_days * 3, 200), (255, 255, 255)) creates a blank white canvas. The width is num_days * 3 pixels (about 1095px for 365 days) and the height is 200 pixels.
- ImageDraw.Draw(image) gives you a drawing tool attached to that canvas. Every shape you draw through it appears on the image.
- The loop draws 365 rectangles, each 2 pixels wide and spanning the full height. Every bar is the same gray color (128, 128, 128), so the strip looks completely flat.
- image.save() writes the image to disk, and image.show() opens it in your default image viewer so you can see it immediately.
Before you run this, what do you think the image will look like? Every bar has the same color, the same width, and the same height. Keep that prediction in mind.
- Save weather_art.py.
- Run your script by entering this command in the terminal:
py weather_art.py
Your default image viewer opens with a gray horizontal strip. It looks like a single flat block of gray. That is exactly right.
- Confirm that weather_strip.png now appears in your project folder by running:
ls
You should see weather_art.py and weather_strip.png listed in the output.
Don't see the image?
- If you see an error about Image or ImageDraw not being defined, make sure the from PIL import Image, ImageDraw line is at the top of your file, right below import requests.
- If the image does not open automatically, check your project folder for weather_strip.png and double-click it to open it manually.
- If you see a NameError for num_days, make sure you added the new code below the existing print statements, not above them.
Still stuck? Help me debug why my Pillow image is not displaying after running weather_art.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved weather_art.py.
ⓧ I'd like to double check the full code
Your weather_art.py file has grown past 30 lines, so instead of one big code wall, confirm each section is present in order by scrolling through your file:
- Line 1: import requests.
- Line 2: from PIL import Image, ImageDraw.
- The fetch_weather_data function (unchanged from Step 2).
- The main section that sets city_name, fetches data, and prints the day count and temperature range (unchanged from Step 2).
- The image creation block: Image.new(), the drawing loop with draw.rectangle(), then image.save() and image.show() at the very end.
Why does the strip look so boring?
Every bar has the exact same color, width, and height. None of the weather data is connected to anything visual. You have 365 days of temperature, rain, and wind data sitting in memory, but nothing in the drawing code uses it.
That gap between having the data and actually seeing it is exactly what the next step closes.
You turned raw API data into your first image. It is just gray for now, but the canvas is ready. Next up, you will wire temperature, rain, and wind into color and size so the strip actually shows the weather.
Map Weather Data to Color and Size
You've got a strip of weather data on screen. The problem is that every single bar looks identical. 365 days of weather, and you can't tell a blizzard from a heatwave.
The raw numbers are sitting right there in your data. They're just not connected to anything visual. In this step, you'll build the mapping layer that turns numbers into art. Temperature will drive color, wind speed will control height, and precipitation will determine thickness.
In this step, get ready to:
- Write two helper functions that turn raw weather values into visual properties.
- Replace the identical gray bars with data-driven colors and sizes.
- Switch to a dark background so the colored bars pop.
Add the data mapping functions
Weather values come in all kinds of ranges. Temperature might span -5 to 35, while wind speed goes from 0 to 80. To map these onto colors and pixel sizes, you first need to squish every value into the same 0-to-1 scale. This process is called normalization.
- In weather_art.py, find the closing line of the fetch_weather_data function (return response.json()).
- Add the following normalize function below it:
def normalize(value, min_val, max_val):
"""Normalize a value to the 0-1 range."""
# If all values are the same, return the midpoint
if max_val == min_val:
return 0.5
return (value - min_val) / (max_val - min_val)
What does this function do?
normalize takes any value and rescales it to sit between 0 and 1. A value equal to the minimum becomes 0. A value equal to the maximum becomes 1. Everything else lands somewhere in between.
The if max_val == min_val guard prevents a division-by-zero error when every value in the dataset is identical.
- Now add the temperature_to_color function directly below normalize:
def temperature_to_color(temp_normalized):
"""Map a normalized temperature (0-1) to a blue-to-red color."""
# Cold half: blend from deep blue toward white
if temp_normalized < 0.5:
ratio = temp_normalized * 2
r = int(50 + ratio * 205)
g = int(50 + ratio * 205)
b = 255
# Hot half: blend from white toward deep red
else:
ratio = (temp_normalized - 0.5) * 2
r = 255
g = int(255 - ratio * 205)
b = int(255 - ratio * 205)
return (r, g, b)
How does the color gradient work?
The function splits the 0-to-1 range in half. Values below 0.5 (cold days) produce colors that range from deep blue to near-white. Values above 0.5 (warm days) range from near-white to deep red.
Each color channel (R, G, B) is an integer from 0 to 255. By ramping channels up or down with the ratio variable, the function creates a smooth visual transition across the full temperature range.
- Save weather_art.py.
- Run the script to confirm there are no syntax errors by running:
py weather_art.py
You should see the same gray strip as before. The new functions exist but nothing calls them yet.
Seeing a SyntaxError?
- Make sure each function starts with def and ends with a return statement. Check that colons appear after each def, if, and else.
- Confirm there are two blank lines between each function. Python expects blank lines between top-level definitions.
- Help me debug a SyntaxError in my normalize or temperature_to_color function.
Connect the data to visual properties
Right now the main section extracts temperature data and prints a range, but it never feeds those numbers into the drawing loop. You need to prepare all four weather variables and compute their ranges so normalize can scale them.
- In weather_art.py, find these three lines in the main section:
temp_maxes = [t for t in daily["temperature_2m_max"] if t is not None]
temp_mins = [t for t in daily["temperature_2m_min"] if t is not None]
print(f"Temperature range: {min(temp_mins):.1f}C to {max(temp_maxes):.1f}C")
- Delete those three lines and replace them with the following data preparation code:
# Extract raw daily arrays from API response
temp_maxes_raw = daily["temperature_2m_max"]
temp_mins_raw = daily["temperature_2m_min"]
precip_raw = daily["precipitation_sum"]
wind_raw = daily["wind_speed_10m_max"]
# Compute average temperature per day, defaulting None to 0
temps = []
for i in range(num_days):
t_max = temp_maxes_raw[i] if temp_maxes_raw[i] is not None else 0
t_min = temp_mins_raw[i] if temp_mins_raw[i] is not None else 0
temps.append((t_max + t_min) / 2)
# Clean precipitation and wind, replacing None with 0
precip = [p if p is not None else 0 for p in precip_raw]
wind = [w if w is not None else 0 for w in wind_raw]
# Calculate ranges for normalization
temp_min, temp_max = min(temps), max(temps)
precip_max = max(precip) if max(precip) > 0 else 1
wind_min, wind_max = min(wind), max(wind)
# Set bar height range in pixels
bar_height_min = 50
bar_height_max = 200
What does this code do?
- The first block grabs the raw arrays for all four weather variables directly from the API response.
- The temps loop averages each day's max and min temperature into a single number. Any None values from the API (missing data) get replaced with 0 so the math doesn't break.
- The min/max calculations give normalize the range it needs to scale each day's value into 0-1.
- bar_height_min and bar_height_max define the pixel range that wind speed maps onto. Calm days get short bars. Windy days get tall bars.
- Now find the old drawing loop:
for i in range(num_days):
x = i * 3
draw.rectangle([x, 0, x + 2, 199], fill=(128, 128, 128))
- Replace that entire loop with the following data-driven version:
for i in range(num_days):
x = i * 3
# Map temperature to a blue-to-red color
temp_norm = normalize(temps[i], temp_min, temp_max)
color = temperature_to_color(temp_norm)
# Map wind speed to bar height
wind_norm = normalize(wind[i], wind_min, wind_max)
bar_height = int(bar_height_min + wind_norm * (bar_height_max - bar_height_min))
# Map precipitation to bar width
precip_norm = precip[i] / precip_max
bar_width = max(1, int(1 + precip_norm * 4))
draw.rectangle([x, 200 - bar_height, x + bar_width, 199], fill=color)
What changed in the loop?
- Color: each day's average temperature is normalized and fed into temperature_to_color. Cold days get blue. Hot days get red.
- Height: wind speed is normalized and mapped to a pixel range between bar_height_min and bar_height_max. The bar is drawn from the bottom up (200 - bar_height) so taller bars grow upward.
- Width: precipitation is normalized against the wettest day. Dry days stay 1 pixel wide. Rainy days stretch up to 5 pixels.
- Save weather_art.py and run the script:
py weather_art.py
The strip now shows bars in different colors and sizes. You can spot blues on the left (winter) shifting toward reds in the middle (summer). The data is finally visible.
Seeing a NameError or all bars look the same?
- A NameError for normalize or temperature_to_color means the functions are not defined before the main section. Make sure they appear between fetch_weather_data and the city_name = "London" line.
- If all bars still look gray, double check that you deleted the old loop and replaced it with the new one. The old loop used fill=(128, 128, 128). The new loop uses fill=color.
- Help me debug why my weather strip still looks gray or is throwing an error.
Switch to a dark background
The colored bars are working, but they're fighting against the white background. A dark canvas will make the blues and reds stand out dramatically.
- In weather_art.py, find the Image.new line:
image = Image.new("RGB", (num_days * 3, 200), (255, 255, 255))
- Replace (255, 255, 255) with (15, 15, 25) so the line looks like this:
image = Image.new("RGB", (num_days * 3, 200), (15, 15, 25))
Before you run the script, what do you think the strip will look like now compared to the white background version?
- Save weather_art.py and run the script:
py weather_art.py
The image now shows bars in shades of blue and red against a dark background. You should see visibly different heights and thicknesses. Cold months glow blue, warm months burn red.
Image looks mostly black?
- If you see the dark background but no colored bars, the drawing loop may still be using the old fill=(128, 128, 128) code. Confirm the loop uses fill=color where color comes from temperature_to_color().
- Check that the data preparation code is above the Image.new line. Variables like temps and wind must be defined before the loop tries to use them.
- Help me figure out why my weather strip looks mostly black.
✔️ Awesome, I've got everything!
Great. Double check you've saved weather_art.py.
ⓧ I'd like to double check the full code
Your weather_art.py file should have these sections in order. Scroll up and confirm each one is present.
- Imports: import requests and from PIL import Image, ImageDraw.
- Function 1: fetch_weather_data(latitude, longitude, year) unchanged from Step 2.
- Function 2: normalize(value, min_val, max_val) added in this step.
- Function 3: temperature_to_color(temp_normalized) added in this step.
- Main section: Sets city_name, fetches data, extracts raw arrays into temp_maxes_raw/temp_mins_raw/precip_raw/wind_raw, computes averages and ranges, then draws colored bars on a (15, 15, 25) background.
- Save and show: image.save("weather_strip.png", "PNG") and image.show() at the end.
Your flat gray strip has transformed into a colorful timeline. You can actually see seasons encoded in the colors and weather patterns in the shapes. Next up, you'll wrap this strip into a circle to create the final radial portrait.
Create the Radial Portrait
In the last step, you connected weather data to visual properties. Temperature drives color, wind controls height, and precipitation sets thickness. Your strip has transformed from a flat gray block into a data-rich timeline.
The strip tells the truth, but it does not look like something you would frame. In this step, you will wrap those 365 bars into a circle, turning each day into a ray that radiates outward from the center. The circular layout reveals seasonal patterns as visible quadrants and turns raw data into a portrait worth sharing.
In this step, get ready to:
- Refactor the drawing code into a reusable create_weather_portrait function that uses polar coordinates.
- Add a centered city name, subtitle, and color legend to the portrait.
- Update the main section to generate and save the radial artwork as a PNG.
Refactor the drawing code into a radial function
A horizontal strip lays days left to right. A radial portrait distributes them around a full circle, so each day becomes a ray pointing outward from the center. To compute ray positions you need trigonometry, which lives in Python's math module. You also need ImageFont from Pillow for adding text labels later.
- In weather_art.py, find the import lines at the top of your file.
- Find these two lines:
import requests
from PIL import Image, ImageDraw
- Replace them with these three lines, which add math and ImageFont:
import requests
import math
from PIL import Image, ImageDraw, ImageFont
Now you will create the create_weather_portrait function. This function will hold all the data processing and drawing logic that currently lives in your main section, plus the new polar coordinate math.
- Start building the new function by adding this code below the temperature_to_color function, above the city_name = "London" line:
def create_weather_portrait(weather_data, city_name, year):
"""Create a radial weather portrait from daily weather data."""
# Extract the four daily data arrays
daily = weather_data["daily"]
temp_maxes = daily["temperature_2m_max"]
temp_mins = daily["temperature_2m_min"]
precip = daily["precipitation_sum"]
wind = daily["wind_speed_10m_max"]
# Average each day's temperature, defaulting None to 0
temps = []
for i in range(len(temp_maxes)):
t_max = temp_maxes[i] if temp_maxes[i] is not None else 0
t_min = temp_mins[i] if temp_mins[i] is not None else 0
temps.append((t_max + t_min) / 2)
precip = [p if p is not None else 0 for p in precip]
wind = [w if w is not None else 0 for w in wind]
num_days = len(temps)
temp_min, temp_max = min(temps), max(temps)
precip_max = max(precip) if max(precip) > 0 else 1
wind_min, wind_max = min(wind), max(wind)
# Create a square dark canvas
size = 1000
center = size // 2
image = Image.new("RGB", (size, size), (15, 15, 25))
draw = ImageDraw.Draw(image)
What does this code do?
This is the first half of your new function. It receives the raw API response and processes it into clean lists, just like your old main section code did.
- The function takes three arguments: the raw weather_data dictionary, a city_name string, and a year. This makes it reusable for any city.
- The canvas is now 1000x1000 pixels (a square) instead of the old rectangular strip, because a circle needs equal width and height.
- Continue building the function by adding this code directly below the draw = ImageDraw.Draw(image) line, still inside create_weather_portrait:
# Radius settings for the ring layout
inner_radius = 120
min_bar_length = 80
max_bar_length = 300
# Draw each day as a colored line radiating from center
for i in range(num_days):
angle = (i / num_days) * 2 * math.pi - math.pi / 2
temp_norm = normalize(temps[i], temp_min, temp_max)
color = temperature_to_color(temp_norm)
wind_norm = normalize(wind[i], wind_min, wind_max)
bar_length = min_bar_length + wind_norm * (max_bar_length - min_bar_length)
precip_norm = precip[i] / precip_max
line_width = max(1, int(1 + precip_norm * 5))
# Convert polar coordinates to cartesian for start/end points
x_start = center + math.cos(angle) * inner_radius
y_start = center + math.sin(angle) * inner_radius
x_end = center + math.cos(angle) * (inner_radius + bar_length)
y_end = center + math.sin(angle) * (inner_radius + bar_length)
draw.line(
[(x_start, y_start), (x_end, y_end)],
fill=color,
width=line_width,
)
How do polar coordinates work here?
Instead of placing each bar at an x position along a horizontal line, the loop places each bar at an angle around a circle. Day 1 sits at the top (12 o'clock), day 90 at the right (3 o'clock), day 180 at the bottom, and day 365 comes back around to the top.
- angle = (i / num_days) * 2 * math.pi - math.pi / 2 spreads 365 days evenly across a full circle (2π radians). The - math.pi / 2 rotates the start from the 3 o'clock position to the 12 o'clock position.
- math.cos(angle) and math.sin(angle) convert each angle into x and y positions on the canvas. Multiplying by the radius controls how far from the center each point sits.
- draw.line() replaces the old draw.rectangle(). Each bar is now a line segment from inner_radius (120px from center) outward, with its length driven by wind speed.
Add a title, subtitle, and legend
The ring of bars needs context. Without a label, nobody knows which city they are looking at or what the colors mean. You will add the city name in the center of the ring, a subtitle below it, and a legend at the bottom of the image.
- Add the font loading and text drawing code below the draw.line() loop, still inside the create_weather_portrait function:
# Load fonts, with a fallback for systems without arial.ttf
try:
font_title = ImageFont.truetype("arial.ttf", 28)
font_sub = ImageFont.truetype("arial.ttf", 16)
except OSError:
font_title = ImageFont.load_default(size=28)
font_sub = ImageFont.load_default(size=16)
# Center the city name inside the ring
title_bbox = draw.textbbox((0, 0), city_name, font=font_title)
title_width = title_bbox[2] - title_bbox[0]
draw.text(
(center - title_width // 2, center - 25),
city_name,
fill=(255, 255, 255),
font=font_title,
)
# Add a subtitle below the city name
subtitle = f"Weather Portrait {year}"
sub_bbox = draw.textbbox((0, 0), subtitle, font=font_sub)
sub_width = sub_bbox[2] - sub_bbox[0]
draw.text(
(center - sub_width // 2, center + 10),
subtitle,
fill=(180, 180, 180),
font=font_sub,
)
What does the font code do?
- ImageFont.truetype("arial.ttf", 28) tries to load Arial at size 28. On Windows, Pillow automatically searches the system fonts/ directory so you do not need a full path.
- The except OSError fallback uses ImageFont.load_default(size=28) so the script still works on systems without Arial installed.
- draw.textbbox() measures the bounding box of the text and returns (left, top, right, bottom). Subtracting left from right gives the text width, which you use to center it horizontally.
- Finish the function by adding the legend and return statement directly below the subtitle code you just pasted:
# Add a legend explaining each visual property
legend_y = size - 80
legend_x = 30
draw.text(
(legend_x, legend_y),
"Color: Temperature (blue = cold, red = hot)",
fill=(150, 150, 150),
font=font_sub,
)
draw.text(
(legend_x, legend_y + 20),
"Length: Wind speed",
fill=(150, 150, 150),
font=font_sub,
)
draw.text(
(legend_x, legend_y + 40),
"Thickness: Precipitation",
fill=(150, 150, 150),
font=font_sub,
)
return image
What does the legend do?
The three draw.text() calls place small gray labels in the bottom-left corner explaining what color, length, and thickness represent. The return image line sends the finished image back to whoever calls the function, so the main section can save and display it.
Update the main section to generate the portrait
The old main section still draws the horizontal strip inline. Now that all the drawing logic lives inside create_weather_portrait, you need to replace the old code with a clean main section that calls the function.
- Select everything from the city_name = "London" line all the way to the end of the file.
- Delete all of that selected code and replace it with this new main section:
# City coordinates and year to visualize
city_name = "London"
latitude = 51.51
longitude = -0.13
year = 2025
print(f"Fetching weather data for {city_name} ({year})...")
weather_data = fetch_weather_data(latitude, longitude, year)
daily = weather_data["daily"]
num_days = len(daily["time"])
print(f"Received {num_days} days of data")
# Print temperature range for reference
temp_maxes = [t for t in daily["temperature_2m_max"] if t is not None]
temp_mins = [t for t in daily["temperature_2m_min"] if t is not None]
print(f"Temperature range: {min(temp_mins):.1f}C to {max(temp_maxes):.1f}C")
# Generate and save the radial portrait
print("Creating weather portrait...")
image = create_weather_portrait(weather_data, city_name, year)
filename = f"weather_portrait_{city_name.lower().replace(' ', '_')}_{year}.png"
image.save(filename, "PNG")
print(f"Saved to {filename}")
image.show()
What changed in the main section?
- All the data processing and drawing code is gone. One call to create_weather_portrait(weather_data, city_name, year) does everything and returns the finished image.
- The filename is now dynamically generated from the city name and year, so saving data for a different city automatically creates a distinct file like weather_portrait_tokyo_2025.png.
- A temperature range print gives you a quick sanity check in the console before the image appears.
- Save weather_art.py.
Before you run this, what shape do you expect the artwork to be? Do you think it will still look like the horizontal strip from before?
- Run the script by entering this command in your terminal:
py weather_art.py
Your default image viewer opens with a circular radial artwork. Blue and red bars radiate outward from a center ring that reads "London" and "Weather Portrait 2025". A legend in the bottom-left explains what color, length, and thickness represent.
In your terminal, you should see output like this:
Fetching weather data for London (2025)...
Received 365 days of data
Temperature range: -0.5C to 32.4C
Creating weather portrait...
Saved to weather_portrait_london_2025.png
What should I see?
Your exact temperature numbers will differ because the API returns real recorded data. The important thing is that you see 365 days of data and a temperature range printed in the console.
In the image, you should see seasonal patterns. The top of the ring (January) trends blue, while the right side (spring/summer) transitions toward red. The left side (autumn/winter) swings back to blue. Thicker bars mark rainy days. Longer bars mark windy days.
Seeing an error instead of the image?
- If you see IndentationError, check that all the code inside create_weather_portrait is indented with 4 spaces. Each chunk you pasted should be at the same indent level (one level inside the function).
- If you see NameError: name 'math' is not defined, make sure you added import math at the top of the file, on its own line after import requests.
- If you see AttributeError: 'NoneType' when saving, check that the return image line is at the very end of the create_weather_portrait function (indented once, after the legend code).
- If you still see the old horizontal strip instead of a circle, make sure you deleted all the old drawing code from the main section (everything from the old city_name = "London" to the old image.show()).
Help me debug my weather portrait script
✔️ Awesome, I've got everything!
Double check you have saved weather_art.py and confirmed the radial image appears.
ⓧ I'd like to double check the full code
Your weather_art.py file is too long to show in one block. Scroll up and confirm these sections appear in order:
- Imports: import requests, import math, from PIL import Image, ImageDraw, ImageFont.
- fetch_weather_data function: unchanged from previous steps.
- normalize function: unchanged from previous steps.
- temperature_to_color function: unchanged from previous steps.
- create_weather_portrait function: data extraction, temp averaging, normalization ranges, 1000x1000 canvas, radius settings (inner_radius = 120), drawing loop with polar coordinates, font loading with OSError fallback, centered title and subtitle, legend, and return image.
- Main section: fetches data, prints day count and temperature range, calls create_weather_portrait, saves as weather_portrait_london_2025.png, and shows the image.
- Confirm that weather_portrait_london_2025.png was saved to your project folder by running:
ls
You should see weather_portrait_london_2025.png alongside weather_art.py and weather_strip.png in the output.
Secret mission
Compare Two Cities
Your London portrait tells one city's story. What would happen if you placed it next to Tokyo's? In this secret mission, create side-by-side weather portraits that reveal how different climates produce completely different visual signatures.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your files for future experimentation or delete them to free up space.
Resources you used:
- Project folder on your Desktop containing weather_art.py and generated PNG images (weather_portrait_london_2025.png, weather_comparison.png).
- Installed Python libraries: Pillow and requests.
Keep everything running
No action needed. Choose this if you want to keep experimenting with your weather portraits.
- Your script and generated artwork are local files with zero ongoing cost.
- You can re-run weather_art.py any time with different city coordinates to generate new portraits.
Pause - I'll come back to this later
There are no running processes or servers to stop. Your files will be waiting whenever you come back.
- Close VS Code to free up memory.
- Your project folder and generated images will stay on your Desktop until you decide to delete them.
Delete - I don't want to use this again
Remove all project files and uninstall the libraries you added.
- Close VS Code.
- Open Finder (macOS) or File Explorer (Windows) and navigate to your Desktop.
- Delete your project folder (the one containing weather_art.py and the generated PNG files).
- Uninstall the Python libraries by opening a terminal and running:
py -m pip uninstall Pillow requests
- Type y and press Enter when prompted to confirm each package removal.
Nice Work!
Nice Work!
You just turned raw weather data into a piece of digital art. Starting from a blank Python script, you built a full pipeline that fetches real-world climate data, transforms it into visual properties, and renders a radial portrait you can actually frame.
You've learned how to:
- Fetch a full year of real weather data from the Open-Meteo Historical API with zero authentication or API keys, and parse the JSON response into usable Python lists.
- Build a data normalization pipeline that maps raw weather values into visual properties. Temperature drives color, wind speed drives length, and precipitation drives thickness.
- Generate a radial artwork using Pillow and polar coordinates, where every day of the year is encoded as a colored bar radiating from a labeled center.
- Secret Mission: Created a side-by-side comparison of two cities on a single canvas, revealing how different climates produce completely different visual signatures.
Ready to quiz yourself?