---
title: "Renewables Became the Cheapest Source of Electricity"
subtitle: "Global levelized cost of energy (LCOE), 2010–2024 · Solar and wind costs fell below fossil fuels, marking a structural shift in energy economics · 2024 USD/kWh"
description: "Solar PV costs fell 90% between 2010 and 2024, dropping from $0.42/kWh to $0.043/kWh — well below the fossil fuel benchmark. This line chart traces the levelized cost of electricity (LCOE) for solar, onshore wind, offshore wind, and fossil fuels over 15 years, marking the crossover years when renewables became cost-competitive. Built with ggplot2 and IRENA data."
date: "2026-04-19"
author:
- name: "Steven Ponce"
url: "https://stevenponce.netlify.app"
citation:
url: "https://stevenponce.netlify.app/data_visualizations/30DayChartChallenge/2026/30dcc_2026_19.html"
categories: ["30DayChartChallenge", "2026"]
tags: [
"30DayChartChallenge",
"Timeseries",
"Evolution",
"Line Chart",
"Energy Economics",
"Renewable Energy",
"LCOE",
"Solar PV",
"Wind Energy",
"Fossil Fuels",
"IRENA",
"Energy Transition",
"ggplot2"
]
image: "thumbnails/30dcc_2026_19.png"
format:
html:
toc: true
toc-depth: 5
code-link: true
code-fold: true
code-tools: true
code-summary: "Show code"
editor_options:
chunk_output_type: inline
execute:
freeze: true
cache: true
error: false
message: false
warning: false
eval: true
---
{#fig-1}
### [**Steps to Create this Graphic**]{.mark}
#### [1. Load Packages & Setup]{.smallcaps}
```{r}
#| label: load
#| warning: false
#| message: false
#| results: "hide"
## 1. LOAD PACKAGES & SETUP ----
suppressPackageStartupMessages({
pacman::p_load(
tidyverse, ggtext, showtext, patchwork,
janitor, scales, glue, ggrepel
)
})
### |- figure size ----
camcorder::gg_record(
dir = here::here("temp_plots"),
device = "png",
width = 10,
height = 8,
units = "in",
dpi = 320
)
# Source utility functions
suppressMessages(source(here::here("R/utils/fonts.R")))
source(here::here("R/utils/social_icons.R"))
source(here::here("R/utils/image_utils.R"))
source(here::here("R/themes/base_theme.R"))
```
#### [2. Read in the Data]{.smallcaps}
```{r}
#| label: read
#| include: true
#| eval: true
#| warning: false
### |- LCOE data (USD/kWh, 2024 USD) ----
# Source: IRENA Renewable Power Generation Costs reports 2010–2024
# All values are global weighted-average LCOE for newly-commissioned utility-scale plants
# Fossil fuel = weighted-average cost of new fossil fuel-fired capacity
lcoe_raw <- tribble(
~year, ~technology, ~lcoe,
# Solar PV
2010, "Solar PV", 0.417,
2011, "Solar PV", 0.360,
2012, "Solar PV", 0.280,
2013, "Solar PV", 0.200,
2014, "Solar PV", 0.165,
2015, "Solar PV", 0.122,
2016, "Solar PV", 0.100,
2017, "Solar PV", 0.086,
2018, "Solar PV", 0.068,
2019, "Solar PV", 0.057,
2020, "Solar PV", 0.057,
2021, "Solar PV", 0.048,
2022, "Solar PV", 0.049,
2023, "Solar PV", 0.044,
2024, "Solar PV", 0.043,
# Onshore Wind
2010, "Onshore Wind", 0.111,
2011, "Onshore Wind", 0.104,
2012, "Onshore Wind", 0.097,
2013, "Onshore Wind", 0.090,
2014, "Onshore Wind", 0.083,
2015, "Onshore Wind", 0.076,
2016, "Onshore Wind", 0.066,
2017, "Onshore Wind", 0.060,
2018, "Onshore Wind", 0.056,
2019, "Onshore Wind", 0.053,
2020, "Onshore Wind", 0.039,
2021, "Onshore Wind", 0.033,
2022, "Onshore Wind", 0.033,
2023, "Onshore Wind", 0.033,
2024, "Onshore Wind", 0.034,
# Offshore Wind
2010, "Offshore Wind", 0.188,
2011, "Offshore Wind", 0.198,
2012, "Offshore Wind", 0.193,
2013, "Offshore Wind", 0.185,
2014, "Offshore Wind", 0.178,
2015, "Offshore Wind", 0.170,
2016, "Offshore Wind", 0.152,
2017, "Offshore Wind", 0.140,
2018, "Offshore Wind", 0.127,
2019, "Offshore Wind", 0.115,
2020, "Offshore Wind", 0.084,
2021, "Offshore Wind", 0.075,
2022, "Offshore Wind", 0.081,
2023, "Offshore Wind", 0.076,
2024, "Offshore Wind", 0.079,
# Fossil Fuel benchmark (weighted-average new capacity)
2010, "Fossil Fuels", 0.090,
2011, "Fossil Fuels", 0.090,
2012, "Fossil Fuels", 0.092,
2013, "Fossil Fuels", 0.093,
2014, "Fossil Fuels", 0.094,
2015, "Fossil Fuels", 0.093,
2016, "Fossil Fuels", 0.085,
2017, "Fossil Fuels", 0.086,
2018, "Fossil Fuels", 0.085,
2019, "Fossil Fuels", 0.084,
2020, "Fossil Fuels", 0.082,
2021, "Fossil Fuels", 0.094,
2022, "Fossil Fuels", 0.142,
2023, "Fossil Fuels", 0.100,
2024, "Fossil Fuels", 0.073
)
```
#### [3. Examine the Data]{.smallcaps}
```{r}
#| label: examine
#| include: true
#| eval: true
#| results: 'hide'
#| warning: false
glimpse(lcoe_raw)
```
#### [4. Tidy Data]{.smallcaps}
```{r}
#| label: tidy
#| warning: false
### |- factor levels and label data ----
tech_order <- c("Solar PV", "Onshore Wind", "Offshore Wind", "Fossil Fuels")
lcoe <- lcoe_raw |>
mutate(
technology = factor(technology, levels = tech_order),
lcoe_cents = lcoe * 100 # convert to cents/kWh for more readable axis
)
### |- endpoint labels (2024 values) ----
labels_2024 <- lcoe |>
filter(year == 2024) |>
mutate(
label = glue("{technology}\n${round(lcoe, 3)}/kWh")
)
### |- compute % decline for annotation ----
solar_2010 <- lcoe |>
filter(technology == "Solar PV", year == 2010) |>
pull(lcoe)
solar_2024 <- lcoe |>
filter(technology == "Solar PV", year == 2024) |>
pull(lcoe)
solar_pct_decline <- round((1 - solar_2024 / solar_2010) * 100, 0)
wind_2010 <- lcoe |>
filter(technology == "Onshore Wind", year == 2010) |>
pull(lcoe)
wind_2024 <- lcoe |>
filter(technology == "Onshore Wind", year == 2024) |>
pull(lcoe)
wind_pct_decline <- round((1 - wind_2024 / wind_2010) * 100, 0)
### |- parity crossover: first year solar <= fossil ----
# Used to place the "cost parity" annotation
solar_parity_yr <- lcoe |>
filter(technology %in% c("Solar PV", "Fossil Fuels")) |>
select(year, technology, lcoe) |>
pivot_wider(names_from = technology, values_from = lcoe) |>
filter(`Solar PV` <= `Fossil Fuels`) |>
slice_min(year) |>
pull(year)
wind_parity_yr <- lcoe |>
filter(technology %in% c("Onshore Wind", "Fossil Fuels")) |>
select(year, technology, lcoe) |>
pivot_wider(names_from = technology, values_from = lcoe) |>
filter(`Onshore Wind` <= `Fossil Fuels`) |>
slice_min(year) |>
pull(year)
```
#### [5. Visualization Parameters]{.smallcaps}
```{r}
#| label: params
#| include: true
#| warning: false
### |- plot aesthetics ----
colors <- get_theme_colors(
palette = list(
solar = "#F4A261",
onshore = "#2A9D8F",
offshore = "#457B9D",
fossil = "#6B6B6B",
bg = "#FAFAF8",
parity = "#E63946"
)
)
### |- titles and caption ----
title_text <- "Renewables Became the Cheapest Source of Electricity"
subtitle_text <- "Global levelized cost of energy (LCOE), 2010–2024 · Solar and wind costs fell below<br>
fossil fuels, marking a structural shift in energy economics · 2024 USD/kWh"
caption_text <- create_dcc_caption(
dcc_year = 2026,
dcc_day = 19,
source_text = "IRENA · Renewable Power Generation Costs reports (2010–2024)"
)
### |- fonts ----
setup_fonts()
fonts <- get_font_families()
### |- technology colors vector ----
tech_colors <- c(
"Solar PV" = colors$palette$solar,
"Onshore Wind" = colors$palette$onshore,
"Offshore Wind" = colors$palette$offshore,
"Fossil Fuels" = "#3D3D3D"
)
tech_linewidth <- c(
"Solar PV" = 1.4,
"Onshore Wind" = 1.4,
"Offshore Wind" = 1.0,
"Fossil Fuels" = 1.3
)
tech_linetype <- c(
"Solar PV" = "solid",
"Onshore Wind" = "solid",
"Offshore Wind" = "solid",
"Fossil Fuels" = "dashed"
)
### |- base theme ----
base_theme <- create_base_theme(colors)
weekly_theme <- extend_weekly_theme(
base_theme,
theme(
# Background
plot.background = element_rect(fill = colors$palette$bg, color = NA),
panel.background = element_rect(fill = colors$palette$bg, color = NA),
# Grid — horizontal only, very light
panel.grid.major.y = element_line(color = "gray88", linewidth = 0.25),
panel.grid.major.x = element_blank(),
panel.grid.minor = element_blank(),
# Axes
axis.ticks = element_blank(),
axis.text = element_text(family = fonts$text, size = 9, color = "gray40"),
axis.title.x = element_blank(),
axis.title.y = element_text(
family = fonts$text, size = 9, color = "gray40",
margin = margin(r = 8)
),
# Title / subtitle / caption
plot.title = element_text(
family = fonts$title, face = "bold",
size = 18, color = "gray10",
margin = margin(b = 6)
),
plot.subtitle = element_markdown(
family = fonts$text, size = 10.5, color = "gray35",
lineheight = 1.4, margin = margin(b = 20)
),
plot.caption = element_markdown(
family = fonts$text, size = 7.5, color = "gray55",
hjust = 0, margin = margin(t = 16)
),
plot.margin = margin(t = 20, r = 100, b = 16, l = 20)
)
)
theme_set(weekly_theme)
```
#### [6. Plot]{.smallcaps}
```{r}
#| label: plot
#| warning: false
### |- main plot ----
p <- ggplot(
data = lcoe,
mapping = aes(
x = year, y = lcoe, color = technology,
linewidth = technology, linetype = technology
)
) +
# Geoms
geom_vline(
xintercept = solar_parity_yr,
color = colors$palette$solar,
linetype = "dotted",
linewidth = 0.55,
alpha = 0.7
) +
geom_vline(
xintercept = wind_parity_yr,
color = colors$palette$onshore,
linetype = "dotted",
linewidth = 0.55,
alpha = 0.7
) +
geom_line(lineend = "round") +
geom_point(
data = labels_2024,
size = 3,
shape = 21,
fill = colors$palette$bg,
stroke = 1.4
) +
# Annotate
annotate(
"text",
x = 2010.2,
y = lcoe |> filter(technology == "Solar PV", year == 2010) |> pull(lcoe),
label = "Solar PV",
hjust = 0,
vjust = -0.5,
size = 3,
family = fonts$text,
color = colors$palette$solar,
fontface = "bold"
) +
annotate(
"text",
x = 2010.2,
y = lcoe |> filter(technology == "Offshore Wind", year == 2010) |> pull(lcoe),
label = "Offshore Wind",
hjust = 0,
vjust = 1.6,
size = 3,
family = fonts$text,
color = colors$palette$offshore,
fontface = "bold"
) +
annotate(
"text",
x = 2010.2,
y = lcoe |> filter(technology == "Onshore Wind", year == 2010) |> pull(lcoe),
label = "Onshore Wind",
hjust = 0,
vjust = -0.5,
size = 3,
family = fonts$text,
color = colors$palette$onshore,
fontface = "bold"
) +
annotate(
"text",
x = 2010.2,
y = lcoe |> filter(technology == "Fossil Fuels", year == 2010) |> pull(lcoe),
label = "Fossil Fuels",
hjust = 0,
vjust = 1.6,
size = 3,
family = fonts$text,
color = colors$palette$fossil,
fontface = "bold"
) +
annotate(
"richtext",
x = 2021,
y = 0.43,
label = glue(
"<span style='font-size:15pt; color:{colors$palette$solar}'>**\u221290%**</span><br>",
"<span style='font-size:8pt; color:gray35'>Solar cost decline since 2010</span>"
),
hjust = 0,
vjust = 1,
size = 3,
family = fonts$text,
fill = NA,
label.color = NA
) +
annotate(
"text",
x = wind_parity_yr + 0.2,
y = 0.35,
label = glue("{wind_parity_yr}: Wind becomes\ncost-competitive"),
hjust = 0,
vjust = 1,
size = 2.8,
family = fonts$text,
color = colors$palette$onshore,
lineheight = 1.25
) +
annotate(
"text",
x = solar_parity_yr + 0.2,
y = 0.35,
label = glue("{solar_parity_yr}: Solar becomes\ncost-competitive"),
hjust = 0,
vjust = 1,
size = 2.8,
family = fonts$text,
color = colors$palette$solar,
lineheight = 1.25
) +
annotate("segment",
x = 2024, xend = 2024.55,
y = labels_2024 |> filter(technology == "Offshore Wind") |> pull(lcoe),
yend = 0.185,
color = colors$palette$offshore, linewidth = 0.35, alpha = 0.6
) +
annotate("segment",
x = 2024, xend = 2024.55,
y = labels_2024 |> filter(technology == "Fossil Fuels") |> pull(lcoe),
yend = 0.145,
color = colors$palette$fossil, linewidth = 0.35, linetype = "dashed", alpha = 0.6
) +
annotate("segment",
x = 2024, xend = 2024.55,
y = labels_2024 |> filter(technology == "Solar PV") |> pull(lcoe),
yend = 0.105,
color = colors$palette$solar, linewidth = 0.35, alpha = 0.6
) +
annotate("segment",
x = 2024, xend = 2024.55,
y = labels_2024 |> filter(technology == "Onshore Wind") |> pull(lcoe),
yend = 0.060,
color = colors$palette$onshore, linewidth = 0.35, alpha = 0.6
) +
annotate("text",
x = 2024.6, y = 0.185,
label = "Offshore Wind",
hjust = 0, vjust = 0.5, size = 2.9,
family = fonts$text, color = colors$palette$offshore,
fontface = "bold"
) +
annotate("text",
x = 2024.6, y = 0.145,
label = "Fossil Fuels",
hjust = 0, vjust = 0.5, size = 2.9,
family = fonts$text, color = colors$palette$fossil,
fontface = "bold"
) +
annotate("richtext",
x = 2024.6, y = 0.105,
label = glue(
"<span style='color:{colors$palette$solar}'>**Solar PV**</span> ",
"<span style='color:{colors$palette$solar}'>\u2212{solar_pct_decline}%</span>"
),
hjust = 0, vjust = 0.5, size = 2.9,
family = fonts$text, fill = NA, label.color = NA, color = "gray35"
) +
annotate("richtext",
x = 2024.6, y = 0.060,
label = glue(
"<span style='color:{colors$palette$onshore}'>**Onshore Wind**</span> ",
"<span style='color:{colors$palette$onshore}'>\u2212{wind_pct_decline}%</span>"
),
hjust = 0, vjust = 0.5, size = 2.9,
family = fonts$text, fill = NA, label.color = NA, color = "gray35"
) +
# Scales
scale_color_manual(values = tech_colors) +
scale_linewidth_manual(values = tech_linewidth) +
scale_linetype_manual(values = tech_linetype) +
scale_x_continuous(
breaks = seq(2010, 2024, 2),
expand = expansion(mult = c(0.02, 0.04))
) +
scale_y_continuous(
labels = label_dollar(suffix = "/kWh", accuracy = 0.01),
breaks = seq(0, 0.40, 0.05),
limits = c(0, 0.45),
expand = expansion(mult = c(0, 0.02))
) +
coord_cartesian(clip = "off") +
# Labs
labs(
title = title_text,
subtitle = subtitle_text,
caption = caption_text,
y = "LCOE (USD/kWh, 2024 real)"
) +
guides(
color = "none",
linewidth = "none",
linetype = "none"
)
```
#### [7. Save]{.smallcaps}
```{r}
#| label: save
#| warning: false
### |- plot image ----
save_plot(
p,
type = "30daychartchallenge",
year = 2026,
day = 19,
width = 10,
height = 8
)
```
#### [8. Session Info]{.smallcaps}
::: {.callout-tip collapse="true"}
##### Expand for Session Info
```{r, echo = FALSE}
#| eval: true
#| warning: false
sessionInfo()
```
:::
#### [9. GitHub Repository]{.smallcaps}
::: {.callout-tip collapse="true"}
##### Expand for GitHub Repo
The complete code for this analysis is available in [`30dcc_2026_19.qmd`](https://github.com/poncest/personal-website/blob/master/data_visualizations/TidyTuesday/2026/30dcc_2026_19.qmd).
For the full repository, [click here](https://github.com/poncest/personal-website/).
:::
#### [10. References]{.smallcaps}
::: {.callout-tip collapse="true"}
##### Expand for References
1. **Data Sources:**
- International Renewable Energy Agency (IRENA).
*Renewable Power Generation Costs* annual reports (2010–2024).
https://www.irena.org/Energy-Transition/Technology/Power-generation-costs
- Data: global weighted-average LCOE by technology, hardcoded from published report tables
- All values in 2024 USD/kWh | License: CC BY 4.0
:::
#### [11. Custom Functions Documentation]{.smallcaps}
::: {.callout-note collapse="true"}
##### 📦 Custom Helper Functions
This analysis uses custom functions from my personal module library for efficiency and consistency across projects.
**Functions Used:**
- **`fonts.R`**: `setup_fonts()`, `get_font_families()` - Font management with showtext
- **`social_icons.R`**: `create_social_caption()` - Generates formatted social media captions
- **`image_utils.R`**: `save_plot()` - Consistent plot saving with naming conventions
- **`base_theme.R`**: `create_base_theme()`, `extend_weekly_theme()`, `get_theme_colors()` - Custom ggplot2 themes
**Why custom functions?**\
These utilities standardize theming, fonts, and output across all my data visualizations. The core analysis (data tidying and visualization logic) uses only standard tidyverse packages.
**Source Code:**\
View all custom functions → [GitHub: R/utils](https://github.com/poncest/personal-website/tree/master/R)
:::