Executive Summary

This Research report delivers a comprehensive quantitative analysis of the United States energy complex. Using live EIA API data, merit-order dispatch modeling, supply–demand equilibria, and R-based simulations, it characterises pricing dynamics across electricity, natural gas, crude oil, LNG, nuclear, renewables, battery storage, and emerging hydrogen markets.

  • Electricity: U.S. net generation reached ~4,178 TWh in 2023. Natural gas (~42%) has displaced coal (~16%) as the dominant fuel. ISO/RTO competitive markets serve roughly two-thirds of U.S. load.
  • Natural Gas: Henry Hub collapsed from ~$6.45/MMBtu (2022 avg) to ~$2.57/MMBtu (2023 avg) as Permian and Marcellus production set records. LNG exports (~12 Bcf/d) now set a global price floor.
  • Crude Oil: WTI averaged ~$77.58/bbl in 2023 (Brent ~$82.17/bbl). The WTI–Brent spread reflects export logistics and Midland-to-coast pipeline fill.
  • Renewables: Wind + solar surpassed coal in U.S. annual generation for the first time in 2022, intensifying CAISO duck-curve dynamics and evening ramp volatility.
  • Nuclear: 93 operating reactors (~95 GW) provide firm baseload at ~$30–35/MWh LCOE. SMR deployment (NuScale, TerraPower) represents a decadal capacity option.
  • Key Trading Signals: Spark spread dislocations, Henry Hub storage surprises vs. 5-year norms, LNG arb windows (JKM − HH − liquefaction − shipping), and CAISO evening ramp volatility.

1 Section 1: Structure of the U.S. Energy System

1.1 1.1 ISO/RTO Market Overview

The U.S. bulk power system is divided between competitive ISO/RTO markets (serving ~70% of load) and vertically integrated bilateral markets in the Southeast and parts of the West. Seven federally-recognised ISOs/RTOs operate day-ahead and real-time energy auctions under FERC oversight.

1.2 1.2 ISO Installed Capacity (GW) — Visual

1.3 1.3 Non-ISO Markets

Southeast Bilateral Markets (TVA, Duke Energy, Southern Company, Dominion) operate under state-regulated integrated resource planning. Power is traded bilaterally at negotiated prices, with no centrally cleared energy auction. These markets cover ~30% of U.S. load and are under increasing FERC scrutiny to adopt market mechanisms.

Western Interconnect (outside CAISO) is served by a patchwork of investor-owned utilities, cooperatives, and federal power authorities (BPA, WAPA). The Western Energy Imbalance Market (WEIM), administered by CAISO, provides a voluntary 5-minute real-time balancing market across the region.


2 Section 2: Generation Mix by Region

2.1 2.1 National Generation Mix

LIVE EIA DATA

Solving the “Other” problem. EIA’s operational-data route returns ~45 fueltypeid codes at mixed granularity — top-level aggregates (COW all coal, PET all petroleum, BIO all biomass) sit alongside their own sub-components. Summing everything double-counts; a hand-maintained exclude list is brittle and silently leaks volume into Other. Instead we request an explicit mutually-exclusive, collectively-exhaustive (MECE) set of 13 codes. The integrity check below dumps every code EIA returns and confirms the curated stack reconciles to the reported all-fuels total — verified live at a 0.00% residual for 2023 (4,183 TWh), so Other is a genuine 0.2%, with pumped storage and other gases (blast-furnace/refinery gas) broken out rather than hidden.

# EIA API v2 — annual net generation by fuel, all sectors, national.
#
# WHY A CURATED CODE LIST: this route exposes ~45 fueltypeid codes at MIXED
# granularity — top-level aggregates (COW=all coal, PET=all petroleum,
# BIO=all biomass) sit alongside their own components (BIT/SUB/LIG, PEL/PC,
# WWW/WAS/LFG…). Summing "everything except a hand-maintained exclude list" is
# fragile: it double-counted biomass (~84 vs 47 TWh) and petroleum (~28 vs 16),
# while pumped-storage/other-gases leaked into "Other". Requesting an explicit,
# mutually-exclusive (MECE) set instead is deterministic and self-reconciling.
#
# Verified live (2023): these 13 codes sum to 4,183.3 TWh — EXACTLY EIA's
# reported all-fuels total (residual 0). "Other" (OTH) is then a true 0.2%.
fuel_map <- c(
  NG  = "Natural Gas",   OOG = "Other Gases",
  NUC = "Nuclear",       COW = "Coal",
  HYC = "Hydro",         HPS = "Pumped Storage",
  WND = "Wind",          SUN = "Solar",
  GEO = "Geothermal",    PET = "Petroleum",
  WWW = "Biomass",       WAS = "Biomass",
  OTH = "Other"
)

gen_raw <- if (api_ok) {
  fetch_eia(
    route     = "electricity/electric-power-operational-data",
    frequency = "annual",
    data_cols = "generation",
    facets    = list(location = "US", sectorid = "99",
                     fueltypeid = names(fuel_map)),   # request only MECE codes
    start     = "2013",
    n         = 5000
  )
} else NULL

# ── Integrity diagnostic: reconcile curated stack against EIA all-fuels total ──
if (api_ok) diagnose_fuel_stack(fuel_map, year = 2023)
## 
## ── Fuel-stack integrity check (2023) ──
## fueltypeid codes returned by EIA:
##  [1] "ALL" "ANT" "AOR" "BIO" "BIS" "BIT" "COL" "COW" "DFO" "DPV" "FOS" "GEO"
## [13] "HPS" "HYC" "LFG" "LIG" "MLG" "MSB" "NG"  "NGO" "NUC" "OB2" "OBW" "OOG"
## [25] "ORW" "OTH" "PC"  "PEL" "PET" "RC"  "REN" "RFO" "SPV" "STH" "SUB" "SUN"
## [37] "TPV" "TSN" "WAS" "WND" "WNS" "WNT" "WOC" "WOO" "WWW"
## 
## EIA all-fuels total   :   4183.3 TWh
## Curated MECE stack    :   4183.3 TWh
## Residual ('Other' risk):     0.0 TWh  (0.00% of total)
# Clean & reshape  (fall back to representative data if API unavailable)
if (!is.null(gen_raw) && nrow(gen_raw) > 0) {
  gen_df <- gen_raw %>%
    mutate(
      fuel    = dplyr::recode(fueltypeid, !!!fuel_map, .default = "Other"),
      year    = as.integer(period),
      gen_twh = as.numeric(generation) / 1e3   # thousand MWh → TWh (unit fix)
    ) %>%
    filter(!is.na(gen_twh)) %>%
    group_by(year, fuel) %>%
    summarise(gen_twh = sum(gen_twh, na.rm = TRUE), .groups = "drop")
  data_source <- "EIA API v2 (live) — curated 13-code MECE stack"
} else {
  # Representative EIA 2013-2023 national generation data (TWh)
  gen_df <- tibble::tribble(
    ~year, ~fuel,           ~gen_twh,
    2023,  "Natural Gas",   1755,
    2023,  "Coal",           669,
    2023,  "Nuclear",        775,
    2023,  "Wind",           425,
    2023,  "Solar",          238,
    2023,  "Hydro",          258,
    2023,  "Petroleum",       19,
    2023,  "Biomass",         55,
    2023,  "Geothermal",      17,
    2022,  "Natural Gas",   1688,
    2022,  "Coal",           773,
    2022,  "Nuclear",        772,
    2022,  "Wind",           435,
    2022,  "Solar",          177,
    2022,  "Hydro",          247,
    2022,  "Petroleum",       22,
    2022,  "Biomass",         57,
    2022,  "Geothermal",      17,
    2021,  "Natural Gas",   1574,
    2021,  "Coal",           899,
    2021,  "Nuclear",        778,
    2021,  "Wind",           379,
    2021,  "Solar",          130,
    2021,  "Hydro",          249,
    2021,  "Petroleum",       20,
    2021,  "Biomass",         57,
    2021,  "Geothermal",      17,
    2020,  "Natural Gas",   1617,
    2020,  "Coal",           774,
    2020,  "Nuclear",        790,
    2020,  "Wind",           338,
    2020,  "Solar",           91,
    2020,  "Hydro",          280,
    2020,  "Petroleum",       17,
    2020,  "Biomass",         56,
    2020,  "Geothermal",      17,
    2019,  "Natural Gas",   1582,
    2019,  "Coal",           966,
    2019,  "Nuclear",        809,
    2019,  "Wind",           295,
    2019,  "Solar",           72,
    2019,  "Hydro",          274,
    2019,  "Petroleum",       22,
    2019,  "Biomass",         60,
    2019,  "Geothermal",      17,
    2015,  "Natural Gas",   1331,
    2015,  "Coal",          1356,
    2015,  "Nuclear",        797,
    2015,  "Wind",           191,
    2015,  "Solar",           26,
    2015,  "Hydro",          249,
    2015,  "Petroleum",       24,
    2015,  "Biomass",         64,
    2015,  "Geothermal",      17,
    2013,  "Natural Gas",   1124,
    2013,  "Coal",          1581,
    2013,  "Nuclear",        789,
    2013,  "Wind",           168,
    2013,  "Solar",            9,
    2013,  "Hydro",          268,
    2013,  "Petroleum",       23,
    2013,  "Biomass",         65,
    2013,  "Geothermal",      17
  )
  data_source <- "EIA (representative historical data)"
}

