| name | temporal-circadian-patterns |
| description | Use when analyzing timestamped activity data to reconstruct activity profiles, detect hourly or daily or weekly or seasonal patterns, identify circadian regularity, find activity bursts, or classify engagement style from posting times across any time-series of user actions |
Temporal and Circadian Pattern Analysis
Overview
Aggregate timestamps into hourly, daily, weekly, and seasonal bins to reconstruct activity profiles and identify temporal patterns. The core principle: timestamps encode behavioral rhythms -- binning at multiple granularities reveals daily regularity, weekly cycles, seasonal trends, and intermittent bursts that single-granularity analysis misses.
When to Use
- Dataset contains timestamps for user actions (posts, commits, purchases, logins, messages)
- Need to reconstruct when a user or system is most/least active
- Investigating circadian regularity (consistent daily patterns vs. erratic timing)
- Detecting weekly cycles (weekday vs. weekend behavior differences)
- Identifying seasonal trends or long-term activity evolution
- Finding activity bursts (sudden spikes above baseline)
- Classifying engagement style from temporal patterns (steady contributor, binge user, weekend warrior)
- Feeding temporal features into downstream profiling or clustering
When NOT to use:
- Timestamps lack any timezone indication AND timezone cannot be reasonably inferred -- temporal binning without timezone normalization produces misleading hour-of-day profiles
- Fewer than 20 total timestamps -- insufficient for any temporal pattern (see Insufficient Data section)
- Need real-time anomaly detection on streaming data -- this skill is for retrospective batch analysis
- Attempting to infer physical location from activity timing (see Boundaries)
digraph granularity_decision {
rankdir=TB;
node [shape=diamond, style=filled, fillcolor="#f0f0f0"];
span [label="Activity timespan?"];
days [label="< 2 weeks", shape=box, fillcolor="#ffffcc"];
weeks [label="2 weeks -\n3 months", shape=box, fillcolor="#ccffcc"];
months [label="3 months -\n2 years", shape=box, fillcolor="#ccffcc"];
years [label="2+ years", shape=box, fillcolor="#ccffcc"];
days_g [label="Hourly + daily bins\nSkip weekly/seasonal", shape=box, fillcolor="#ffffcc"];
weeks_g [label="Hourly + daily +\nday-of-week bins", shape=box, fillcolor="#ccffcc"];
months_g [label="All granularities\nWeekly + monthly trends", shape=box, fillcolor="#ccffcc"];
years_g [label="All granularities +\nseasonal decomposition", shape=box, fillcolor="#ccffcc"];
span -> days [label="< 14 days"];
span -> weeks [label="14-90 days"];
span -> months [label="90-730 days"];
span -> years [label="730+ days"];
days -> days_g;
weeks -> weeks_g;
months -> months_g;
years -> years_g;
}
Quick Reference
| Concept | Value / Threshold | Notes |
|---|
| Minimum timestamps (any analysis) | 20 | Below this, list raw timestamps only |
| Minimum for hourly bins | 50+ across 7+ distinct days | Sparse days produce empty/misleading hour bins |
| Minimum for weekly patterns | 4+ weeks of data | Need at least 4 full weeks to detect weekly cycles |
| Minimum for seasonal decomposition | 2+ full seasonal cycles | For weekly seasonality: 14+ weeks; for annual: 2+ years |
| Timezone normalization | ALWAYS before hour-of-day binning | UTC-only analysis misaligns circadian patterns by offset |
| DST transitions | Use pytz or zoneinfo with tz_localize | Naive arithmetic offsets produce duplicate/missing hours at DST boundaries |
| Burst detection threshold | Activity > mean + 2*std in a window | Simpler alternative to Kleinberg; adjust multiplier per corpus |
| Heatmap axes | Rows = day-of-week, Columns = hour-of-day | Standard orientation; 7x24 grid is the canonical activity heatmap |
| Resampling aliases (pandas) | h=hourly, D=daily, W=weekly, ME=month-end | Use ME not deprecated M in pandas >= 2.0 |
Workflow
Copy this checklist and track progress:
Temporal and Circadian Pattern Analysis Progress:
- [ ] Step 1: Validate timestamp data and timezone information
- [ ] Step 2: Normalize timezones and handle DST
- [ ] Step 3: Multi-granularity binning (hourly, daily, weekly, monthly)
- [ ] Step 4: Build activity heatmap (day-of-week x hour-of-day)
- [ ] Step 5: Detect circadian regularity
- [ ] Step 6: Identify weekly and day-of-week patterns
- [ ] Step 7: Seasonal decomposition (if timespan sufficient)
- [ ] Step 8: Burst detection
- [ ] Step 9: Classify engagement style
- [ ] Step 10: Write findings to docs/analysis/12-temporal-circadian-patterns.md
Step 1: Validate Timestamp Data and Timezone Information
Before any binning, verify timestamp quality and timezone availability.
import pandas as pd
import numpy as np
def validate_timestamps(df, ts_col, tz_col=None):
"""Validate timestamp column and assess timezone information.
Adapt ts_col and tz_col to your data:
- 'created_utc', 'timestamp', 'date', 'created_at', etc.
- tz_col may be a dedicated column or embedded in the timestamp string.
"""
ts = pd.to_datetime(df[ts_col], errors='coerce')
valid_count = ts.notna().sum()
invalid_count = ts.isna().sum()
timespan = (ts.max() - ts.min()) if valid_count >= 2 else pd.Timedelta(0)
tz_info = 'none'
if ts.dt.tz is not None:
tz_info = f'tz-aware: {ts.dt.tz}'
elif tz_col and tz_col in df.columns:
tz_info = f'separate tz column: {df[tz_col].nunique()} unique zones'
elif ts_col.endswith('_utc') or 'utc' in ts_col.lower():
tz_info = 'inferred UTC from column name'
report = {
'total_records': len(df),
'valid_timestamps': valid_count,
'invalid_timestamps': invalid_count,
'timespan': timespan,
'timespan_days': timespan.days,
'earliest': ts.min(),
'latest': ts.max(),
'timezone_status': tz_info,
'distinct_dates': ts.dt.date.nunique(),
'distinct_hours': ts.dt.hour.nunique(),
}
report['viable_hourly'] = valid_count >= 50 and report['distinct_dates'] >= 7
report['viable_weekly'] = timespan.days >= 28
report['viable_seasonal'] = timespan.days >= 365
report['viable_any'] = valid_count >= 20
return report, ts
If timestamps lack timezone info:
- Check if column name implies UTC (e.g.,
created_utc, timestamp_utc)
- Check if a separate timezone/offset column exists
- If neither: document the assumption ("timestamps treated as UTC; hour-of-day analysis may be offset from local time") and proceed with UTC
- Do NOT guess a timezone from activity patterns -- that reverses the analysis direction (see Boundaries)
Step 2: Normalize Timezones and Handle DST
from zoneinfo import ZoneInfo
def normalize_timezone(ts_series, source_tz='UTC', target_tz=None):
"""Convert timestamps to a consistent timezone for analysis.
source_tz: what the raw timestamps represent
target_tz: what to convert to (None = keep source)
"""
if ts_series.dt.tz is None:
ts_series = ts_series.dt.tz_localize(source_tz)
if target_tz and target_tz != source_tz:
ts_series = ts_series.dt.tz_convert(target_tz)
return ts_series
DST pitfall: Naive UTC offset arithmetic (timestamp + timedelta(hours=-5) for US Eastern) produces duplicate hours at fall-back and missing hours at spring-forward. Always use tz_convert() with a proper timezone database.
Step 3: Multi-Granularity Binning
def compute_activity_bins(ts_series):
"""Bin timestamps at multiple granularities.
Returns a dict of Series, each indexed by the bin period."""
ts = ts_series.dropna().sort_values()
bins = {}
bins['hourly'] = ts.dt.floor('h').value_counts().sort_index()
bins['hour_of_day'] = ts.dt.hour.value_counts().sort_index()
bins['daily'] = ts.dt.date.value_counts().sort_index()
bins['day_of_week'] = ts.dt.dayofweek.value_counts().sort_index()
bins['day_of_week'].index = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
weekly = ts.to_frame('ts').set_index('ts').resample('W').size()
bins['weekly'] = weekly
monthly = ts.to_frame('ts').set_index('ts').resample('ME').size()
bins['monthly'] = monthly
return bins
Always compute hour-of-day and day-of-week aggregates. These cross-day/cross-week aggregates reveal the repeating patterns that per-instance hourly or daily counts obscure with noise.
Step 4: Build Activity Heatmap
The 7x24 heatmap (day-of-week rows, hour-of-day columns) is the single most informative temporal visualization.
def build_heatmap_matrix(ts_series):
"""Build a 7x24 activity count matrix for heatmap visualization.
Rows: Monday(0) through Sunday(6). Columns: hours 0-23."""
df = pd.DataFrame({
'dow': ts_series.dt.dayofweek,
'hour': ts_series.dt.hour,
})
matrix = df.groupby(['dow', 'hour']).size().unstack(fill_value=0)
matrix = matrix.reindex(index=range(7), columns=range(24), fill_value=0)
matrix.index = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
return matrix
Step 5: Detect Circadian Regularity
Circadian regularity measures how consistently activity clusters at the same hours across different days.
def compute_circadian_regularity(ts_series, min_days=7):
"""Measure consistency of hour-of-day patterns across days.
Returns regularity score (0-1) where 1 = identical hourly
distribution every day, 0 = completely random.
Uses cosine similarity between per-day hourly distributions.
"""
from sklearn.metrics.pairwise import cosine_similarity
df = pd.DataFrame({
'date': ts_series.dt.date,
'hour': ts_series.dt.hour,
})
daily_profiles = df.groupby(['date', 'hour']).size().unstack(fill_value=0)
daily_profiles = daily_profiles.reindex(columns=range(24), fill_value=0)
if len(daily_profiles) < min_days:
return None, f"Only {len(daily_profiles)} active days (need {min_days}+)"
daily_profiles = daily_profiles[daily_profiles.sum(axis=1) >= 3]
if len(daily_profiles) < min_days:
return None, f"Only {len(daily_profiles)} days with 3+ actions"
normalized = daily_profiles.div(daily_profiles.sum(axis=1), axis=0)
sim_matrix = cosine_similarity(normalized)
np.fill_diagonal(sim_matrix, np.nan)
regularity = np.nanmean(sim_matrix)
return round(regularity, 3), {
'active_days': len(daily_profiles),
'interpretation': interpret_regularity(regularity),
}
def interpret_regularity(score):
if score >= 0.85:
return 'Highly regular -- consistent daily rhythm'
elif score >= 0.65:
return 'Moderately regular -- recognizable pattern with variation'
elif score >= 0.40:
return 'Weakly regular -- some recurring tendencies but variable'
else:
return 'Irregular -- no consistent daily pattern'
Step 6: Identify Weekly and Day-of-Week Patterns
from scipy import stats
def analyze_weekly_patterns(ts_series):
"""Detect whether activity differs significantly by day of week."""
df = pd.DataFrame({
'date': ts_series.dt.date,
'dow': ts_series.dt.dayofweek,
})
daily_counts = df.groupby('date').size().reset_index(name='count')
daily_counts['dow'] = pd.to_datetime(daily_counts['date']).dt.dayofweek
dow_groups = [
daily_counts[daily_counts['dow'] == d]['count'].values
for d in range(7)
]
dow_groups = [g for g in dow_groups if len(g) > 0]
if len(dow_groups) < 2:
return None, "Insufficient day-of-week variation"
stat, p_value = stats.kruskal(*dow_groups)
dow_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
dow_means = {
dow_names[d]: daily_counts[daily_counts['dow'] == d]['count'].mean()
for d in range(7)
}
weekday = daily_counts[daily_counts['dow'] < 5]['count']
weekend = daily_counts[daily_counts['dow'] >= 5]['count']
if len(weekday) > 0 and len(weekend) > 0:
wk_stat, wk_p = stats.mannwhitneyu(
weekday, weekend, alternative='two-sided'
)
weekday_vs_weekend = {
'weekday_mean': round(weekday.mean(), 2),
'weekend_mean': round(weekend.mean(), 2),
'u_statistic': round(wk_stat, 2),
'p_value': round(wk_p, 4),
'significant': wk_p < 0.05,
}
else:
weekday_vs_weekend = None
return {
'kruskal_wallis_stat': round(stat, 2),
'kruskal_wallis_p': round(p_value, 4),
'significant_dow_difference': p_value < 0.05,
'per_dow_means': dow_means,
'weekday_vs_weekend': weekday_vs_weekend,
}, None
Step 7: Seasonal Decomposition
Only run if the timespan covers at least 2 full seasonal cycles.
from statsmodels.tsa.seasonal import STL
def seasonal_decomposition(ts_series, period='weekly'):
"""Decompose activity time series into trend, seasonal, and residual.
period: 'weekly' (period=7 on daily data) or 'annual' (period=365).
STL is preferred over seasonal_decompose for robustness to outliers.
"""
daily = ts_series.to_frame('ts').set_index('ts').resample('D').size()
daily.name = 'count'
full_range = pd.date_range(daily.index.min(), daily.index.max(), freq='D')
daily = daily.reindex(full_range, fill_value=0)
period_map = {'weekly': 7, 'annual': 365}
p = period_map.get(period, 7)
if len(daily) < 2 * p:
return None, f"Need {2*p}+ days for {period} decomposition, have {len(daily)}"
stl = STL(daily, period=p, robust=True)
result = stl.fit()
return {
'trend': result.trend,
'seasonal': result.seasonal,
'residual': result.resid,
'trend_direction': 'increasing' if result.trend.iloc[-1] > result.trend.iloc[0]
else 'decreasing',
'seasonal_strength': 1 - (result.resid.var() /
(result.seasonal + result.resid).var()),
}, None
Seasonal strength interpretation:
- 0.0-0.2: No meaningful seasonality
- 0.2-0.5: Weak seasonal pattern
- 0.5-0.8: Moderate seasonality
- 0.8-1.0: Strong seasonal pattern
Step 8: Burst Detection
Identify periods of unusually high activity relative to the baseline.
def detect_bursts(ts_series, window='D', threshold_std=2.0):
"""Detect activity bursts using a statistical threshold.
A burst is a window where activity exceeds mean + threshold_std * std.
Uses rolling baseline to account for trend changes.
For more sophisticated detection, consider Kleinberg's burst detection
algorithm (burst_detection package).
"""
counts = ts_series.to_frame('ts').set_index('ts').resample(window).size()
counts.name = 'count'
rolling_mean = counts.rolling(window=30, min_periods=7, center=True).mean()
rolling_std = counts.rolling(window=30, min_periods=7, center=True).std()
threshold = rolling_mean + threshold_std * rolling_std
bursts = counts[counts > threshold]
burst_periods = []
for idx, count in bursts.items():
burst_periods.append({
'period': idx,
'count': int(count),
'baseline_mean': round(rolling_mean.loc[idx], 2),
'baseline_std': round(rolling_std.loc[idx], 2),
'intensity': round(
(count - rolling_mean.loc[idx]) / rolling_std.loc[idx], 2
) if rolling_std.loc[idx] > 0 else float('inf'),
})
return {
'total_windows': len(counts),
'burst_count': len(bursts),
'burst_rate': round(len(bursts) / len(counts), 3) if len(counts) > 0 else 0,
'bursts': pd.DataFrame(burst_periods),
'interpretation': interpret_burst_rate(
len(bursts) / len(counts) if len(counts) > 0 else 0
),
}
def interpret_burst_rate(rate):
if rate < 0.02:
return 'Steady -- activity rarely spikes above baseline'
elif rate < 0.10:
return 'Occasional bursts -- periodic spikes above normal activity'
elif rate < 0.25:
return 'Bursty -- frequent activity spikes; engagement comes in waves'
else:
return 'Highly bursty -- activity is dominated by spikes rather than steady output'
Step 9: Classify Engagement Style
Synthesize the temporal features into an engagement style classification.
def classify_engagement_style(regularity_score, burst_rate,
weekday_vs_weekend, hourly_concentration):
"""Classify the overall temporal engagement pattern.
hourly_concentration: fraction of activity in the top-3 hours (0-1).
"""
styles = []
if regularity_score and regularity_score >= 0.75:
styles.append('routine-driven')
elif regularity_score and regularity_score < 0.40:
styles.append('irregular')
if burst_rate >= 0.15:
styles.append('binge-pattern')
elif burst_rate < 0.03:
styles.append('steady-contributor')
if weekday_vs_weekend and weekday_vs_weekend.get('significant'):
if weekday_vs_weekend['weekday_mean'] > weekday_vs_weekend['weekend_mean'] * 1.5:
styles.append('weekday-focused')
elif weekday_vs_weekend['weekend_mean'] > weekday_vs_weekend['weekday_mean'] * 1.5:
styles.append('weekend-warrior')
if hourly_concentration >= 0.60:
styles.append('time-concentrated')
elif hourly_concentration <= 0.25:
styles.append('time-dispersed')
if not styles:
styles.append('mixed-pattern')
return styles
Engagement style taxonomy:
| Style | Indicator | Description |
|---|
| routine-driven | Regularity >= 0.75 | Consistent daily rhythm; similar hours each day |
| irregular | Regularity < 0.40 | No predictable daily pattern |
| binge-pattern | Burst rate >= 15% | Activity comes in intense bursts |
| steady-contributor | Burst rate < 3% | Even, sustained activity over time |
| weekday-focused | Weekday mean > 1.5x weekend | Activity concentrated on work days |
| weekend-warrior | Weekend mean > 1.5x weekday | Activity concentrated on weekends |
| time-concentrated | Top-3 hours >= 60% of activity | Active in narrow daily window |
| time-dispersed | Top-3 hours <= 25% of activity | Spread across many hours |
Step 10: Write Report
Write all findings to docs/analysis/12-temporal-circadian-patterns.md. See Report Output Template below.
Report Output Template
The final report MUST be written to docs/analysis/12-temporal-circadian-patterns.md with this structure:
# Temporal and Circadian Pattern Analysis
## Methodology
- **Data source:** [describe dataset and timestamp field used]
- **Timestamps analyzed:** [N valid of M total, N invalid/excluded]
- **Timespan:** [earliest] to [latest] ([N days])
- **Timezone handling:** [tz-aware / assumed UTC / converted to X / unknown -- state assumption]
- **DST handling:** [proper tz_convert / not applicable / N transitions in range]
- **Granularities computed:** [hourly, daily, day-of-week, weekly, monthly, seasonal]
- **Date of analysis:** [date]
## Timestamp Validation
- Valid timestamps: [N] ([%])
- Invalid/unparseable: [N] ([%])
- Timezone status: [description]
- Timespan: [N days] across [N distinct dates]
- [Any data quality issues or assumptions]
## Activity Distribution by Granularity
### Hour-of-Day Profile
| Hour | Count | % of Total |
|------|-------|-----------|
| 0 | [N] | [%] |
| ... | ... | ... |
| 23 | [N] | [%] |
- Peak hours: [list top 3 hours with counts]
- Quiet hours: [list bottom 3 hours]
- Top-3 hour concentration: [X%] of all activity
### Day-of-Week Profile
| Day | Count | % of Total |
|-----|-------|-----------|
| Monday | [N] | [%] |
| ... | ... | ... |
| Sunday | [N] | [%] |
- Peak day: [day] ([N] actions)
- Weekday mean: [X], Weekend mean: [Y]
- Weekday vs weekend: [significant / not significant] (p=[value])
### Activity Heatmap (Day-of-Week x Hour-of-Day)
[7x24 matrix or description of the heatmap pattern]
### Monthly/Weekly Trend (if applicable)
[Time series of weekly or monthly counts; trend direction]
## Circadian Regularity
- Regularity score: [X.XXX]
- Active days analyzed: [N]
- Interpretation: [highly regular / moderately regular / weakly regular / irregular]
- [Description of the daily rhythm pattern]
## Weekly Patterns
- Kruskal-Wallis test: H=[value], p=[value] -- [significant / not significant]
- Weekday vs weekend: U=[value], p=[value]
- [Interpretation of day-of-week differences]
## Seasonal Decomposition (if applicable)
- Period: [weekly / annual]
- Seasonal strength: [value] -- [none / weak / moderate / strong]
- Trend direction: [increasing / decreasing / stable]
- [Description of seasonal pattern]
## Burst Detection
- Analysis window: [daily / hourly / weekly]
- Threshold: mean + [X] * std
- Bursts detected: [N] out of [M] windows ([X%])
- Burst interpretation: [steady / occasional / bursty / highly bursty]
- [Table of burst periods if count is manageable]
## Engagement Style Classification
- Classified styles: [list of applicable styles]
- Primary pattern: [dominant style]
- [Narrative synthesis of temporal engagement profile]
## Limitations and Caveats
- [Timezone assumptions and their impact on hour-of-day analysis]
- [Timespan limitations on seasonal analysis]
- [Sparse periods and their impact on regularity scores]
- Activity timing does NOT reveal physical location or timezone
- Circadian patterns describe when activity occurs, NOT sleep schedules or health
- [Any granularities skipped and why]
## References
- [List tools and methods used]
Good Patterns
- Always normalize timezone before hour-of-day binning -- a UTC-stored timestamp from a US Pacific user is 8 hours off; the "3 AM activity" is actually 7 PM
- Bin at multiple granularities simultaneously -- hourly resolution for circadian patterns, daily for burst detection, weekly for cycle detection, monthly for trends
- Use the 7x24 heatmap as the primary visualization -- it compresses the most important temporal signals into a single view
- Apply STL over classical seasonal_decompose -- STL handles outliers via robust estimation and allows non-integer seasonal smoothing
- Compare weekday vs. weekend with a non-parametric test -- activity counts are rarely normally distributed; use Mann-Whitney U, not t-test
- Compute circadian regularity as cross-day similarity -- not just "most active hour" but how consistent the full daily profile is
- Report burst rate as a fraction of total windows -- "5 bursts" means nothing without knowing the total observation period
Anti-Patterns
| Anti-Pattern | Why It Fails | Instead |
|---|
| Analyzing hour-of-day without timezone normalization | 2 AM UTC is not 2 AM local time; circadian conclusions are wrong | Always normalize to a known timezone before hourly analysis |
| Using only one granularity | Daily counts miss hourly rhythm; hourly counts miss weekly cycles | Always compute at least hour-of-day, day-of-week, and daily trend |
| Ignoring DST transitions | Naive UTC offset arithmetic creates duplicate or missing hours | Use tz_convert() with zoneinfo or pytz, never manual timedelta |
| Over-interpreting patterns from < 4 weeks of data | Weekly cycle detection requires multiple weeks; 1 week is a single observation | Report the timespan limitation; skip weekly statistical tests |
| Treating all activity as equal weight | A 3-word comment and a 2000-word post both count as "1 action" at the same hour | Consider weighting by content length or engagement if available |
| Inferring timezone from peak activity hours | "Peak at 9 PM UTC, so user is probably in US Eastern" reverses the analysis | Report activity timing; do NOT claim to identify physical location |
| Claiming to identify sleep schedules | Low activity from 2-7 AM could be sleep, or a different timezone, or a night shift | Describe "low-activity windows," never "sleep periods" |
| Running seasonal decomposition on < 2 cycles | STL needs at least 2 full periods to separate trend from seasonality | Check timespan before decomposing; skip and document if insufficient |
| Using deprecated pandas frequency aliases | M is deprecated in pandas >= 2.0; causes warnings or errors | Use ME for month-end, MS for month-start, h for hourly |
| Making health or psychological assessments | Irregular activity timing does not indicate insomnia, depression, or any condition | Describe temporal patterns objectively; do NOT make health inferences |
Boundaries
This skill SHOULD:
- Reconstruct activity profiles from timestamp aggregation at multiple granularities
- Produce 7x24 day-of-week x hour-of-day heatmap matrices
- Measure circadian regularity via cross-day profile similarity
- Detect weekly cycles and weekday-vs-weekend differences with statistical tests
- Run seasonal decomposition (STL) when timespan is sufficient
- Identify activity bursts above rolling baseline
- Classify engagement style from temporal feature synthesis
- Write report to
docs/analysis/12-temporal-circadian-patterns.md
This skill should NOT:
- Infer physical location or timezone from activity timing patterns (timing reveals when, not where)
- Claim to identify sleep schedules, wake times, or rest periods (low activity has many explanations)
- Make health, psychological, or wellness assessments from activity timing (irregular patterns are not diagnoses)
- Predict future activity or forecast when the user will next be active (this is retrospective analysis)
- Treat timestamps without timezone normalization as valid for hour-of-day analysis
- Force seasonal decomposition when the timespan is insufficient
Insufficient Data Handling
digraph insufficient_data {
rankdir=TB;
node [shape=diamond, style=filled, fillcolor="#f0f0f0"];
start [label="Total valid\ntimestamps?", shape=diamond];
tiny [label="< 20", shape=box, fillcolor="#ffcccc"];
small [label="20-49", shape=box, fillcolor="#fff3cc"];
moderate [label="50-199", shape=box, fillcolor="#ffffcc"];
adequate [label="200+", shape=box, fillcolor="#ccffcc"];
start -> tiny [label="< 20"];
start -> small [label="20-49"];
start -> moderate [label="50-199"];
start -> adequate [label="200+"];
tiny_action [label="STOP: List raw timestamps.\nReport earliest/latest only.\nNo binning or patterns.", shape=box, fillcolor="#ffcccc"];
small_action [label="BASIC: Daily counts +\nday-of-week distribution.\nNo hourly analysis.\nNo regularity/bursts.\nFlag all as low-confidence.", shape=box, fillcolor="#fff3cc"];
moderate_action [label="STANDARD: Hourly + daily +\nday-of-week bins. Heatmap\nif 7+ active days. Skip\nseasonal decomposition.\nBurst detection if 30+ days.", shape=box, fillcolor="#ffffcc"];
adequate_action [label="FULL: All granularities.\nRegularity scoring.\nBurst detection.\nSeasonal if timespan allows.\nEngagement classification.", shape=box, fillcolor="#ccffcc"];
tiny -> tiny_action;
small -> small_action;
moderate -> moderate_action;
adequate -> adequate_action;
}
| Condition | Impact | Action |
|---|
| < 20 timestamps | No temporal pattern is detectable | List raw timestamps. Report only timespan (earliest to latest). Do NOT compute distributions or patterns. |
| 20-49 timestamps | Basic distribution visible, hourly bins unreliable | Compute daily counts and day-of-week totals. Skip hourly analysis (too sparse). Flag all statistics as "low-confidence (n=X)." |
| 50-199 timestamps | Hourly and daily viable, weekly marginal | Full hourly + daily + day-of-week bins. Heatmap if 7+ distinct active days. Skip seasonal decomposition. Burst detection only if 30+ days of data. |
| 200+ timestamps | Full analysis viable | Complete pipeline. Seasonal decomposition if timespan allows (2+ cycles). |
| Timestamps lack timezone info | Hour-of-day analysis may be misleading | Document the assumption explicitly: "Timestamps treated as [UTC/unknown]; hour-of-day profile may not reflect local time." Proceed but caveat every hourly finding. |
| Timespan < 14 days | Cannot detect weekly patterns | Skip weekly statistical tests. Report raw day-of-week counts but caveat: "Fewer than 2 full weeks; day-of-week distribution reflects this specific period only." |
| Timespan < 2 seasonal cycles | Seasonal decomposition invalid | Skip STL entirely. Document: "Timespan of [N days] is insufficient for [weekly/annual] seasonal decomposition (need [M days])." |
| > 50% of days have zero activity | Sparse activity inflates regularity denominator | Report active days vs. total days. Compute regularity only over active days. Note: "Activity occurred on [N] of [M] days ([X%]); regularity measured over active days only." |
| All activity in a single hour | No temporal variation to analyze | Report the single active hour. Note: "All [N] actions occurred at hour [H]; no temporal variation detected." |
Minimum requirements per granularity:
| Granularity | Minimum Data | Rationale |
|---|
| Hour-of-day profile | 50+ timestamps, 7+ active days | Fewer days leave most hour bins empty |
| Day-of-week profile | 20+ timestamps, 2+ full weeks | Need multiple instances of each day |
| Daily trend | 14+ days of data | Shorter spans show too few points for trend |
| Weekly aggregation | 4+ weeks of data | Need multiple weeks to detect cycle |
| Monthly aggregation | 3+ months of data | Need multiple months for trend |
| Seasonal decomposition (weekly) | 14+ weeks of data | STL needs 2+ full seasonal periods |
| Seasonal decomposition (annual) | 2+ years of data | Need 2+ annual cycles |
| Circadian regularity | 50+ timestamps, 7+ active days with 3+ actions each | Cosine similarity is meaningless on sparse day profiles |
| Burst detection | 30+ windows (e.g., 30 days for daily bursts) | Rolling statistics need sufficient baseline |
Common Mistakes
| Mistake | Fix |
|---|
| Binning by hour without timezone conversion | Always tz_convert() to target timezone before extracting .dt.hour |
Using M frequency alias in pandas >= 2.0 | Use ME (month-end) or MS (month-start) to avoid deprecation warnings |
| Computing regularity over all days including zero-activity days | Filter to days with 3+ actions before computing cross-day similarity |
| Reporting "most active at 3 AM" without stating timezone | Always qualify: "most active at 3 AM UTC" or "3 AM US/Eastern" |
| Running Kruskal-Wallis on day-of-week with < 4 weeks | Need multiple instances of each day; skip statistical test and report raw counts |
| Treating seasonal strength > 0.5 as "strong" | 0.5-0.8 is moderate; only > 0.8 is strong. Calibrate language to standard thresholds. |
| Comparing raw activity counts across months of different lengths | Normalize to per-day rates when comparing months (February has 28 days, July has 31) |
| Not filling missing dates with zero before STL | STL requires continuous time series; missing dates break the decomposition |
| Calling burst detection with < 30 windows | Rolling mean/std are unstable with fewer baseline points; results are noise |
References