When Technology Outruns the Forecast: Experience Curves and Pakistan’s Solar Price Collapse

Wright’s Law vs Moore’s Law, the identification trap, and scenario-based price forecasting for policy

Author

Prof. Dr. Zahid Asghar

Published

September 22, 2026

1 Why technological forecasting is different

Macroeconomic forecasting treats price as a stochastic process: the trend is “drift,” a nuisance parameter estimated and extrapolated. Technological forecasting treats price as a function of accumulated deployment. Wright’s Law (Wright, 1936) states that unit cost falls a fixed percentage with every doubling of cumulative production; Moore’s Law-type formulations posit a fixed exponential decline in calendar time. The distinction is not academic. It is the reason the IEA under-forecast solar deployment and over-forecast solar costs in essentially every World Energy Outlook for two decades, and it is the reason the ARIMA models of the companion analysis lost, out of sample, to a naive random walk: a statistical model has no concept of a technology whose cost declines because it is being deployed.

This note estimates the experience-curve relationship for Pakistan’s solar panel prices (January 2017 - May 2026, n = 113), subjects it to the econometric discipline it is usually spared, and converts it into a scenario-based five-year price forecast. Two findings complicate the naive story in instructive ways, and both are treated as central results: the Wright/Moore identification trap, and the reduced-form (imported) nature of the local learning curve.

Code
pkgs <- c("forecast", "ggplot2", "scales", "knitr")
invisible(lapply(pkgs, function(x) if (!requireNamespace(x, quietly = TRUE))
  install.packages(x, repos = "https://cran.r-project.org")))
library(forecast); library(ggplot2); library(scales); library(knitr)

brand_green <- "#2C5F2D"; brand_brown <- "#8B7355"

2 Data

Code
df <- read.csv(params$data_path, stringsAsFactors = FALSE)
df$Date <- as.Date(df$Date, format = "%d/%m/%Y")
df <- df[order(df$Date), ]
stopifnot(!anyNA(df[, 4:6]))

p  <- df$log_commodity_price          # Pakistan panel price (log)
q  <- df$log_cumulative_capacity_mw   # Pakistan cumulative capacity (log MW)
ps <- df$log_polysilicon_usd          # global polysilicon price (log)
Q  <- exp(q); n <- nrow(df); t <- 1:n

kable(head(df, 6), caption = "First six observations")
First six observations
Area Date Commodity.type log_commodity_price log_cumulative_capacity_mw log_polysilicon_usd
Pakistan 2017-01-01 Panels -0.967584 4.182813 2.8603
Pakistan 2017-02-01 Panels -0.967584 4.729686 2.8746
Pakistan 2017-03-01 Panels -0.967584 5.519058 2.8100
Pakistan 2017-04-01 Panels -0.967584 5.920130 2.6187
Pakistan 2017-05-01 Panels -0.967584 6.279197 2.6754
Pakistan 2017-06-01 Panels -0.967584 6.359383 2.7074
Code
annual_add <- tapply(Q, format(df$Date, "%Y"), max) -
              tapply(Q, format(df$Date, "%Y"), min)
add_df <- data.frame(Year = names(annual_add), MW = as.numeric(annual_add))
ggplot(add_df, aes(Year, MW)) +
  geom_col(fill = brand_green) +
  scale_y_continuous(labels = comma) +
  labs(x = NULL, y = "MW added") + theme_minimal()

Annual capacity additions (MW): the diffusion S-curve in action

Cumulative capacity grew from 66 MW to 55,744 MW - 9.7 doublings in nine years. Annual additions exploded from roughly 690 MW in 2017 to about 15 GW in 2024, timed exactly with the global panel price collapse, before decelerating visibly in 2025-2026 as buy-back reform and tax uncertainty arrived. Note for later: the deceleration matters for the scenario ceilings in Section 7.

3 Wright’s Law vs Moore’s Law