2.2 2.2 Generation Trend (2013–present)

2.3 2.3 Regional Generation Mix by ISO/RTO

REPRESENTATIVE DATA

2.4 2.4 Capacity Factor by Technology

\[CF_i = \frac{\text{Actual Generation}_i}{\text{Nameplate Capacity}_i \times 8760\text{ hrs}}\]

2.5 2.5 Thermal Generation Technology — Simple-Cycle vs Combined-Cycle vs sCO₂

Interpretation. “Single-phase” vs “dual-phase” is read here as the number of thermodynamic cycles: a simple-cycle gas turbine runs one open Brayton cycle and vents ~600 °C exhaust; a combined-cycle unit adds a second Rankine (steam) bottoming cycle via a Heat-Recovery Steam Generator (HRSG), recovering that exhaust to lift efficiency from ~40% to ~60%. The emerging supercritical-CO₂ (sCO₂) machine replaces the working fluid with CO₂ held above its critical point (31 °C, 73.8 bar) in a closed Brayton loop.

Efficiency ↔︎ heat rate: \[\eta_{\text{LHV}} = \frac{3412\ \text{Btu/kWh}}{\text{Heat Rate}} \qquad \text{Fuel Cost}\ (\$/\text{MWh}) = \frac{\text{Heat Rate}}{1000}\times P_{\text{gas}}\ (\$/\text{MMBtu})\]

A CCGT at 6,000 Btu/kWh burning $3.00 gas costs ~$18/MWh in fuel; a simple-cycle peaker at 10,500 Btu/kWh costs ~$31/MWh — the efficiency gap is the merit-order gap.

Which gas generation is most efficient? Ranked by net efficiency (LHV): H/J-class combined-cycle wins at ~63–64% (heat rate ~5,400 Btu/kWh) — GE’s 9HA.02 and Mitsubishi’s M501JAC are the class leaders. Efficiency then falls down the ladder: F-class CCGT ~58% → E-class CCGT ~53% → aeroderivative simple-cycle ~42% → heavy-frame simple-cycle peaker ~39% → legacy gas boilers ~36%. The rule of thumb: every cycle you add and every degree of firing temperature buys efficiency. A combined-cycle unit is more efficient than a simple-cycle one precisely because the Rankine bottoming cycle recovers exhaust heat the peaker throws away — which is why CCGTs sit low in the merit order (baseload) and peakers sit at the top (scarcity only).

Shaft configuration refers to how the gas and steam turbines are mechanically arranged in a combined-cycle block:
  • Single-shaft (1×1): one gas turbine + one steam turbine on the same shaft driving one generator (steam turbine usually via a synchro-clutch). Most compact, fastest to permit/build, and typically the highest efficiency — the H/J-class record-setters are single-shaft. Best where a standard block size fits.
  • Multi-shaft (2×1, 3×1): several gas turbines, each with its own generator, feed heat-recovery steam generators whose combined steam drives one separate steam-turbine generator. Slightly lower peak efficiency but far more operational flexibility — gas turbines can start, stop, and part-load independently, valuable for following the renewables-driven net load of Section 5.

So “most efficient” is a single-shaft H/J-class CCGT; “most flexible” is a multi-shaft block or an aeroderivative. Desks care about both: efficiency sets the spark-spread breakeven, flexibility sets who captures the evening ramp.

Why sCO₂ matters for markets. Supercritical CO₂ is ~10× denser than steam, so the turbomachinery is dramatically smaller (a utility-scale sCO₂ turbine fits on a workbench-scale footprint), enabling fast thermal ramps and cheap dry cooling — attractive where water is scarce. The Allam-Fetvedt cycle burns gas in pure oxygen with an sCO₂ working fluid, so the combustion product is a high-pressure CO₂ stream that is pipeline-ready for sequestration or EOR at near-zero energy penalty — potentially decoupling firm gas generation from carbon exposure. Status is pre-commercial: DOE’s 10 MWe STEP pilot (San Antonio) and NET Power’s La Porte demo are the bellwethers; materials/recuperator cost and high-pressure sealing are the gating risks. For a trading desk, commercial sCO₂ would compress the spark-spread advantage peakers enjoy during scarcity while giving gas a low-carbon compliance pathway.


3 Section 3: Electricity Market Clearing

3.1 3.1 Merit-Order Dispatch

In competitive wholesale markets, electricity prices are set by the marginal plant — the most expensive generator needed to satisfy demand at any given moment. This is the foundational pricing mechanism in all ISO/RTO energy auctions.

Market-Clearing Price:

\[P_t = MC_{\text{marginal plant}}\]

where marginal cost is:

\[MC_i = \underbrace{\text{Fuel Cost}_i}_{\text{Heat Rate} \times P_{\text{fuel}}} + \text{Variable O\&M}_i + \underbrace{\text{Emissions Cost}_i}_{E_i \times P_{CO_2}}\]

3.2 3.2 Marginal Cost Components

Why does the most expensive plant set the price? Under uniform-price auction rules, every accepted bid — including cheap wind and nuclear — receives the same clearing price as the marginal (most expensive) plant dispatched. This creates an economic rent for inframarginal units and is the mechanism that makes zero-marginal-cost renewables profitable under energy-only market designs.

3.3 3.3 How Prices Are Set — The ISO Reverse Auction

A wholesale power market is a uniform-price reverse (procurement) auction. In an ordinary auction buyers bid a price up; in a reverse auction, sellers (generators) offer prices down and the buyer — the ISO, acting for load — selects the cheapest offers until demand is met. Every cleared unit is then paid the single marginal clearing price, not its own offer (uniform-price, not pay-as-bid). All seven U.S. ISOs run this as a two-settlement system:

  1. Day-Ahead Market (DAM) — financially binding. Generators submit offer curves (price–quantity pairs) plus start-up and no-load costs; load and virtual traders submit bids. The ISO solves Security-Constrained Unit Commitment (SCUC) to decide which units run, then Security-Constrained Economic Dispatch (SCED) to set hourly output and prices.
  2. Real-Time Market (RTM) — a 5-minute SCED balances actual conditions; deviations from the day-ahead schedule settle at the real-time LMP.

The auction is a constrained optimisation. SCUC minimises total as-offered cost:

\[\min_{g_i,\,u_i}\ \sum_i \Big( C_i^{\text{energy}}(g_i) + C_i^{\text{start}}\,u_i + C_i^{\text{noload}}\,u_i \Big)\]

subject to energy balance, transmission limits, ramp and min-up/min-down constraints. The LMP is the dual variable (shadow price) of the nodal energy-balance constraint — which is exactly why it decomposes into energy + congestion + loss (see Section 15):

\[\text{LMP}_n = \lambda_{\text{energy}} + \sum_{l}\mu_l\,\text{PTDF}_{l,n} + \nu_n\]

