Ljung-Box Test
The engine for Residual Integrity. This 'Portmanteau' test audits the entire cluster of autocorrelations in your residuals, revealing if your model has successfully extracted all the 'Signal' or if patterns remain hidden in the noise.
What is it?
Ljung-Box Test analyzes sequences of data points ordered chronologically over time to extract patterns, model trends, and make forecasts.
The engine for Residual Integrity. This 'Portmanteau' test audits the entire cluster of autocorrelations in your residuals, revealing if your model has successfully extracted all the 'Signal' or if patterns remain hidden in the noise.
Goals & Indications
- Residual Signal Audit: Determine if the 'Errors' of your model are truly random (White Noise) or contain unmodeled information.
- Autocorrelation Shield: Protect your p-values and forecasts by ensuring no serial correlation persists across a vector of lags.
- Forensic Model Validation: Quantify the 'Goodness-of-Fit' for ARIMA and VAR models by proving the absence of temporal patterning.
Core Idea Diagram
Claims tested
How it works
- Select maximum lag parameter h for autocorrelation check.
- Compute sample autocorrelations up to lag h for the residual series.
- Compute Ljung-Box Q statistic weighting higher lags appropriately.
- Evaluate Q statistic against Chi-square distribution with h degrees of freedom.
Assumptions
Important Note
CRITICAL INTERPRETATION: p > 0.05 is GOOD (white noise, model adequate). p < 0.05 is BAD (autocorrelation remains, need better model). This is opposite of typical hypothesis tests! The Ljung-Box test is primarily a diagnostic tool for model validation, not a standalone analysis. It tests whether residuals from a fitted time series model exhibit autocorrelation. If p>0.05, we fail to reject H₀ (residuals are white noise, model captured all temporal structure). If p<0.05, we reject H₀ (residuals autocorrelated, model inadequate). Key distinction from Durbin-Watson: Ljung-Box tests autocorrelation at multiple lags jointly (omnibus test), DW tests only first-order. Key distinction from Breusch-Godfrey: Ljung-Box uses autocorrelations directly, BG uses regression framework (more flexible for regression models). The test statistic follows χ² distribution with degrees of freedom adjusted for model parameters (h for raw series, h-p-q for ARIMA(p,d,q) residuals).
Worked Example
| Lag (h) | Q-Stat | df | Crit Value | p-value |
|---|---|---|---|---|
| Lag 10 | 8.42 | 10 | 18.31 | 0.587 |
Ljung-Box Serial Correlation Test
Test for independence in a time series. Increase ρ to introduce serial dependence. Observe how the Ljung-Box Q statistic jumps above critical thresholds.
Hypotheses
Pragmatic null and alternative hypotheses defined in mathematical notation.
H₀: No serial correlation in residuals up to lag h (residuals are white noise)
Hₐ: Serial correlation present at one or more lags up to h (model inadequate)
CRITICAL INTERPRETATION: p > 0.05 is GOOD (white noise, model adequate). p < 0.05 is BAD (autocorrelation remains, need better model). This is opposite of typical hypothesis tests! The Ljung-Box test is primarily a diagnostic tool for model validation, not a standalone analysis. It tests whether residuals from a fitted time series model exhibit autocorrelation. If p>0.05, we fail to reject H₀ (residuals are white noise, model captured all temporal structure). If p<0.05, we reject H₀ (residuals autocorrelated, model inadequate). Key distinction from Durbin-Watson: Ljung-Box tests autocorrelation at multiple lags jointly (omnibus test), DW tests only first-order. Key distinction from Breusch-Godfrey: Ljung-Box uses autocorrelations directly, BG uses regression framework (more flexible for regression models). The test statistic follows χ² distribution with degrees of freedom adjusted for model parameters (h for raw series, h-p-q for ARIMA(p,d,q) residuals).
Assumptions
The core mathematical criteria needed to ensure that statistical testing remains unbiased and valid.
Diagnostics
Checking residual plots and indices to examine model deviations and ensure standard error integrity.
- ACF plot of residuals to visually identify significant spikes at specific lags.
- Selection of maximum lag (m) based on ln(N) or 2*sqrt(N) benchmarks.
- Q-statistic vs. Critical Chi-Square value comparison.
- Audit of degrees of freedom (must be m minus the number of fitted parameters).
- P-value threshold check (p > .05 confirms model adequacy).
- Box-Pierce statistic comparison for small-sample robustness.
- Sensitivity audit across multiple lag windows (e.g., m=5, 10, 20).
- Cross-correlation function (CCF) audit if residuals come from multivariate systems.
- Cumulative periodogram check for periodic/cyclic rhythmic ghosts.
- Runs test on residual signs to detect non-linear temporal clusters.
Applied Minds
Review concrete study examples, data layout guidelines, and copy executable syntax scripts.
ARIMA Residual Diagnostics for Monthly Sales
Demonstrates Ljung-Box test for ARIMA residual diagnostics on monthly sales data (n=120 months, 10 years). Shows complete workflow: fit adequate ARIMA model and verify residuals are white noise (Ljung-Box p>0.05, GOOD), then fit inadequate model and detect autocorrelation (Ljung-Box p<0.05, BAD). Includes testing at multiple lags (h=10,15,20), ACF/PACF visualization of residuals, interpretation of test results, and guidance on when to increase model order. Illustrates critical interpretation: p>0.05 means model is adequate (we WANT to fail to reject H₀). Also shows testing raw series for randomness before modeling.
# ============================================================================
# LJUNG-BOX TEST: ARIMA Residual Diagnostics
# ============================================================================
# Demonstrates: Testing residuals for white noise after ARIMA fitting
# Data: 120 months of sales with trend + seasonality
# Goal: Verify model residuals have no autocorrelation (white noise)
# Critical: p > 0.05 is GOOD (model adequate), p < 0.05 is BAD (inadequate)
# ============================================================================
# Load required packages
library(forecast) # ARIMA modeling
library(tseries) # Additional tests
library(ggplot2) # Visualization
library(gridExtra) # Multiple plots
set.seed(123)
# ============================================================================
# 1. DATA GENERATION
# ============================================================================
cat("=== GENERATING TIME SERIES DATA ===\n\n")
# Generate 120 months of sales data with trend and seasonality
n <- 120
time_idx <- 1:n
# True model: trend + seasonality + noise
trend <- 1000 + 12 * time_idx
seasonality <- 150 * sin(2 * pi * time_idx / 12) + 80 * cos(2 * pi * time_idx / 12)
noise <- rnorm(n, mean = 0, sd = 40)
sales <- trend + seasonality + noise
sales_ts <- ts(sales, start = c(2014, 1), frequency = 12)
cat("Generated", n, "monthly observations\n")
cat("Range:", round(min(sales_ts), 1), "to", round(max(sales_ts), 1), "\n\n")
# Plot original series
par(mfrow = c(1, 1))
plot(sales_ts, main = "Monthly Sales(Original Series)",
ylab = "Sales", xlab = "Time", col = "steelblue", lwd = 2)
# ============================================================================
# 2. TEST RAW SERIES FOR RANDOMNESS (before modeling)
# ============================================================================
cat("=== LJUNG-BOX TEST ON RAW SERIES ===\n")
cat("Testing if raw series is white noise(random)...\n\n")
# Test raw series at h=20 lags
# For raw series: df = h (no model parameters to adjust for)
lb_raw <- Box.test(sales_ts, lag = 20, type = "Ljung-Box", fitdf = 0)
cat("Ljung-Box test(raw series, h=20):\n")
cat(" Q statistic:", round(lb_raw$statistic, 4), "\n")
cat(" Degrees of freedom:", lb_raw$parameter, "\n")
cat(" p-value:", round(lb_raw$p.value, 6), "\n\n")
if (lb_raw$p.value < 0.05) {
cat(" Conclusion: p < 0.05, REJECT H₀\n")
cat(" Interpretation: Raw series shows autocorrelation(NOT white noise)\n")
cat(" This is EXPECTED for series with trend/seasonality\n")
cat(" Action: Model the autocorrelation structure with ARIMA\n\n")
} else {
cat(" Conclusion: p > 0.05, fail to reject H₀\n")
cat(" Interpretation: Series appears to be white noise(random)\n")
cat(" No ARIMA modeling needed\n\n")
}
# ACF of raw series (should show strong autocorrelation)
par(mfrow = c(1, 2))
acf(sales_ts, lag.max = 36, main = "ACF: Raw Series\n(Strong autocorrelation expected)")
pacf(sales_ts, lag.max = 36, main = "PACF: Raw Series")
par(mfrow = c(1, 1))
cat("ACF shows slow decay(trend) and seasonal spikes(seasonality)\n")
cat("This confirms autocorrelation detected by Ljung-Box\n\n")
# ============================================================================
# 3. FIT ADEQUATE ARIMA MODEL
# ============================================================================
cat("=== FITTING ADEQUATE ARIMA MODEL ===\n\n")
# Use auto.arima to select appropriate model
adequate_model <- auto.arima(sales_ts, seasonal = TRUE,
stepwise = TRUE, approximation = FALSE,
ic = "bic")
cat("Selected model:\n")
print(adequate_model)
model_order <- arimaorder(adequate_model)
cat("\nModel order(p,d,q)(P,D,Q)[s]:",
sprintf("(%d,%d,%d)(%d,%d,%d)[12]\n",
model_order[1], model_order[2], model_order[3],
model_order[4], model_order[5], model_order[6]))
cat("AIC:", round(adequate_model$aic, 2), "\n")
cat("BIC:", round(adequate_model$bic, 2), "\n\n")
# Extract residuals from adequate model
resid_adequate <- residuals(adequate_model)
# ============================================================================
# 4. LJUNG-BOX TEST ON ADEQUATE MODEL RESIDUALS
# ============================================================================
cat("=== LJUNG-BOX TEST ON ADEQUATE MODEL RESIDUALS ===\n\n")
# Degrees of freedom adjustment for ARIMA(p,d,q)(P,D,Q)
# df = h - p - q - P - Q (don't adjust for d or D, only AR/MA terms)
p_order <- model_order[1]
q_order <- model_order[3]
P_order <- model_order[4]
Q_order <- model_order[6]
fitdf_adequate <- p_order + q_order + P_order + Q_order
cat("Model has p=", p_order, ", q=", q_order,
", P=", P_order, ", Q=", Q_order, "\n")
cat("fitdf(total AR+MA parameters) =", fitdf_adequate, "\n\n")
# Test at multiple lag values for robustness
lag_values <- c(10, 15, 20)
cat("Testing at multiple lags for robustness:\n\n")
for (h in lag_values) {
lb_test <- Box.test(resid_adequate, lag = h, type = "Ljung-Box",
fitdf = fitdf_adequate)
df_test <- h - fitdf_adequate
cat("Lag h =", h, ":\n")
cat(" Q statistic:", round(lb_test$statistic, 4), "\n")
cat(" df = h - fitdf =", df_test, "\n")
cat(" p-value:", round(lb_test$p.value, 4), "\n")
if (lb_test$p.value > 0.05) {
cat(" Result: p > 0.05 - GOOD! (Residuals are white noise)\n")
} else {
cat(" Result: p < 0.05 - BAD! (Autocorrelation remains)\n")
}
cat("\n")
}
cat("Interpretation: If all p-values > 0.05, model is ADEQUATE\n")
cat("The model has captured all autocorrelation structure\n")
cat("We WANT to fail to reject H₀ (opposite of typical tests!)\n\n")
# ============================================================================
# 5. VISUAL DIAGNOSTICS FOR ADEQUATE MODEL
# ============================================================================
cat("=== VISUAL DIAGNOSTICS(ADEQUATE MODEL) ===\n\n")
par(mfrow = c(2, 2))
# 1. Residuals over time (should look random)
plot(resid_adequate, main = "Residuals Over Time(Adequate Model)",
ylab = "Residuals", xlab = "Time", col = "darkblue")
abline(h = 0, col = "red", lty = 2)
# 2. ACF of residuals (should be within bands)
acf(resid_adequate, lag.max = 36,
main = "ACF: Residuals(Adequate Model)\n(All lags should be in bands)")
# 3. PACF of residuals
pacf(resid_adequate, lag.max = 36,
main = "PACF: Residuals(Adequate Model)")
# 4. Histogram
hist(resid_adequate, breaks = 20, col = "lightblue", freq = FALSE,
main = "Histogram: Residuals", xlab = "Residuals")
curve(dnorm(x, mean = mean(resid_adequate), sd = sd(resid_adequate)),
add = TRUE, col = "red", lwd = 2)
par(mfrow = c(1, 1))
cat("Visual checks:\n")
cat(" - Residuals appear random over time(no patterns)\n")
cat(" - ACF/PACF within confidence bands(no significant spikes)\n")
cat(" - Histogram approximately normal\n")
cat(" All diagnostics support that residuals are white noise\n\n")
# ============================================================================
# 6. FIT INADEQUATE MODEL (for comparison)
# ============================================================================
cat("=== FITTING INADEQUATE MODEL(for comparison) ===\n\n")
# Deliberately fit wrong model: ARIMA(0,1,0) - just random walk
# This will be inadequate for data with seasonality
inadequate_model <- Arima(sales_ts, order = c(0, 1, 0),
seasonal = list(order = c(0, 0, 0)))
cat("Inadequate model: ARIMA(0,1,0) - random walk only\n")
cat("This ignores seasonality in the data\n")
cat("AIC:", round(inadequate_model$aic, 2), "\n")
cat("BIC:", round(inadequate_model$bic, 2), "\n\n")
resid_inadequate <- residuals(inadequate_model)
# ============================================================================
# 7. LJUNG-BOX TEST ON INADEQUATE MODEL RESIDUALS
# ============================================================================
cat("=== LJUNG-BOX TEST ON INADEQUATE MODEL RESIDUALS ===\n\n")
# For ARIMA(0,1,0): p=0, q=0, so fitdf=0
fitdf_inadequate <- 0
cat("Testing at multiple lags:\n\n")
for (h in lag_values) {
lb_test <- Box.test(resid_inadequate, lag = h, type = "Ljung-Box",
fitdf = fitdf_inadequate)
cat("Lag h =", h, ":\n")
cat(" Q statistic:", round(lb_test$statistic, 4), "\n")
cat(" df =", h, "\n")
cat(" p-value:", format.pval(lb_test$p.value, digits = 4), "\n")
if (lb_test$p.value > 0.05) {
cat(" Result: p > 0.05 - residuals appear white noise\n")
} else {
cat(" Result: p < 0.05 - BAD! Autocorrelation detected\n")
cat(" Action needed: Improve model(add AR/MA/seasonal terms)\n")
}
cat("\n")
}
cat("Interpretation: If p < 0.05, model is INADEQUATE\n")
cat("Residuals still contain autocorrelation\n")
cat("Model hasn't captured all structure in data\n\n")
# ============================================================================
# 8. VISUAL DIAGNOSTICS FOR INADEQUATE MODEL
# ============================================================================
cat("=== VISUAL DIAGNOSTICS(INADEQUATE MODEL) ===\n\n")
par(mfrow = c(2, 2))
# 1. Residuals over time
plot(resid_inadequate, main = "Residuals Over Time(Inadequate Model)",
ylab = "Residuals", xlab = "Time", col = "darkred")
abline(h = 0, col = "blue", lty = 2)
# 2. ACF of residuals (expect spikes outside bands)
acf(resid_inadequate, lag.max = 36,
main = "ACF: Residuals(Inadequate Model)\n(Expect spikes at seasonal lags)")
# 3. PACF of residuals
pacf(resid_inadequate, lag.max = 36,
main = "PACF: Residuals(Inadequate Model)")
# 4. Histogram
hist(resid_inadequate, breaks = 20, col = "lightcoral", freq = FALSE,
main = "Histogram: Residuals", xlab = "Residuals")
curve(dnorm(x, mean = mean(resid_inadequate), sd = sd(resid_inadequate)),
add = TRUE, col = "blue", lwd = 2)
par(mfrow = c(1, 1))
cat("Visual checks reveal problems:\n")
cat(" - ACF shows significant spikes at seasonal lags(12, 24, 36)\n")
cat(" - Patterns indicate remaining autocorrelation\n")
cat(" - Confirms Ljung-Box test result(p < 0.05)\n")
cat(" - Need to add seasonal terms to model\n\n")
# ============================================================================
# 9. COMPARE MODELS
# ============================================================================
cat("=== MODEL COMPARISON ===\n\n")
# Compare at h=20
lb_adeq_20 <- Box.test(resid_adequate, lag = 20, type = "Ljung-Box",
fitdf = fitdf_adequate)
lb_inadeq_20 <- Box.test(resid_inadequate, lag = 20, type = "Ljung-Box",
fitdf = fitdf_inadequate)
comparison <- data.frame(
Model = c("Adequate(auto.arima)", "Inadequate(random walk)"),
AIC = c(adequate_model$aic, inadequate_model$aic),
BIC = c(adequate_model$bic, inadequate_model$bic),
LB_Q = c(lb_adeq_20$statistic, lb_inadeq_20$statistic),
LB_p = c(lb_adeq_20$p.value, lb_inadeq_20$p.value),
Decision = c(
ifelse(lb_adeq_20$p.value > 0.05, "ADEQUATE", "INADEQUATE"),
ifelse(lb_inadeq_20$p.value > 0.05, "ADEQUATE", "INADEQUATE")
)
)
cat("Model Comparison(Ljung-Box at h=20):\n")
print(comparison, row.names = FALSE)
cat("\nInterpretation:\n")
cat(" - Lower AIC/BIC indicates better fit\n")
cat(" - Ljung-Box p > 0.05 indicates adequate model(GOOD)\n")
cat(" - Ljung-Box p < 0.05 indicates inadequate model(BAD)\n")
cat(" - Adequate model has both better fit AND white noise residuals\n\n")
# ============================================================================
# 10. P-VALUE ACROSS MULTIPLE LAGS (sensitivity analysis)
# ============================================================================
cat("=== SENSITIVITY ANALYSIS: P-VALUES ACROSS LAGS ===\n\n")
# Compute Ljung-Box at many lag values
lag_range <- 1:30
p_values_adeq <- numeric(length(lag_range))
p_values_inadeq <- numeric(length(lag_range))
for (i in seq_along(lag_range)) {
h <- lag_range[i]
# Skip if df would be <= 0
if (h > fitdf_adequate) {
lb_a <- Box.test(resid_adequate, lag = h, type = "Ljung-Box",
fitdf = fitdf_adequate)
p_values_adeq[i] <- lb_a$p.value
} else {
p_values_adeq[i] <- NA
}
if (h > fitdf_inadequate) {
lb_i <- Box.test(resid_inadequate, lag = h, type = "Ljung-Box",
fitdf = fitdf_inadequate)
p_values_inadeq[i] <- lb_i$p.value
} else {
p_values_inadeq[i] <- NA
}
}
# Plot p-values across lags
par(mfrow = c(1, 1))
plot(lag_range, p_values_adeq, type = "b", col = "darkgreen", pch = 16,
ylim = c(0, 1), xlab = "Lag(h)", ylab = "p-value",
main = "Ljung-Box p-values Across Lags")
lines(lag_range, p_values_inadeq, type = "b", col = "darkred", pch = 17)
abline(h = 0.05, col = "blue", lty = 2, lwd = 2)
legend("topright",
legend = c("Adequate model(p > 0.05 = GOOD)",
"Inadequate model(p < 0.05 = BAD)",
"Significance threshold(0.05)"),
col = c("darkgreen", "darkred", "blue"),
lty = c(1, 1, 2), pch = c(16, 17, NA), lwd = c(1, 1, 2),
bty = "n")
cat("Plot interpretation:\n")
cat(" - Adequate model: p-values consistently > 0.05 (above threshold)\n")
cat(" - Inadequate model: p-values < 0.05 (below threshold)\n")
cat(" - Results are robust across different lag choices\n\n")
# ============================================================================
# 11. FINAL SUMMARY
# ============================================================================
cat("=== FINAL SUMMARY ===\n\n")
cat("LJUNG-BOX TEST INTERPRETATION(Critical!):\n")
cat(" p > 0.05: GOOD - Residuals are white noise, model adequate\n")
cat(" p < 0.05: BAD - Autocorrelation remains, model inadequate\n")
cat(" (This is OPPOSITE of typical hypothesis tests!)\n\n")
cat("ADEQUATE MODEL RESULTS:\n")
cat(" Model:", paste(capture.output(adequate_model)[1]), "\n")
cat(" Ljung-Box p-value(h=20):", round(lb_adeq_20$p.value, 4), "\n")
cat(" Decision: p > 0.05, residuals are white noise\n")
cat(" Conclusion: Model has captured all autocorrelation\n")
cat(" Action: ACCEPT model, proceed to forecasting\n\n")
cat("INADEQUATE MODEL RESULTS:\n")
cat(" Model: ARIMA(0,1,0) - random walk only\n")
cat(" Ljung-Box p-value(h=20):", format.pval(lb_inadeq_20$p.value), "\n")
cat(" Decision: p < 0.05, autocorrelation detected\n")
cat(" Conclusion: Model inadequate, seasonality not captured\n")
cat(" Action: REJECT model, add seasonal terms\n\n")
cat("BEST PRACTICES:\n")
cat(" 1. Always test residuals after fitting time series model\n")
cat(" 2. Test at multiple lags(h=10,15,20) for robustness\n")
cat(" 3. Combine Ljung-Box with ACF/PACF visual inspection\n")
cat(" 4. If p < 0.05: increase model order or add seasonal terms\n")
cat(" 5. Never accept a model with autocorrelated residuals\n")
cat(" 6. Remember: p > 0.05 is GOOD for residual diagnostics!\n\n")
cat("Ljung-Box Test demonstration complete.\n")
# ============================================================================
# END OF LJUNG-BOX TEST EXAMPLE
# ============================================================================Alternatives
Structured fallback pathways for choosing alternative tests when normality or slopes requirements fail.
- Durbin-Watson Strike — Use if you specifically care only about the first temporal lag (AR1).
- Breusch-Godfrey Test — The required alternative for residuals from multivariable regression models.
- McLeod-Li Test — Audit for 'ARCH Effects' by applying the Ljung-Box strike to the squared residuals.
- BDS Test — The high-fidelity alternative for detecting complex non-linear temporal ghosts.
- Box-Pierce Strike — Utilize the simpler (but less biased) version of the Q-statistic for very small samples.
Post-hoc
Group mean comparisons and correction controls (e.g. Tukey HSD, Bonferroni) to protect against Family-Wise Error Rates.
- Vary number of lags tested (common: 10, 20, or sqrt(n))
- Compare with Box-Pierce test (asymptotically equivalent)
- Examine ACF/PACF plots alongside test results
- Apply to squared residuals for ARCH effects
- Compare with Breusch-Godfrey LM test for regression residuals
Ljung-Box tests for autocorrelation in residuals. Post-hoc tests are not applicable.
Effect Size
Understanding effect sizes (e.g., Cohen's d, Partial Eta-Squared) and clinical impact benchmarks.
Sample Size
Guidelines for minimum sample requirements and power analysis parameters.
The 'History Minimum': A minimum of 50 residuals is required. The Q-statistic loses its Chi-Square approximation accuracy if the temporal depth is too shallow to calculate a cluster of autocorrelations.
| Effect Size | Parameters | Required n |
|---|---|---|
| Small Effect | Low Signal (r=.10) | n ≈ 800 |
| Medium Effect | Moderate Signal (r=.30) | n ≈ 90 |
| Large Effect | Strong Signal (r=.50) | n ≈ 35 |
The 'p+q' Penalty: If you are testing residuals from a fitted model, you MUST reduce the degrees of freedom by the number of parameters estimated. This reduces power, requiring a 15% N-buffer to maintain the same sensitivity.
Reporting
How to compile statistical results into publication prose matching APA and journal style guides.
Manuscript Lab
Copy standard summary tables and forensic reporting grids to outline analysis details.
| Variable | Q-Statistic | df | p-value | Status |
|---|---|---|---|---|
| ARIMA(1,1,1) Residuals | 8.42 | 10 | .582 | SUCCESS (White Noise) |
| Raw Data Series | 145.2 | 10 | < .001 | AUTOCORRELATED |
The 'Pattern' Total. Sums the autocorrelations across all lags up to the specified limit. Higher Q = more evidence of hidden patterns.
The Randomness Probability. If p > .05, we conclude the residuals are random 'White Noise' and the model has captured all available information.
Command Center
Syntax libraries and function parameters for executing calculations in stats packages.
# 1. Execute Ljung-Box Test
Box.test(residuals(model), lag = 10, type = 'Ljung-Box')
# 2. Automated Diagnostic Plot
forecast::checkresiduals(model)Ljung-Box is not just for residuals. Use it on raw data to prove that a time series is NOT a 'Random Walk' before you start building complex models.
# Audit for Non-randomness in Raw Series
Box.test(ts_data, type = 'Ljung-Box')Common Mistakes
Analytical caveats and corrections to maintain modeling integrity.
References
Scholarly lineage and citation keys grounding the statistical framework.