Animating footprint time series
fluxfootprints.footprint_animation turns the time-resolved footprint rasters produced by any model in this package into a video or animated GIF, one frame per time step, with the timestamp drawn on each frame.
This notebook covers:
building a footprint time series from half-hourly tower data,
animating it at hourly, daily, and monthly time steps,
drawing the footprint over a georeferenced basemap,
exporting MP4 as well as GIF, and
the styling options (color scaling, masking, source-area contours).
Requirements
GIF output works with a bare install. Basemaps and MP4/WebM need the optional extra, which pulls in contextily (map tiles) and imageio-ffmpeg (a bundled ffmpeg binary):
pip install "fluxfootprints[animation]"
An ffmpeg already on your PATH is used in preference to the bundled copy.
Setup
[1]:
import base64
import os
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from IPython.display import HTML
sys.path.insert(0, os.path.abspath("../../src")) # adjust path as needed
from fluxfootprints import (
FootprintAnimator,
animate_footprint,
build_climatology,
summarize_periods,
)
from fluxfootprints.footprint_animation import ensure_ffmpeg
OUT = Path("output")
OUT.mkdir(exist_ok=True)
def show(path, width=460):
"""Embed an animation inline, base64-encoded.
``IPython.display.Image`` renders GIFs in Jupyter but the ``image/gif``
output is dropped when Sphinx builds these pages, so the animations below
are embedded as HTML instead -- that renders everywhere.
"""
path = Path(path)
data = base64.b64encode(path.read_bytes()).decode("ascii")
if path.suffix == ".mp4":
return HTML(
f'<video controls loop width="{width}" '
f'src="data:video/mp4;base64,{data}"></video>'
)
mime = "image/gif" if path.suffix == ".gif" else "image/png"
return HTML(f'<img src="data:{mime};base64,{data}" width="{width}">')
# Example site location (used for georeferencing further down):
# irrigated pasture in Cache Valley, Utah.
STATION_LAT, STATION_LON = 41.66, -111.89
1. Half-hourly tower data
The animator needs a footprint time series, which in turn needs half-hourly micrometeorology. For a real site you would read it with load_amf_df(); here we synthesise six months of plausible-looking data so the notebook runs anywhere.
The wind direction drifts on a multi-day cycle and the turbulence follows a diurnal cycle, so the footprint both rotates and grows/shrinks over time – which is what makes the animation worth watching.
[2]:
rng = np.random.default_rng(42)
times = pd.date_range("2023-04-01", "2023-09-30 23:30", freq="30min")
n = len(times)
hour = (times.hour + times.minute / 60).to_numpy()
doy = times.dayofyear.to_numpy()
# Daytime fraction: 0 at night, 1 at solar noon.
day = np.clip(np.sin(np.pi * (hour - 6) / 12), 0, None)
met = pd.DataFrame(
{
# prevailing direction swings back and forth over ~25-day cycles
"wind_dir": (200 + 70 * np.sin(2 * np.pi * doy / 25) + rng.normal(0, 12, n)) % 360,
"umean": 2.0 + 2.5 * day + rng.normal(0, 0.3, n),
"ustar": (0.12 + 0.35 * day + rng.normal(0, 0.04, n)).clip(0.05, None),
# unstable by day, stable at night
"ol": np.where(day > 0.1, -50.0 / (day + 0.05), 120.0),
"sigmav": (0.30 + 0.60 * day + rng.normal(0, 0.05, n)).clip(0.10, None),
"zm": 2.0, # measurement height (m)
"z0": 0.05, # roughness length (m)
"h": 500 + 1200 * day, # boundary-layer height (m)
"LE": (20 + 220 * day + rng.normal(0, 10, n)).clip(0, None), # W m-2
},
index=times,
)
met.index.name = "datetime_start"
met.head()
[2]:
| wind_dir | umean | ustar | ol | sigmav | zm | z0 | h | LE | |
|---|---|---|---|---|---|---|---|---|---|
| datetime_start | |||||||||
| 2023-04-01 00:00:00 | 149.720678 | 1.902912 | 0.105943 | 120.0 | 0.325440 | 2.0 | 0.05 | 500.0 | 32.588329 |
| 2023-04-01 00:30:00 | 133.584264 | 2.127531 | 0.097906 | 120.0 | 0.351048 | 2.0 | 0.05 | 500.0 | 10.238564 |
| 2023-04-01 01:00:00 | 155.069487 | 2.492085 | 0.169734 | 120.0 | 0.255680 | 2.0 | 0.05 | 500.0 | 27.798096 |
| 2023-04-01 01:30:00 | 157.350850 | 2.505937 | 0.088282 | 120.0 | 0.309927 | 2.0 | 0.05 | 500.0 | 18.950612 |
| 2023-04-01 02:00:00 | 122.651651 | 1.591055 | 0.103610 | 120.0 | 0.292963 | 2.0 | 0.05 | 500.0 | 21.253645 |
2. Build the footprint time series
Any model registered with build_climatology() works. The animator reads the per-timestep rasters through get_footprint_timeseries(), so it is model-agnostic – swap model_type for "kormann-meixner", "ls", or "wang" and everything below is unchanged.
[3]:
model = build_climatology(
met,
model_type="ffp",
ustar="ustar",
ol="ol",
umean="umean",
sigmav="sigmav",
wind_dir="wind_dir",
zm="zm",
z0="z0",
h="h",
dx=15.0,
dy=15.0,
domain=(-400.0, 400.0, -400.0, 400.0),
verbosity=0,
)
f_2d = model.get_footprint_timeseries()
print("footprint time series:", dict(f_2d.sizes))
print("time range:", str(f_2d.time.values[0])[:16], "->", str(f_2d.time.values[-1])[:16])
footprint time series: {'time': 7426, 'x': 55, 'y': 55}
time range: 2023-04-01T00:00 -> 2023-09-30T23:30
3. Hourly animation
freq="hourly" averages the half-hourly rasters into hourly frames. The other accepted values are "daily", "monthly", "native"/None (no resampling), and any pandas offset alias such as "3h".
A few options that make footprints legible:
mask_quantilemakes the near-zero background transparent, so only the meaningful part of the footprint is painted;contour_fractions=(0.5, 0.8)outlines the 50 % and 80 % source areas;time_slicetrims the series – here to a single day, to keep the GIF small.
The timestamp format defaults to the time step (%Y-%m-%d %H:%M for hourly) and can be overridden with timestamp_format.
[4]:
hourly_gif = animate_footprint(
model,
OUT / "ffp_hourly.gif",
freq="hourly",
time_slice=("2023-06-05", "2023-06-05 23:59"),
mask_quantile=0.55,
contour_fractions=(0.5, 0.8),
title="Hourly footprint",
fps=5,
dpi=72,
figsize=(5.0, 4.0),
)
show(hourly_gif)
[4]:
4. Daily and monthly steps
Two routes give you daily and monthly frames:
pass
freq="daily"/freq="monthly"and let the animator resample, oranimate the summaries from
summarize_periods()withFootprintAnimator.from_summary(), which also gives you the ET-weighted layers.
from_summary takes the timestamp format from the layer, so monthly frames are labelled June 2023 rather than 2023-06-01.
[5]:
summaries = summarize_periods(model, met, et_source="LE", is_le=True)
daily_gif = FootprintAnimator.from_summary(
summaries,
layer="daily_mean",
time_slice=("2023-06-01", "2023-06-30"),
mask_quantile=0.55,
contour_fractions=(0.8,),
title="Daily mean footprint, June 2023",
fps=8,
dpi=72,
figsize=(5.0, 4.0),
).save(OUT / "ffp_daily.gif")
show(daily_gif)
[5]:
Monthly frames from the same summaries. layer="monthly_etw" would use the evapotranspiration-weighted footprint instead of the plain mean – the layer that answers “where did the water actually come from this month?”
[6]:
monthly_gif = FootprintAnimator.from_summary(
summaries,
layer="monthly_mean",
mask_quantile=0.5,
contour_fractions=(0.5, 0.8),
title="Monthly mean footprint",
fps=1,
dpi=80,
figsize=(5.5, 4.5),
).save(OUT / "ffp_monthly.gif")
show(monthly_gif)
[6]:
5. Georeferenced basemap
Give the animator station_lat / station_lon and the grid stops being “metres from the tower”: it is placed in the local UTM zone (or whatever projected CRS you pass as crs_out), using the same convention as export_rasters_geotiff().
With basemap=True, contextily fetches map tiles for that window once and draws them under every frame. Because the footprint is drawn semi-transparently (alpha, default 0.75 with a basemap) and the background is masked out, you can see which fields the flux is coming from.
Tile fetching needs network access. If it fails, the animation is still written – you just get a warning and no basemap.
[7]:
import contextily as cx
basemap_gif = animate_footprint(
summaries.f_daily_mean,
OUT / "ffp_daily_basemap.gif",
time_slice=("2023-06-01", "2023-06-10"),
station_lat=STATION_LAT,
station_lon=STATION_LON,
basemap=True,
basemap_source=cx.providers.Esri.WorldImagery,
norm="log",
cmap="inferno",
mask_quantile=0.6,
alpha=0.7,
contour_fractions=(0.8,),
title="Daily footprint over aerial imagery",
fps=3,
dpi=72,
figsize=(5.0, 4.2),
)
show(basemap_gif)
[7]:
The example above asks for Esri aerial imagery; leaving basemap_source out uses contextily’s default (OpenStreetMap), and any other contextily provider works too. basemap_zoom pins the tile zoom level when the automatic choice is too coarse or too slow:
animate_footprint(
summaries.f_daily_mean,
"out.gif",
station_lat=STATION_LAT,
station_lon=STATION_LON,
basemap=True,
basemap_source=cx.providers.OpenStreetMap.Mapnik,
basemap_zoom=16,
)
Note the log color scale in the example: footprint densities span many orders of magnitude, so norm="log" with log_decades (4 by default) shows the tail of the footprint that a linear scale flattens to a single color.
6. MP4 output
The file suffix picks the writer: .gif (Pillow), .mp4 / .mov / .m4v / .webm (ffmpeg), and .html (a self-contained player). ensure_ffmpeg() reports which binary will be used – an ffmpeg on PATH if there is one, otherwise the copy bundled with imageio-ffmpeg.
[8]:
ffmpeg_exe = ensure_ffmpeg()
print("ffmpeg:", Path(ffmpeg_exe).name if ffmpeg_exe else "not found")
mp4 = animate_footprint(
model,
OUT / "ffp_hourly.mp4",
freq="hourly",
time_slice=("2023-06-05", "2023-06-07"),
mask_quantile=0.55,
title="Hourly footprint",
fps=6,
dpi=80,
figsize=(5.5, 4.5),
)
print(f"{mp4} ({mp4.stat().st_size / 1024:.0f} KB)")
show(mp4)
ffmpeg: ffmpeg-win-x86_64-v7.1.exe
output\ffp_hourly.mp4 (59 KB)
[8]:
7. Styling, frames, and embedding
color_scale decides whether frames are comparable to each other:
"global"(default) fixes the color limits across the whole animation, so a fading footprint really looks like it is fading;"per_frame"rescales every frame to its own maximum, which maximises contrast but hides changes in magnitude.
annotation_fn adds a second line of text under the timestamp, and frames_dir writes every frame out as a numbered PNG next to the animation – handy for pulling a single date into a paper figure.
[9]:
anim = FootprintAnimator(
summaries.f_monthly_mean,
station_lat=STATION_LAT,
station_lon=STATION_LON,
color_scale="per_frame",
norm="log",
mask_quantile=0.5,
contour_fractions=(0.5, 0.8),
annotation_fn=lambda ts, arr: f"peak {np.nanmax(arr):.1e} m$^{{-2}}$",
timestamp_loc="lower right",
title="Monthly footprint, per-frame scaling",
fps=1,
dpi=80,
figsize=(5.5, 4.5),
)
anim.save(OUT / "ffp_monthly_styled.gif", frames_dir=OUT / "frames")
frames = sorted((OUT / "frames").glob("*.png"))
print(f"{len(frames)} frames written, e.g. {frames[0].name}")
show(frames[2])
6 frames written, e.g. frame_0_20230401T0000.png
[9]:
To embed an animation in a notebook without writing a file at all, use build() and matplotlib’s own HTML player:
fig, animation = anim.build()
HTML(animation.to_jshtml())
Parameter cheat-sheet
Parameter |
What it does |
|---|---|
|
|
|
how frames are aggregated when resampling ( |
|
rescale each frame to sum to 1 (compare shape, not magnitude) |
|
|
|
georeference the grid; required for basemaps |
|
target projected CRS ( |
|
contextily tile layer |
|
color scaling |
|
|
|
hide/soften the near-zero background |
|
source-area contours drawn per frame |
|
text |
|
output size and speed |
See the API reference for the full signature.