0% found this document useful (0 votes)
4 views22 pages

IAM Programming Project

The document details a numerical solution for the 2D heat equation on a metal plate using two schemes: explicit FTCS and Crank–Nicolson ADI. It covers problem setup, stability analysis, and implementation of the methods, including initial conditions and boundary conditions. The results include stability checks and visualizations of temperature evolution over time.

Uploaded by

manzoor ahmed
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
4 views22 pages

IAM Programming Project

The document details a numerical solution for the 2D heat equation on a metal plate using two schemes: explicit FTCS and Crank–Nicolson ADI. It covers problem setup, stability analysis, and implementation of the methods, including initial conditions and boundary conditions. The results include stability checks and visualizations of temperature evolution over time.

Uploaded by

manzoor ahmed
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd

23/05/2026, 14:59 IAM_Programming_Project

Numerical solution of the 2D heat equation


on a metal plate
The equation:

$$\frac{\partial u}{\partial t} = \alpha \left( \frac{\partial^2 u}{\partial x^2} + \frac{\partial^2


u}{\partial y^2} \right)$$

where $u(x, y, t)$ is temperature, $\alpha$ is thermal diffusivity, and the domain is $[0, L_x]
\times [0, L_y]$.

Two schemes are implemented: explicit FTCS and Crank–Nicolson ADI. The notebook also
covers stability limits, a convergence study, and verification against an exact analytical
solution where one exists.

1. Imports and configuration


In [1]: import numpy as np
import [Link] as plt
import [Link] as animation
from [Link] import GridSpec
from [Link] import solve
from [Link] import lil_matrix, csc_matrix
from [Link] import spsolve
import time
import warnings
[Link]('ignore')

%matplotlib inline
[Link]({
'[Link]': 120,
'[Link]': 11,
'[Link]': 13,
'[Link]': 12,
'[Link]': 'hot'
})

print("Libraries loaded successfully.")

Libraries loaded successfully.

2. Problem setup

[Link] 1/22
23/05/2026, 14:59 IAM_Programming_Project

The plate has fixed-temperature edges (Dirichlet conditions). Initial condition options:
Gaussian hot spot, linear gradient, sinusoidal, or uniform.

Spatial grid: $\Delta x = L_x/(N_x-1)$, $\Delta y = L_y/(N_y-1)$. Time step $\Delta t$ is fixed.

In [2]: # ─── Physical Parameters ───────────────────────────────────────────────────


Lx, Ly = 1.0, 1.0 # Plate dimensions (m)
alpha = 1.0e-4 # Thermal diffusivity (m²/s), ~stainless steel
run_time = 5000.0 # Total simulation time (s)

# ─── Boundary Conditions (Dirichlet) ──────────────────────────────────────


temp_left = 100.0 # °C (x = 0)
temp_right = 0.0 # °C (x = Lx)
temp_bottom = 0.0 # °C (y = 0)
temp_top = 0.0 # °C (y = Ly)

# ─── Grid Resolution ──────────────────────────────────────────────────────


Nx, Ny = 51, 51 # Number of grid points (including boundaries)
dx = Lx / (Nx - 1)
dy = Ly / (Ny - 1)

x = [Link](0, Lx, Nx)


y = [Link](0, Ly, Ny)
X, Y = [Link](x, y) # shape (Ny, Nx)

print(f"Grid: {Nx} × {Ny} points | dx = {dx:.4f} m | dy = {dy:.4f} m")


print(f"Thermal diffusivity α = {alpha:.2e} m²/s")
print(f"Simulation time: {run_time:.0f} s")

Grid: 51 × 51 points | dx = 0.0200 m | dy = 0.0200 m


Thermal diffusivity α = 1.00e-04 m²/s
Simulation time: 5000 s

In [3]: def make_initial_condition(Nx, Ny, x, y, start_type='hotspot'):


"""
Generate initial temperature field.
start_type: 'hotspot' | 'gradient' | 'sine' | 'uniform'
"""
X_, Y_ = [Link](x, y) # shape (Ny, Nx)
u = [Link]((Ny, Nx))

if start_type == 'hotspot':
# Gaussian hot spot at center
cx, cy = Lx / 2, Ly / 2
sigma = 0.1
u = 200 * [Link](-((X_ - cx)**2 + (Y_ - cy)**2) / (2 * sigma**2))

elif start_type == 'gradient':


# Linear gradient in x
u = 100 * (1 - X_ / Lx)

elif start_type == 'sine':


# Sinusoidal — has a known analytical solution
u = 100 * [Link]([Link] * X_ / Lx) * [Link]([Link] * Y_ / Ly)

[Link] 2/22
23/05/2026, 14:59 IAM_Programming_Project

elif start_type == 'uniform':


u = [Link]((Ny, Nx), 100.0)

# Enforce Dirichlet BCs


u = apply_bc(u)
return u

def apply_bc(u):
"""Apply Dirichlet boundary conditions."""
u[:, 0] = temp_left # left edge
u[:, -1] = temp_right # right edge
u[0, :] = temp_bottom # bottom edge
u[-1, :] = temp_top # top edge
return u

# ─── Visualise all ICs ─────────────────────────────────────────────────────


