Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Appendix A: Python Reference for Monte Carlo

This appendix collects skeleton code for the computational labs and a reference for the key libraries.


A.1 Environment Setup

# Recommended: create a dedicated conda environment
conda create -n mc_course python=3.11
conda activate mc_course
pip install numpy scipy matplotlib numba tqdm
pip install vegas  # for adaptive MC integration

A.2 Random Number Generation

import numpy as np

# Modern API: always use default_rng with a seed
rng = np.random.default_rng(seed=42)

# Uniform samples
u = rng.uniform(0, 1, size=10_000)

# Standard normal
z = rng.standard_normal(size=10_000)

# Exponential (radioactive decay lifetimes)
lam = 1.0  # decay constant
t = rng.exponential(scale=1/lam, size=10_000)
# or via inverse CDF:
t_inv = -np.log(rng.uniform(size=10_000)) / lam

# Uniform on S^2 (correct method)
cos_theta = rng.uniform(-1, 1, size=1000)
phi = rng.uniform(0, 2*np.pi, size=1000)
sin_theta = np.sqrt(1 - cos_theta**2)
x = sin_theta * np.cos(phi)
y = sin_theta * np.sin(phi)
z = cos_theta  # unit vectors on sphere

A.3 Maxwell-Boltzmann Sampling (Chapter 2 Lab)

import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import chi

def sample_maxwell_boltzmann(T, m, N, rng=None):
    """Sample N speeds from Maxwell-Boltzmann at temperature T."""
    if rng is None:
        rng = np.random.default_rng()
    kB = 1.380649e-23
    sigma = np.sqrt(kB * T / m)
    # v = sqrt(vx^2 + vy^2 + vz^2), vx ~ N(0, sigma)
    v = np.sqrt(np.sum(rng.normal(0, sigma, size=(N, 3))**2, axis=1))
    return v

# Argon parameters
T = 300.0          # K
m = 6.634e-26      # kg (Ar mass)
N = 100_000
rng = np.random.default_rng(seed=0)

v = sample_maxwell_boltzmann(T, m, N, rng)

kB = 1.380649e-23
v_p = np.sqrt(2 * kB * T / m)   # most probable speed

print(f"<v>   = {np.mean(v):.1f} m/s  (theory: {np.sqrt(8*kB*T/(np.pi*m)):.1f})")
print(f"v_rms = {np.sqrt(np.mean(v**2)):.1f} m/s  (theory: {np.sqrt(3*kB*T/m):.1f})")
print(f"v_mp  = {v_p:.1f} m/s (bin peak ~ {v[np.argmax(np.histogram(v,100)[0])]:.1f})")

A.4 Metropolis for the 1D Double-Well (Chapter 4 Lab)

import numpy as np

def double_well(x):
    return (x**2 - 1)**2

def metropolis_1d(beta, N_steps, delta, x0=0.0, seed=42):
    rng = np.random.default_rng(seed)
    x = x0
    trajectory = np.zeros(N_steps)
    n_accept = 0
    for t in range(N_steps):
        x_new = x + rng.uniform(-delta, delta)
        dE = double_well(x_new) - double_well(x)
        if np.log(rng.uniform()) < -beta * dE:
            x = x_new
            n_accept += 1
        trajectory[t] = x
    return trajectory, n_accept / N_steps

beta = 2.0
traj, acc = metropolis_1d(beta, N_steps=500_000, delta=0.5)
print(f"Acceptance rate: {acc:.2f}")

A.5 2D Ising Model (Chapter 5 Lab)

import numpy as np

def ising_energy(spins, J=1.0):
    """Total energy of 2D Ising model with periodic BC."""
    E = -J * np.sum(spins * (
        np.roll(spins, 1, 0) + np.roll(spins, -1, 0) +
        np.roll(spins, 1, 1) + np.roll(spins, -1, 1)
    ))
    return E / 2  # avoid double-counting

def ising_metropolis(L, T, N_sweeps, J=1.0, seed=42):
    rng = np.random.default_rng(seed)
    beta = 1.0 / T
    spins = rng.choice([-1, 1], size=(L, L))
    
    # Pre-compute acceptance probabilities
    dE_vals = np.array([-8, -4, 0, 4, 8]) * J
    acc_prob = {dE: min(1.0, np.exp(-beta * dE)) for dE in dE_vals}
    
    E_list, M_list = [], []
    
    for sweep in range(N_sweeps):
        # N random single-spin flips per sweep
        for _ in range(L * L):
            i, j = rng.integers(0, L, size=2)
            nb_sum = (spins[(i+1)%L, j] + spins[(i-1)%L, j] +
                      spins[i, (j+1)%L] + spins[i, (j-1)%L])
            dE = 2 * J * spins[i, j] * nb_sum
            if rng.uniform() < acc_prob[dE]:
                spins[i, j] *= -1
        
        E_list.append(ising_energy(spins, J) / (L*L))
        M_list.append(np.mean(spins))
    
    return np.array(E_list), np.array(M_list)