The critical design split is how capacity gets paid:

  • Energy-only (ERCOT, SPP): generators earn only from energy + ancillary services. Because marginal-cost pricing alone can’t cover fixed costs, these markets rely on scarcity pricing — an Operating Reserve Demand Curve (ORDC) that administratively lifts price toward the offer cap as reserves thin, letting occasional spikes fund capital (“missing money” via scarcity rents).
  • Capacity-structured (PJM, ISO-NE, NYISO, MISO): a separate forward capacity auction pays units to be available years ahead, so energy prices needn’t spike as violently to keep the lights on. CAISO is a hybrid — a bilateral Resource Adequacy obligation rather than a centralised capacity auction.

3.4 3.4 Price-Setting Mechanisms by ISO/RTO

Why the design split matters for risk. Energy-only markets (ERCOT, SPP) concentrate a generator’s annual margin into a handful of scarcity hours, so revenue is fat-tailed and hedging shifts to the far right tail of the price distribution modelled in Section 11. Capacity markets (PJM, ISO-NE) pre-pay availability, damping energy spikes but adding a separate capacity-auction price to forecast. The 2021 Winter Storm Uri event — ERCOT pinned at the then-$9,000 cap for days — is the canonical illustration of energy-only tail risk, and drove the cap down to $5,000.


4 Section 4: Supply Curve Model

4.1 4.1 Merit Order Stack

Generators are sorted by marginal cost and committed in ascending order until cumulative capacity meets demand. The price-setting plant sits at the intersection of the supply stack and the vertical demand curve.

\[\text{Supply}(Q) = \sum_{i} \mathbf{1}\left[MC_i \leq P^*\right] \cdot C_i\]

where \(C_i\) is installed capacity of plant \(i\) and \(P^*\) is the clearing price.

The merit order:

\[MC_{\text{wind}} \approx MC_{\text{solar}} < MC_{\text{nuclear}} < MC_{\text{hydro}} < MC_{\text{coal}} < MC_{\text{gas CCGT}} < MC_{\text{gas peaker}}\]


5 Section 5: Duck Curve Phenomenon

5.1 5.1 CAISO Net Load Profile

The duck curve describes the characteristic shape of net load (total load minus solar generation) in solar-heavy grids. As utility-scale and rooftop solar generation peaks around midday, net load plummets — creating a surplus that must be absorbed by storage, exports, or curtailment. By late afternoon, solar drops and net load surges, requiring extreme evening ramp capability.

\[\text{Net Load}_t = \text{Total Load}_t - \text{Solar}_{t} - \text{Wind}_t\]

Evening Ramp Rate (CAISO, 2023): ~14,000 MW over 3 hours (~4,700 MW/hr)

5.2 5.2 Ramp Rate Requirements & Mitigation


6 Section 6: Natural Gas Market Structure

6.1 6.1 Production Basins

REPRESENTATIVE DATA

6.2 6.2 Henry Hub Spot Price

LIVE EIA DATA

# EIA API v2: Henry Hub Natural Gas Spot Price (monthly)
hh_raw <- if (api_ok) {
  fetch_eia(
    route      = "natural-gas/pri/sum",
    frequency  = "monthly",
    data_cols  = "value",
    facets     = list(duoarea = "NUS", series = "RNGWHHD"),
    start      = "2015-01",
    n          = 500
  )
} else NULL

if (!is.null(hh_raw) && nrow(hh_raw) > 0) {
  hh_df <- hh_raw %>%
    mutate(date  = as.Date(paste0(period, "-01")),
           price = as.numeric(value)) %>%
    filter(!is.na(price)) %>%
    arrange(date)
  hh_source <- "EIA API v2 (live)"
} else {
  # Representative monthly Henry Hub data
  set.seed(42)
  hh_df <- tibble(
    date  = seq(as.Date("2015-01-01"), as.Date("2023-12-01"), by = "month"),
    price = c(
      # 2015: low prices
      rep(2.9,3), rep(2.6,3), rep(2.7,3), rep(2.3,3),
      # 2016: trough
      rep(2.2,3), rep(2.6,3), rep(2.9,3), rep(3.2,3),
      # 2017
      rep(3.0,3), rep(2.8,3), rep(2.9,3), rep(2.8,3),
      # 2018: rise
      rep(2.8,3), rep(2.9,3), rep(3.1,3), rep(4.5,3),
      # 2019
      rep(3.0,3), rep(2.5,3), rep(2.4,3), rep(2.4,3),
      # 2020: COVID crash
      rep(1.9,3), rep(1.7,3), rep(2.1,3), rep(2.6,3),
      # 2021: recovery
      rep(2.6,3), rep(2.7,3), rep(4.0,3), rep(5.9,3),
      # 2022: energy crisis
      rep(4.5,3), rep(7.2,3), rep(8.8,3), rep(5.5,3),
      # 2023: collapse
      rep(3.2,3), rep(2.2,3), rep(2.5,3), rep(2.7,3)
    )
  ) %>% slice(1:108)
  hh_source <- "EIA (representative historical data)"
}

6.3 6.3 Natural Gas Storage Seasonality

LIVE EIA DATA

storage_raw <- if (api_ok) {
  fetch_eia(
    route      = "natural-gas/stor/wkly",
    frequency  = "weekly",
    data_cols  = "value",
    facets     = list(duoarea = "NUS", series = "NW2_EPG0_SWO_NUS_BCF"),
    start      = "2018-01-01",
    n          = 1000
  )
} else NULL

if (!is.null(storage_raw) && nrow(storage_raw) > 0) {
  stor_df <- storage_raw %>%
    mutate(date    = as.Date(period),
           storage = as.numeric(value),
           week    = lubridate::week(date),
           year    = lubridate::year(date)) %>%
    filter(!is.na(storage))
  stor_source <- "EIA API v2 (live)"
} else {
  # Synthetic seasonal storage pattern (Bcf)
  weeks <- 1:52
  base_seasonal <- 1700 + 1300 * sin((weeks - 12) * pi / 26)
  set.seed(99)
  stor_df <- bind_rows(lapply(2018:2023, function(yr) {
    tibble(year   = yr,
           week   = weeks,
           date   = as.Date(paste0(yr,"-01-01")) + (weeks - 1) * 7,
           storage = pmax(100, base_seasonal + rnorm(52, 0, 80) +
                         ifelse(yr == 2022, -300, 0)))
  }))
  stor_source <- "EIA (representative seasonal pattern)"
}

# Compute 5-year average and bounds
stor_5yr <- stor_df %>%
  filter(year %in% 2018:2022) %>%
  group_by(week) %>%
  summarise(avg = mean(storage, na.rm = TRUE),
            lo  = min(storage, na.rm = TRUE),
            hi  = max(storage, na.rm = TRUE))

stor_2023 <- stor_df %>% filter(year == max(stor_df$year))

6.4 6.4 Gas Price Model

\[P_{\text{gas}} = f(\underbrace{S}_{\text{storage surplus/deficit}},\ \underbrace{W}_{\text{HDD/CDD}},\ \underbrace{L}_{\text{LNG exports}},\ \underbrace{Q}_{\text{production}})\]

Henry Hub embodies a balance of domestic supply, seasonal weather-driven demand, and the increasingly important LNG export floor (~12 Bcf/d in 2023). Storage injections/withdrawals versus the 5-year average norm are the primary short-term price signal used by trading desks.


7 Section 7: LNG Global Trade

7.1 7.1 Global LNG Trade Flows

7.2 7.2 Benchmark Price Comparison

7.3 7.3 LNG Arbitrage Economics

LNG Arbitrage Profit:

\[\text{LNG}_{\text{arb}} = P_{\text{Asia (JKM)}} - \left(P_{\text{US (HH)}} + C_{\text{liquefaction}} + C_{\text{shipping}} + C_{\text{regasification}}\right)\]

Typical cost components (2023):

Component $/MMBtu
Liquefaction tolling ~2.50
LNG shipping (Gulf → NE Asia) ~1.50–2.50
Regasification ~0.30–0.50
Total supply chain cost ~4.30–5.50