Code
ggplot(df, aes(log_cumulative_capacity_mw, log_commodity_price)) +
  geom_point(color = brand_green, alpha = 0.7) +
  geom_smooth(method = "lm", se = TRUE, color = brand_brown) +
  labs(x = "log cumulative capacity (MW)", y = "log panel price") +
  theme_minimal()

The experience-curve view: log price against log cumulative capacity
Code
m_wright <- lm(p ~ q)      # price ~ cumulative experience
m_moore  <- lm(p ~ t)      # price ~ calendar time
b_w <- -coef(m_wright)["q"]

comp <- data.frame(
  Model = c("Wright (experience)", "Moore (time trend)"),
  Slope = c(round(coef(m_wright)["q"], 4), round(coef(m_moore)["t"], 5)),
  `R2`  = c(round(summary(m_wright)$r.squared, 3),
            round(summary(m_moore)$r.squared, 3)),
  Implied = c(paste0(percent(1 - 2^(-b_w), 0.1), " per doubling"),
              paste0(percent(1 - exp(12 * coef(m_moore)["t"]), 0.1), " per year"))
)
kable(comp, caption = "Wright vs Moore on the same series")
Wright vs Moore on the same series
Model Slope R2 Implied
q Wright (experience) -0.24950 0.735 15.9% per doubling
t Moore (time trend) -0.01137 0.748 12.8% per year
Code
cat("cor(log capacity, time) =", round(cor(q, t), 4), "\n")
cor(log capacity, time) = 0.9763 
Code
cat("R2 of q ~ t (exponential-growth check):",
    round(summary(lm(q ~ t))$r.squared, 3), "\n")
R2 of q ~ t (exponential-growth check): 0.953 
ImportantThe identification trap

The naive Wright regression implies a learning rate of 15.9% per doubling - deceptively close to the canonical ~20% global solar figure. But the Moore time-trend fits this sample slightly better, and log-capacity correlates with calendar time at 0.976. This is the Nagy et al. (2013) result live in our own data: when deployment grows exponentially, experience and time are observationally near-equivalent, and no regression on a single national series can establish that deployment causes cost decline. Any policy argument built on “our deployment drives learning” is unidentified here.

4 Econometric guards

Experience curves are usually estimated as static log-log regressions on two trending series - exactly the setting in which spurious fit flourishes. Three guards are applied: first differences, cointegration testing with correct critical values, and a dynamic OLS estimator for the long-run elasticities.

4.1 First differences

Code
fd  <- lm(diff(p) ~ diff(q))
fd2 <- lm(diff(p) ~ diff(q) + diff(ps))
kable(round(summary(fd2)$coefficients, 4),
      caption = "First-difference regression: monthly price changes")
First-difference regression: monthly price changes
Estimate Std. Error t value Pr(>|t|)
(Intercept) -0.0107 0.0067 -1.5980 0.1129
diff(q) 0.0177 0.0579 0.3057 0.7604
diff(ps) 0.0484 0.0442 1.0966 0.2753

Month to month, capacity growth predicts nothing about price changes (the Wright slope collapses to zero, t ≈ 0.3). This is not a failure of the data - it is the economically correct result. Pakistan holds roughly 2-3% of global cumulative solar capacity; the learning happens in Chinese factories, and Pakistan imports the learning curve. The local price-capacity relationship also runs substantially in reverse: global price declines trigger Pakistani adoption surges (the 2023-24 additions boom). What looks like a learning curve is, in the short run, a diffusion curve wearing its clothes.

4.2 Cointegration with correct critical values

Code
eg2 <- lm(p ~ q)          # bivariate
eg3 <- lm(p ~ q + ps)     # trivariate
tau <- function(res) {
  r <- res; d <- diff(r); l <- r[-length(r)]
  summary(lm(d ~ l - 1))$coefficients[1, "t value"]
}
cat("EG tau, p ~ q      :", round(tau(residuals(eg2)), 3),
    " (5% MacKinnon crit ~ -3.37)\n")
