This document presents a linear programming (LP)
model to evaluate whether Australia can achieve net-zero
greenhouse gas emissions from its electricity sector by 2050. The model
is built using the ompr algebraic modelling interface and
solved with the open-source GLPK solver.
The model is deliberately kept transparent and modular:
ggplot2 visualisations.| Source | Type | Notes |
|---|---|---|
| Coal | Fossil | No new builds; existing fleet depreciates |
| Gas | Fossil | Flexible backup; phased down |
| Solar | Renewable | High build potential |
| Wind | Renewable | High build potential |
| Hydro | Renewable | Limited expansion potential |
| Nuclear | Low-carbon | Long lead times; included as option |
library(ompr) # Algebraic LP/MIP modelling interface
library(ompr.roi) # Bridge between ompr and ROI solvers
library(ROI.plugin.glpk) # GLPK solver plugin
library(ggplot2) # Visualisation
library(dplyr) # Data wrangling
library(tidyr) # Data reshaping
library(scales) # Axis formatting helpers
library(knitr) # Table rendering
library(kableExtra) # Enhanced table styling# Energy source labels
sources <- c("coal", "gas", "solar", "wind", "hydro", "nuclear")
# Planning horizon
years <- 2025:2050
# Integer indices used inside ompr expressions
n_sources <- length(sources) # I = 6
n_years <- length(years) # T = 26
src_idx <- seq_len(n_sources)
yr_idx <- seq_len(n_years)
cat("Sources:", paste(sources, collapse = ", "), "\n")#> Sources: coal, gas, solar, wind, hydro, nuclear
#> Years: 2025 – 2050 ( 26 periods )
Scenario analysis: All parameters live in this section. To run an alternative scenario, change the values here and re-knit the document. The model structure remains unchanged.
# Demand grows linearly from 300 TWh (2025) to ~425 TWh (2050)
# reflecting electrification of transport, industry, and heating.
D <- 300 + 5 * (yr_idx - 1)
data.frame(year = years, demand_TWh = D) |>
filter(year %in% seq(2025, 2050, 5)) |>
kable(caption = "Demand projection (selected years)") |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| year | demand_TWh |
|---|---|
| 2025 | 300 |
| 2030 | 325 |
| 2035 | 350 |
| 2040 | 375 |
| 2045 | 400 |
| 2050 | 425 |
# Variable (production) cost c[i] — $M per TWh
c_prod <- c(coal = 40, gas = 60, solar = 15, wind = 20, hydro = 10, nuclear = 35)
# Capital (build) cost k[i] — $M per GW installed
k_cap <- c(coal = 3000, gas = 1000, solar = 1200, wind = 1500, hydro = 4000, nuclear = 8000)
data.frame(
Source = sources,
`Production cost ($/MWh)` = c_prod,
`Capital cost ($M/GW)` = k_cap,
check.names = FALSE
) |>
kable(caption = "Cost parameters") |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Source | Production cost (\(/MWh) </th> <th style="text-align:right;"> Capital cost (\)M/GW) | ||
|---|---|---|---|
| coal | coal | 40 | 3000 |
| gas | gas | 60 | 1000 |
| solar | solar | 15 | 1200 |
| wind | wind | 20 | 1500 |
| hydro | hydro | 10 | 4000 |
| nuclear | nuclear | 35 | 8000 |
# Renewables and nuclear treated as zero lifecycle emissions (simplified)
e_int <- c(coal = 0.88, gas = 0.45, solar = 0.00, wind = 0.00, hydro = 0.00, nuclear = 0.00)
data.frame(Source = sources, `Emission intensity (Mt CO2/TWh)` = e_int,
check.names = FALSE) |>
kable(caption = "Emission intensities") |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Source | Emission intensity (Mt CO2/TWh) | |
|---|---|---|
| coal | coal | 0.88 |
| gas | gas | 0.45 |
| solar | solar | 0.00 |
| wind | wind | 0.00 |
| hydro | hydro | 0.00 |
| nuclear | nuclear | 0.00 |
# Annual depreciation rate delta[i] (fraction of capacity retired per year)
delta <- c(coal = 0.02, gas = 0.03, solar = 0.04, wind = 0.04, hydro = 0.01, nuclear = 0.02)
# Maximum new capacity built per year B_max[i] (GW/yr) — supply-chain constraint
B_max <- c(coal = 0.0, gas = 2.0, solar = 10.0, wind = 8.0, hydro = 0.5, nuclear = 1.0)
# Initial installed capacity K0[i] in 2025 (GW)
K0 <- c(coal = 20.0, gas = 12.0, solar = 20.0, wind = 12.0, hydro = 8.0, nuclear = 0.0)
# Capacity factor cf[i] — fraction of hours at full output
cf <- c(coal = 0.70, gas = 0.60, solar = 0.22, wind = 0.35, hydro = 0.45, nuclear = 0.90)
# Maximum annual production per GW of installed capacity (TWh / GW / year)
TWh_per_GW <- 8.76 * cf
data.frame(
Source = sources,
`K0 (GW)` = K0,
`Depreciation` = delta,
`Build limit (GW/yr)` = B_max,
`Capacity factor` = cf,
`TWh/GW/yr` = round(TWh_per_GW, 2),
check.names = FALSE
) |>
kable(caption = "Technical parameters") |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Source | K0 (GW) | Depreciation | Build limit (GW/yr) | Capacity factor | TWh/GW/yr | |
|---|---|---|---|---|---|---|
| coal | coal | 20 | 0.02 | 0.0 | 0.70 | 6.13 |
| gas | gas | 12 | 0.03 | 2.0 | 0.60 | 5.26 |
| solar | solar | 20 | 0.04 | 10.0 | 0.22 | 1.93 |
| wind | wind | 12 | 0.04 | 8.0 | 0.35 | 3.07 |
| hydro | hydro | 8 | 0.01 | 0.5 | 0.45 | 3.94 |
| nuclear | nuclear | 0 | 0.02 | 1.0 | 0.90 | 7.88 |
| Variable | Domain | Description |
|---|---|---|
| \(x_{i,t}\) | \(\mathbb{R}_+\) | Energy produced by source \(i\) in year \(t\) (TWh) |
| \(K_{i,t}\) | \(\mathbb{R}_+\) | Installed capacity of source \(i\) in year \(t\) (GW) |
| \(B_{i,t}\) | \(\mathbb{R}_+\) | New capacity built for source \(i\) in year \(t\) (GW) |
\[\min \sum_{i} \sum_{t} c_i \cdot x_{i,t} + \sum_{i} \sum_{t} k_i \cdot B_{i,t}\]
Demand — supply must meet or exceed demand in every year: \[\sum_{i} x_{i,t} \geq D_t \quad \forall\, t\]
Capacity — production bounded by available capacity: \[x_{i,t} \leq K_{i,t} \cdot \text{TWh/GW}_i \quad \forall\, i, t\]
Capacity evolution — stock accounting with depreciation: \[K_{i,t+1} = K_{i,t} + B_{i,t} - \delta_i \cdot K_{i,t} \quad \forall\, i,\; t < T\]
Initial capacity: \[K_{i,1} = K^0_i \quad \forall\, i\]
Build limits — supply-chain constraint: \[B_{i,t} \leq B^{\max}_i \quad \forall\, i, t\]
No new coal — policy constraint: \[B_{\text{coal},\, t} = 0 \quad \forall\, t\]
Net-zero target — hard emissions ceiling in 2050: \[\sum_{i} e_i \cdot x_{i,T} \leq 0\]
# Helper functions to extract scalar parameters inside ompr lambda expressions
# (ompr passes i, t as plain integer indices)
get_c <- function(i) c_prod[i]
get_k <- function(i) k_cap[i]
get_e <- function(i) e_int[i]
get_d <- function(t) D[t]
get_Bm <- function(i) B_max[i]
get_K0 <- function(i) K0[i]
get_tw <- function(i) TWh_per_GW[i]
get_dl <- function(i) delta[i]
model <- MIPModel() |>
# ── Decision Variables ──────────────────────────────────────────────────────
add_variable(x[i, t], i = src_idx, t = yr_idx, type = "continuous", lb = 0) |>
add_variable(K[i, t], i = src_idx, t = yr_idx, type = "continuous", lb = 0) |>
add_variable(B[i, t], i = src_idx, t = yr_idx, type = "continuous", lb = 0) |>
# ── Objective ───────────────────────────────────────────────────────────────
set_objective(
sum_expr(get_c(i) * x[i, t], i = src_idx, t = yr_idx) +
sum_expr(get_k(i) * B[i, t], i = src_idx, t = yr_idx),
sense = "min"
) |>
# ── C1: Demand ──────────────────────────────────────────────────────────────
add_constraint(
sum_expr(x[i, t], i = src_idx) >= get_d(t),
t = yr_idx
) |>
# ── C2: Capacity bound ──────────────────────────────────────────────────────
add_constraint(
x[i, t] <= get_tw(i) * K[i, t],
i = src_idx, t = yr_idx
) |>
# ── C3a: Initial capacity ───────────────────────────────────────────────────
add_constraint(K[i, 1] == get_K0(i), i = src_idx) |>
# ── C3b: Capacity evolution ─────────────────────────────────────────────────
add_constraint(
K[i, t + 1] == K[i, t] + B[i, t] - get_dl(i) * K[i, t],
i = src_idx, t = yr_idx[-n_years]
) |>
# ── C4: Build limits ────────────────────────────────────────────────────────
add_constraint(B[i, t] <= get_Bm(i), i = src_idx, t = yr_idx) |>
# ── C5: No new coal ─────────────────────────────────────────────────────────
add_constraint(B[1, t] == 0, t = yr_idx) |>
# ── C6: Net-zero in 2050 ────────────────────────────────────────────────────
add_constraint(
sum_expr(get_e(i) * x[i, n_years], i = src_idx) <= 0
)
E0 <- sum(K0 * TWh_per_GW * e_int)
model <- model |>
add_constraint(
sum_expr(e_int[i] * x[i, t], i = src_idx) <=
E0 * (1 - (t - 1)/(n_years - 1)),
t = yr_idx
)result <- solve_model(model, with_ROI(solver = "glpk", verbose = FALSE))
cat("Solver status :", result$status, "\n")#> Solver status : error
#> Total cost ($M) : 566,037
# Extract ALL solutions first
x_sol <- get_solution(result, x[i, t])
K_sol <- get_solution(result, K[i, t])
B_sol <- get_solution(result, B[i, t])
# Map indices back to labels
prod_df <- x_sol |>
mutate(
source = sources[i],
year = years[t],
production = value
) |>
select(source, year, production)
cap_df <- K_sol |>
mutate(
source = sources[i],
year = years[t],
capacity = value
) |>
select(source, year, capacity)
build_df <- B_sol |>
mutate(
source = sources[i],
year = years[t],
build = value
) |>
select(source, year, build)
# Emissions
emissions_df <- prod_df |>
mutate(ef = e_int[source]) |>
group_by(year) |>
summarise(emissions_Mt = sum(production * ef), .groups = "drop")prod_df |>
filter(year %in% c(2025, 2030, 2035, 2040, 2045, 2050)) |>
mutate(production = round(production, 1)) |>
pivot_wider(names_from = year, values_from = production) |>
kable(caption = "Optimal energy production by source (TWh)") |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| source | 2025 | 2030 | 2035 | 2040 | 2045 | 2050 |
|---|---|---|---|---|---|---|
| coal | 122.6 | 110.9 | 92.9 | 62.0 | 31.0 | 0.0 |
| gas | 63.1 | 25.5 | 0.0 | 0.0 | 0.0 | 0.0 |
| solar | 38.5 | 120.4 | 187.1 | 241.5 | 285.9 | 302.8 |
| wind | 36.8 | 68.2 | 70.0 | 71.5 | 83.1 | 94.5 |
| hydro | 31.5 | 0.0 | 0.0 | 0.0 | 0.0 | 24.5 |
| nuclear | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 3.2 |
cap_df |>
filter(year %in% c(2025, 2030, 2035, 2040, 2045, 2050)) |>
mutate(capacity = round(capacity, 1)) |>
pivot_wider(names_from = year, values_from = capacity) |>
kable(caption = "Installed capacity by source (GW)") |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| source | 2025 | 2030 | 2035 | 2040 | 2045 | 2050 |
|---|---|---|---|---|---|---|
| coal | 20 | 18.1 | 16.3 | 14.8 | 13.4 | 12.1 |
| gas | 12 | 10.3 | 8.8 | 7.6 | 6.5 | 5.6 |
| solar | 20 | 62.5 | 97.1 | 125.3 | 148.3 | 157.1 |
| wind | 12 | 22.3 | 22.8 | 23.3 | 27.1 | 30.8 |
| hydro | 8 | 7.6 | 7.2 | 6.9 | 6.5 | 6.2 |
| nuclear | 0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.4 |
emissions_df |>
filter(year %in% c(2025, 2030, 2035, 2040, 2045, 2050)) |>
mutate(emissions_Mt = round(emissions_Mt, 2)) |>
kable(col.names = c("Year", "Emissions (Mt CO₂)"),
caption = "Annual grid emissions (selected years)") |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Year | Emissions (Mt CO₂) |
|---|---|
| 2025 | 136.31 |
| 2030 | 109.04 |
| 2035 | 81.78 |
| 2040 | 54.52 |
| 2045 | 27.26 |
| 2050 | 0.00 |
# Consistent colour palette across all plots
source_colours <- c(
coal = "#4a4a4a",
gas = "#f4a261",
solar = "#e9c46a",
wind = "#57cc99",
hydro = "#4cc9f0",
nuclear = "#9b5de5"
)
# Shared theme
theme_netzero <- theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey40", size = 10),
legend.position = "bottom",
legend.title = element_text(face = "bold"),
panel.grid.minor = element_blank()
)demand_line <- data.frame(year = years, demand = D)
ggplot(prod_df, aes(x = year, y = production, fill = source)) +
geom_area(alpha = 0.9, colour = "white", linewidth = 0.2) +
geom_line(data = demand_line, aes(x = year, y = demand),
colour = "black", linewidth = 0.9, linetype = "dashed",
inherit.aes = FALSE) +
annotate("text", x = 2043, y = max(D) * 1.04,
label = "Demand", size = 3.2, colour = "black") +
scale_fill_manual(values = source_colours, name = "Source") +
scale_x_continuous(breaks = seq(2025, 2050, 5)) +
scale_y_continuous(labels = comma) +
labs(
title = "Australia's Optimal Energy Mix, 2025–2050",
subtitle = "Net-zero constraint enforced in 2050",
x = "Year", y = "Energy Production (TWh)"
) +
theme_netzeroStacked area chart showing the optimal energy mix. The dashed line is total demand.
ggplot(emissions_df, aes(x = year, y = emissions_Mt)) +
geom_area(fill = "#e76f51", alpha = 0.35) +
geom_line(colour = "#e76f51", linewidth = 1.2) +
geom_point(colour = "#e76f51", size = 2.2) +
geom_hline(yintercept = 0, linetype = "dashed",
colour = "grey20", linewidth = 0.8) +
annotate("text", x = 2026, y = max(emissions_df$emissions_Mt) * 0.04,
label = "Net-zero target", hjust = 0, size = 3.2) +
scale_x_continuous(breaks = seq(2025, 2050, 5)) +
scale_y_continuous(labels = comma) +
labs(
title = "CO₂ Emissions Trajectory, 2025–2050",
subtitle = "Grid emissions under cost-optimal decarbonisation pathway",
x = "Year", y = "Emissions (Mt CO₂)"
) +
theme_netzero +
theme(legend.position = "none")Total annual CO₂ emissions declining to zero in 2050.
ggplot(cap_df, aes(x = year, y = capacity, fill = source)) +
geom_area(alpha = 0.85, colour = "white", linewidth = 0.2) +
scale_fill_manual(values = source_colours, name = "Source") +
scale_x_continuous(breaks = seq(2025, 2050, 5)) +
scale_y_continuous(labels = comma) +
labs(
title = "Installed Capacity Growth by Source, 2025–2050",
subtitle = "GW of installed generation capacity",
x = "Year", y = "Installed Capacity (GW)"
) +
theme_netzeroTotal installed capacity by source over the planning horizon.
build_df |>
filter(build > 0.01) |>
ggplot(aes(x = year, y = build, fill = source)) +
geom_col(position = "stack", width = 0.8) +
scale_fill_manual(values = source_colours, name = "Source") +
scale_x_continuous(breaks = seq(2025, 2050, 5)) +
labs(
title = "Annual New Capacity Built, 2025–2050",
subtitle = "GW of new generation capacity commissioned each year",
x = "Year", y = "New Capacity (GW)"
) +
theme_netzeroNew capacity commissioned each year by source type.
The parameter block in Section 4 is the single point of modification for alternative scenarios. Adjust any values there and re-knit the document to regenerate all tables and charts automatically.
| Scenario | Parameter change |
|---|---|
| A — Accelerated solar | B_max["solar"] <- 15 |
| B — Nuclear phase-in | K0["nuclear"] <- 3; B_max["nuclear"] <- 2 |
| C — High demand growth | D <- 300 + 8 * (yr_idx - 1) |
| D — Stricter interim target | Add interim emissions cap constraint at t = 11 (year 2035) |
| E — Carbon capture on gas | Reduce e_int["gas"] to reflect partial CCS |
To compare multiple scenarios programmatically, wrap Sections 4–8 in a named list of parameter sets and bind result data frames prior to plotting.
#> R version 4.5.1 (2025-06-13)
#> Platform: aarch64-apple-darwin20
#> Running under: macOS Tahoe 26.2
#>
#> Matrix products: default
#> BLAS: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
#>
#> locale:
#> [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
#>
#> time zone: Australia/Sydney
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] kableExtra_1.4.0 knitr_1.50 scales_1.4.0
#> [4] tidyr_1.3.1 dplyr_1.1.4 ggplot2_3.5.2
#> [7] ROI.plugin.glpk_1.0-0 ompr.roi_1.0.2 ompr_1.0.4
#>
#> loaded via a namespace (and not attached):
#> [1] sass_0.4.10 generics_0.1.4 xml2_1.3.8
#> [4] slam_0.1-55 stringi_1.8.7 lattice_0.22-7
#> [7] digest_0.6.37 magrittr_2.0.3 evaluate_1.0.4
#> [10] grid_4.5.1 RColorBrewer_1.1-3 fastmap_1.2.0
#> [13] jsonlite_2.0.0 Matrix_1.7-3 backports_1.5.0
#> [16] purrr_1.1.0 listcomp_0.4.1 viridisLite_0.4.2
#> [19] textshaping_1.0.1 numDeriv_2016.8-1.1 lazyeval_0.2.2
#> [22] jquerylib_0.1.4 cli_3.6.5 registry_0.5-1
#> [25] rlang_1.1.6 ROI_1.0-2 Rglpk_0.6-5.1
#> [28] withr_3.0.2 cachem_1.1.0 yaml_2.3.10
#> [31] tools_4.5.1 checkmate_2.3.3 vctrs_0.6.5
#> [34] R6_2.6.1 lifecycle_1.0.4 stringr_1.5.1
#> [37] pkgconfig_2.0.3 bslib_0.9.0 pillar_1.11.0
#> [40] gtable_0.3.6 data.table_1.17.8 glue_1.8.0
#> [43] systemfonts_1.3.2 xfun_0.52 tibble_3.3.0
#> [46] tidyselect_1.2.1 rstudioapi_0.17.1 farver_2.1.2
#> [49] htmltools_0.5.8.1 labeling_0.4.3 svglite_2.2.2
#> [52] rmarkdown_2.29 compiler_4.5.1