At HH = $2.57 and JKM = $13.00: arb ≈ $13.00 − ($2.57 + $5.00) = $5.43/MMBtu ✔ Profitable


8 Section 8: Oil Markets

8.1 8.1 Crude Oil Benchmarks

LIVE EIA DATA

# WTI Cushing spot price
wti_raw <- if (api_ok) {
  fetch_eia(
    route     = "petroleum/pri/spt",
    frequency = "monthly",
    data_cols = "value",
    facets    = list(series = "RWTC"),
    start     = "2015-01",
    n         = 500
  )
} else NULL

# Brent spot price
brent_raw <- if (api_ok) {
  fetch_eia(
    route     = "petroleum/pri/spt",
    frequency = "monthly",
    data_cols = "value",
    facets    = list(series = "RBRTE"),
    start     = "2015-01",
    n         = 500
  )
} else NULL

# Parse or use fallback
parse_oil <- function(raw) {
  if (!is.null(raw) && nrow(raw) > 0) {
    raw %>%
      mutate(date  = as.Date(paste0(period, "-01")),
             price = as.numeric(value)) %>%
      filter(!is.na(price)) %>%
      arrange(date) %>%
      select(date, price)
  } else NULL
}

wti_df   <- parse_oil(wti_raw)
brent_df <- parse_oil(brent_raw)

if (is.null(wti_df) || is.null(brent_df)) {
  # fall back for BOTH series if either fetch fails — otherwise the inner_join
  # below joins a data frame against NULL and aborts the knit
  dates <- seq(as.Date("2015-01-01"), as.Date("2023-12-01"), by = "month")
  # Representative WTI & Brent monthly closing prices
  wti_vals <- c(47,44,47,52,59,60,55,45,44,46,48,37,
                31,26,38,45,47,48,43,42,45,44,52,53,
                54,55,52,48,47,46,50,48,55,57,60,58,
                57,62,57,55,62,68,70,68,70,71,60,55,
                61,55,58,60,55,71,73,68,70,78,78,77,
                80,91,100,95,88,102,112,95,88,95,92,80,
                74,78,76,75,80,73,71,69,72,68,78,77,
                77,72,78,81,75,73,72,77,80,78,77,79,
                75,76,78,77,78,75,72,74,82,83,77,74)
  brent_vals <- wti_vals + runif(length(wti_vals), 2, 6)
  wti_df   <- tibble(date = dates, price = head(wti_vals,  length(dates)))
  brent_df <- tibble(date = dates, price = head(brent_vals, length(dates)))
  oil_source <- "EIA (representative historical data)"
} else {
  oil_source <- "EIA API v2 (live)"
}

spread_df <- inner_join(wti_df, brent_df, by = "date", suffix = c("_wti","_brent")) %>%
  mutate(spread = price_brent - price_wti)

8.2 8.2 Crude Quality & Refinery Yield Model

\[\text{Yield}_{\text{gasoline}} = f(\underbrace{\text{API Gravity}}_{\text{lighter = more gasoline}},\ \underbrace{\text{Sulfur \%}}_{\text{lower = cheaper to process}})\]


9 Section 9: Strategic Petroleum Reserve

9.1 9.1 SPR Overview

The Strategic Petroleum Reserve (SPR) is the world’s largest emergency crude oil stockpile, maintained by the U.S. Department of Energy in four salt cavern sites along the Gulf Coast (Bryan Mound, Big Hill, West Hackberry, Bayou Choctaw). Authorised capacity: ~714 million barrels. The SPR holds sour crude (~65%) and sweet crude (~35%) to match U.S. refinery feedstock needs.

LIVE EIA DATA

spr_raw <- if (api_ok) {
  fetch_eia(
    route     = "petroleum/stoc/wstk",
    frequency = "weekly",
    data_cols = "value",
    facets    = list(duoarea = "NUS", product = "EPC0", series = "WCSSTUS1"),
    start     = "2000-01-01",
    n         = 2000
  )
} else NULL

if (!is.null(spr_raw) && nrow(spr_raw) > 0) {
  spr_df <- spr_raw %>%
    mutate(date    = as.Date(period),
           spr_mb  = as.numeric(value)) %>%
    filter(!is.na(spr_mb)) %>%
    arrange(date)
  spr_source <- "EIA API v2 (live)"
} else {
  # Representative SPR levels: ~600mb peak (2010) → 350mb (2023)
  spr_df <- tibble(
    date   = seq(as.Date("2000-01-01"), as.Date("2023-12-01"), by = "month"),
    spr_mb = c(
      seq(542, 700, length.out = 120),   # 2000–2010 fill
      seq(700, 695, length.out = 60),    # 2010–2015 plateau
      seq(695, 640, length.out = 60),    # 2015–2020 drawdown
      seq(640, 595, length.out = 24),    # 2020–2021
      seq(595, 372, length.out = 24)     # 2022–2023 Biden drawdown
    ) + rnorm(288, 0, 5)
  ) %>% slice(1:288)
  spr_source <- "EIA (representative historical data)"
}

10 Section 10: Electricity Demand Model

10.1 10.1 Demand Function

Electricity demand is modelled as a linear function of weather, economic activity, and structural trends:

\[D_t = \alpha + \beta_1 \cdot HDD_t + \beta_2 \cdot CDD_t + \beta_3 \cdot GDP_t + \beta_4 \cdot EV_t + \varepsilon_t\]

where: - \(HDD_t\) = heating degree days (cold-weather demand) - \(CDD_t\) = cooling degree days (hot-weather AC demand) - \(GDP_t\) = real GDP index (industrial/commercial load) - \(EV_t\) = EV charging load term (structural electrification)

10.2 10.2 Weather & Macro Drivers (FRED + EIA STEO)

FRED API EIA STEO

# ── Live macro drivers ───────────────────────────────────────────────────────
# GDP proxy: FRED INDPRO (monthly industrial production index)
# Weather:   EIA STEO ZWHDPUS / ZWCDPUS national degree days (FRED has none)
fetch_macro_drivers <- function(start = "2019-01-01", n_months = 60) {
  end <- format(Sys.Date(), "%Y-%m-%d")

  # ── GDP proxy: FRED industrial production index (INDPRO, monthly, live) ──────
  indpro <- fetch_fred_series("INDPRO", start, end)
  if (!is.null(indpro) && nrow(indpro) >= 12) {
    indpro <- indpro %>%
      dplyr::mutate(month = lubridate::floor_date(date, "month")) %>%
      dplyr::group_by(month) %>%
      dplyr::summarise(GDP_idx = mean(value, na.rm = TRUE), .groups = "drop") %>%
      dplyr::arrange(month) %>%
      dplyr::mutate(GDP_idx = 100 * GDP_idx / GDP_idx[1])   # rebase to 100
  } else indpro <- NULL

  # ── Weather: EIA STEO national degree days ──────────────────────────────────
  # FRED does NOT publish HDD/CDD (verified: series 400 / empty search). EIA's
  # Short-Term Energy Outlook does: ZWHDPUS (heating) / ZWCDPUS (cooling), U.S.
  # population-weighted, monthly. STEO also carries a forward forecast, so we
  # trim to observed months (<= today) before fitting.
  start_ym <- format(as.Date(start), "%Y-%m")
  hdd <- fetch_steo_series("ZWHDPUS", start = start_ym)
  cdd <- fetch_steo_series("ZWCDPUS", start = start_ym)
  weather <- NULL
  if (!is.null(hdd) && !is.null(cdd)) {
    weather <- dplyr::full_join(
      hdd %>% dplyr::transmute(month = lubridate::floor_date(date, "month"), HDD = value),
      cdd %>% dplyr::transmute(month = lubridate::floor_date(date, "month"), CDD = value),
      by = "month"
    ) %>%
      dplyr::filter(month <= Sys.Date()) %>%   # drop STEO forward forecast
      dplyr::arrange(month)
  }

  if (!is.null(indpro) && !is.null(weather)) {
    df <- indpro %>%
      dplyr::inner_join(weather, by = "month") %>%
      dplyr::arrange(month) %>%
      dplyr::slice_tail(n = n_months)
    if (nrow(df) >= 12)
      return(list(df = df, source = "FRED INDPRO (GDP) + EIA STEO HDD/CDD (live)"))
  }
  if (!is.null(indpro)) {
    df <- indpro %>%
      dplyr::arrange(month) %>%
      dplyr::slice_tail(n = n_months) %>%
      dplyr::mutate(
        month_n = dplyr::row_number(),
        HDD = pmax(0, 500 * (cos((month_n - 3) * pi / 6) + 1) / 2),
        CDD = pmax(0, 300 * sin((month_n - 3) * pi / 6))
      )
    return(list(df = df %>% dplyr::select(month, HDD, CDD, GDP_idx),
                source = "FRED INDPRO (GDP) + seasonal HDD/CDD proxy (STEO unavailable)"))
  }
  NULL
}