# Example: L=32, T=2.27 (near Tc)
E, M = ising_metropolis(32, T=2.27, N_sweeps=5000)
print(f"<E>/N = {np.mean(E[1000:]):.4f} ± {np.std(E[1000:])/np.sqrt(4000):.4f}")
print(f"<|M|> = {np.mean(np.abs(M[1000:])):.4f}")

A.6 Wolff Cluster Algorithm (Chapter 5 Lab)

import numpy as np
from collections import deque

def wolff_sweep(spins, beta, J=1.0, rng=None):
    if rng is None:
        rng = np.random.default_rng()
    L = spins.shape[0]
    p_add = 1 - np.exp(-2 * beta * J)
    
    # Pick random seed spin
    i0, j0 = rng.integers(0, L, size=2)
    cluster_spin = spins[i0, j0]
    in_cluster = np.zeros((L, L), dtype=bool)
    in_cluster[i0, j0] = True
    queue = deque([(i0, j0)])
    
    while queue:
        i, j = queue.popleft()
        for di, dj in [(-1,0),(1,0),(0,-1),(0,1)]:
            ni, nj = (i+di)%L, (j+dj)%L
            if not in_cluster[ni, nj] and spins[ni, nj] == cluster_spin:
                if rng.uniform() < p_add:
                    in_cluster[ni, nj] = True
                    queue.append((ni, nj))
    
    spins[in_cluster] *= -1
    return np.sum(in_cluster)  # cluster size

A.7 BKL / Gillespie KMC (Chapter 8 Lab)

import numpy as np

def kmc_adatom_diffusion(L, T, E_a, nu0, N_events, seed=42):
    """
    KMC for single adatom diffusion on 2D square lattice.
    Returns trajectory (positions) and times.
    """
    rng = np.random.default_rng(seed)
    kB = 8.617e-5  # eV/K
    
    k_hop = nu0 * np.exp(-E_a / (kB * T))
    K_tot = 4 * k_hop   # 4 neighbours
    
    x, y = L // 2, L // 2  # start at centre
    t = 0.0
    times = np.zeros(N_events)
    positions = np.zeros((N_events, 2), dtype=int)
    
    moves = np.array([[1,0],[-1,0],[0,1],[0,-1]])
    
    for n in range(N_events):
        # Time advance
        dt = -np.log(rng.uniform()) / K_tot
        t += dt
        # Choose event (uniform since all rates equal)
        move = moves[rng.integers(4)]
        x = (x + move[0]) % L
        y = (y + move[1]) % L
        times[n] = t
        positions[n] = [x, y]
    
    return times, positions

# Example: Cu adatom, E_a = 0.49 eV, T = 600 K
times, pos = kmc_adatom_diffusion(100, T=600, E_a=0.49,
                                   nu0=3e12, N_events=100_000)
# Compute MSD
origin = pos[0]
msd = np.mean((pos - pos[0])**2, axis=1)
D_fit = np.polyfit(times[100:], msd[100:], 1)[0] / 4  # 2D: MSD = 4Dt
print(f"D = {D_fit:.4e} m^2/s")

A.8 Blocking Analysis for Autocorrelated Data

import numpy as np

def blocking_analysis(data, max_block_size=None):
    """
    Compute standard error as a function of block size.
    Returns block_sizes, standard_errors.
    """
    N = len(data)
    if max_block_size is None:
        max_block_size = N // 4
    
    block_sizes = []
    std_errors = []
    
    b = 1
    while b <= max_block_size:
        n_blocks = N // b
        blocks = data[:n_blocks*b].reshape(n_blocks, b).mean(axis=1)
        se = np.std(blocks, ddof=1) / np.sqrt(n_blocks)
        block_sizes.append(b)
        std_errors.append(se)
        b = max(b + 1, int(b * 1.5))
    
    return np.array(block_sizes), np.array(std_errors)

# Usage:
# bs, se = blocking_analysis(magnetisation_timeseries)
# plt.semilogx(bs, se); plt.xlabel('Block size'); plt.ylabel('SE')
# True error = plateau value