Aligning Seasonal Travel Patterns Across Multiple Years
TL;DR: Map raw timestamps onto a fixed 365-day reference grid, adjust floating holidays to their per-year anchors before normalization, and encode the aligned day index as paired sine/cosine features. This eliminates calendar drift, leap-year discontinuities, and artificial year-end breaks so that Q3 2019 and Q3 2023 represent identical seasonal contexts for direct volume comparison.
Why This Happens
Multi-year mobility datasets accumulate three distinct sources of temporal misalignment, all rooted in the same parent problem covered in Seasonal & Cyclical Alignment:
- Leap-year offset. A leap year inserts day 60 (February 29), pushing every subsequent day-of-year (
doy) value up by one. June 1 in a leap year isdoy153; in a standard year it is 152. Naive cross-year aggregation on rawdoyvalues merges different calendar dates. - Floating holiday drift. Thanksgiving (US) shifts by up to 7 days annually. Easter and Lunar New Year can drift by nearly 30 days. Demand spikes anchored to these events land in different
doybuckets each year, masking or amplifying the apparent seasonality. - Year-boundary discontinuity. Linear time indexing treats December 31 (day 365) and January 1 (day 1) as numerically distant. This breaks distance-based algorithms, confuses gradient-based optimizers, and corrupts rolling statistics for mobility metrics that span the year boundary.
This page addresses all three. The broader context sits within Temporal Aggregation & Window Mapping, which governs how normalized periods are subsequently binned.
Alignment Pipeline Overview
Steps at a glance:
- Normalize to 365-day grid — shift all
doyvalues after February 28 back by one in leap years so the same calendar date always maps to the same index. - Phase-adjust floating holidays — compute per-year holiday dates, then offset seasonal windows relative to those anchors before normalization.
- Cyclical encoding — project the aligned
doyonto a unit circle viasin/costo make December 31 adjacent to January 1 in feature space. - Validate — overlay multi-year traces on the reference calendar and assert boundary continuity.
Production-Ready Implementation
The function below handles all four steps. It is compatible with pandas ≥ 2.0 and numpy ≥ 1.24, accepts UTC-aware or naive timestamps, and raises descriptive errors on bad inputs rather than silently returning empty frames.
import pandas as pd
import numpy as np
from typing import Literal
def align_seasonal_mobility(
df: pd.DataFrame,
date_col: str = "timestamp",
volume_col: str = "trips",
reference_year: int = 2023,
leap_handling: Literal["shift", "drop"] = "shift",
) -> pd.DataFrame:
"""
Align multi-year mobility data to a fixed 365-day seasonal reference frame.
Parameters
----------
df : pd.DataFrame
Must contain `date_col` (datetime-like) and `volume_col` (numeric).
date_col : str
Column holding observation timestamps. UTC-aware or naive both accepted.
volume_col : str
Numeric column to carry through alignment (e.g. trip counts, km driven).
reference_year : int
Calendar year onto which all aligned dates are projected (e.g. 2023).
leap_handling : {"shift", "drop"}
"shift" — days after Feb 28 in a leap year are shifted back by 1 to keep
365-day continuity and preserve all observations.
"drop" — Feb 29 observations are removed; simpler but loses ~0.27% of
leap-year data and can introduce gaps in rolling windows.
Returns
-------
pd.DataFrame with columns:
seasonal_date — datetime mapped to reference_year (for cross-year groupby)
doy_aligned — integer 1–365
doy_sin — sin(2π · doy_aligned / 365)
doy_cos — cos(2π · doy_aligned / 365)
<volume_col> — original volume, unchanged
Raises
------
ValueError — missing columns, empty DataFrame, or unknown leap_handling value.
"""
if df.empty:
raise ValueError("Input DataFrame is empty.")
for col in (date_col, volume_col):
if col not in df.columns:
raise ValueError(f"Missing required column: '{col}'")
if leap_handling not in ("shift", "drop"):
raise ValueError(f"leap_handling must be 'shift' or 'drop', got '{leap_handling}'")
df = df.copy()
df[date_col] = pd.to_datetime(df[date_col], utc=False)
# Strip timezone info if present so Timestamp arithmetic stays naive.
if df[date_col].dt.tz is not None:
df[date_col] = df[date_col].dt.tz_convert("UTC").dt.tz_localize(None)
df["_doy_raw"] = df[date_col].dt.dayofyear
df["_is_leap"] = df[date_col].dt.is_leap_year
if leap_handling == "shift":
# Feb 29 is doy 60 in a leap year. All days > 60 get shifted back by 1
# so June 1 always maps to doy 152, regardless of whether the source year
# is a leap year.
leap_post = df["_is_leap"] & (df["_doy_raw"] > 60)
df["doy_aligned"] = df["_doy_raw"].where(~leap_post, df["_doy_raw"] - 1)
else:
# Drop Feb 29 entirely (doy == 60 in a leap year).
drop_mask = df["_is_leap"] & (df["_doy_raw"] == 60)
if drop_mask.any():
dropped = drop_mask.sum()
import warnings
warnings.warn(
f"Dropping {dropped} Feb-29 observations (leap_handling='drop').",
stacklevel=2,
)
df = df[~drop_mask].copy()
df["doy_aligned"] = df["_doy_raw"]
df["doy_aligned"] = df["doy_aligned"].astype("int32")
# Project onto reference year so cross-year groupby works on real dates.
ref_start = pd.Timestamp(f"{reference_year}-01-01")
df["seasonal_date"] = ref_start + pd.to_timedelta(df["doy_aligned"] - 1, unit="D")
# Cyclical encoding: map doy onto a unit circle.
# Using 365 (not 365.25) because doy_aligned is already bounded to [1, 365].
angle = 2 * np.pi * df["doy_aligned"] / 365
df["doy_sin"] = np.sin(angle)
df["doy_cos"] = np.cos(angle)
# Clean up temporaries.
df.drop(columns=["_doy_raw", "_is_leap"], inplace=True)
return df[["seasonal_date", "doy_aligned", "doy_sin", "doy_cos", volume_col]]
Holiday phase offset helper
Call this before align_seasonal_mobility to shift demand windows relative to floating holidays. The example anchors on Thanksgiving (US), but the same pattern applies to Easter (Computus algorithm), Lunar New Year, or astronomical solstices.
from datetime import date, timedelta
import calendar
def nth_weekday_of_month(year: int, month: int, weekday: int, n: int) -> date:
"""Return the nth occurrence of `weekday` (0=Mon … 6=Sun) in the given month."""
first = date(year, month, 1)
first_weekday = first.weekday()
delta = (weekday - first_weekday) % 7
target = first + timedelta(days=delta + (n - 1) * 7)
if target.month != month:
raise ValueError(f"No {n}th weekday {weekday} in {year}-{month:02d}")
return target
def thanksgiving_offset_days(year: int, reference_year: int = 2023) -> int:
"""
Return how many days the Thanksgiving window in `year` is offset from
the reference-year Thanksgiving. Negative means earlier that year.
"""
ref_tg = nth_weekday_of_month(reference_year, 11, 3, 4) # 4th Thursday Nov
yr_tg = nth_weekday_of_month(year, 11, 3, 4)
ref_doy = ref_tg.timetuple().tm_yday
yr_doy = yr_tg.timetuple().tm_yday
return yr_doy - ref_doy # positive → later in the year than reference
def apply_holiday_window_offset(
df: pd.DataFrame,
date_col: str,
volume_col: str,
reference_year: int = 2023,
) -> pd.DataFrame:
"""
Shift each year's rows by the Thanksgiving offset so peak demand aligns
before the 365-day normalization step. Returns a modified copy.
"""
df = df.copy()
df[date_col] = pd.to_datetime(df[date_col])
df["_year"] = df[date_col].dt.year
df["_offset"] = df["_year"].map(
lambda y: thanksgiving_offset_days(y, reference_year)
)
df[date_col] = df[date_col] - pd.to_timedelta(df["_offset"], unit="D")
df.drop(columns=["_year", "_offset"], inplace=True)
return df
Validation Block
Run these checks immediately after alignment to catch misalignment before it propagates downstream.
import logging
logger = logging.getLogger(__name__)
def validate_alignment(aligned: pd.DataFrame, volume_col: str = "trips") -> None:
"""Assert structural invariants on the output of align_seasonal_mobility."""
# 1. Shape and non-emptiness.
assert not aligned.empty, "Aligned DataFrame is empty."
expected_cols = {"seasonal_date", "doy_aligned", "doy_sin", "doy_cos", volume_col}
missing = expected_cols - set(aligned.columns)
assert not missing, f"Missing output columns: {missing}"
# 2. doy_aligned must be within [1, 365] — no leap-day bleed-through.
assert aligned["doy_aligned"].between(1, 365).all(), (
"doy_aligned contains values outside [1, 365]; leap-year handling failed."
)
# 3. Cyclical features must lie on the unit circle (allow floating-point epsilon).
unit_check = (aligned["doy_sin"] ** 2 + aligned["doy_cos"] ** 2)
assert np.allclose(unit_check, 1.0, atol=1e-9), (
"sin² + cos² ≠ 1; cyclical encoding is corrupted."
)
# 4. Year-boundary continuity: Dec 31 (doy 365) and Jan 1 (doy 1) must be
# close in cyclical feature space. Angular distance should be near 2π/365.
day1 = aligned[aligned["doy_aligned"] == 1][["doy_sin", "doy_cos"]].values
day365 = aligned[aligned["doy_aligned"] == 365][["doy_sin", "doy_cos"]].values
if day1.size > 0 and day365.size > 0:
dot = np.dot(day1[0], day365[0]) # cosine similarity on unit vectors
angle_gap = np.degrees(np.arccos(np.clip(dot, -1, 1)))
assert angle_gap < 3.0, (
f"Year-boundary angular gap is {angle_gap:.2f}°; expected < 3° (≈1 day)."
)
# 5. seasonal_date must all map to reference year (single-year range).
years = aligned["seasonal_date"].dt.year.unique()
assert len(years) == 1, f"seasonal_date spans multiple years: {years}"
logger.info(
"Alignment validated: %d rows, doy range [%d, %d], volume sum %.0f",
len(aligned),
aligned["doy_aligned"].min(),
aligned["doy_aligned"].max(),
aligned[volume_col].sum(),
)
Expected output shape and sanity checks:
doy_alignedrange: 1–365 with no gaps larger than 1 for well-sampled data.seasonal_dateall inreference_year; no NaT values.doy_sin² + doy_cos²equals 1.0 for every row (unit-circle constraint).- Plotting
volume_colagainstseasonal_dategrouped by source year: curves should visually stack, with peaks at the samedoy_alignedoffset regardless of source year.
Common Mistakes and Gotchas
-
Applying the leap-year shift after encoding doy_sin/cos. The cyclical encoding must operate on
doy_aligned, notdoy_raw. Encoding before normalization misplaces roughly half of all leap-year observations by one day. -
Using 365.25 as the period divisor. This is correct for astronomical calculations but wrong here:
doy_alignedis already bounded to [1, 365], so dividing by 365 produces exact unit-circle closure. Using 365.25 leaves a fractional gap at the year boundary. -
Applying holiday offsets after normalization. Holiday offsets must be subtracted from the raw timestamps before the 365-day projection step. Reversing the order forces the offset to be re-applied to already-projected
seasonal_datevalues in the reference year, which scrambles the day index. -
Ignoring timezone mismatches across years. Mobility data collected near DST transitions or from cross-border fleets can shift timestamps by ±1 hour, pushing late-night observations into the wrong calendar day. Normalize to UTC before extracting
doy_raw. See handling timezone shifts in cross-border mobility data for the full treatment. -
Grouping by
seasonal_datewithout first summing within a year. If the source dataset has sub-daily granularity, grouping directly byseasonal_dateacross all years mixes volume scales when coverage differs by year. Aggregate to daily volumes per year first, then align and overlay.
Related
- Seasonal & Cyclical Alignment — parent topic covering decomposition, rolling windows, and frequency-domain methods
- Interpolating Missing GPS Points with Kalman Filters — gap-fill sparse trajectories before alignment to avoid spurious doy gaps
- Computing Rolling Average Speed over Sliding Time Windows — downstream consumer of aligned seasonal baselines
- Choosing Optimal Bin Sizes for Urban Mobility Heatmaps — selecting bin widths that respect the aligned seasonal reference frame
- Handling Timezone Shifts in Cross-Border Mobility Data — prerequisite UTC normalization step
Back to Seasonal & Cyclical Alignment