# Live GDP needs FRED; live weather needs the EIA key — run if either is set
macro_live <- if (fred_ok || api_ok) fetch_macro_drivers() else NULL

if (!is.null(macro_live)) {
  demand_df <- macro_live$df %>%
    dplyr::transmute(
      month   = month,
      month_n = dplyr::row_number(),
      HDD     = HDD, CDD = CDD, GDP_idx = GDP_idx
    )
  macro_source <- macro_live$source
} else {
  set.seed(42)
  n <- 60
  demand_df <- tibble(
    month   = seq(as.Date("2019-01-01"), by = "month", length.out = n),
    month_n = 1:n,
    CDD     = pmax(0, 300 * sin((month_n - 3) * pi / 6) + rnorm(n, 0, 40)),
    HDD     = pmax(0, 500 * (cos((month_n - 3) * pi / 6) + 1) / 2 + rnorm(n, 0, 60)),
    GDP_idx = 100 + cumsum(rnorm(n, 0.3, 0.5))
  )
  macro_source <- if (fred_ok) "FRED (partial — fallback synthetic weather/GDP)"
                  else "Simulated (set fred_key param for live FRED data)"
  if (!fred_ok)
    message("Note: Set params$fred_key for live GDP/HDD/CDD from FRED.")
}

# True demand model coefficients (representative regression estimates)
alpha <- 3500; b1 <- 0.45; b2 <- 0.60; b3 <- 8.5

if (!"demand_mw" %in% names(demand_df)) {
  demand_df <- demand_df %>%
    mutate(
      demand_mw = alpha + b1 * HDD + b2 * CDD + b3 * (GDP_idx - 100) + rnorm(n(), 0, 80)
    )
}

# Fit OLS
model <- lm(demand_mw ~ HDD + CDD + GDP_idx, data = demand_df)
demand_df$fitted <- predict(model)

cat("Macro data source:", macro_source, "\n")
## Macro data source: FRED INDPRO (GDP) + EIA STEO HDD/CDD (live)
cat("Model Coefficients:\n")
## Model Coefficients:
print(round(coef(model), 3))
## (Intercept)         HDD         CDD     GDP_idx 
##    1788.011       0.433       0.642      17.521
cat("\nR-squared:", round(summary(model)$r.squared, 4))
## 
## R-squared: 0.4706

10.3 10.3 ISO Live Market Data Integration

EIA RTO + ISO APIs

# Live ISO fuel mix — EIA RTO grid monitor (hourly, aggregated over a recent window)
iso_fuel_live <- fetch_iso_fuel_mix(days = 3)

if (!is.null(iso_fuel_live)) {
  iso_fuel_summary <- iso_fuel_live %>% dplyr::arrange(ISO, dplyr::desc(gen_gwh))
  cat("Live ISO fuel mix — trailing ~3 days (GWh), EIA RTO grid monitor:\n")
  print(iso_fuel_summary %>% dplyr::group_by(ISO) %>%
          dplyr::slice_head(n = 3) %>% dplyr::ungroup())
  iso_data_source <- "EIA API v2 — electricity/rto/fuel-type-data (hourly)"
} else {
  iso_fuel_summary <- NULL
  iso_data_source  <- "Representative (EIA RTO API unavailable)"
}
## Live ISO fuel mix — trailing ~3 days (GWh), EIA RTO grid monitor:
## # A tibble: 21 × 3
##    ISO    fuel        gen_gwh
##    <chr>  <chr>         <dbl>
##  1 CAISO  Solar         445. 
##  2 CAISO  Natural Gas   321. 
##  3 CAISO  Wind          145. 
##  4 ERCOT  Natural Gas  1672. 
##  5 ERCOT  Solar         676. 
##  6 ERCOT  Wind          633. 
##  7 ISO-NE Natural Gas   474. 
##  8 ISO-NE Nuclear       199. 
##  9 ISO-NE Other          52.0
## 10 MISO   Natural Gas  2081. 
## # ℹ 11 more rows
# Live nodal LMP is pulled directly from CAISO OASIS in Section 15.3.
# fetch_iso_lmp_snapshot() also offers a gridstatus/Python path for manual use,
# but it is NOT called here to avoid triggering a py_install during knit.
cat("\nNodal LMP: see Section 15.3 for a live CAISO OASIS decomposition.\n")
## 
## Nodal LMP: see Section 15.3 for a live CAISO OASIS decomposition.

11 Section 11: R Simulation — Electricity Market Clearing

11.1 11.1 Merit Order Dispatch Algorithm

This section builds the merit-order dispatch in idiomatic R, adds a deterministic price–demand sensitivity curve, and then runs a multi-factor Monte Carlo that jointly shocks demand, fuel prices, and generator availability — yielding a realistic clearing-price distribution with tail-risk metrics (P95, CVaR, scarcity probability) rather than a handful of discrete price points.

# ── Plant Stack ──────────────────────────────────────────────────────────────
plants_sim <- tibble::tribble(
  ~plant,          ~capacity_mw, ~mc_per_mwh,
  "Wind",           1000,          5,
  "Solar",           800,          5,
  "Nuclear",        1200,         15,
  "Coal",           1500,         30,
  "Gas CCGT",       2000,         45,
  "Gas Peaker",      500,        120
) %>% arrange(mc_per_mwh) %>%
  mutate(cum_cap = cumsum(capacity_mw))

# ── Single dispatch ───────────────────────────────────────────────────────────
dispatch_price <- function(demand_mw, stack = plants_sim) {
  marginal <- stack %>% filter(cum_cap >= demand_mw) %>% slice(1)
  if (nrow(marginal) == 0) return(list(price = NA, plant = "Insufficient Capacity"))
  list(price = marginal$mc_per_mwh, plant = marginal$plant)
}

# Base case
d0     <- 3500
result <- dispatch_price(d0)
cat("Base demand:", d0, "MW\n")
## Base demand: 3500 MW
cat("Market clearing price: $", result$price, "/MWh\n")
## Market clearing price: $ 30 /MWh
cat("Marginal plant:", result$plant, "\n\n")
## Marginal plant: Coal
# ── Sensitivity across demand range ──────────────────────────────────────────
sensitivity <- tibble(
  demand = seq(500, 7000, by = 100)
) %>%
  mutate(price = sapply(demand, function(d) {
    p <- dispatch_price(d)
    if (is.na(p$price)) 200 else p$price
  }),
  marginal_plant = sapply(demand, function(d) dispatch_price(d)$plant))

# ── Multi-factor Monte Carlo ─────────────────────────────────────────────────
# The old model varied demand ONLY, through a fixed stack, so the clearing price
# could take just 6 discrete values (the six plant MCs) — a near-degenerate
# histogram. A credible distribution needs the joint, correlated drivers a desk
# actually hedges:
#   (1) demand shocks   (2) fuel-price shocks that RE-PRICE the gas fleet
#   (3) forced generator outages that pull capacity out of the merit order.
# A common latent factor z couples cold/hot snaps to BOTH high load and gas spikes.
mc_stack <- tibble::tribble(
  ~plant,       ~capacity_mw, ~fuel,     ~heat_rate, ~vom, ~for_rate,
  "Wind",        1000,        "wind",     NA_real_,    1,   0.05,
  "Solar",        800,        "solar",    NA_real_,    1,   0.03,
  "Nuclear",     1200,        "uranium",  10.4,        4,   0.02,
  "Coal",        1500,        "coal",     10.2,        5,   0.08,
  "Gas CCGT",    2000,        "gas",       7.0,        4,   0.05,
  "Gas Peaker",   500,        "gas",      11.0,       12,   0.10
)
fuel_base <- c(gas = 3.00, coal = 2.50, uranium = 0.70, wind = 0, solar = 0)  # $/MMBtu
VOLL      <- 200   # scarcity price cap ($/MWh) when demand exceeds available capacity