EG tau, p ~ q      : -2.522  (5% MacKinnon crit ~ -3.37)
Code
cat("EG tau, p ~ q + ps :", round(tau(residuals(eg3)), 3),
    " (5% MacKinnon crit ~ -3.80)\n")
EG tau, p ~ q + ps : -3.586  (5% MacKinnon crit ~ -3.80)

Price and capacity alone are not cointegrated. Adding polysilicon moves the statistic to the borderline of its (correct, MacKinnon) critical value: the long-run anchor, to the extent one exists, is joint - global technological progress proxied by cumulative deployment, plus the upstream input cost.

4.3 DOLS long-run elasticities and the error-correction structure

Code
k <- 2
lead_lag <- function(x, j) {
  if (j > 0) c(x[-(1:j)], rep(NA, j)) else if (j < 0)
    c(rep(NA, -j), x[1:(length(x) + j)]) else x
}
dq <- c(NA, diff(q)); dps <- c(NA, diff(ps))
D  <- data.frame(p = p, q = q, ps = ps)
for (j in -k:k) {
  D[[paste0("dq",  j)]] <- lead_lag(dq,  j)
  D[[paste0("dps", j)]] <- lead_lag(dps, j)
}
dols <- lm(p ~ ., data = D)
cd <- summary(dols)$coefficients
b_q <- cd["q", "Estimate"]; b_ps <- cd["ps", "Estimate"]
kable(round(cd[c("q", "ps"), ], 4),
      caption = "DOLS (Stock-Watson) long-run elasticities, leads/lags k = 2")
DOLS (Stock-Watson) long-run elasticities, leads/lags k = 2
Estimate Std. Error t value Pr(>|t|)
q -0.2203 0.0105 -20.9153 0
ps 0.2924 0.0168 17.3946 0
Code
cat("DOLS-implied learning rate:", percent(1 - 2^(b_q), 0.1), "per doubling\n")
DOLS-implied learning rate: 14.2% per doubling
Code
r3  <- residuals(eg3)
ecm <- lm(diff(p) ~ r3[-n] + diff(q) + diff(ps))
kable(round(summary(ecm)$coefficients, 4),
      caption = "Error-correction model for monthly price changes")
Error-correction model for monthly price changes
Estimate Std. Error t value Pr(>|t|)
(Intercept) -0.0082 0.0067 -1.2293 0.2216
r3[-n] -0.0983 0.0489 -2.0098 0.0469
diff(q) -0.0222 0.0604 -0.3666 0.7146
diff(ps) 0.0634 0.0442 1.4342 0.1544
Code
lambda <- coef(ecm)["r3[-n]"]
cat("Adjustment speed:", round(lambda, 3), "per month | half-life:",
    round(log(0.5) / log(1 + lambda), 1), "months\n")
Adjustment speed: -0.098 per month | half-life: 6.7 months
Code
cat("Current disequilibrium: price is",
    percent(exp(tail(r3, 1)) - 1, 0.1), "above the long-run relation\n")
Current disequilibrium: price is 22.5% above the long-run relation
ImportantThe disequilibrium premium

As of May 2026 the panel price sits roughly 22.5% above the estimated long-run relation - the footprint of the 2026 GST scare and the polysilicon rebound at the end of the sample. The error-correction term (significant, half-life about 6.7 months) implies steady downward reversion pressure if policy uncertainty clears. This is a falsifiable near-term prediction that no ARIMA can generate, because a drift model has no concept of “above equilibrium.” It is also a direct measure of what policy signaling alone costs consumers: the 18% GST never passed, yet its shadow is in the price.

5 Decomposing the 2017-2026 decline