fig, axes = [Link](1, 4, figsize=(18, 4))
ic_types = ['hotspot', 'gradient', 'sine', 'uniform']
titles = ['Gaussian Hot Spot', 'Linear Gradient', 'Sinusoidal', 'Uniform 100 °C'

for ax, ic, title in zip(axes, ic_types, titles):


u_start = make_initial_condition(Nx, Ny, x, y, start_type=ic)
im = [Link](X, Y, u_start, levels=20, cmap='hot')
[Link](im, ax=ax, label='T (°C)')
ax.set_title(title)
ax.set_xlabel('x (m)')
ax.set_ylabel('y (m)')

[Link]('Initial Condition Options', fontsize=14, fontweight='bold')


plt.tight_layout()
[Link]()

3. Stability analysis
Explicit (FTCS): Von Neumann criterion
For the 2D explicit scheme, the stability condition is:

$$r = \alpha \frac{\Delta t}{\Delta x^2} \leq \frac{1}{4} \quad (\text{for equal spacing})$$

More generally:

[Link] 3/22
23/05/2026, 14:59 IAM_Programming_Project

$$\alpha \Delta t \left(\frac{1}{\Delta x^2} + \frac{1}{\Delta y^2}\right) \leq \frac{1}{2}$$

Crank–Nicolson is unconditionally stable, so $\Delta t$ can be as large as accuracy allows.

In [4]: def stability_check(alpha, dt, dx, dy):


"""Compute and report the stability parameter r for the explicit scheme."""
r = alpha * dt * (1/dx**2 + 1/dy**2)
rx = alpha * dt / dx**2
ry = alpha * dt / dy**2
stable = r <= 0.5
print(f" rx = α·Δt/Δx² = {rx:.4f}")
print(f" ry = α·Δt/Δy² = {ry:.4f}")
print(f" r = rx + ry = {r:.4f} (must be ≤ 0.5 for stability)")
print(f" Stable: {'✓ YES' if stable else '✗ NO — UNSTABLE!'}")
return r, stable

# ─── Maximum stable dt ─────────────────────────────────────────────────────


max_dt = 0.5 / (alpha * (1/dx**2 + 1/dy**2))
dt_safe = 0.9 * max_dt # slightly below limit
dt_bad = 1.5 * max_dt # above limit (will blow up)

print(f"Maximum stable Δt = {max_dt:.2f} s\n")


print("--- Stable case ---")
stability_check(alpha, dt_safe, dx, dy)
print("\n--- Unstable case ---")
stability_check(alpha, dt_bad, dx, dy)

# ─── Stability map: vary dt and alpha ─────────────────────────────────────


alphas = [Link](-6, -2, 200)
dt_max_arr = 0.5 / (alphas * (1/dx**2 + 1/dy**2))

fig, ax = [Link](figsize=(8, 4))


[Link](alphas, dt_max_arr, 'b-', lw=2, label='Stability boundary (r = 0.5)')
ax.fill_between(alphas, dt_max_arr, 1e8, alpha=0.15, color='green', label='Stable r
ax.fill_between(alphas, 0, dt_max_arr, alpha=0.15, color='red', label='Unstable r
[Link]([alpha], [dt_safe], color='green', s=80, zorder=5, label=f'Our stable
[Link]([alpha], [dt_bad], color='red', s=80, zorder=5, label=f'Unstable dt={d
ax.set_xlabel('Thermal diffusivity α (m²/s)')
ax.set_ylabel('Maximum stable Δt (s)')
ax.set_title('FTCS Stability Boundary for 2D Heat Equation')
[Link](fontsize=9)
[Link](True, which='both', ls='--', alpha=0.5)
plt.tight_layout()
[Link]()

[Link] 4/22
23/05/2026, 14:59 IAM_Programming_Project

Maximum stable Δt = 1.00 s

--- Stable case ---


rx = α·Δt/Δx² = 0.2250
ry = α·Δt/Δy² = 0.2250
r = rx + ry = 0.4500 (must be ≤ 0.5 for stability)
Stable: ✓ YES

--- Unstable case ---


rx = α·Δt/Δx² = 0.3750
ry = α·Δt/Δy² = 0.3750
r = rx + ry = 0.7500 (must be ≤ 0.5 for stability)
Stable: ✗ NO — UNSTABLE!

4. Explicit (FTCS) method


The forward-time, centered-space update at each interior point:

$$u_{i,j}^{n+1} = u_{i,j}^n + r_x\left(u_{i+1,j}^n - 2u_{i,j}^n + u_{i-1,j}^n\right) +


r_y\left(u_{i,j+1}^n - 2u_{i,j}^n + u_{i,j-1}^n\right)$$

where $r_x = \alpha\Delta t/\Delta x^2$ and $r_y = \alpha\Delta t/\Delta y^2$.

In [5]: def solve_explicit(u_start, alpha, dx, dy, dt, num_steps, save_every=100):


"""
Explicit (FTCS) solver for 2D heat equation.
Returns list of saved snapshots and corresponding times.
"""
rx = alpha * dt / dx**2
ry = alpha * dt / dy**2

u = u_start.copy()
snapshots = [[Link]()]
times = [0.0]

[Link] 5/22
23/05/2026, 14:59 IAM_Programming_Project

for step in range(1, num_steps + 1):


u_next = [Link]()
# Interior points only (vectorised)
u_next[1:-1, 1:-1] = (
u[1:-1, 1:-1]
+ rx * (u[1:-1, 2:] - 2*u[1:-1, 1:-1] + u[1:-1, :-2])
+ ry * (u[2:, 1:-1] - 2*u[1:-1, 1:-1] + u[:-2, 1:-1])
)
apply_bc(u_next)
u = u_next

if step % save_every == 0:
[Link]([Link]())
[Link](step * dt)

return snapshots, times

# ─── Run Explicit solver ───────────────────────────────────────────────────


dt_explicit = 0.9 * max_dt
num_steps = int(run_time / dt_explicit)
save_step_exp = max(1, num_steps // 50)

u_start = make_initial_condition(Nx, Ny, x, y, start_type='hotspot')

print(f"Explicit solver: dt = {dt_explicit:.2f} s | steps = {num_steps:,}")


t0 = [Link]()
frames_exp, times_exp = solve_explicit(u_start, alpha, dx, dy, dt_explicit, num_ste
print(f"Wall time: {[Link]()-t0:.2f} s | Snapshots saved: {len(frames_exp)}")

Explicit solver: dt = 0.90 s | steps = 5,555


Wall time: 0.26 s | Snapshots saved: 51

In [6]: # ─── Plot snapshots at 4 time points ──────────────────────────────────────


frame_picks = [0, len(frames_exp)//4, len(frames_exp)//2, -1]
fig, axes = [Link](1, 4, figsize=(18, 4))

vmin = min([Link]() for s in frames_exp)


vmax = max([Link]() for s in frames_exp)

for ax, idx in zip(axes, frame_picks):


im = [Link](X, Y, frames_exp[idx], levels=25, cmap='hot', vmin=vmin, vmax=
[Link](im, ax=ax, label='T (°C)')
ax.set_title(f't = {times_exp[idx]:.0f} s')
ax.set_xlabel('x (m)')
ax.set_ylabel('y (m)')

[Link]('Explicit (FTCS) — Temperature Evolution (Hot Spot IC)', fontsize=13,


plt.tight_layout()
[Link]()

[Link] 6/22
23/05/2026, 14:59 IAM_Programming_Project

5. Crank–Nicolson method
CN averages explicit and implicit fluxes. The result is second-order accuracy in time with no
stability constraint on $\Delta t$:

$$\frac{u^{n+1} - u^n}{\Delta t} = \frac{\alpha}{2}\left(\delta^2_x +


\delta^2_y\right)\left(u^{n+1} + u^n\right)$$

Solving the full 2D implicit system at each step is expensive. The ADI (Alternating Direction
Implicit) approach splits it into two sweeps:

Half-step: implicit in $x$, explicit in $y$


Full-step: explicit in $x$, implicit in $y$

Each sweep reduces to a 1D tridiagonal solve.

In [7]: def build_tridiagonal(N, r):


"""
Build (N-2)x(N-2) tridiagonal matrix for CN 1D sub-problem.
A = I + r * L where L is the 1D Laplacian.
"""
n = N - 2 # interior points only
diag = [Link](n, 1 + 2*r)
off_diag = [Link](n-1, -r)
A = [Link](diag) + [Link](off_diag, 1) + [Link](off_diag, -1)
return A

def solve_crank_nicolson_adi(u_start, alpha, dx, dy, dt, num_steps, save_every=100)


"""
Crank–Nicolson via Alternating Direction Implicit (ADI).
Each full time step = two half-steps with 1D tridiagonal solves.
"""
rx = alpha * dt / (2 * dx**2)
ry = alpha * dt / (2 * dy**2)
num_cols = u_start.shape[1]
num_rows = u_start.shape[0]

Ax = build_tridiagonal(num_cols, rx) # x-direction implicit matrix


Ay = build_tridiagonal(num_rows, ry) # y-direction implicit matrix

[Link] 7/22
23/05/2026, 14:59 IAM_Programming_Project

u = u_start.copy()
snapshots = [[Link]()]
times = [0.0]

for step in range(1, num_steps + 1):


u_mid = [Link]()

# ── Half-step: implicit in x, explicit in y ──────────────────────


for j in range(1, num_rows - 1): # row by row
rhs = (ry * u[j-1, 1:-1]
+ (1 - 2*ry) * u[j, 1:-1]
+ ry * u[j+1, 1:-1])
# Boundary contribution
rhs[0] += rx * u_mid[j, 0]
rhs[-1] += rx * u_mid[j, -1]
u_mid[j, 1:-1] = [Link](Ax, rhs)

apply_bc(u_mid)

u_next = u_mid.copy()

# ── Full-step: explicit in x, implicit in y ──────────────────────


for i in range(1, num_cols - 1): # column by column
rhs = (rx * u_mid[1:-1, i-1]
+ (1 - 2*rx) * u_mid[1:-1, i]
+ rx * u_mid[1:-1, i+1])
rhs[0] += ry * u_next[0, i]
rhs[-1] += ry * u_next[-1, i]
u_next[1:-1, i] = [Link](Ay, rhs)

apply_bc(u_next)
u = u_next

if step % save_every == 0:
[Link]([Link]())
[Link](step * dt)

return snapshots, times

# ─── Run CN solver (larger dt allowed!) ───────────────────────────────────


dt_cn = 10 * max_dt # 10× larger than explicit limit
num_steps_cn = int(run_time / dt_cn)
save_step_cn = max(1, num_steps_cn // 50)

print(f"Crank–Nicolson ADI: dt = {dt_cn:.1f} s | steps = {num_steps_cn:,}")


t0 = [Link]()
frames_cn, times_cn = solve_crank_nicolson_adi(
u_start, alpha, dx, dy, dt_cn, num_steps_cn, save_step_cn)
print(f"Wall time: {[Link]()-t0:.2f} s | Snapshots saved: {len(frames_cn)}")

Crank–Nicolson ADI: dt = 10.0 s | steps = 500


Wall time: 1.91 s | Snapshots saved: 51

In [8]: # ─── Plot CN snapshots ─────────────────────────────────────────────────────


frame_picks = [0, len(frames_cn)//4, len(frames_cn)//2, -1]

[Link] 8/22
23/05/2026, 14:59 IAM_Programming_Project

fig, axes = [Link](1, 4, figsize=(18, 4))

for ax, idx in zip(axes, frame_picks):


im = [Link](X, Y, frames_cn[idx], levels=25, cmap='hot', vmin=vmin, vmax=v
[Link](im, ax=ax, label='T (°C)')
ax.set_title(f't = {times_cn[idx]:.0f} s')
ax.set_xlabel('x (m)')
ax.set_ylabel('y (m)')

[Link]('Crank–Nicolson ADI — Temperature Evolution (Hot Spot IC)', fontsize=1


plt.tight_layout()
[Link]()

6. Method comparison
Final temperature fields, centre-point temperature history, and a note on how many steps
each method needed to reach the same physical time.

In [9]: # ─── Side-by-side final state comparison ──────────────────────────────────


fig, axes = [Link](1, 3, figsize=(16, 5))

final_exp = frames_exp[-1]
final_cn = frames_cn[-1]
diff = final_exp - final_cn

im0 = axes[0].contourf(X, Y, final_exp, levels=25, cmap='hot')


axes[0].set_title(f'Explicit FTCS\n(dt={dt_explicit:.1f}s, {num_steps:,} steps)')
[Link](im0, ax=axes[0], label='T (°C)')

im1 = axes[1].contourf(X, Y, final_cn, levels=25, cmap='hot')


axes[1].set_title(f'Crank–Nicolson ADI\n(dt={dt_cn:.1f}s, {num_steps_cn:,} steps)')
[Link](im1, ax=axes[1], label='T (°C)')

im2 = axes[2].contourf(X, Y, diff, levels=25, cmap='coolwarm')


axes[2].set_title(f'Difference (Exp − CN)\nMax |diff| = {[Link](diff).max():.3f} °C
[Link](im2, ax=axes[2], label='ΔT (°C)')

for ax in axes:
ax.set_xlabel('x (m)')
ax.set_ylabel('y (m)')

[Link](f'Final State Comparison at t = {run_time:.0f} s', fontsize=13, fontwe


plt.tight_layout()

[Link] 9/22
23/05/2026, 14:59 IAM_Programming_Project

[Link]()

print(f"Max absolute difference between methods: {[Link](diff).max():.4f} °C")


print(f"RMS difference: {[Link]([Link](diff**2)):.4f} °C")

Max absolute difference between methods: 0.0000 °C


RMS difference: 0.0000 °C

In [10]: # ─── Centre-point temperature vs time ─────────────────────────────────────


mid_col = Nx // 2
mid_row = Ny // 2

center_temps_exp = [s[mid_row, mid_col] for s in frames_exp]


center_temps_cn = [s[mid_row, mid_col] for s in frames_cn]

fig, (ax1, ax2) = [Link](1, 2, figsize=(14, 5))

[Link](times_exp, center_temps_exp, 'b-', lw=1.5, label='Explicit FTCS')


[Link](times_cn, center_temps_cn, 'r--', lw=2, label='Crank–Nicolson')
ax1.set_xlabel('Time (s)')
ax1.set_ylabel('Temperature (°C)')
ax1.set_title('Centre-Point Temperature vs Time')
[Link]()
[Link](True, ls='--', alpha=0.5)

# Temperature profiles along centreline at final time


[Link](x, final_exp[mid_row, :], 'b-', lw=2, label='Explicit FTCS')
[Link](x, final_cn [mid_row, :], 'r--', lw=2, label='Crank–Nicolson')
ax2.set_xlabel('x (m)')
ax2.set_ylabel('Temperature (°C)')
ax2.set_title(f'Centreline Profile at t = {run_time:.0f} s (y = {y[mid_row]:.2f} m
[Link]()
[Link](True, ls='--', alpha=0.5)

plt.tight_layout()
[Link]()

[Link] 10/22
23/05/2026, 14:59 IAM_Programming_Project

7. Verification against the analytical solution


For a sinusoidal initial condition $u_0 = A\sin(\pi x/L)\sin(\pi y/L)$ with zero Dirichlet BCs, the
exact solution is:

$$u(x, y, t) = A\sin\!\left(\frac{\pi x}{L}\right)\sin\!\left(\frac{\pi y}


{L}\right)\exp\!\left(-2\alpha\pi^2 t / L^2\right)$$

This gives actual error numbers rather than just a relative comparison between the two
schemes.

In [11]: def exact_solution(X, Y, t, alpha, Lx, Ly, A=100.0):


"""Exact solution for sinusoidal IC with zero Dirichlet BCs."""
decay = [Link](-alpha * [Link]**2 * (1/Lx**2 + 1/Ly**2) * t)
return A * [Link]([Link] * X / Lx) * [Link]([Link] * Y / Ly) * decay

u_start_sine = make_initial_condition(Nx, Ny, x, y, start_type='sine')

# Run solvers with sine IC


verify_time = 2000.0
steps_verify = int(verify_time / dt_explicit)
frames_verify_exp, times_verify_exp = solve_explicit(
u_start_sine, alpha, dx, dy, dt_explicit, steps_verify, save_every=max(1, steps

steps_verify_cn = int(verify_time / dt_cn)


frames_verify_cn, times_verify_cn = solve_crank_nicolson_adi(
u_start_sine, alpha, dx, dy, dt_cn, steps_verify_cn, save_every=max(1, steps_ve

# Compute L2 error over time


errors_exp, errors_cn = [], []
t_arr_exp = [Link](times_verify_exp)
t_arr_cn = [Link](times_verify_cn)

for snap, t in zip(frames_verify_exp, times_verify_exp):


exact = exact_solution(X, Y, t, alpha, Lx, Ly)
errors_exp.append([Link]([Link]((snap - exact)**2)))

[Link] 11/22
23/05/2026, 14:59 IAM_Programming_Project

for snap, t in zip(frames_verify_cn, times_verify_cn):


exact = exact_solution(X, Y, t, alpha, Lx, Ly)
errors_cn.append([Link]([Link]((snap - exact)**2)))

# Plot
fig, axes = [Link](1, 2, figsize=(14, 5))

axes[0].semilogy(times_verify_exp, errors_exp, 'b-', lw=2, label='Explicit FTCS')


axes[0].semilogy(times_verify_cn, errors_cn, 'r--', lw=2, label='Crank–Nicolson')
axes[0].set_xlabel('Time (s)')
axes[0].set_ylabel('L2 Error (°C)')
axes[0].set_title('L2 Error vs Analytical Solution')
axes[0].legend()
axes[0].grid(True, which='both', ls='--', alpha=0.5)

# Compare fields at t = verify_time


exact_final = exact_solution(X, Y, verify_time, alpha, Lx, Ly)
diff_exp_exact = frames_verify_exp[-1] - exact_final
im = axes[1].contourf(X, Y, diff_exp_exact, levels=20, cmap='coolwarm')
[Link](im, ax=axes[1], label='Error (°C)')
axes[1].set_title(f'Explicit Error Field at t={verify_time:.0f}s\nMax={[Link](diff_
axes[1].set_xlabel('x (m)')
axes[1].set_ylabel('y (m)')

plt.tight_layout()
[Link]()

print(f"Final L2 error — Explicit: {errors_exp[-1]:.4e} °C | CN: {errors_cn[-1]:.4e

Final L2 error — Explicit: 3.6882e+01 °C | CN: 3.6891e+01 °C

8. Instability demonstration
What happens when you exceed the stability limit? The explicit scheme diverges fast. Both a
stable and an unstable case are shown below so the difference is clear.

In [12]: def solve_explicit_early_stop(u_start, alpha, dx, dy, dt, num_steps, max_val=1e6):


"""Run explicit solver; stop if solution blows up."""
rx = alpha * dt / dx**2

[Link] 12/22
23/05/2026, 14:59 IAM_Programming_Project

ry = alpha * dt / dy**2
u = u_start.copy()
max_temps = [[Link]()]
times = [0.0]

for step in range(1, num_steps + 1):


u_next = [Link]()
u_next[1:-1, 1:-1] = (
u[1:-1, 1:-1]
+ rx * (u[1:-1, 2:] - 2*u[1:-1, 1:-1] + u[1:-1, :-2])
+ ry * (u[2:, 1:-1] - 2*u[1:-1, 1:-1] + u[:-2, 1:-1])
)
apply_bc(u_next)
u = u_next
max_temps.append([Link]([Link](u)))
[Link](step * dt)
if [Link]([Link](u)) > max_val or [Link]([Link](u)):
print(f" Blow-up detected at step {step} (t={step*dt:.2f}s)")
break

return u, times, max_temps

print("Running unstable explicit scheme...")


stability_check(alpha, dt_bad, dx, dy)
_, times_unstable, max_temp_unstable = solve_explicit_early_stop(
u_start, alpha, dx, dy, dt_bad, num_steps=300)

print("\nRunning stable explicit scheme for comparison...")


_, times_stable, max_temp_stable = solve_explicit_early_stop(
u_start, alpha, dx, dy, dt_safe, num_steps=300)

fig, ax = [Link](figsize=(10, 5))


[Link](times_unstable, max_temp_unstable, 'r-', lw=2, label=f'UNSTABLE dt={d
[Link](times_stable, max_temp_stable, 'b-', lw=2, label=f'Stable dt={dt_sa
[Link](200, ls='--', color='gray', label='Initial max T')
ax.set_xlabel('Time (s)')
ax.set_ylabel('Max |T| (°C)')
ax.set_title('Stability Demonstration: Explicit FTCS')
[Link]()
[Link](True, which='both', ls='--', alpha=0.5)
plt.tight_layout()
[Link]()

Running unstable explicit scheme...


rx = α·Δt/Δx² = 0.3750
ry = α·Δt/Δy² = 0.3750
r = rx + ry = 0.7500 (must be ≤ 0.5 for stability)
Stable: ✗ NO — UNSTABLE!
Blow-up detected at step 28 (t=42.00s)

Running stable explicit scheme for comparison...

[Link] 13/22
23/05/2026, 14:59 IAM_Programming_Project

9. Parameter studies
How does thermal diffusivity affect how fast heat spreads, and does the spatial error
converge at the expected $O(\Delta x^2)$ rate? The first two studies answer both.

A third test pushes the Crank–Nicolson time step well past the explicit limit to see where
accuracy starts to degrade.

In [13]: # ─── Study 1: Effect of thermal diffusivity α ─────────────────────────────


# Materials: copper, steel, glass, air (illustrative values)

materials = {
'Copper': 1.17e-4,
'Stainless Steel': 4.0e-6,
'Glass': 3.4e-7,
'Concrete': 7.0e-7,
}

study_time = 8000.0
fig, axes = [Link](2, 4, figsize=(20, 9))

for col, (mat, a) in enumerate([Link]()):


dt_mat = 0.9 * 0.5 / (a * (1/dx**2 + 1/dy**2))
steps_mat = int(study_time / dt_mat)
save_step_mat = max(1, steps_mat // 5)
u_start_mat = make_initial_condition(Nx, Ny, x, y, start_type='hotspot')
frames_mat, times_mat = solve_explicit(u_start_mat, a, dx, dy, dt_mat, steps_ma

# Top row: initial state


im0 = axes[0, col].contourf(X, Y, frames_mat[0], levels=20, cmap='hot')
[Link](im0, ax=axes[0, col])
axes[0, col].set_title(f'{mat}\nα={a:.1e} m²/s\nt=0 s')
axes[0, col].set_xlabel('x'); axes[0, col].set_ylabel('y')

[Link] 14/22
23/05/2026, 14:59 IAM_Programming_Project

# Bottom row: final state


im1 = axes[1, col].contourf(X, Y, frames_mat[-1], levels=20, cmap='hot')
[Link](im1, ax=axes[1, col])
axes[1, col].set_title(f't = {study_time:.0f} s')
axes[1, col].set_xlabel('x'); axes[1, col].set_ylabel('y')

[Link]('Effect of Thermal Diffusivity α on Heat Diffusion\n(Hot Spot IC, same


fontsize=13, fontweight='bold')
plt.tight_layout()
[Link]()

In [14]: # ─── Study 2: Grid Convergence (vary Nx = Ny) ─────────────────────────────


# Compare centreline profiles for different grid resolutions

grid_sizes = [11, 21, 41, 81]


conv_time = 1000.0

fig, (ax1, ax2) = [Link](1, 2, figsize=(14, 5))


colors = [Link]([Link](0, 0.9, len(grid_sizes)))

grid_errors = []
spacings = []

for grid_n, col in zip(grid_sizes, colors):


step_x = Lx / (grid_n - 1)
step_y = Ly / (grid_n - 1)
xs = [Link](0, Lx, grid_n)
ys = [Link](0, Ly, grid_n)
XX, YY = [Link](xs, ys)
dt_grid = 0.9 * 0.5 / (alpha * (1/step_x**2 + 1/step_y**2))
steps_grid = int(conv_time / dt_grid)

u_start_grid = 100 * [Link]([Link] * XX / Lx) * [Link]([Link] * YY / Ly)


u_start_grid[0,:] = u_start_grid[-1,:] = u_start_grid[:,0] = u_start_grid[:,-1]

frames_grid, _ = solve_explicit(u_start_grid, alpha, step_x, step_y, dt_grid, s


u_numerical = frames_grid[-1]
u_exact = exact_solution(XX, YY, conv_time, alpha, Lx, Ly)

[Link] 15/22
23/05/2026, 14:59 IAM_Programming_Project

error = [Link]([Link]((u_numerical - u_exact)**2))


grid_errors.append(error)
[Link](step_x)

cx = grid_n // 2
[Link](xs, u_numerical[cx, :], color=col, lw=1.5, label=f'N={grid_n}, Δx={ste

[Link](xs, exact_solution(XX, YY, conv_time, alpha, Lx, Ly)[grid_n//2, :],


'k--', lw=2, label='Analytical')
ax1.set_xlabel('x (m)')
ax1.set_ylabel('T (°C)')
ax1.set_title(f'Centreline Profiles at t={conv_time:.0f}s')
[Link](fontsize=9)
[Link](True, ls='--', alpha=0.5)

# Log-log convergence plot


[Link](spacings, grid_errors, 'bo-', ms=8, lw=2, label='Explicit FTCS')
# Reference slope O(dx^2)
ref_x = [Link](spacings)
[Link](ref_x, grid_errors[0] * (ref_x/ref_x[0])**2,
'k--', label='O(Δx²) reference')
ax2.set_xlabel('Δx (m)')
ax2.set_ylabel('L2 Error (°C)')
ax2.set_title('Grid Convergence Study')
[Link]()
[Link](True, which='both', ls='--', alpha=0.5)

plt.tight_layout()
[Link]()

print("Grid convergence:")
for grid_n, step_x, err in zip(grid_sizes, spacings, grid_errors):
print(f" N={grid_n:3d}, Δx={step_x:.4f}, L2 error={err:.4e}")

Grid convergence:
N= 11, Δx=0.1000, L2 error=3.7501e+01
N= 21, Δx=0.0500, L2 error=3.6511e+01
N= 41, Δx=0.0250, L2 error=3.5799e+01
N= 81, Δx=0.0125, L2 error=3.5392e+01

In [15]: # ─── Study 3: Time Step Sensitivity (CN, unconditionally stable) ──────────
dt_multipliers = [1, 5, 20, 100]
test_time = 3000.0

[Link] 16/22
23/05/2026, 14:59 IAM_Programming_Project

fig, axes = [Link](1, len(dt_multipliers), figsize=(18, 4))

for ax, factor in zip(axes, dt_multipliers):


dt_test = factor * max_dt
steps_test = int(test_time / dt_test)
frames_test, _ = solve_crank_nicolson_adi(
u_start, alpha, dx, dy, dt_test, steps_test, save_every=steps_test)
im = [Link](X, Y, frames_test[-1], levels=20, cmap='hot')
[Link](im, ax=ax, label='T (°C)')
ax.set_title(f'CN: dt = {factor}×max_dt\n({dt_test:.1f}s, {steps_test} steps)')
ax.set_xlabel('x'); ax.set_ylabel('y')

[Link](f'Crank–Nicolson: Effect of Time Step Size (t = {test_time:.0f}s)',


fontsize=13, fontweight='bold')
plt.tight_layout()
[Link]()

10. Advanced visualizations


10a. 3D surface and heat flux
In [16]: from mpl_toolkits.mplot3d import Axes3D

# ─── 3D surface at final time ─────────────────────────────────────────────


fig = [Link](figsize=(16, 6))

ax1 = fig.add_subplot(121, projection='3d')


surf = ax1.plot_surface(X, Y, frames_exp[-1], cmap='hot', edgecolor='none', alpha=0
[Link](surf, ax=ax1, shrink=0.5, label='T (°C)')
ax1.set_xlabel('x (m)')
ax1.set_ylabel('y (m)')
ax1.set_zlabel('T (°C)')
ax1.set_title(f'3D Temperature Surface\nt = {times_exp[-1]:.0f} s (Explicit)')
ax1.view_init(elev=30, azim=-60)

# ─── Heat flux / gradient vectors (streamlines) ───────────────────────────


ax2 = fig.add_subplot(122)
u_f = frames_exp[-1]
# Gradient (heat flux direction: negative gradient)
dTdy, dTdx = [Link](u_f, dy, dx)
speed = [Link](dTdx**2 + dTdy**2)
speed[speed == 0] = 1e-10

[Link] 17/22
23/05/2026, 14:59 IAM_Programming_Project

im = [Link](X, Y, u_f, levels=25, cmap='hot', alpha=0.8)


[Link](im, ax=ax2, label='T (°C)')
# Subsample for arrow plot
step_q = 4
[Link](X[::step_q, ::step_q], Y[::step_q, ::step_q],
-dTdx[::step_q, ::step_q]/speed[::step_q, ::step_q],
-dTdy[::step_q, ::step_q]/speed[::step_q, ::step_q],
color='cyan', alpha=0.6, scale=30, width=0.003)
ax2.set_xlabel('x (m)')
ax2.set_ylabel('y (m)')
ax2.set_title('Heat Flux Directions (arrows = −∇T)')

plt.tight_layout()
[Link]()

In [17]: # ─── 10b. Temperature History at Multiple Probe Points ─────────────────────


probes = {
'Centre (0.5, 0.5)': (Nx//2, Ny//2),
'Quarter (0.25,0.25)': (Nx//4, Ny//4),
'Near left (0.1, 0.5)': (int(0.1*(Nx-1)), Ny//2),
'Near top (0.5, 0.9)': (Nx//2, int(0.9*(Ny-1))),
}

fig, ax = [Link](figsize=(12, 5))


probe_colors = [Link].tab10([Link](0, 0.6, len(probes)))

for (label, (xi, yi)), col in zip([Link](), probe_colors):


probe_temps_exp = [s[yi, xi] for s in frames_exp]
probe_temps_cn = [s[yi, xi] for s in frames_cn]
[Link](times_exp, probe_temps_exp, '-', color=col, lw=1.5, label=f'{label} [E
[Link](times_cn, probe_temps_cn, '--', color=col, lw=1.5, alpha=0.7, label=f

ax.set_xlabel('Time (s)')
ax.set_ylabel('Temperature (°C)')
ax.set_title('Temperature History at Probe Points (solid=Explicit, dashed=CN)')
[Link](fontsize=8, ncol=2)
[Link](True, ls='--', alpha=0.5)
plt.tight_layout()
[Link]()

[Link] 18/22
23/05/2026, 14:59 IAM_Programming_Project

In [18]: # ─── 10c. Animation of heat diffusion ─────────────────────────────────────


# (renders inline if using %matplotlib notebook; saves to file otherwise)

fig_movie, ax_movie = [Link](figsize=(6, 5))


color_min, color_max = 0, 200

cf = ax_movie.contourf(X, Y, frames_exp[0], levels=25,


cmap='hot', vmin=color_min, vmax=color_max)
cb = [Link](cf, ax=ax_movie, label='T (°C)')
ax_movie.set_xlabel('x (m)')
ax_movie.set_ylabel('y (m)')
title = ax_movie.set_title(f't = 0 s')

def animate(i):
ax_movie.clear()
cf_ = ax_movie.contourf(X, Y, frames_exp[i], levels=25,
cmap='hot', vmin=color_min, vmax=color_max)
ax_movie.set_xlabel('x (m)')
ax_movie.set_ylabel('y (m)')
ax_movie.set_title(f'Explicit FTCS — t = {times_exp[i]:.0f} s')
return cf_.collections

anim = [Link](
fig_movie, animate,
frames=range(0, len(frames_exp), max(1, len(frames_exp)//40)),
interval=120, blit=False)

# Save as GIF
try:
[Link]('/home/claude/heat_diffusion.gif', writer='pillow', fps=8, dpi=80)
print("Animation saved: heat_diffusion.gif")
except Exception as e:
print(f"Could not save animation: {e}")

[Link](fig_movie)

# Static multi-frame summary instead


num_frames = 8
frame_indices = [Link](0, len(frames_exp)-1, num_frames, dtype=int)
fig, axes = [Link](2, 4, figsize=(18, 8))

[Link] 19/22
23/05/2026, 14:59 IAM_Programming_Project

for ax, fi in zip([Link], frame_indices):


im = [Link](X, Y, frames_exp[fi], levels=20,
cmap='hot', vmin=color_min, vmax=color_max)
[Link](im, ax=ax)
ax.set_title(f't = {times_exp[fi]:.0f} s')
ax.set_xlabel('x'); ax.set_ylabel('y')

[Link]('Heat Diffusion Evolution (Explicit FTCS, Hot Spot IC)', fontsize=13,


plt.tight_layout()
[Link]()

Could not save animation: [WinError 3] The system cannot find the path specified:
'C:\\home\\claude'

11. Summary
Property Explicit FTCS Crank–Nicolson (ADI)

Temporal
$O(\Delta t)$ $O(\Delta t^2)$
accuracy

$O(\Delta x^2, \Delta


Spatial accuracy $O(\Delta x^2, \Delta y^2)$
y^2)$

Stability Conditional: $r \leq 0.5$ Unconditional

$\Delta t_{\max} = 0.5/(\alpha(1/\Delta


Time step limit None
x^2+1/\Delta y^2))$

Cost per step O(N) vectorised O(N) tridiagonal solves

Implementation Simple Moderate

Long simulations, large


Best for Short runs, quick tests
$\Delta t$

In [19]: # ─── Final summary dashboard ───────────────────────────────────────────────


fig = [Link](figsize=(16, 10))

[Link] 20/22
23/05/2026, 14:59 IAM_Programming_Project

gs = GridSpec(2, 3, figure=fig, hspace=0.4, wspace=0.4)

# 1. Final explicit field


ax1 = fig.add_subplot(gs[0, 0])
im1 = [Link](X, Y, frames_exp[-1], levels=20, cmap='hot')
[Link](im1, ax=ax1)
ax1.set_title('Explicit — Final State')
ax1.set_xlabel('x'); ax1.set_ylabel('y')

# 2. Final CN field
ax2 = fig.add_subplot(gs[0, 1])
im2 = [Link](X, Y, frames_cn[-1], levels=20, cmap='hot')
[Link](im2, ax=ax2)
ax2.set_title('Crank–Nicolson — Final State')
ax2.set_xlabel('x'); ax2.set_ylabel('y')

# 3. Stability boundary
ax3 = fig.add_subplot(gs[0, 2])
alphas_ = [Link](-6, -2, 200)
dt_max_ = 0.5 / (alphas_ * (1/dx**2 + 1/dy**2))
[Link](alphas_, dt_max_, 'b-', lw=2)
ax3.fill_between(alphas_, dt_max_, 1e8, alpha=0.15, color='green', label='Stable')
ax3.fill_between(alphas_, 0, dt_max_, alpha=0.15, color='red', label='Unstable')
[Link]([alpha], [dt_safe], color='green', s=80, zorder=5)
ax3.set_xlabel('α (m²/s)'); ax3.set_ylabel('Δt (s)')
ax3.set_title('Stability Boundary'); [Link](); [Link](True, which='both', ls=

# 4. Centre-point T(t)
ax4 = fig.add_subplot(gs[1, 0])
[Link](times_exp, center_temps_exp, 'b-', lw=1.5, label='Explicit')
[Link](times_cn, center_temps_cn, 'r--', lw=2, label='CN')
ax4.set_xlabel('t (s)'); ax4.set_ylabel('T (°C)')
ax4.set_title('Centre Temperature vs Time')
[Link](); [Link](True, ls='--', alpha=0.4)

# 5. Grid convergence
ax5 = fig.add_subplot(gs[1, 1])
[Link](spacings, grid_errors, 'bo-', ms=8, lw=2)
[Link](ref_x, grid_errors[0]*(ref_x/ref_x[0])**2, 'k--', label='O(Δx²)')
ax5.set_xlabel('Δx (m)'); ax5.set_ylabel('L2 Error')
ax5.set_title('Grid Convergence'); [Link](); [Link](True, which='both', ls='-

# 6. Centreline profile comparison


ax6 = fig.add_subplot(gs[1, 2])
[Link](x, frames_exp[-1][Ny//2, :], 'b-', lw=2, label='Explicit')
[Link](x, frames_cn[-1] [Ny//2, :], 'r--', lw=2, label='CN')
ax6.set_xlabel('x (m)'); ax6.set_ylabel('T (°C)')
ax6.set_title('Centreline Profile (Final)')
[Link](); [Link](True, ls='--', alpha=0.4)

[Link]('2D Heat Equation — Summary Dashboard', fontsize=15, fontweight='bold'


[Link]()
print("\n✅ All sections complete.")

[Link] 21/22
23/05/2026, 14:59 IAM_Programming_Project

✅ All sections complete.

[Link] 22/22

You might also like