# One stochastic dispatch: joint demand + gas-price + outage draws re-price and
# re-sort the stack, then the marginal available unit sets the clearing price.
draw_clearing_price <- function(i, mean_d = 3500, sd_d = 400) {
  z      <- rnorm(1)                                             # latent weather factor
  demand <- mean_d + sd_d * z + rnorm(1, 0, 150)                 # load rises with z
  gas_p  <- fuel_base["gas"] * exp(0.28 * z + rnorm(1, 0, 0.18)) # gas spikes with z (lognormal)
  avail  <- runif(nrow(mc_stack)) > mc_stack$for_rate           # forced-outage draw
  s      <- mc_stack[avail, , drop = FALSE]
  fp     <- fuel_base; fp["gas"] <- gas_p
  mc     <- ifelse(is.na(s$heat_rate), s$vom, s$heat_rate * fp[s$fuel] + s$vom)
  ord    <- order(mc); s <- s[ord, ]; mc <- mc[ord]
  k      <- which(cumsum(s$capacity_mw) >= max(100, demand))[1]
  if (is.na(k)) VOLL else unname(mc[k])
}

set.seed(2024)
mc_n <- MC_DRAWS
if (USE_PAR && requireNamespace("furrr", quietly = TRUE) &&
    requireNamespace("future", quietly = TRUE)) {
  n_workers <- max(1L, parallel::detectCores() - 1L)
  future::plan(future::multisession, workers = n_workers)
  mc_prices <- furrr::future_map_dbl(seq_len(mc_n), draw_clearing_price,
                                     .options = furrr::furrr_options(seed = TRUE))
  future::plan(future::sequential)
  mc_engine <- sprintf("furrr parallel (%d workers)", n_workers)
} else {
  mc_prices <- purrr::map_dbl(seq_len(mc_n), draw_clearing_price)
  mc_engine <- "purrr sequential"
}

# ── Risk metrics ─────────────────────────────────────────────────────────────
mc_q   <- quantile(mc_prices, c(.05, .50, .95), names = FALSE)
cvar95 <- mean(mc_prices[mc_prices >= mc_q[3]])   # expected shortfall, worst 5%
mc_df  <- tibble(price = mc_prices)
cat(sprintf("Monte Carlo engine  : %s\n", mc_engine))
## Monte Carlo engine  : furrr parallel (7 workers)
cat(sprintf("Draws               : %s  (demand x gas price x outages)\n", format(mc_n, big.mark = ",")))
## Draws               : 100,000  (demand x gas price x outages)
cat(sprintf("Mean / median price : $%.2f / $%.2f per MWh\n", mean(mc_prices), mc_q[2]))
## Mean / median price : $25.21 / $25.35 per MWh
cat(sprintf("P5 - P95 range      : $%.2f - $%.2f per MWh\n", mc_q[1], mc_q[3]))
## P5 - P95 range      : $11.28 - $30.53 per MWh
cat(sprintf("CVaR(95%%) shortfall  : $%.2f per MWh\n", cvar95))
## CVaR(95%) shortfall  : $50.60 per MWh
cat(sprintf("P(price > $60 spike) : %.2f%%\n", 100 * mean(mc_prices > 60)))
## P(price > $60 spike) : 0.53%
cat(sprintf("P(scarcity = VOLL)  : %.2f%%\n", 100 * mean(mc_prices >= VOLL)))
## P(scarcity = VOLL)  : 0.34%

12 Section 12: Battery Storage Economics

12.1 12.1 Price Arbitrage Model

Battery storage profits by charging during low-price hours and discharging during high-price hours:

\[\text{Profit} = \sum_{t} \left(P_t^{\text{peak}} - P_t^{\text{off-peak}}\right) \times E \times \eta_{\text{RT}}\]

where \(\eta_{\text{RT}}\) is the round-trip efficiency and \(E\) is energy throughput (MWh).

Key Parameters (4-hr BESS, 2023 U.S. Average):

Parameter Value
Round-trip efficiency \(\eta_{RT}\) 85–90%
Cycle degradation ~20% over 3,000 cycles
Installed cost ($/kWh) \(250–350/kWh | | LCOE (\)/MWh dispatched)
Target spread for profitability >$40/MWh
# Simulate a year of hourly prices with a peaky structure
set.seed(55)
hours <- 0:23
base_price <- 35

price_profile <- tibble(
  hour        = hours,
  avg_price   = base_price + 15 * sin((hours - 6) * pi / 12) +
                20 * pmax(0, sin((hours - 16) * pi / 4)) +
                rnorm(24, 0, 5)
)

# Battery strategy: charge cheapest 4 hrs, discharge most expensive 4 hrs
n_charge    <- 4   # hours to charge
RTE         <- 0.87
capacity_mw <- 100 # MW
energy_mwh  <- 400 # MWh (4-hr battery)

charge_hrs <- price_profile %>% slice_min(avg_price, n = n_charge)
discharge_hrs <- price_profile %>% slice_max(avg_price, n = n_charge)

charge_cost    <- sum(charge_hrs$avg_price)    * capacity_mw / 1000  # $k
discharge_rev  <- sum(discharge_hrs$avg_price) * capacity_mw * RTE / 1000
daily_profit   <- discharge_rev - charge_cost
annual_profit  <- daily_profit * 365

cat("Daily charge cost:    $", round(charge_cost * 1000), "\n")
## Daily charge cost:    $ 9194
cat("Daily discharge rev:  $", round(discharge_rev * 1000), "\n")
## Daily discharge rev:  $ 21436
cat("Daily gross profit:   $", round(daily_profit * 1000), "\n")
## Daily gross profit:   $ 12242
cat("Annual gross profit:  $", round(annual_profit * 1000, -3), "\n")
## Annual gross profit:  $ 4468000

13 Section 13: Hydrogen Future Markets

13.1 13.1 Production Pathways

\[\text{H}_2 = \frac{\text{Electricity Input (kWh)}}{\text{Electrolyzer Efficiency}} \quad \text{(Green)}\]

The U.S. Inflation Reduction Act (IRA, 2022) created the Clean Hydrogen PTC (§ 45V), offering up to $3/kg for green hydrogen with lifecycle emissions < 0.45 kg CO₂e/kg H₂. This subsidy effectively bridges the current grey/green cost gap, making green H₂ competitive at renewable electricity prices below ~$30/MWh.


14 Section 14: Nuclear Energy Future

14.1 14.1 Current Fleet & LCOE

14.2 14.2 Load-Following Capability

Load following requires reactors to ramp output up and down to track demand, unlike traditional baseload operation. Key constraints: - Xenon poisoning: \(^{135}\)Xe buildup after power reduction temporarily limits ramping up - Fuel rod thermal cycling: fatigue reduces fuel lifetime - Economic incentive: negative power prices during high-renewable periods may justify curtailment

French EDF fleet successfully load-follows at 25–100% within 30 min. U.S. fleet operates at maximum output to maximise revenue given high fixed costs. New SMR designs (Natrium) incorporate molten salt thermal storage to decouple electricity output from reactor thermal power, enabling flexible grid services.


15 Section 15: Equilibrium Model

15.1 15.1 Supply–Demand Equilibrium

In an unconstrained market, the price \(P^*\) solves:

\[S(P^*) = D(P^*)\]

where: \[S(P) = \sum_i C_i \cdot \mathbf{1}[MC_i \leq P] \quad \text{(supply stack)}\] \[D(P) = D_0 - \epsilon \cdot P \quad \text{(price-elastic demand, } \epsilon \ll 1 \text{)}\]

# Supply function: cumulative capacity at each MC level
supply_pts <- plants_sim %>%
  select(mc_per_mwh, cum_cap) %>%
  mutate(cum_start = lag(cum_cap, default = 0)) %>%
  rename(price = mc_per_mwh, q_supply = cum_cap)

# Demand function: D(P) = 5000 - 2*P  (slightly elastic)
D0    <- 5000
d_elas <- 2
price_range <- seq(0, 150, by = 1)
demand_curve <- tibble(
  price    = price_range,
  q_demand = pmax(0, D0 - d_elas * price_range)
)

# Find equilibrium numerically
eq_data <- inner_join(
  tibble(price = price_range,
         q_supply = sapply(price_range, function(p) {
           plants_sim %>% filter(mc_per_mwh <= p) %>% pull(cum_cap) %>% max(., 0)
         })),
  demand_curve, by = "price"
)

# Equilibrium: where supply first meets or exceeds demand
eq_row <- eq_data %>%
  filter(q_supply >= q_demand) %>%
  slice(1)

cat("Equilibrium Price:    $", eq_row$price, "/MWh\n")
## Equilibrium Price:    $ 45 /MWh
cat("Equilibrium Quantity:", round(eq_row$q_demand), "MW\n")
## Equilibrium Quantity: 4910 MW

15.2 15.2 Security-Constrained Economic Dispatch (SCED)

Real ISO/RTO markets do not clear as a simple unconstrained equilibrium. SCED adds:

\[\min_{g_i} \sum_i MC_i \cdot g_i\]

subject to: - \(\sum_i g_i = D_t\) (energy balance at each node) - \(0 \leq g_i \leq \bar{C}_i\) (capacity limits) - \(|F_l| \leq \bar{F}_l, \quad \forall l \in \mathcal{L}\) (transmission flow limits) - \(\Delta g_i / \Delta t \leq R_i\) (ramp rate limits)

This yields Locational Marginal Prices (LMPs) that vary by node, reflecting energy cost, congestion rent, and marginal loss:

\[\text{LMP}_n = \underbrace{\lambda_{\text{energy}}}_{\text{system balance}} + \underbrace{\sum_{l \in \mathcal{L}} \mu_l \cdot \text{PTDF}_{l,n}}_{\text{congestion rent}} + \underbrace{\nu_n}_{\text{marginal loss}}\]

Energy-only markets (ERCOT, SPP) clear on energy alone; capacity-structured markets (PJM, ISO-NE, NYISO, MISO, CAISO) add a reliability price adder.

# ── ISO-specific plant stacks with nodal assignments ─────────────────────────
build_iso_stack <- function(iso) {
  switch(iso,
    PJM = tibble::tribble(
      ~plant, ~node, ~capacity_mw, ~mc_per_mwh,
      "Nuclear", "EAST",  3500,  15,
      "Coal",    "WEST",  2000,  32,
      "Gas CCGT","EAST",  4500,  42,
      "Wind",    "WEST",  1200,   5,
      "Solar",   "EAST",   800,   5,
      "Gas Peak","EAST",  1000, 110
    ),
    ERCOT = tibble::tribble(
      ~plant, ~node, ~capacity_mw, ~mc_per_mwh,
      "Wind",    "WEST",  3500,  5,
      "Solar",   "SOUTH", 2000,  5,
      "Gas CCGT","NORTH", 4000, 38,
      "Coal",    "NORTH",  800, 35,
      "Gas Peak","SOUTH", 1500, 95
    ),
    MISO = tibble::tribble(
      ~plant, ~node, ~capacity_mw, ~mc_per_mwh,
      "Coal",    "MISO_N", 3500, 30,
      "Gas CCGT","MISO_N", 3000, 40,
      "Wind",    "MISO_N", 2500,  5,
      "Nuclear", "MISO_S", 1500, 15,
      "Solar",   "MISO_S",  600,  5
    ),
    CAISO = tibble::tribble(
      ~plant, ~node, ~capacity_mw, ~mc_per_mwh,
      "Solar",   "SP15",  4000,  5,
      "Gas CCGT","NP15",  2500, 50,
      "Imports", "ZP26",  1500, 55,
      "Hydro",   "NP15",  1200, 10,
      "Gas Peak","SP15",   800, 120
    ),
    NYISO = tibble::tribble(
      ~plant, ~node, ~capacity_mw, ~mc_per_mwh,
      "Nuclear", "UPST", 2000, 15,
      "Gas CCGT","NYC",  3000, 48,
      "Hydro",   "UPST", 1500,  8,
      "Wind",    "LONG",  800,  5,
      "Gas Peak","NYC",   600, 115
    ),
    `ISO-NE` = tibble::tribble(
      ~plant, ~node, ~capacity_mw, ~mc_per_mwh,
      "Gas CCGT","MA", 2500, 52,
      "Nuclear", "MA", 1500, 15,
      "Wind",    "ME",  900,  5,
      "Hydro",   "ME",  600,  8,
      "Gas Peak","CT",  500, 125
    ),
    SPP = tibble::tribble(
      ~plant, ~node, ~capacity_mw, ~mc_per_mwh,
      "Wind",    "SPP_N", 4000,  5,
      "Gas CCGT","SPP_S", 2500, 35,
      "Coal",    "SPP_S", 2000, 28,
      "Solar",   "SPP_N",  800,  5,
      "Gas Peak","SPP_S",  600,  90
    )
  )
}

iso_names  <- c("PJM","ERCOT","MISO","CAISO","NYISO","ISO-NE","SPP")
iso_stacks <- setNames(lapply(iso_names, build_iso_stack), iso_names)
# Demand is set to ~78% of each (illustrative) stack's capacity so a mid/upper-
# merit unit is always marginal. The earlier hard-coded GW peak loads were ~10x
# these toy stacks, leaving no marginal unit and emptying the LMP table.
iso_demands <- vapply(iso_stacks, function(s) round(sum(s$capacity_mw) * 0.78), numeric(1))

sced_results <- purrr::imap(iso_demands, function(demand, iso) {
  sced_lmp_iso(iso, demand, iso_stacks[[iso]], iso_network_params(iso))
})

# Compile nodal LMP table
lmp_table <- purrr::map_dfr(sced_results, function(r) {
  if (is.null(r$nodes)) return(NULL)
  r$nodes %>%
    dplyr::mutate(
      ISO = r$iso, lambda_energy = r$lambda_energy,
      market_type = r$market_type, binding = r$binding_constraint
    )
})

cat("SCED/LMP Decomposition — 7 ISOs/RTOs\n\n")
## SCED/LMP Decomposition — 7 ISOs/RTOs
if (nrow(lmp_table) > 0) {
  lmp_table %>%
    dplyr::select(ISO, node, lmp_energy, lmp_cong, lmp_loss, lmp_total,
                  capacity_adder, lmp_all_in, market_type) %>%
    dplyr::arrange(ISO, dplyr::desc(lmp_all_in)) %>%
    dplyr::mutate(dplyr::across(where(is.numeric), ~ round(.x, 2))) %>%
    print(n = 30)
} else {
  cat("No nodal LMPs produced (check ISO stacks vs demand).\n")
}
## # A tibble: 18 × 9
##    ISO    node  lmp_energy lmp_cong lmp_loss lmp_total capacity_adder lmp_all_in
##    <chr>  <chr>      <dbl>    <dbl>    <dbl>     <dbl>          <dbl>      <dbl>
##  1 CAISO  SP15          55     2.7      0.3       58               18       76  
##  2 CAISO  NP15          55     0        0         55               18       73  
##  3 CAISO  ZP26          55    -1.2      0.13      53.9             18       71.9
##  4 ERCOT  NORTH         38     2.4      0.19      40.6              0       40.6
##  5 ERCOT  WEST          38     0.6      0.05      38.6              0       38.6
##  6 ERCOT  SOUTH         38    -1.2      0.1       36.9              0       36.9
##  7 ISO-NE CT            52     1.08     0.15      53.2             20       73.2
##  8 ISO-NE MA            52     0        0         52               20       72  
##  9 ISO-NE ME            52    -0.72     0.1       51.4             20       71.4
## 10 MISO   MISO…         40     1.5      0.22      41.7             11       52.7
## 11 MISO   MISO…         40    -0.72     0.11      39.4             11       50.4
## 12 NYISO  NYC           48     3        0.4       51.4             22       73.4
## 13 NYISO  LONG          48     1        0.13      49.1             22       71.1
## 14 NYISO  UPST          48     0        0         48               22       70  
## 15 PJM    EAST          42     2.8      0.29      45.1             14       59.1
## 16 PJM    WEST          42    -1.2      0.13      40.9             14       54.9
## 17 SPP    SPP_N         35     0.75     0.11      35.8              0       35.8
## 18 SPP    SPP_S         35    -0.5      0.07      34.6              0       34.6
## # ℹ 1 more variable: market_type <chr>
cat("\nSystem energy component (λ) by ISO:\n")
## 
## System energy component (λ) by ISO:
purrr::map_dfr(sced_results, ~ tibble(
  ISO = .x$iso, lambda_energy = .x$lambda_energy,
  marginal_unit = .x$marginal_unit, market_type = .x$market_type
)) %>% print()
## # A tibble: 7 × 4
##   ISO    lambda_energy marginal_unit market_type
##   <chr>          <dbl> <chr>         <chr>      
## 1 PJM               42 Gas CCGT      capacity   
## 2 ERCOT             38 Gas CCGT      energy_only
## 3 MISO              40 Gas CCGT      capacity   
## 4 CAISO             55 Imports       capacity   
## 5 NYISO             48 Gas CCGT      capacity   
## 6 ISO-NE            52 Gas CCGT      capacity   
## 7 SPP               35 Gas CCGT      energy_only

15.3 15.3 Live Nodal LMP — CAISO OASIS (real congestion & loss)

LIVE CAISO OASIS

The SCED model in 15.2 is illustrative. Here we pull actual day-ahead LMPs from CAISO OASIS (public, no key) and decompose each into its true components. CAISO’s LMP is distinctive: on top of energy + congestion + loss it carries a marginal GHG cost (MGHG) from California’s cap-and-trade program — a live carbon price embedded directly in the wholesale clearing price.

\[\text{LMP}_n^{\text{CAISO}} = \underbrace{\text{MCE}}_{\text{energy}} + \underbrace{\text{MCC}}_{\text{congestion}} + \underbrace{\text{MCL}}_{\text{loss}} + \underbrace{\text{MGHG}}_{\text{cap-and-trade carbon}}\]

# NP15 (north) and SP15 (south) trading hubs — the classic CAISO congestion pair.
caiso_hubs <- c(NP15 = "TH_NP15_GEN-APND", SP15 = "TH_SP15_GEN-APND")
caiso_lmp  <- fetch_caiso_hubs(caiso_hubs, days_back = 7)   # OASIS is public, no key

if (!is.null(caiso_lmp) && nrow(caiso_lmp) > 0) {
  caiso_summary <- caiso_lmp %>%
    dplyr::group_by(hub) %>%
    dplyr::summarise(dplyr::across(c(energy, congestion, loss, ghg, lmp),
                                   ~ round(mean(.x, na.rm = TRUE), 2)), .groups = "drop")
  cat("CAISO day-ahead LMP decomposition (24-hr mean, $/MWh):\n")
  print(caiso_summary)
  caiso_src <- "CAISO OASIS PRC_LMP (live day-ahead)"
} else {
  caiso_summary <- NULL
  caiso_src <- "CAISO OASIS unavailable (throttled/offline) — see modeled SCED in 15.2"
  cat(caiso_src, "\n")
}
## CAISO day-ahead LMP decomposition (24-hr mean, $/MWh):
## # A tibble: 2 × 6
##   hub   energy congestion  loss   ghg   lmp
##   <chr>  <dbl>      <dbl> <dbl> <dbl> <dbl>
## 1 NP15    19.7      -0.17 -0.54  8.58  27.6
## 2 SP15    19.7      -1.6  -1.09  8.58  25.6

Model vs reality. The illustrative SCED (15.2) can’t be literally replaced by OASIS data — real PTDF/shift-factor matrices aren’t recoverable from prices alone — but this live pull validates it: the same energy + congestion + loss structure appears in the observed CAISO decomposition, plus the California-specific GHG adder. Congestion (MCC) and loss (MCL) are typically small at the hubs on an unstressed day and swing hardest during the evening ramp and heat events; the NP15–SP15 spread is the tradeable north–south congestion signal. To extend this to other ISOs, swap in ERCOT (ercot.com public API), PJM (Data Miner 2), or the Python gridstatus library via fetch_iso_lmp_snapshot().


16 Section 16: Data Sources


17 Section 17: Trading Strategy Implications

17.1 17.1 Spark Spread

The spark spread measures the profitability of a gas-fired power plant:

\[\text{Spark Spread} = P_{\text{power}} - \left(\text{Heat Rate} \times P_{\text{gas}}\right)\]

A positive spark spread indicates gas generators are in-the-money; negative spreads force economic curtailment.

# Simulate spark spread using model price series
set.seed(33)
n_months <- 96  # 8 years
dates_ss  <- seq(as.Date("2016-01-01"), by = "month", length.out = n_months)

gas_prices  <- pmax(1.5, 3.0 + cumsum(rnorm(n_months, 0, 0.3)) +
                    1.5 * sin(1:n_months * pi / 6))  # seasonal
power_prices <- 25 + 7 * gas_prices + rnorm(n_months, 0, 8) +
                5  * sin((1:n_months + 3) * pi / 6)  # correlated + seasonal

heat_rate    <- 7.0  # MMBtu / MWh (efficient CCGT)

spread_ss_df <- tibble(
  date         = dates_ss,
  gas          = gas_prices,
  power        = power_prices,
  spark_spread = power_prices - heat_rate * gas_prices
)

# Dark spread (coal)
coal_prices     <- 2.5 + rnorm(n_months, 0, 0.2)
heat_rate_coal  <- 10.2
spread_ss_df <- spread_ss_df %>%
  mutate(dark_spread = power - heat_rate_coal * coal_prices)

cat("Mean Spark Spread:", round(mean(spread_ss_df$spark_spread), 2), "$/MWh\n")
## Mean Spark Spread: 25.65 $/MWh
cat("% Months Negative Spark:", round(mean(spread_ss_df$spark_spread < 0) * 100, 1), "%\n")
## % Months Negative Spark: 0 %

17.2 17.2 Trading Signal Framework


18 Section 18: Final Output Summary

Analytical Summary & Key Takeaways

Electricity Markets: Merit-order dispatch with rising renewable penetration is compressing average wholesale prices while simultaneously increasing intra-day volatility. The duck curve is intensifying in CAISO and spreading to ERCOT and PJM as solar capacity scales. Battery storage is the structural beneficiary, with 4-hour BESS increasingly co-located with solar to capture evening ramp spreads.

Natural Gas: The U.S. has transitioned from price taker to global price setter via LNG exports (~12 Bcf/d, 2023). Henry Hub is now loosely tethered to JKM and TTF via arb mechanics. Storage trajectory vs the 5-year norm remains the highest-frequency tradeable signal. Marcellus oversupply structurally caps HH below $4/MMBtu absent an LNG demand shock or severe winter.

Crude Oil: WTI–Brent spread dynamics are driven by U.S. export logistics capacity and Cushing inventory. API gravity premiums for light-sweet crude (Permian, Bakken) persist as U.S. refiners are configured for heavier feedstocks. SPR drawdown risk remains elevated with stocks at 40-year lows.

Emerging Markets: Green hydrogen economics hinge on IRA § 45V PTC + sub-$30/MWh renewable electricity. SMR deployment (NuScale, TerraPower) faces project finance and regulatory timeline risk but represents a long-dated optionality on 24/7 carbon-free firm capacity. Watch for advanced nuclear licensing activity at the NRC as a leading indicator.

## Report generated: 2026-07-13 17:18:10 UTC
## R version: R version 4.4.0 (2024-04-24)
## Data sources: EIA Live API + FRED Live API + Representative / Simulated
## Monte Carlo draws: 100,000