Code
dq_tot <- q[n] - q[1]; dps_tot <- ps[n] - ps[1]; dp_tot <- p[n] - p[1]
c_q  <- b_q  * dq_tot
c_ps <- b_ps * dps_tot
dec <- data.frame(
  Component = c("Capacity / technological progress (proxy)",
                "Polysilicon input cost",
                "Unexplained residual (tax, tariff, FX, margins)"),
  `Log contribution` = round(c(c_q, c_ps, dp_tot - c_q - c_ps), 3),
  `Share of fitted decline` = c(percent(c_q / (c_q + c_ps), 1),
                                percent(c_ps / (c_q + c_ps), 1), "-")
)
kable(dec, caption = "Decomposition of the actual log price change (Jan 2017 - May 2026)")
Decomposition of the actual log price change (Jan 2017 - May 2026)
Component Log.contribution Share.of.fitted.decline
Capacity / technological progress (proxy) -1.486 76%
Polysilicon input cost -0.470 24%
Unexplained residual (tax, tariff, FX, margins) 0.804 -

Roughly 76% of the fitted decline tracks the deployment/learning proxy and 24% tracks polysilicon. The sizeable positive residual is domestic: it is the wedge that GST episodes, import tariffs, rupee depreciation, and dealer margins drive between the world module price and what a Pakistani buyer actually pays.

6 Uncertainty in the learning rate

Code
set.seed(42)
B <- 2000; bl <- 12; nb <- ceiling(n / bl)
sl <- numeric(B)
for (i in 1:B) {
  starts <- sample(1:(n - bl + 1), nb, replace = TRUE)
  idx <- unlist(lapply(starts, function(s) s:(s + bl - 1)))[1:n]
  sl[i] <- coef(lm(p[idx] ~ q[idx] + ps[idx]))[2]
}
lr_ci <- 1 - 2^(quantile(sl, c(0.95, 0.5, 0.05)))
cat("Learning rate 90% interval:", percent(lr_ci[1], 0.1), "to",
    percent(lr_ci[3], 0.1), "| median", percent(lr_ci[2], 0.1), "\n")
Learning rate 90% interval: 9.2% to 17.9% | median 12.6% 

A twelve-month block bootstrap puts the learning rate between roughly 9.2% and 17.9% per doubling. Carrying this parameter uncertainty into the forecast - rather than reporting a single curve - is what separates the probabilistic experience-curve tradition (Way et al., 2022) from both deterministic “law” extrapolation and falsely-narrow ARIMA intervals.

7 Scenario engine: capacity paths to price paths

Code
fit_logistic <- function(K) {
  if (any(Q >= K)) return(NULL)
  z <- log(Q / (K - Q))
  f <- lm(z ~ t)
  list(K = K, a = coef(f)[1], b = coef(f)[2],
       ss = sum((Q - K / (1 + exp(-fitted(f))))^2))
}
grid <- Filter(Negate(is.null), lapply(seq(60000, 400000, 5000), fit_logistic))
best <- grid[[which.min(sapply(grid, `[[`, "ss"))]]
cat("Best-fit logistic saturation ceiling K =", comma(best$K), "MW\n")
Best-fit logistic saturation ceiling K = 150,000 MW
Code
H <- 60; t_f <- (n + 1):(n + H)
cap_path <- function(K, a, b) K / (1 + exp(-(a + b * t_f)))
scen <- list(
  baseline    = { f <- fit_logistic(best$K);  cap_path(f$K, f$a, f$b) },
  constrained = { f <- fit_logistic(100000);  cap_path(f$K, f$a, f$b) },
  accelerated = { f <- fit_logistic(250000);  cap_path(f$K, f$a, f$b) }
)

ps_f <- tryCatch(
  as.numeric(forecast(auto.arima(ts(ps, frequency = 12)), h = H)$mean),
  error = function(e) rep(ps[n], H))

a0 <- coef(eg3)[1]
forecast_price <- function(cap, b_q_use = b_q) {
  pf <- numeric(H); pc <- p[n]
  for (h in 1:H) {
    p_star <- a0 + b_q_use * log(cap[h]) + b_ps * ps_f[h]
    pc <- pc + lambda * (pc - p_star)
    pf[h] <- pc
  }
  pf
}
paths <- sapply(scen, forecast_price)

