Sats/Joule Redux

We’ve been throwing up a sats/joule calculation from time to time, now that 15 years have passed we can put a plot or two to show how this exercise has evolved over time . Without further ado:

The top panel shows the bitcoin (BTC) sats/Joule as calculated by the amount of sats produced in mining using a Joule, the bottom panel shows the sats/Joule as calculated by the amount of sats used to purchase a Joule of energy as gas or electricity.

Finally, we give you the ratio of the sats/Joule determined from the top plot divided by the average of the numbers in the bottom plot:

Please do notice the top panel of the first chart is in mSat/Joule. As you can tell from the bottom plot the ratio between going one way (sats-joule) and the other (joule-sats) has been hovering around 万 for a while.

Finally, here’s some python code to generate the plots, in case you are curious:

"""
satsJ.py — Historical Bitcoin sats-per-joule analysis.

Produces:
  1. Mining yield (sats produced per joule burned)
  2. Energy purchasing power (sats required per joule bought, grid + gasoline)
  3. Dimensionless ratio: mining yield / average purchasing power

Data sources:
  - Blockchain.com Charts API (hashrate)        [free, no key]
  - CoinGecko API (BTC price)                   [free, no key]
  - EIA historical tables (electricity, gas)    [hardcoded fallback]
"""

import os
import time
from datetime import datetime

import numpy as np
import pandas as pd
import requests
import matplotlib.pyplot as plt

# ============================================================
# 1. CONFIGURATION
# ============================================================
CACHE_DIR = "cache"
os.makedirs(CACHE_DIR, exist_ok=True)

BLOCKCHAIN_API = "https://api.blockchain.info/charts"
COINGECKO_API = "https://api.coingecko.com/api/v3"

# Network constants (post-April-2024 halving)
BLOCK_REWARD_SATS = 312_500_000       # 3.125 BTC
BLOCK_INTERVAL_SECONDS = 600          # 10-minute target
SATS_PER_BTC = 100_000_000

# Energy unit conversions
J_PER_KWH = 3.6e6
J_PER_GALLON_GASOLINE = 132_000_000   # ~132 MJ/gallon

# Hardware efficiency anchors (J/TH) — best commercially available ASIC
# Sources: minerstat, Glassnode, academic tables
EFFICIENCY_ANCHORS = {
    "2013-01-01": 10000,   # early ASICs (rough order of magnitude)
    "2015-09-01": 273,     # Antminer S7
    "2016-06-01": 98,      # Antminer S9
    "2017-01-01": 126,     # Antminer T9
    "2018-12-01": 57,      # Antminer S15
    "2019-04-01": 45,      # Antminer S17
    "2020-05-01": 29.5,    # Antminer S19 Pro
    "2022-10-01": 20.8,    # Antminer S19 XP Hyd
    "2024-12-01": 13.5,    # Antminer S21 XP
    "2026-01-01": 9.5,     # Antminer S23 Hyd
}

# ============================================================
# 2. DATA FETCHERS (CSV cache — no parquet dependency)
# ============================================================
def _cache_path(name):
    return os.path.join(CACHE_DIR, f"{name}.csv")

def _load_cache(name):
    path = _cache_path(name)
    if os.path.exists(path):
        return pd.read_csv(path, index_col=0, parse_dates=True)
    return None

def _save_cache(name, df):
    df.to_csv(_cache_path(name))


def fetch_blockchain_chart(chart_name, timespan="all"):
    """Fetch a historical chart from Blockchain.com's free API."""
    cache_name = f"bc_{chart_name}_{timespan}"
    cached = _load_cache(cache_name)
    if cached is not None:
        print(f"  [cache] {chart_name}")
        return cached

    url = f"{BLOCKCHAIN_API}/{chart_name}"
    params = {"timespan": timespan, "format": "json"}
    resp = requests.get(url, params=params, timeout=30)
    resp.raise_for_status()
    data = resp.json()

    df = pd.DataFrame(data["values"])
    df["date"] = pd.to_datetime(df["x"], unit="s")
    df = df.set_index("date")[["y"]].rename(columns={"y": chart_name})
    _save_cache(cache_name, df)
    time.sleep(0.5)
    return df


def fetch_bitcoin_price():
    """Fetch historical BTC price from CoinGecko (free tier)."""
    cache_name = "btc_price"
    cached = _load_cache(cache_name)
    if cached is not None:
        print("  [cache] BTC price")
        return cached

    url = f"{COINGECKO_API}/coins/bitcoin/market_chart"
    params = {"vs_currency": "usd", "days": "max", "interval": "daily"}
    resp = requests.get(url, params=params, timeout=30)
    resp.raise_for_status()
    data = resp.json()

    prices = pd.DataFrame(data["prices"], columns=["timestamp", "price"])
    prices["date"] = pd.to_datetime(prices["timestamp"], unit="ms")
    prices = prices.set_index("date")[["price"]]
    _save_cache(cache_name, prices)
    return prices


