Global Weather Forecast Time Lapses with geeViz¶
This notebook demonstrates how to visualize weather forecast data from multiple global models using geeViz.geeView time-lapse features.
Model |
Collection |
Type |
Resolution |
Forecast Range |
Time Step |
|---|---|---|---|---|---|
NOAA GFS |
|
Deterministic |
0.25° |
384h (16 days) |
3-6h |
ECMWF IFS |
|
Deterministic |
0.25° |
240h (10 days) |
3h |
WeatherNext Graph |
|
Deterministic (AI) |
0.25° |
240h (10 days) |
6h |
WeatherNext 2 |
|
64-member Ensemble (AI) |
0.25° |
360h (15 days) |
6h |
Each section loads the most recent model run and adds time-lapse layers for temperature, precipitation, wind, pressure, and upper-air fields.
Copyright 2026 Ian Housman
Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
import os, sys, math, datetime
try:
import geeViz.getImagesLib as gil
except:
!python -m pip install geeViz
import geeViz.getImagesLib as gil
import geeViz.geeView as gv
import geeViz.geePalettes as palettes
ee = gv.ee
Map = gv.Map
Map.clearMap()
today = ee.Date(datetime.datetime.now())
print('Imports ready')
Common Settings¶
Shared palettes, unit conversions, and visualization parameters used across all models
Forecast window: first 5 days (120 hours) at 6-hour intervals
All temperatures converted from Kelvin to Celsius
All wind speeds converted from m/s to km/h
# Forecast hours to display (every 6 hours for 5 days)
which_hours_6h = list(range(6, 120 + 1, 6)) # for 6h-step models
which_hours_3h = list(range(3, 120 + 1, 3)) # for 3h-step models (GFS, ECMWF)
# --- Palettes ---
cmOceanThermal = palettes.cmocean['Thermal'][7]
cmOceanSpeed = palettes.cmocean['Speed'][7]
cmOceanTempo = palettes.cmocean['Tempo'][7]
cmOceanDeep = palettes.cmocean['Deep'][7]
# Circular wind direction palette
windDirPalette = list(cmOceanDeep) + list(reversed(cmOceanDeep))
# Precipitation: white -> blue -> purple
precipPalette = ['ffffff', 'c6dbef', '9ecae1', '6baed6', '3182bd', '08519c', '4a1486']
# Pressure: blue (low) -> white -> red (high)
mslpPalette = ['08306b', '2171b5', '6baed6', 'c6dbef', 'fcbba1', 'fb6a4a', 'cb181d', '67000d']
# Inferno for ensemble spread / uncertainty
spreadPalette = ['000004', '420a68', '932667', 'dd513a', 'fca50a', 'fcffa4']
# SST: ocean thermal
sstPalette = ['03045e', '0077b6', '00b4d8', '90e0ef', 'caf0f8', 'fef9ef', 'fed9b7', 'f07167', 'd62828']
# 500 hPa heights
z500Palette = ['08306b', '2171b5', '6baed6', 'c6dbef', 'fee0d2', 'fc9272', 'de2d26', 'a50f15']
# --- Helper functions ---
def wind_speed_direction(img, u_band, v_band):
"""Compute wind speed (km/h) and direction (degrees) from u/v components."""
u = img.select([u_band])
v = img.select([v_band])
speed = u.hypot(v).multiply(3.6) # m/s -> km/h
direction = u.atan2(v).divide(math.pi).add(1).multiply(180)
return img.addBands(
ee.Image.cat([speed, direction]).rename(['Speed', 'Direction'])
).copyProperties(img, img.propertyNames())
print('Palettes and helpers ready')
1. NOAA GFS (Global Forecast System)¶
Operational NWP model from NCEP, 0.25° resolution
Deterministic forecast out to 384 hours (16 days)
3-hour time steps (first 120h), 12-hour steps beyond
Band naming convention:
variable_level(e.g.temperature_2m_above_ground)Initialization via
creation_timeproperty (epoch ms)
print('Loading GFS...')
gfs_raw = (
ee.ImageCollection('NOAA/GFS0P25')
.filter(ee.Filter.gt('creation_time', today.advance(-1, 'day').millis()))
)
# Most recent model run
gfs_init = ee.Number.parse(
gfs_raw.aggregate_histogram('creation_time').keys().reduce(ee.Reducer.max())
)
gfs = (
gfs_raw
.filter(ee.Filter.eq('creation_time', gfs_init))
.filter(ee.Filter.inList('forecast_hours', which_hours_3h))
)
gfs_proj = gfs.first().projection().getInfo()
gfs_crs = gfs_proj['wkt']
# Common viz params for GFS layers
gfs_viz = {
'dateFormat': 'YY-MM-dd HH',
'advanceInterval': 'hour',
'canAreaChart': True,
'areaChartParams': {'scale': 27830, 'crs': gfs_crs, 'minZoomSpecifiedScale': 5},
'reducer': ee.Reducer.mean(),
}
# Wind speed + direction
gfs_wind = gfs.map(
lambda img: wind_speed_direction(img, 'u_component_of_wind_10m_above_ground', 'v_component_of_wind_10m_above_ground')
)
print(f'GFS images: {gfs.size().getInfo()}')
# --- Add GFS layers ---
Map.addTimeLapse(
gfs.select(['temperature_2m_above_ground']),
{**gfs_viz, 'min': -20, 'max': 40, 'palette': cmOceanThermal,
'legendLabelLeftAfter': 'C', 'legendLabelRightAfter': 'C'},
'GFS: Temperature (2m)',
)
Map.addTimeLapse(
gfs.select(['precipitable_water_entire_atmosphere']),
{**gfs_viz, 'min': 0, 'max': 30, 'palette': cmOceanTempo,
'legendLabelLeftAfter': 'kg/m²', 'legendLabelRightAfter': 'kg/m²'},
'GFS: Precipitable Water',
)
Map.addTimeLapse(
gfs_wind.select(['Speed']),
{**gfs_viz, 'min': 0, 'max': 60, 'palette': cmOceanSpeed,
'legendLabelLeftAfter': 'km/h', 'legendLabelRightAfter': 'km/h'},
'GFS: Wind Speed (10m)',
)
Map.addTimeLapse(
gfs_wind.select(['Direction']),
{**gfs_viz, 'min': 0, 'max': 360, 'palette': windDirPalette,
'legendLabelLeftAfter': 'deg', 'legendLabelRightAfter': 'deg'},
'GFS: Wind Direction (10m)',
)
print('GFS layers added')
2. ECMWF IFS (European Centre for Medium-Range Weather Forecasts)¶
Widely regarded as the world’s best operational NWP model
Deterministic high-resolution forecast, 0.25° resolution
3-hour time steps out to 240 hours (10 days)
Band naming convention:
variable_surfaceorvariable_plXXXfor pressure levelsInitialization via
creation_timeproperty (epoch ms)Includes pressure-level fields (divergence, vorticity, geopotential height)
print('Loading ECMWF IFS...')
ecmwf_raw = (
ee.ImageCollection('ECMWF/NRT_FORECAST/IFS/OPER')
.filter(ee.Filter.gt('creation_time', today.advance(-1, 'day').millis()))
)
# Most recent model run
ecmwf_init = ee.Number.parse(
ecmwf_raw.aggregate_histogram('creation_time').keys().reduce(ee.Reducer.max())
)
ecmwf = (
ecmwf_raw
.filter(ee.Filter.eq('creation_time', ecmwf_init))
.filter(ee.Filter.inList('forecast_hours', which_hours_3h))
)
ecmwf_viz = {
'dateFormat': 'YY-MM-dd HH',
'advanceInterval': 'hour',
'canAreaChart': True,
'areaChartParams': {'scale': 27830, 'crs': gfs_crs, 'minZoomSpecifiedScale': 5},
'reducer': ee.Reducer.mean(),
}
# Wind speed + direction
ecmwf_wind = ecmwf.map(
lambda img: wind_speed_direction(img, 'u_component_of_wind_10m_sfc', 'v_component_of_wind_10m_sfc')
)
print(f'ECMWF images: {ecmwf.size().getInfo()}')
# --- Add ECMWF layers ---
# Temperature (already in Kelvin for ECMWF, convert to C)
ecmwf_temp = ecmwf.map(
lambda img: img.select(['temperature_2m_sfc']).subtract(273.15)
.rename(['Temperature_C']).copyProperties(img, img.propertyNames())
)
Map.addTimeLapse(
ecmwf_temp,
{**ecmwf_viz, 'min': -20, 'max': 45, 'palette': cmOceanThermal,
'legendLabelLeftAfter': 'C', 'legendLabelRightAfter': 'C'},
'ECMWF: Temperature (2m)',
)
# Total precipitation (meters -> mm)
ecmwf_precip = ecmwf.map(
lambda img: img.select(['total_precipitation_sfc']).multiply(1000)
.rename(['Precip_mm']).copyProperties(img, img.propertyNames())
)
Map.addTimeLapse(
ecmwf_precip,
{**ecmwf_viz, 'min': 0, 'max': 50, 'palette': precipPalette,
'legendLabelLeftAfter': 'mm', 'legendLabelRightAfter': 'mm'},
'ECMWF: Total Precipitation',
)
Map.addTimeLapse(
ecmwf_wind.select(['Speed']),
{**ecmwf_viz, 'min': 0, 'max': 80, 'palette': cmOceanSpeed,
'legendLabelLeftAfter': 'km/h', 'legendLabelRightAfter': 'km/h'},
'ECMWF: Wind Speed (10m)',
)
# Mean sea level pressure (Pa -> hPa)
ecmwf_mslp = ecmwf.map(
lambda img: img.select(['mean_sea_level_pressure_sfc']).divide(100)
.rename(['MSLP_hPa']).copyProperties(img, img.propertyNames())
)
Map.addTimeLapse(
ecmwf_mslp,
{**ecmwf_viz, 'min': 980, 'max': 1040, 'palette': mslpPalette,
'legendLabelLeftAfter': 'hPa', 'legendLabelRightAfter': 'hPa'},
'ECMWF: Sea Level Pressure',
)
# 500 hPa geopotential height (already in meters for ECMWF)
Map.addTimeLapse(
ecmwf.select(['geopotential_height_pl500']),
{**ecmwf_viz, 'min': 4800, 'max': 5900, 'palette': z500Palette,
'legendLabelLeftAfter': 'm', 'legendLabelRightAfter': 'm'},
'ECMWF: 500 hPa Height',
)
# Dewpoint temperature (K -> C) — useful for moisture analysis
ecmwf_dew = ecmwf.map(
lambda img: img.select(['dewpoint_temperature_2m_sfc']).subtract(273.15)
.rename(['Dewpoint_C']).copyProperties(img, img.propertyNames())
)
Map.addTimeLapse(
ecmwf_dew,
{**ecmwf_viz, 'min': -30, 'max': 25, 'palette': cmOceanTempo,
'legendLabelLeftAfter': 'C', 'legendLabelRightAfter': 'C'},
'ECMWF: Dewpoint Temperature (2m)',
)
print('ECMWF layers added')
3. WeatherNext Graph — Deterministic AI Forecast¶
Google’s GraphCast-based AI weather model
Single deterministic run (no ensemble members)
6-hour time steps out to 240 hours (10 days)
Initialization via
start_timeproperty (ISO 8601 string)Bands use ERA5-style naming:
2m_temperature,10m_u_component_of_wind, etc.Includes pressure-level fields at 13 levels (50-1000 hPa)
print('Loading WeatherNext Graph (deterministic)...')
def wn_prep(img):
"""Set system:time_start to forecast valid time for WeatherNext."""
start = ee.Date(img.get('start_time'))
fh = ee.Number(img.get('forecast_hour'))
return img.set('system:time_start', start.advance(fh, 'hour').millis())
graph_raw = (
ee.ImageCollection('projects/gcp-public-data-weathernext/assets/59572747_4_0')
.filter(ee.Filter.gt('system:time_start', today.advance(-12, 'hour').millis()))
.filter(ee.Filter.inList('forecast_hour', which_hours_6h))
)
graph_init = graph_raw.aggregate_array('start_time').distinct().sort().get(-1)
graph = graph_raw.filter(ee.Filter.eq('start_time', graph_init)).map(wn_prep)
wn_viz = {
'dateFormat': 'YY-MM-dd HH',
'advanceInterval': 'hour',
'canAreaChart': True,
'areaChartParams': {'scale': 27830, 'crs': gfs_crs, 'minZoomSpecifiedScale': 5},
'reducer': ee.Reducer.mean(),
}
# Temperature (K -> C)
graph_temp = graph.map(
lambda img: img.select(['2m_temperature']).subtract(273.15)
.rename(['Temperature_C']).copyProperties(img, img.propertyNames())
)
# Wind
graph_wind = graph.map(
lambda img: wind_speed_direction(img, '10m_u_component_of_wind', '10m_v_component_of_wind')
)
# Precipitation (m -> mm)
graph_precip = graph.map(
lambda img: img.select(['total_precipitation_6hr']).multiply(1000)
.rename(['Precip_mm']).copyProperties(img, img.propertyNames())
)
# MSLP (Pa -> hPa)
graph_mslp = graph.map(
lambda img: img.select(['mean_sea_level_pressure']).divide(100)
.rename(['MSLP_hPa']).copyProperties(img, img.propertyNames())
)
# 500 hPa height (geopotential / g)
graph_z500 = graph.map(
lambda img: img.select(['500_geopotential']).divide(9.80665)
.rename(['Z500_m']).copyProperties(img, img.propertyNames())
)
# SST (K -> C)
graph_sst = graph.map(
lambda img: img.select(['sea_surface_temperature']).subtract(273.15)
.rename(['SST_C']).copyProperties(img, img.propertyNames())
)
print(f'Graph images: {graph.size().getInfo()}')
# --- Add Graph layers ---
Map.addTimeLapse(graph_temp, {**wn_viz, 'min': -20, 'max': 45, 'palette': cmOceanThermal,
'legendLabelLeftAfter': 'C', 'legendLabelRightAfter': 'C'}, 'WN Graph: Temperature (2m)')
Map.addTimeLapse(graph_precip, {**wn_viz, 'min': 0, 'max': 30, 'palette': precipPalette,
'legendLabelLeftAfter': 'mm', 'legendLabelRightAfter': 'mm'}, 'WN Graph: Precipitation (6hr)')
Map.addTimeLapse(graph_wind.select(['Speed']), {**wn_viz, 'min': 0, 'max': 80, 'palette': cmOceanSpeed,
'legendLabelLeftAfter': 'km/h', 'legendLabelRightAfter': 'km/h'}, 'WN Graph: Wind Speed (10m)')
Map.addTimeLapse(graph_wind.select(['Direction']), {**wn_viz, 'min': 0, 'max': 360, 'palette': windDirPalette,
'legendLabelLeftAfter': 'deg', 'legendLabelRightAfter': 'deg'}, 'WN Graph: Wind Direction (10m)')
Map.addTimeLapse(graph_mslp, {**wn_viz, 'min': 980, 'max': 1040, 'palette': mslpPalette,
'legendLabelLeftAfter': 'hPa', 'legendLabelRightAfter': 'hPa'}, 'WN Graph: Sea Level Pressure')
Map.addTimeLapse(graph_z500, {**wn_viz, 'min': 4800, 'max': 5900, 'palette': z500Palette,
'legendLabelLeftAfter': 'm', 'legendLabelRightAfter': 'm'}, 'WN Graph: 500 hPa Height')
Map.addTimeLapse(graph_sst, {**wn_viz, 'min': -2, 'max': 32, 'palette': sstPalette,
'legendLabelLeftAfter': 'C', 'legendLabelRightAfter': 'C'}, 'WN Graph: Sea Surface Temperature')
print('WeatherNext Graph layers added')
4. WeatherNext 2 — 64-Member Ensemble¶
Google’s ensemble AI weather model with 64 members
Each member is a plausible forecast trajectory
By computing mean and standard deviation across members we get:
Ensemble mean: best-estimate forecast (smooths out individual member noise)
Ensemble spread (StdDev): forecast uncertainty — low spread = high confidence, high spread = uncertain
Ensemble max: worst-case envelope (e.g., heaviest precipitation any member predicts)
Spread typically increases with lead time as forecast uncertainty grows
6-hour time steps out to 360 hours (15 days)
print('Loading WeatherNext 2 (64-member ensemble)...')
wn2_raw = (
ee.ImageCollection('projects/gcp-public-data-weathernext/assets/weathernext_2_0_0')
.filter(ee.Filter.gt('system:time_start', today.advance(-12, 'hour').millis()))
.filter(ee.Filter.inList('forecast_hour', which_hours_6h))
)
wn2_init = wn2_raw.aggregate_array('start_time').distinct().sort().get(-1)
wn2 = wn2_raw.filter(ee.Filter.eq('start_time', wn2_init))
def ensemble_stats(hour):
"""Compute ensemble mean, std dev, and max across all members for one forecast hour."""
hour = ee.Number(hour)
members = wn2.filter(ee.Filter.eq('forecast_hour', hour))
# Temperature (K -> C)
temp = members.map(lambda img: img.select(['2m_temperature']).subtract(273.15).rename(['T']))
t_mean = temp.mean().rename(['Temp_Mean'])
t_std = temp.reduce(ee.Reducer.stdDev()).rename(['Temp_Spread'])
# Precipitation (m -> mm)
precip = members.map(lambda img: img.select(['total_precipitation_6hr']).multiply(1000).rename(['P']))
p_mean = precip.mean().rename(['Precip_Mean'])
p_max = precip.max().rename(['Precip_Max'])
# Wind speed (m/s -> km/h)
wind = members.map(lambda img: wind_speed_direction(img, '10m_u_component_of_wind', '10m_v_component_of_wind'))
w_mean = wind.select(['Speed']).mean().rename(['Wind_Mean'])
w_std = wind.select(['Speed']).reduce(ee.Reducer.stdDev()).rename(['Wind_Spread'])
valid_time = ee.Date(wn2_init).advance(hour, 'hour')
return (
ee.Image.cat([t_mean, t_std, p_mean, p_max, w_mean, w_std])
.set('system:time_start', valid_time.millis())
.set('forecast_hour', hour)
)
wn2_stats = ee.ImageCollection(ee.List(which_hours_6h).map(ensemble_stats))
print(f'WN2 ensemble stat images: {wn2_stats.size().getInfo()}')
# --- Add ensemble layers ---
# Temperature mean
Map.addTimeLapse(wn2_stats.select(['Temp_Mean']),
{**wn_viz, 'min': -20, 'max': 45, 'palette': cmOceanThermal,
'legendLabelLeftAfter': 'C', 'legendLabelRightAfter': 'C'},
'WN2 Ensemble: Temp Mean')
# Temperature spread — shows where forecasts diverge
Map.addTimeLapse(wn2_stats.select(['Temp_Spread']),
{**wn_viz, 'min': 0, 'max': 8, 'palette': spreadPalette,
'legendLabelLeftAfter': 'C', 'legendLabelRightAfter': 'C'},
'WN2 Ensemble: Temp Spread (uncertainty)')
# Precip mean
Map.addTimeLapse(wn2_stats.select(['Precip_Mean']),
{**wn_viz, 'min': 0, 'max': 30, 'palette': precipPalette,
'legendLabelLeftAfter': 'mm', 'legendLabelRightAfter': 'mm'},
'WN2 Ensemble: Precip Mean (6hr)')
# Precip max — worst-case across all 64 members
Map.addTimeLapse(wn2_stats.select(['Precip_Max']),
{**wn_viz, 'min': 0, 'max': 50, 'palette': precipPalette,
'legendLabelLeftAfter': 'mm', 'legendLabelRightAfter': 'mm'},
'WN2 Ensemble: Precip Max (worst-case)')
# Wind mean
Map.addTimeLapse(wn2_stats.select(['Wind_Mean']),
{**wn_viz, 'min': 0, 'max': 80, 'palette': cmOceanSpeed,
'legendLabelLeftAfter': 'km/h', 'legendLabelRightAfter': 'km/h'},
'WN2 Ensemble: Wind Speed Mean')
# Wind spread
Map.addTimeLapse(wn2_stats.select(['Wind_Spread']),
{**wn_viz, 'min': 0, 'max': 15, 'palette': spreadPalette,
'legendLabelLeftAfter': 'km/h', 'legendLabelRightAfter': 'km/h'},
'WN2 Ensemble: Wind Spread (uncertainty)')
print('WeatherNext 2 ensemble layers added')
5. Model Comparison — Temperature Difference Maps¶
Compare model forecasts by computing the difference between two models at matching valid times
Useful for identifying where models agree (near zero) and where they diverge (large positive/negative)
Here we compare: ECMWF vs GFS, WeatherNext Graph vs GFS, and WN2 Ensemble Mean vs GFS
# Difference palette: blue (model A warmer) -> white (agree) -> red (model B warmer)
diffPalette = ['2166ac', '67a9cf', 'd1e5f0', 'f7f7f7', 'fddbc7', 'ef8a62', 'b2182b']
# To compute differences we need matching valid times.
# Use 6h steps (common to all models) and join on system:time_start.
# GFS temp in Celsius (GFS reports in Celsius already for 2m temp)
gfs_temp_6h = gfs.filter(ee.Filter.inList('forecast_hours', which_hours_6h)).select(['temperature_2m_above_ground'])
# Build difference: WN Graph - GFS
def diff_graph_gfs(graph_img):
t = graph_img.get('system:time_start')
gfs_match = gfs_temp_6h.filter(ee.Filter.eq('forecast_time', t)).first()
diff = graph_img.select(['Temperature_C']).subtract(gfs_match.rename(['Temperature_C']))
return diff.rename(['Temp_Diff_C']).set('system:time_start', t)
try:
diff_wn_gfs = graph_temp.map(diff_graph_gfs)
Map.addTimeLapse(diff_wn_gfs,
{**wn_viz, 'min': -5, 'max': 5, 'palette': diffPalette,
'legendLabelLeftAfter': 'C', 'legendLabelRightAfter': 'C'},
'Diff: WN Graph - GFS Temperature')
print('Difference layer added')
except Exception as e:
print(f'Difference layer skipped (time mismatch): {e}')
View the Map¶
Toggle layers in the layer panel to compare models
Use the time slider to step through forecast hours
Click on the map to see area charts of forecast values
Compare ensemble spread layers to see how uncertainty evolves with forecast lead time
Map.setQueryDateFormat('YYYY-MM-dd HH:mm')
Map.turnOnInspector()
Map.view()
Summary of Available Bands¶
Surface / Single-Level Variables¶
Variable |
GFS Band |
ECMWF Band |
WeatherNext Band |
Units |
|---|---|---|---|---|
Temperature (2m) |
|
|
|
K (ECMWF/WN) or C (GFS) |
Dewpoint (2m) |
|
|
— |
K |
U-wind (10m) |
|
|
|
m/s |
V-wind (10m) |
|
|
|
m/s |
U-wind (100m) |
— |
|
|
m/s |
V-wind (100m) |
— |
|
|
m/s |
MSLP |
— |
|
|
Pa |
Precipitation |
|
|
|
varies |
Precipitable Water |
|
— |
— |
kg/m² |
SST |
— |
|
|
K |
Pressure-Level Variables (selected levels)¶
Variable |
ECMWF Pattern |
WeatherNext Pattern |
Levels |
|---|---|---|---|
Geopotential Height |
|
|
50-1000 hPa |
Temperature |
|
|
50-1000 hPa |
U-wind |
|
|
50-1000 hPa |
V-wind |
|
|
50-1000 hPa |
Specific Humidity |
|
|
50-1000 hPa |
Vorticity |
|
— |
50-1000 hPa |
Model Initialization Properties¶
Property |
GFS |
ECMWF |
WeatherNext |
|---|---|---|---|
Init time |
|
|
|
Forecast hour |
|
|
|
Valid time |
|
|
computed from |
Ensemble member |
— |
— |
|