fan <- sapply(sample(sl, 500), function(bq) forecast_price(scen$baseline, bq))
fan_lo <- apply(fan, 1, quantile, 0.05)
fan_hi <- apply(fan, 1, quantile, 0.95)

The engine has three moving parts: (i) a logistic diffusion curve fitted to cumulative capacity, with the saturation ceiling K chosen by grid search (best fit near 150,000 MW) and bracketed by a policy-constrained scenario (K = 100 GW: net-billing and tax friction bite) and an accelerated scenario (K = 250 GW: batteries unlock a second adoption wave); (ii) an auxiliary ARIMA projection of polysilicon; (iii) the error-correction forecaster, in which the price converges at rate 0.098/month toward a moving equilibrium defined by the DOLS elasticities. The forecast therefore respects both the current disequilibrium premium and the falling technological anchor.

Code
arima_fc <- tryCatch(
  as.numeric(forecast(auto.arima(ts(p, frequency = 12)), h = H)$mean),
  error = function(e) rep(NA, H))
naive_fc <- rep(p[n], H)

dates_f <- seq(df$Date[n] + 31, by = "month", length.out = H)
plot_df <- rbind(
  data.frame(Date = df$Date, value = p, series = "Actual"),
  data.frame(Date = dates_f, value = paths[, "baseline"],    series = "Wright-ECM baseline (K=150GW)"),
  data.frame(Date = dates_f, value = paths[, "constrained"], series = "Policy-constrained (K=100GW)"),
  data.frame(Date = dates_f, value = paths[, "accelerated"], series = "Accelerated (K=250GW)"),
  data.frame(Date = dates_f, value = arima_fc,               series = "ARIMA drift"),
  data.frame(Date = dates_f, value = naive_fc,               series = "Naive")
)
ribbon_df <- data.frame(Date = dates_f, lo = fan_lo, hi = fan_hi)
ggplot(plot_df, aes(Date, value, color = series)) +
  geom_ribbon(data = ribbon_df, aes(x = Date, ymin = lo, ymax = hi),
              inherit.aes = FALSE, alpha = 0.15, fill = brand_green) +
  geom_line(linewidth = 0.8) +
  scale_color_manual(values = c(
    "Actual" = "black",
    "Wright-ECM baseline (K=150GW)" = brand_green,
    "Policy-constrained (K=100GW)" = brand_brown,
    "Accelerated (K=250GW)" = "#4C9A2A",
    "ARIMA drift" = "steelblue", "Naive" = "grey55")) +
  labs(y = "log panel price", x = NULL, color = NULL,
       subtitle = "Ribbon: 90% learning-rate uncertainty on the baseline") +
  theme_minimal() + theme(legend.position = "bottom")

Five-year forecasts: technological (scenario fan) vs statistical benchmarks
Code
fc_tbl <- data.frame(
  Horizon      = c("12 months", "36 months", "60 months"),
  Baseline     = round(paths[c(12, 36, 60), "baseline"], 3),
  Constrained  = round(paths[c(12, 36, 60), "constrained"], 3),
  Accelerated  = round(paths[c(12, 36, 60), "accelerated"], 3),
  ARIMA        = round(arima_fc[c(12, 36, 60)], 3),
  Naive        = round(naive_fc[c(12, 36, 60)], 3)
)
kable(fc_tbl, caption = "Forecast comparison (log price)")
Forecast comparison (log price)
Horizon Baseline Constrained Accelerated ARIMA Naive
12 months -2.623 -2.602 -2.643 -2.072 -2.12
36 months -2.889 -2.834 -2.945 -2.072 -2.12
60 months -2.953 -2.879 -3.038 -2.072 -2.12
Code
cat("Baseline implied cumulative price change at 60 months:",
    percent(exp(paths[60, "baseline"] - p[n]) - 1, 0.1), "\n")
Baseline implied cumulative price change at 60 months: -56.5% 

The baseline implies a further decline of roughly 56.5% over five years, but the honest headline is the width of the fan: at 60 months the 90% band spans about -3.68 to -1.98 in log terms. The scenarios diverge slowly at 12 months and widely at 60 - technological forecasts earn their keep precisely at the horizons where statistical models extrapolate blind.