def fetch_electricity_price():
    """US average retail electricity price (USD/kWh), annual averages."""
    cache_name = "electricity_price"
    cached = _load_cache(cache_name)
    if cached is not None:
        return cached

    data = {
        "2013-01-01": 0.1019, "2014-01-01": 0.1045, "2015-01-01": 0.1057,
        "2016-01-01": 0.1042, "2017-01-01": 0.1048, "2018-01-01": 0.1053,
        "2019-01-01": 0.1046, "2020-01-01": 0.1037, "2021-01-01": 0.1110,
        "2022-01-01": 0.1249, "2023-01-01": 0.1282, "2024-01-01": 0.1282,
        "2025-01-01": 0.1340, "2026-01-01": 0.1448,
    }
    df = pd.DataFrame(
        list(data.items()), columns=["date", "price_kwh"]
    ).set_index("date")
    df.index = pd.to_datetime(df.index)
    _save_cache(cache_name, df)
    return df


def fetch_gasoline_price():
    """US regular gasoline retail price (USD/gallon), annual averages."""
    cache_name = "gasoline_price"
    cached = _load_cache(cache_name)
    if cached is not None:
        return cached

    data = {
        "2013-01-01": 3.53, "2014-01-01": 3.36, "2015-01-01": 2.43,
        "2016-01-01": 2.14, "2017-01-01": 2.42, "2018-01-01": 2.72,
        "2019-01-01": 2.60, "2020-01-01": 2.17, "2021-01-01": 3.01,
        "2022-01-01": 3.95, "2023-01-01": 3.52, "2024-01-01": 3.30,
        "2025-01-01": 3.10, "2026-01-01": 3.00,
    }
    df = pd.DataFrame(
        list(data.items()), columns=["date", "price_gal"]
    ).set_index("date")
    df.index = pd.to_datetime(df.index)
    _save_cache(cache_name, df)
    return df


# ============================================================
# 3. HARDWARE EFFICIENCY INTERPOLATION
# ============================================================
def build_efficiency_series(date_index):
    """Step-interpolate hardware efficiency (J/TH) across a date index."""
    anchor_dates = pd.to_datetime(list(EFFICIENCY_ANCHORS.keys()))
    anchor_values = list(EFFICIENCY_ANCHORS.values())

    efficiency = pd.Series(index=date_index, dtype=float)
    for i, (d, v) in enumerate(zip(anchor_dates, anchor_values)):
        if i == len(anchor_dates) - 1:
            mask = date_index >= d
        else:
            mask = (date_index >= d) & (date_index < anchor_dates[i + 1])
        efficiency[mask] = v

    return efficiency.bfill().ffill()


# ============================================================
# 4. CORE CALCULATIONS
# ============================================================
def compute_mining_yield(hashes_per_second, efficiency_j_th):
    """Sats produced per joule of mining energy (sat/J)."""
    th_per_second = hashes_per_second / 1e12
    power_watts = th_per_second * efficiency_j_th      # J/s
    sats_per_second = BLOCK_REWARD_SATS / BLOCK_INTERVAL_SECONDS
    return sats_per_second / power_watts


def compute_energy_purchasing_power(btc_price, energy_price_usd, joules_per_unit):
    """Sats required to purchase one joule of energy (sat/J)."""
    usd_per_joule = energy_price_usd / joules_per_unit
    sats_per_usd = SATS_PER_BTC / btc_price
    return usd_per_joule * sats_per_usd