8 What this means for policy

ImportantPolicy relevance
  • Planning institutions are structurally exposed. IGCEP-style generation planning and DISCO revenue models built on ARIMA-class or static cost assumptions will keep being ambushed: Pakistan’s ~56 GW of largely behind-the-meter capacity arrived because official projections treated cost declines as drift rather than as a function of deployment. The February 2026 net-billing rush is the institutional aftershock of a forecasting failure.
  • Suppressing adoption does not stop the cost decline - it shifts its composition. An 18% GST or an Rs 11/unit buy-back pushes households toward self-consumption plus storage; batteries sit on their own ~25-30% learning curve and will hit the grid as a second, larger surprise on a predictable schedule.
  • Policy signaling has a measurable price. The 22.5% disequilibrium premium at the end of the sample is the cost of GST uncertainty alone - the tax never passed, yet its shadow is in the price paid by every buyer in early 2026.
  • The right planning input is a scenario range, not a point forecast. Tariff design, cross-subsidy recovery, and IPP capacity-payment exposure should be stress-tested against the fan above, in the spirit of Way et al. (2022): on experience-curve evidence, the fast-transition path is the cheap path, and models that assume static technology costs compound against the state.

9 Key references

Nagy, B., Farmer, J. D., Bui, Q. M., & Trancik, J. E. (2013). Statistical basis for predicting technological progress. PLoS ONE, 8(2), e52669.

Way, R., Ives, M. C., Mealy, P., & Farmer, J. D. (2022). Empirically grounded technology forecasts and the energy transition. Joule, 6(9), 2057-2082.

Wright, T. P. (1936). Factors affecting the cost of airplanes. Journal of the Aeronautical Sciences, 3(4), 122-128.

Kavlak, G., McNerney, J., & Trancik, J. E. (2018). Evaluating the causes of cost reduction in photovoltaic modules. Energy Policy, 123, 700-710.

10 Reproducibility

Code
R version 4.6.0 (2026-04-24 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 22621)

Matrix products: default
  LAPACK version 3.12.1

locale:
[1] LC_COLLATE=English_Pakistan.utf8  LC_CTYPE=English_Pakistan.utf8   
[3] LC_MONETARY=English_Pakistan.utf8 LC_NUMERIC=C                     
[5] LC_TIME=English_Pakistan.utf8    

time zone: Asia/Karachi
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
[1] knitr_1.51     scales_1.4.0   ggplot2_4.0.3  forecast_9.0.2

loaded via a namespace (and not attached):
 [1] Matrix_1.7-5       gtable_0.3.6       jsonlite_2.0.0     dplyr_1.2.1       
 [5] compiler_4.6.0     tidyselect_1.2.1   Rcpp_1.1.1-1       parallel_4.6.0    
 [9] splines_4.6.0      yaml_2.3.12        fastmap_1.2.0      lattice_0.22-9    
[13] R6_2.6.1           labeling_0.4.3     generics_0.1.4     htmlwidgets_1.6.4 
[17] tibble_3.3.1       timeDate_4052.112  pillar_1.11.1      RColorBrewer_1.1-3
[21] rlang_1.2.0        urca_1.3-4         xfun_0.57          S7_0.2.2          
[25] otel_0.2.0         cli_3.6.6          mgcv_1.9-4         withr_3.0.2       
[29] magrittr_2.0.5     digest_0.6.39      grid_4.6.0         rstudioapi_0.18.0 
[33] lifecycle_1.0.5    nlme_3.1-169       fracdiff_1.5-3     vctrs_0.7.3       
[37] evaluate_1.0.5     glue_1.8.1         farver_2.1.2       zoo_1.8-15        
[41] colorspace_2.1-2   rmarkdown_2.31     tools_4.6.0        pkgconfig_2.0.3   
[45] htmltools_0.5.9   
Back to top