# ============================================================
# 5. MAIN PIPELINE
# ============================================================
def main():
    print("Fetching network data...")

    hashrate_df = fetch_blockchain_chart("hash-rate", timespan="all")
    hashrate_df = hashrate_df.resample("W").mean().dropna()
    print(f"  Hashrate: {len(hashrate_df)} weekly points "
          f"from {hashrate_df.index[0].date()} to {hashrate_df.index[-1].date()}")

    try:
        price_df = fetch_bitcoin_price()
        price_df = price_df.resample("W").last().dropna()
        print(f"  BTC price: {len(price_df)} weekly points")
    except Exception as e:
        print(f"  BTC price fetch failed: {e}")
        price_df = None

    start_date = pd.Timestamp("2013-01-01")
    date_index = hashrate_df.index[hashrate_df.index >= start_date]

    print("Building hardware efficiency series...")
    efficiency_series = build_efficiency_series(date_index)

    print("Computing mining yield...")
    raw_hashrate = hashrate_df["hash-rate"].reindex(date_index)
    # Blockchain.com returns hash-rate in GH/s
    hashes_per_second = raw_hashrate * 1e9
    mining_yield = compute_mining_yield(hashes_per_second, efficiency_series)

    print("Computing energy purchasing power...")
    elec_df = fetch_electricity_price().reindex(date_index, method="ffill")
    gas_df = fetch_gasoline_price().reindex(date_index, method="ffill")

    if price_df is not None:
        btc_price = price_df["price"].reindex(date_index, method="ffill")
    else:
        btc_price = pd.Series(50000, index=date_index)

    elec_sats_per_joule = compute_energy_purchasing_power(
        btc_price, elec_df["price_kwh"], J_PER_KWH
    )
    gas_sats_per_joule = compute_energy_purchasing_power(
        btc_price, gas_df["price_gal"], J_PER_GALLON_GASOLINE
    )

    # ============================================================
    # 6. PLOT 1 & 2: MINING YIELD AND PURCHASING POWER
    # ============================================================
    print("Generating plots...")

    fig, axes = plt.subplots(2, 1, figsize=(14, 10), sharex=True)

    # --- Panel 1: mining yield ---
    ax1 = axes[0]
    ax1.plot(mining_yield.index, mining_yield.values * 1000,
             color="#1f77b4", linewidth=1.5,
             label="Mining yield (mSat produced per J burned)")
    ax1.set_ylabel("mSat produced / J burned", fontsize=12)
    ax1.set_title("Bitcoin Mining Energy Yield: Sats Produced per Joule",
                  fontsize=14)
    ax1.set_yscale("log")
    ax1.grid(True, alpha=0.3)
    ax1.legend()

    # --- Panel 2: purchasing power ---
    ax2 = axes[1]
    ax2.plot(elec_sats_per_joule.index, elec_sats_per_joule.values,
             color="#ff7f0e", linewidth=1.5,
             label="Grid electricity (sat spent / J purchased)")
    ax2.plot(gas_sats_per_joule.index, gas_sats_per_joule.values,
             color="#2ca02c", linewidth=1.5,
             label="Gasoline (sat spent / J purchased)")
    ax2.set_ylabel("Sat spent / J purchased", fontsize=12)
    ax2.set_xlabel("Date", fontsize=12)
    ax2.set_title("Energy Purchasing Power: Sats Required per Joule",
                  fontsize=14)
    ax2.set_yscale("log")
    ax2.grid(True, alpha=0.3)
    ax2.legend()

    plt.tight_layout()
    plt.savefig("sats_per_joule_history.png", dpi=150, bbox_inches="tight")
    print("Saved: sats_per_joule_history.png")

    # ============================================================
    # 7. PLOT 3: DIMENSIONLESS RATIO
    # ============================================================
    combined = pd.DataFrame({
        "mining_yield": mining_yield,
        "elec": elec_sats_per_joule,
        "gas": gas_sats_per_joule,
    }).dropna()

    avg_purchasing_power = combined[["elec", "gas"]].mean(axis=1)
    ratio = combined["mining_yield"] / avg_purchasing_power

    fig2, ax3 = plt.subplots(figsize=(14, 6))
    ax3.plot(ratio.index, ratio.values, color="#9467bd", linewidth=1.8,
             label="Mining yield ÷ avg energy purchasing power")
    ax3.axhline(1.0, color="gray", linestyle="--", linewidth=1,
                label="Parity (ratio = 1)")
    ax3.set_ylabel("Dimensionless ratio", fontsize=12)
    ax3.set_xlabel("Date", fontsize=12)
    ax3.set_title(
        "Sats-per-Joule Ratio: Mining Yield vs. Average Energy Purchasing Power",
        fontsize=14,
    )
    ax3.set_yscale("log")
    ax3.grid(True, alpha=0.3)
    ax3.legend()

    plt.tight_layout()
    plt.savefig("sats_per_joule_ratio.png", dpi=150, bbox_inches="tight")
    print("Saved: sats_per_joule_ratio.png")
    plt.show()

    # ============================================================
    # 8. CSV EXPORT
    # ============================================================
    output_df = pd.DataFrame({
        "mining_yield_mSat_per_J": mining_yield * 1000,
        "elec_sats_per_J": elec_sats_per_joule,
        "gas_sats_per_J": gas_sats_per_joule,
        "avg_purchasing_power_sat_per_J": avg_purchasing_power,
        "ratio_mining_over_purchasing": ratio,
        "efficiency_J_per_TH": efficiency_series,
        "btc_price_usd": btc_price,
    })
    output_df.to_csv("sats_per_joule_data.csv")
    print("Saved: sats_per_joule_data.csv")


if __name__ == "__main__":
    main()


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *