09. ์—ด๋ฐฉ์ •์‹ (Heat Equation)

09. ์—ด๋ฐฉ์ •์‹ (Heat Equation)

ํ•™์Šต ๋ชฉํ‘œ

  • 1D/2D ์—ด๋ฐฉ์ •์‹์˜ ๋ฌผ๋ฆฌ์  ์˜๋ฏธ ์ดํ•ด
  • FTCS (Forward Time Central Space) ์–‘ํ•ด๋ฒ• ๊ตฌํ˜„
  • BTCS (Backward Time Central Space) ์Œํ•ด๋ฒ• ๊ตฌํ˜„
  • Crank-Nicolson ๋ฐฉ๋ฒ• ์ดํ•ด ๋ฐ ๊ตฌํ˜„
  • ๋‹ค์–‘ํ•œ ๊ฒฝ๊ณ„์กฐ๊ฑด ์ฒ˜๋ฆฌ ๋ฐฉ๋ฒ• ํ•™์Šต

1. ์—ด๋ฐฉ์ •์‹ ์ด๋ก 

1.1 ๋ฌผ๋ฆฌ์  ๋ฐฐ๊ฒฝ

์—ด๋ฐฉ์ •์‹์€ ์—ด์ „๋„ ํ˜„์ƒ์„ ๊ธฐ์ˆ ํ•˜๋Š” ํฌ๋ฌผ์„ ํ˜• PDE์ž…๋‹ˆ๋‹ค.

1D ์—ด๋ฐฉ์ •์‹:
โˆ‚u/โˆ‚t = ฮฑ ยท โˆ‚ยฒu/โˆ‚xยฒ

์—ฌ๊ธฐ์„œ:
- u(x,t): ์˜จ๋„
- ฮฑ: ์—ดํ™•์‚ฐ๊ณ„์ˆ˜ (thermal diffusivity)
- x: ๊ณต๊ฐ„ ์ขŒํ‘œ
- t: ์‹œ๊ฐ„

1.2 ์—ดํ™•์‚ฐ๊ณ„์ˆ˜

"""
์—ดํ™•์‚ฐ๊ณ„์ˆ˜ ฮฑ = k / (ฯยทc)

์—ฌ๊ธฐ์„œ:
- k: ์—ด์ „๋„๋„ (W/mยทK)
- ฯ: ๋ฐ€๋„ (kg/mยณ)
- c: ๋น„์—ด (J/kgยทK)
"""

# ๋ฌผ์งˆ๋ณ„ ์—ดํ™•์‚ฐ๊ณ„์ˆ˜ (mยฒ/s)
thermal_diffusivity = {
    '๊ตฌ๋ฆฌ': 1.11e-4,
    '์•Œ๋ฃจ๋ฏธ๋Š„': 9.7e-5,
    '์ฒ ': 2.3e-5,
    '์ฝ˜ํฌ๋ฆฌํŠธ': 7.5e-7,
    '๋ฌผ': 1.43e-7,
    '๊ณต๊ธฐ': 2.2e-5,
}

for material, alpha in thermal_diffusivity.items():
    print(f"{material}: ฮฑ = {alpha:.2e} mยฒ/s")

1.3 ํ•ด์„ํ•ด (๋ถ„๋ฆฌ๋ณ€์ˆ˜๋ฒ•)

๊ฒฝ๊ณ„์กฐ๊ฑด u(0,t) = u(L,t) = 0, ์ดˆ๊ธฐ์กฐ๊ฑด u(x,0) = f(x)์ธ ๊ฒฝ์šฐ:

u(x,t) = ฮฃ Bโ‚™ ยท sin(nฯ€x/L) ยท exp(-ฮฑ(nฯ€/L)ยฒt)

Bโ‚™ = (2/L) โˆซโ‚€^L f(x)ยทsin(nฯ€x/L) dx
import numpy as np
import matplotlib.pyplot as plt

def exact_solution_heat(x, t, alpha, L, n_terms=50):
    """
    ์—ด๋ฐฉ์ •์‹ ํ•ด์„ํ•ด (ํ‘ธ๋ฆฌ์— ๊ธ‰์ˆ˜)

    ์ดˆ๊ธฐ์กฐ๊ฑด: u(x,0) = sin(ฯ€x/L) (์ฒซ ๋ฒˆ์งธ ๋ชจ๋“œ๋งŒ)
    ๊ฒฝ๊ณ„์กฐ๊ฑด: u(0,t) = u(L,t) = 0
    """
    # ๋‹จ์ˆœ ์ดˆ๊ธฐ์กฐ๊ฑด์˜ ๊ฒฝ์šฐ
    return np.sin(np.pi * x / L) * np.exp(-alpha * (np.pi / L)**2 * t)

# ์‹œ๊ฐํ™”
L = 1.0
alpha = 0.01
x = np.linspace(0, L, 101)

fig, ax = plt.subplots(figsize=(10, 6))

times = [0, 0.5, 1.0, 2.0, 5.0]
for t in times:
    u = exact_solution_heat(x, t, alpha, L)
    ax.plot(x, u, label=f't = {t}')

ax.set_xlabel('x')
ax.set_ylabel('u(x,t)')
ax.set_title('1D ์—ด๋ฐฉ์ •์‹ ํ•ด์„ํ•ด (์‹œ๊ฐ„์— ๋”ฐ๋ฅธ ๋ณ€ํ™”)')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
# plt.savefig('heat_exact.png', dpi=150)
# plt.show()

2. FTCS ์–‘ํ•ด๋ฒ• (Explicit Scheme)

2.1 ์ด์‚ฐํ™”

FTCS = Forward Time, Central Space

์‹œ๊ฐ„: ์ „๋ฐฉ์ฐจ๋ถ„
โˆ‚u/โˆ‚t โ‰ˆ (u_i^{n+1} - u_i^n) / ฮ”t

๊ณต๊ฐ„: ์ค‘์‹ฌ์ฐจ๋ถ„
โˆ‚ยฒu/โˆ‚xยฒ โ‰ˆ (u_{i+1}^n - 2u_i^n + u_{i-1}^n) / ฮ”xยฒ

๊ฒฐํ•ฉ:
u_i^{n+1} = u_i^n + rยท(u_{i+1}^n - 2u_i^n + u_{i-1}^n)

์—ฌ๊ธฐ์„œ r = ฮฑยทฮ”t/ฮ”xยฒ (์•ˆ์ • ์กฐ๊ฑด: r โ‰ค 0.5)

2.2 FTCS ์Šคํ…์‹ค ์‹œ๊ฐํ™”

์‹œ๊ฐ„ n+1:         [i]
                   โ†‘
์‹œ๊ฐ„ n:    [i-1]--[i]--[i+1]

2.3 FTCS ๊ตฌํ˜„

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

class HeatEquation1D_FTCS:
    """
    1D ์—ด๋ฐฉ์ •์‹ FTCS ์–‘ํ•ด๋ฒ•

    โˆ‚u/โˆ‚t = ฮฑ ยท โˆ‚ยฒu/โˆ‚xยฒ
    """

    def __init__(self, L=1.0, alpha=0.01, nx=51, T=1.0, safety=0.4):
        """
        Parameters:
        -----------
        L : float - ์˜์—ญ ๊ธธ์ด
        alpha : float - ์—ดํ™•์‚ฐ๊ณ„์ˆ˜
        nx : int - ๊ณต๊ฐ„ ๊ฒฉ์ž์  ์ˆ˜
        T : float - ์ตœ์ข… ์‹œ๊ฐ„
        safety : float - CFL ์•ˆ์ „๊ณ„์ˆ˜ (0 < safety โ‰ค 0.5)
        """
        self.L = L
        self.alpha = alpha
        self.nx = nx
        self.T = T

        # ๊ฒฉ์ž ์ƒ์„ฑ
        self.dx = L / (nx - 1)
        self.x = np.linspace(0, L, nx)

        # ์•ˆ์ • ์กฐ๊ฑด์— ๋”ฐ๋ฅธ ์‹œ๊ฐ„ ๊ฐ„๊ฒฉ ๊ฒฐ์ •
        self.dt = safety * self.dx**2 / alpha
        self.nt = int(np.ceil(T / self.dt))
        self.dt = T / self.nt  # ์ •ํ™•ํžˆ T์— ๋„๋‹ฌํ•˜๋„๋ก ์กฐ์ •

        self.r = alpha * self.dt / self.dx**2

        print(f"FTCS ์—ด๋ฐฉ์ •์‹ ์„ค์ •")
        print(f"  dx = {self.dx:.4f}, dt = {self.dt:.6f}")
        print(f"  r = ฮฑยทdt/dxยฒ = {self.r:.4f}")
        print(f"  ์‹œ๊ฐ„ ์Šคํ… ์ˆ˜: {self.nt}")
        print(f"  ์•ˆ์ •์„ฑ: {'OK' if self.r <= 0.5 else 'WARNING!'}")

    def set_initial_condition(self, func):
        """์ดˆ๊ธฐ์กฐ๊ฑด ์„ค์ •"""
        self.u = func(self.x)
        self.u0 = self.u.copy()

    def set_boundary_conditions(self, left_type='dirichlet', left_value=0,
                                  right_type='dirichlet', right_value=0):
        """๊ฒฝ๊ณ„์กฐ๊ฑด ์„ค์ •"""
        self.bc = {
            'left': {'type': left_type, 'value': left_value},
            'right': {'type': right_type, 'value': right_value}
        }

    def apply_bc(self, u):
        """๊ฒฝ๊ณ„์กฐ๊ฑด ์ ์šฉ"""
        # ์™ผ์ชฝ ๊ฒฝ๊ณ„
        if self.bc['left']['type'] == 'dirichlet':
            u[0] = self.bc['left']['value']
        elif self.bc['left']['type'] == 'neumann':
            # โˆ‚u/โˆ‚x = flux => u[0] = u[1] - flux * dx
            u[0] = u[1] - self.bc['left']['value'] * self.dx

        # ์˜ค๋ฅธ์ชฝ ๊ฒฝ๊ณ„
        if self.bc['right']['type'] == 'dirichlet':
            u[-1] = self.bc['right']['value']
        elif self.bc['right']['type'] == 'neumann':
            # โˆ‚u/โˆ‚x = flux => u[-1] = u[-2] + flux * dx
            u[-1] = u[-2] + self.bc['right']['value'] * self.dx

        return u

    def step(self):
        """ํ•œ ์‹œ๊ฐ„ ์Šคํ… ์ง„ํ–‰ (FTCS)"""
        u_new = self.u.copy()

        # ๋‚ด๋ถ€์  ์—…๋ฐ์ดํŠธ
        u_new[1:-1] = self.u[1:-1] + self.r * (
            self.u[2:] - 2*self.u[1:-1] + self.u[:-2]
        )

        # ๊ฒฝ๊ณ„์กฐ๊ฑด ์ ์šฉ
        u_new = self.apply_bc(u_new)

        self.u = u_new

    def solve(self, save_interval=None):
        """์ „์ฒด ์‹œ๊ฐ„ ๊ตฌ๊ฐ„ ํ’€์ด"""
        if save_interval is None:
            save_interval = max(1, self.nt // 100)

        history = [self.u.copy()]
        times = [0]

        for n in range(self.nt):
            self.step()
            if (n + 1) % save_interval == 0:
                history.append(self.u.copy())
                times.append((n + 1) * self.dt)

        return np.array(times), np.array(history)


def demo_ftcs():
    """FTCS ๋ฐ๋ชจ"""
    # ๋ฌธ์ œ ์„ค์ •
    solver = HeatEquation1D_FTCS(L=1.0, alpha=0.01, nx=51, T=2.0, safety=0.4)

    # ์ดˆ๊ธฐ์กฐ๊ฑด: ์‚ฌ์ธํŒŒ
    solver.set_initial_condition(lambda x: np.sin(np.pi * x))

    # ๊ฒฝ๊ณ„์กฐ๊ฑด: ์–‘๋ ๊ณ ์ •
    solver.set_boundary_conditions('dirichlet', 0, 'dirichlet', 0)

    # ํ’€์ด
    times, history = solver.solve(save_interval=20)

    # ํ•ด์„ํ•ด์™€ ๋น„๊ต
    u_exact = exact_solution_heat(solver.x, times[-1], solver.alpha, solver.L)

    # ์‹œ๊ฐํ™”
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    # ์‹œ๊ฐ„์— ๋”ฐ๋ฅธ ํ•ด
    ax1 = axes[0]
    colors = plt.cm.viridis(np.linspace(0, 1, len(times)))
    for i, (t, u) in enumerate(zip(times[::10], history[::10])):
        ax1.plot(solver.x, u, color=colors[i*10] if i*10 < len(colors) else colors[-1],
                label=f't={t:.2f}')
    ax1.set_xlabel('x')
    ax1.set_ylabel('u')
    ax1.set_title('FTCS ์—ด๋ฐฉ์ •์‹ ํ•ด')
    ax1.legend(loc='upper right')
    ax1.grid(True, alpha=0.3)

    # ์ตœ์ข… ์‹œ๊ฐ„์—์„œ ๋น„๊ต
    ax2 = axes[1]
    ax2.plot(solver.x, history[-1], 'b-', label='FTCS ์ˆ˜์น˜ํ•ด', linewidth=2)
    ax2.plot(solver.x, u_exact, 'r--', label='ํ•ด์„ํ•ด', linewidth=2)
    ax2.set_xlabel('x')
    ax2.set_ylabel('u')
    ax2.set_title(f't = {times[-1]:.2f}์—์„œ ํ•ด์„ํ•ด ๋น„๊ต')
    ax2.legend()
    ax2.grid(True, alpha=0.3)

    error = np.max(np.abs(history[-1] - u_exact))
    print(f"\n์ตœ์ข… ์‹œ๊ฐ„์—์„œ ์ตœ๋Œ€ ์˜ค์ฐจ: {error:.2e}")

    plt.tight_layout()
    plt.savefig('heat_ftcs.png', dpi=150, bbox_inches='tight')
    plt.show()

    return solver, times, history

# solver, times, history = demo_ftcs()

3. BTCS ์Œํ•ด๋ฒ• (Implicit Scheme)

3.1 ์ด์‚ฐํ™”

BTCS = Backward Time, Central Space

์‹œ๊ฐ„: ํ›„๋ฐฉ์ฐจ๋ถ„ (n+1 ์‹œ์ ์—์„œ ํ‰๊ฐ€)
โˆ‚u/โˆ‚t โ‰ˆ (u_i^{n+1} - u_i^n) / ฮ”t

๊ณต๊ฐ„: ์ค‘์‹ฌ์ฐจ๋ถ„ (n+1 ์‹œ์ ์—์„œ)
โˆ‚ยฒu/โˆ‚xยฒ โ‰ˆ (u_{i+1}^{n+1} - 2u_i^{n+1} + u_{i-1}^{n+1}) / ฮ”xยฒ

์ •๋ฆฌ:
-rยทu_{i-1}^{n+1} + (1+2r)ยทu_i^{n+1} - rยทu_{i+1}^{n+1} = u_i^n

3.2 ํ–‰๋ ฌ ํ˜•ํƒœ

A ยท u^{n+1} = u^n

์—ฌ๊ธฐ์„œ A๋Š” ์‚ผ์ค‘๋Œ€๊ฐ ํ–‰๋ ฌ:
    | 1+2r  -r    0   ...  |
    | -r   1+2r  -r   ...  |
A = |  0    -r  1+2r  ...  |
    | ...               -r |
    |             -r  1+2r |

3.3 BTCS ๊ตฌํ˜„

from scipy.sparse import diags
from scipy.sparse.linalg import spsolve

class HeatEquation1D_BTCS:
    """
    1D ์—ด๋ฐฉ์ •์‹ BTCS ์Œํ•ด๋ฒ•

    ๋ฌด์กฐ๊ฑด ์•ˆ์ • (unconditionally stable)
    """

    def __init__(self, L=1.0, alpha=0.01, nx=51, T=1.0, nt=100):
        """
        Parameters:
        -----------
        L : float - ์˜์—ญ ๊ธธ์ด
        alpha : float - ์—ดํ™•์‚ฐ๊ณ„์ˆ˜
        nx : int - ๊ณต๊ฐ„ ๊ฒฉ์ž์  ์ˆ˜
        T : float - ์ตœ์ข… ์‹œ๊ฐ„
        nt : int - ์‹œ๊ฐ„ ์Šคํ… ์ˆ˜
        """
        self.L = L
        self.alpha = alpha
        self.nx = nx
        self.T = T
        self.nt = nt

        # ๊ฒฉ์ž ์ƒ์„ฑ
        self.dx = L / (nx - 1)
        self.dt = T / nt
        self.x = np.linspace(0, L, nx)

        self.r = alpha * self.dt / self.dx**2

        print(f"BTCS ์—ด๋ฐฉ์ •์‹ ์„ค์ •")
        print(f"  dx = {self.dx:.4f}, dt = {self.dt:.6f}")
        print(f"  r = ฮฑยทdt/dxยฒ = {self.r:.4f}")
        print(f"  BTCS๋Š” ๋ฌด์กฐ๊ฑด ์•ˆ์ • (r์— ์ œํ•œ ์—†์Œ)")

        # ํ–‰๋ ฌ A ์ƒ์„ฑ (๋‚ด๋ถ€์ ๋งŒ)
        self._build_matrix()

    def _build_matrix(self):
        """BTCS ํ–‰๋ ฌ ์ƒ์„ฑ"""
        n = self.nx - 2  # ๋‚ด๋ถ€์  ์ˆ˜

        main_diag = (1 + 2*self.r) * np.ones(n)
        off_diag = -self.r * np.ones(n - 1)

        self.A = diags([off_diag, main_diag, off_diag], [-1, 0, 1], format='csr')

    def set_initial_condition(self, func):
        """์ดˆ๊ธฐ์กฐ๊ฑด ์„ค์ •"""
        self.u = func(self.x)
        self.u0 = self.u.copy()

    def set_boundary_conditions(self, left_value=0, right_value=0):
        """๋””๋ฆฌํด๋ ˆ ๊ฒฝ๊ณ„์กฐ๊ฑด ์„ค์ •"""
        self.u_left = left_value
        self.u_right = right_value

    def step(self):
        """ํ•œ ์‹œ๊ฐ„ ์Šคํ… ์ง„ํ–‰ (BTCS)"""
        # ์šฐ๋ณ€ ๋ฒกํ„ฐ (๋‚ด๋ถ€์ )
        b = self.u[1:-1].copy()

        # ๊ฒฝ๊ณ„์กฐ๊ฑด ๊ธฐ์—ฌ
        b[0] += self.r * self.u_left
        b[-1] += self.r * self.u_right

        # ์„ ํ˜• ์‹œ์Šคํ…œ ํ’€์ด
        u_inner = spsolve(self.A, b)

        # ์ „์ฒด ํ•ด ์—…๋ฐ์ดํŠธ
        self.u[1:-1] = u_inner
        self.u[0] = self.u_left
        self.u[-1] = self.u_right

    def solve(self, save_interval=None):
        """์ „์ฒด ์‹œ๊ฐ„ ๊ตฌ๊ฐ„ ํ’€์ด"""
        if save_interval is None:
            save_interval = max(1, self.nt // 100)

        history = [self.u.copy()]
        times = [0]

        for n in range(self.nt):
            self.step()
            if (n + 1) % save_interval == 0:
                history.append(self.u.copy())
                times.append((n + 1) * self.dt)

        return np.array(times), np.array(history)


def compare_ftcs_btcs():
    """FTCS vs BTCS ๋น„๊ต"""
    L = 1.0
    alpha = 0.01
    nx = 51
    T = 2.0

    # FTCS (CFL ์ œํ•œ๋จ)
    ftcs = HeatEquation1D_FTCS(L, alpha, nx, T, safety=0.4)
    ftcs.set_initial_condition(lambda x: np.sin(np.pi * x))
    ftcs.set_boundary_conditions('dirichlet', 0, 'dirichlet', 0)
    times_ftcs, history_ftcs = ftcs.solve()

    # BTCS (ํฐ ์‹œ๊ฐ„ ๊ฐ„๊ฒฉ ์‚ฌ์šฉ ๊ฐ€๋Šฅ)
    btcs = HeatEquation1D_BTCS(L, alpha, nx, T, nt=50)  # ํ›จ์”ฌ ์ ์€ ์‹œ๊ฐ„ ์Šคํ…
    btcs.set_initial_condition(lambda x: np.sin(np.pi * x))
    btcs.set_boundary_conditions(0, 0)
    times_btcs, history_btcs = btcs.solve()

    # ํ•ด์„ํ•ด
    u_exact = exact_solution_heat(ftcs.x, T, alpha, L)

    # ๋น„๊ต
    print(f"\n๋น„๊ต ๊ฒฐ๊ณผ:")
    print(f"  FTCS ์‹œ๊ฐ„ ์Šคํ… ์ˆ˜: {ftcs.nt}")
    print(f"  BTCS ์‹œ๊ฐ„ ์Šคํ… ์ˆ˜: {btcs.nt}")
    print(f"  FTCS ์ตœ๋Œ€ ์˜ค์ฐจ: {np.max(np.abs(history_ftcs[-1] - u_exact)):.2e}")
    print(f"  BTCS ์ตœ๋Œ€ ์˜ค์ฐจ: {np.max(np.abs(history_btcs[-1] - u_exact)):.2e}")

    # ์‹œ๊ฐํ™”
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    ax1 = axes[0]
    ax1.plot(ftcs.x, history_ftcs[-1], 'b-', label=f'FTCS (dt={ftcs.dt:.5f})', linewidth=2)
    ax1.plot(btcs.x, history_btcs[-1], 'g--', label=f'BTCS (dt={btcs.dt:.4f})', linewidth=2)
    ax1.plot(ftcs.x, u_exact, 'r:', label='ํ•ด์„ํ•ด', linewidth=2)
    ax1.set_xlabel('x')
    ax1.set_ylabel('u')
    ax1.set_title(f't = {T}์—์„œ ๋น„๊ต')
    ax1.legend()
    ax1.grid(True, alpha=0.3)

    ax2 = axes[1]
    ax2.semilogy(ftcs.x, np.abs(history_ftcs[-1] - u_exact) + 1e-16, 'b-', label='FTCS ์˜ค์ฐจ')
    ax2.semilogy(btcs.x, np.abs(history_btcs[-1] - u_exact) + 1e-16, 'g--', label='BTCS ์˜ค์ฐจ')
    ax2.set_xlabel('x')
    ax2.set_ylabel('|์˜ค์ฐจ|')
    ax2.set_title('์ˆ˜์น˜ ์˜ค์ฐจ ๋น„๊ต')
    ax2.legend()
    ax2.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig('heat_ftcs_vs_btcs.png', dpi=150, bbox_inches='tight')
    plt.show()

# compare_ftcs_btcs()

4. Crank-Nicolson ๋ฐฉ๋ฒ•

4.1 ์ด๋ก 

Crank-Nicolson = FTCS์™€ BTCS์˜ ํ‰๊ท  (2์ฐจ ์ •ํ™•๋„)

(u_i^{n+1} - u_i^n) / ฮ”t = (ฮฑ/2) ยท [(โˆ‚ยฒu/โˆ‚xยฒ)^n + (โˆ‚ยฒu/โˆ‚xยฒ)^{n+1}]

์ •๋ฆฌ:
-r/2ยทu_{i-1}^{n+1} + (1+r)ยทu_i^{n+1} - r/2ยทu_{i+1}^{n+1}
    = r/2ยทu_{i-1}^n + (1-r)ยทu_i^n + r/2ยทu_{i+1}^n

4.2 ํ–‰๋ ฌ ํ˜•ํƒœ

A ยท u^{n+1} = B ยท u^n

A: (1+r) ๋Œ€๊ฐ์„ , -r/2 ๋น„๋Œ€๊ฐ์„ 
B: (1-r) ๋Œ€๊ฐ์„ , r/2 ๋น„๋Œ€๊ฐ์„ 

4.3 Crank-Nicolson ๊ตฌํ˜„

from scipy.sparse import diags
from scipy.sparse.linalg import spsolve

class HeatEquation1D_CrankNicolson:
    """
    1D ์—ด๋ฐฉ์ •์‹ Crank-Nicolson ๋ฐฉ๋ฒ•

    - ๋ฌด์กฐ๊ฑด ์•ˆ์ •
    - 2์ฐจ ์ •ํ™•๋„ (์‹œ๊ฐ„, ๊ณต๊ฐ„ ๋ชจ๋‘)
    """

    def __init__(self, L=1.0, alpha=0.01, nx=51, T=1.0, nt=100):
        """
        Parameters:
        -----------
        L : float - ์˜์—ญ ๊ธธ์ด
        alpha : float - ์—ดํ™•์‚ฐ๊ณ„์ˆ˜
        nx : int - ๊ณต๊ฐ„ ๊ฒฉ์ž์  ์ˆ˜
        T : float - ์ตœ์ข… ์‹œ๊ฐ„
        nt : int - ์‹œ๊ฐ„ ์Šคํ… ์ˆ˜
        """
        self.L = L
        self.alpha = alpha
        self.nx = nx
        self.T = T
        self.nt = nt

        # ๊ฒฉ์ž ์ƒ์„ฑ
        self.dx = L / (nx - 1)
        self.dt = T / nt
        self.x = np.linspace(0, L, nx)

        self.r = alpha * self.dt / self.dx**2

        print(f"Crank-Nicolson ์—ด๋ฐฉ์ •์‹ ์„ค์ •")
        print(f"  dx = {self.dx:.4f}, dt = {self.dt:.6f}")
        print(f"  r = ฮฑยทdt/dxยฒ = {self.r:.4f}")
        print(f"  2์ฐจ ์ •ํ™•๋„ & ๋ฌด์กฐ๊ฑด ์•ˆ์ •")

        # ํ–‰๋ ฌ ์ƒ์„ฑ
        self._build_matrices()

    def _build_matrices(self):
        """Crank-Nicolson ํ–‰๋ ฌ A, B ์ƒ์„ฑ"""
        n = self.nx - 2  # ๋‚ด๋ถ€์  ์ˆ˜
        r = self.r

        # A ํ–‰๋ ฌ: ์ขŒ๋ณ€ (์•”์‹œ์  ๋ถ€๋ถ„)
        main_A = (1 + r) * np.ones(n)
        off_A = (-r/2) * np.ones(n - 1)
        self.A = diags([off_A, main_A, off_A], [-1, 0, 1], format='csr')

        # B ํ–‰๋ ฌ: ์šฐ๋ณ€ (๋ช…์‹œ์  ๋ถ€๋ถ„)
        main_B = (1 - r) * np.ones(n)
        off_B = (r/2) * np.ones(n - 1)
        self.B = diags([off_B, main_B, off_B], [-1, 0, 1], format='csr')

    def set_initial_condition(self, func):
        """์ดˆ๊ธฐ์กฐ๊ฑด ์„ค์ •"""
        self.u = func(self.x)
        self.u0 = self.u.copy()

    def set_boundary_conditions(self, left_value=0, right_value=0):
        """๋””๋ฆฌํด๋ ˆ ๊ฒฝ๊ณ„์กฐ๊ฑด ์„ค์ •"""
        self.u_left = left_value
        self.u_right = right_value

    def step(self):
        """ํ•œ ์‹œ๊ฐ„ ์Šคํ… ์ง„ํ–‰ (Crank-Nicolson)"""
        r = self.r

        # ์šฐ๋ณ€: Bยทu^n + ๊ฒฝ๊ณ„์กฐ๊ฑด ๊ธฐ์—ฌ
        b = self.B @ self.u[1:-1]

        # ๊ฒฝ๊ณ„์กฐ๊ฑด ๊ธฐ์—ฌ (์ขŒ๋ณ€๊ณผ ์šฐ๋ณ€ ๋ชจ๋‘์—์„œ)
        b[0] += (r/2) * (self.u_left + self.u_left)  # n๊ณผ n+1 ์‹œ์ ์˜ BC
        b[-1] += (r/2) * (self.u_right + self.u_right)

        # ์„ ํ˜• ์‹œ์Šคํ…œ ํ’€์ด
        u_inner = spsolve(self.A, b)

        # ์ „์ฒด ํ•ด ์—…๋ฐ์ดํŠธ
        self.u[1:-1] = u_inner
        self.u[0] = self.u_left
        self.u[-1] = self.u_right

    def solve(self, save_interval=None):
        """์ „์ฒด ์‹œ๊ฐ„ ๊ตฌ๊ฐ„ ํ’€์ด"""
        if save_interval is None:
            save_interval = max(1, self.nt // 100)

        history = [self.u.copy()]
        times = [0]

        for n in range(self.nt):
            self.step()
            if (n + 1) % save_interval == 0:
                history.append(self.u.copy())
                times.append((n + 1) * self.dt)

        return np.array(times), np.array(history)


def compare_all_schemes():
    """FTCS, BTCS, Crank-Nicolson ๋น„๊ต"""
    L = 1.0
    alpha = 0.01
    nx = 51
    T = 2.0

    # ๋™์ผํ•œ (ํฐ) ์‹œ๊ฐ„ ๊ฐ„๊ฒฉ์œผ๋กœ ๋น„๊ต
    nt = 40  # FTCS๋Š” ๋ถˆ์•ˆ์ •ํ•  ๊ฒƒ

    # Crank-Nicolson
    cn = HeatEquation1D_CrankNicolson(L, alpha, nx, T, nt)
    cn.set_initial_condition(lambda x: np.sin(np.pi * x))
    cn.set_boundary_conditions(0, 0)
    times_cn, history_cn = cn.solve()

    # BTCS
    btcs = HeatEquation1D_BTCS(L, alpha, nx, T, nt)
    btcs.set_initial_condition(lambda x: np.sin(np.pi * x))
    btcs.set_boundary_conditions(0, 0)
    times_btcs, history_btcs = btcs.solve()

    # FTCS (์•ˆ์ •ํ•œ ์„ค์ •)
    ftcs = HeatEquation1D_FTCS(L, alpha, nx, T, safety=0.4)
    ftcs.set_initial_condition(lambda x: np.sin(np.pi * x))
    ftcs.set_boundary_conditions('dirichlet', 0, 'dirichlet', 0)
    times_ftcs, history_ftcs = ftcs.solve()

    # ํ•ด์„ํ•ด
    u_exact = exact_solution_heat(cn.x, T, alpha, L)

    # ์˜ค์ฐจ ๋น„๊ต
    print(f"\n์ •ํ™•๋„ ๋น„๊ต (t = {T}):")
    print(f"  FTCS (dt={ftcs.dt:.5f}, {ftcs.nt} steps): {np.max(np.abs(history_ftcs[-1] - u_exact)):.2e}")
    print(f"  BTCS (dt={btcs.dt:.4f}, {btcs.nt} steps): {np.max(np.abs(history_btcs[-1] - u_exact)):.2e}")
    print(f"  C-N  (dt={cn.dt:.4f}, {cn.nt} steps): {np.max(np.abs(history_cn[-1] - u_exact)):.2e}")

    # ์‹œ๊ฐํ™”
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    ax1 = axes[0]
    ax1.plot(cn.x, history_ftcs[-1], 'b-', label='FTCS', linewidth=2)
    ax1.plot(cn.x, history_btcs[-1], 'g--', label='BTCS', linewidth=2)
    ax1.plot(cn.x, history_cn[-1], 'm:', label='Crank-Nicolson', linewidth=2)
    ax1.plot(cn.x, u_exact, 'r-.', label='ํ•ด์„ํ•ด', linewidth=2)
    ax1.set_xlabel('x')
    ax1.set_ylabel('u')
    ax1.set_title(f't = {T}์—์„œ ์„ธ ์Šคํ‚ด ๋น„๊ต')
    ax1.legend()
    ax1.grid(True, alpha=0.3)

    # ์ •ํ™•๋„ ์ˆ˜๋ ด ํ…Œ์ŠคํŠธ
    ax2 = axes[1]
    dt_values = []
    errors_btcs = []
    errors_cn = []

    for nt_test in [20, 40, 80, 160, 320]:
        dt_test = T / nt_test
        dt_values.append(dt_test)

        # BTCS
        solver = HeatEquation1D_BTCS(L, alpha, nx, T, nt_test)
        solver.set_initial_condition(lambda x: np.sin(np.pi * x))
        solver.set_boundary_conditions(0, 0)
        _, hist = solver.solve()
        errors_btcs.append(np.max(np.abs(hist[-1] - u_exact)))

        # Crank-Nicolson
        solver = HeatEquation1D_CrankNicolson(L, alpha, nx, T, nt_test)
        solver.set_initial_condition(lambda x: np.sin(np.pi * x))
        solver.set_boundary_conditions(0, 0)
        _, hist = solver.solve()
        errors_cn.append(np.max(np.abs(hist[-1] - u_exact)))

    ax2.loglog(dt_values, errors_btcs, 'gs-', label='BTCS (1์ฐจ)', linewidth=2)
    ax2.loglog(dt_values, errors_cn, 'mo-', label='Crank-Nicolson (2์ฐจ)', linewidth=2)

    # ๊ธฐ์ค€์„ 
    dt_ref = np.array(dt_values)
    ax2.loglog(dt_ref, 0.5*dt_ref, 'k--', alpha=0.5, label='O(ฮ”t)')
    ax2.loglog(dt_ref, 0.5*dt_ref**2, 'k:', alpha=0.5, label='O(ฮ”tยฒ)')

    ax2.set_xlabel('ฮ”t')
    ax2.set_ylabel('์ตœ๋Œ€ ์˜ค์ฐจ')
    ax2.set_title('์‹œ๊ฐ„ ์ •ํ™•๋„ ์ˆ˜๋ ด')
    ax2.legend()
    ax2.grid(True, alpha=0.3, which='both')

    plt.tight_layout()
    plt.savefig('heat_scheme_comparison.png', dpi=150, bbox_inches='tight')
    plt.show()

# compare_all_schemes()

5. 2D ์—ด๋ฐฉ์ •์‹

5.1 2D ์—ด๋ฐฉ์ •์‹

โˆ‚u/โˆ‚t = ฮฑ ยท (โˆ‚ยฒu/โˆ‚xยฒ + โˆ‚ยฒu/โˆ‚yยฒ) = ฮฑ ยท โˆ‡ยฒu

5.2 FTCS 2D ๊ตฌํ˜„

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

class HeatEquation2D_FTCS:
    """
    2D ์—ด๋ฐฉ์ •์‹ FTCS ์–‘ํ•ด๋ฒ•

    โˆ‚u/โˆ‚t = ฮฑ ยท (โˆ‚ยฒu/โˆ‚xยฒ + โˆ‚ยฒu/โˆ‚yยฒ)
    """

    def __init__(self, Lx=1.0, Ly=1.0, alpha=0.01, nx=51, ny=51, T=0.5, safety=0.2):
        """
        Parameters:
        -----------
        Lx, Ly : float - ์˜์—ญ ํฌ๊ธฐ
        alpha : float - ์—ดํ™•์‚ฐ๊ณ„์ˆ˜
        nx, ny : int - ๊ฒฉ์ž์  ์ˆ˜
        T : float - ์ตœ์ข… ์‹œ๊ฐ„
        safety : float - CFL ์•ˆ์ „๊ณ„์ˆ˜
        """
        self.Lx = Lx
        self.Ly = Ly
        self.alpha = alpha
        self.nx = nx
        self.ny = ny
        self.T = T

        # ๊ฒฉ์ž ์ƒ์„ฑ
        self.dx = Lx / (nx - 1)
        self.dy = Ly / (ny - 1)
        self.x = np.linspace(0, Lx, nx)
        self.y = np.linspace(0, Ly, ny)
        self.X, self.Y = np.meshgrid(self.x, self.y)

        # 2D CFL ์กฐ๊ฑด: r_x + r_y โ‰ค 0.5
        dt_cfl = safety * 0.5 / (alpha * (1/self.dx**2 + 1/self.dy**2))
        self.dt = dt_cfl
        self.nt = int(np.ceil(T / self.dt))
        self.dt = T / self.nt

        self.rx = alpha * self.dt / self.dx**2
        self.ry = alpha * self.dt / self.dy**2

        print(f"2D FTCS ์—ด๋ฐฉ์ •์‹ ์„ค์ •")
        print(f"  ๊ฒฉ์ž: {nx} x {ny}")
        print(f"  dx = {self.dx:.4f}, dy = {self.dy:.4f}, dt = {self.dt:.6f}")
        print(f"  r_x = {self.rx:.4f}, r_y = {self.ry:.4f}")
        print(f"  r_x + r_y = {self.rx + self.ry:.4f} (โ‰ค 0.5์ด์–ด์•ผ ์•ˆ์ •)")

    def set_initial_condition(self, func):
        """์ดˆ๊ธฐ์กฐ๊ฑด ์„ค์ •: u(x,y,0) = func(X, Y)"""
        self.u = func(self.X, self.Y)
        self.u0 = self.u.copy()

    def set_boundary_conditions(self, bc_value=0):
        """๋””๋ฆฌํด๋ ˆ ๊ฒฝ๊ณ„์กฐ๊ฑด (๋ชจ๋“  ๊ฒฝ๊ณ„์—์„œ ๋™์ผ ๊ฐ’)"""
        self.bc_value = bc_value

    def apply_bc(self, u):
        """๊ฒฝ๊ณ„์กฐ๊ฑด ์ ์šฉ"""
        u[0, :] = self.bc_value   # ์•„๋ž˜
        u[-1, :] = self.bc_value  # ์œ„
        u[:, 0] = self.bc_value   # ์™ผ์ชฝ
        u[:, -1] = self.bc_value  # ์˜ค๋ฅธ์ชฝ
        return u

    def step(self):
        """ํ•œ ์‹œ๊ฐ„ ์Šคํ… ์ง„ํ–‰ (2D FTCS)"""
        u_new = self.u.copy()

        # ๋‚ด๋ถ€์  ์—…๋ฐ์ดํŠธ
        u_new[1:-1, 1:-1] = self.u[1:-1, 1:-1] + \
            self.rx * (self.u[1:-1, 2:] - 2*self.u[1:-1, 1:-1] + self.u[1:-1, :-2]) + \
            self.ry * (self.u[2:, 1:-1] - 2*self.u[1:-1, 1:-1] + self.u[:-2, 1:-1])

        # ๊ฒฝ๊ณ„์กฐ๊ฑด
        u_new = self.apply_bc(u_new)

        self.u = u_new

    def solve(self, save_interval=None):
        """์ „์ฒด ์‹œ๊ฐ„ ๊ตฌ๊ฐ„ ํ’€์ด"""
        if save_interval is None:
            save_interval = max(1, self.nt // 50)

        history = [self.u.copy()]
        times = [0]

        for n in range(self.nt):
            self.step()
            if (n + 1) % save_interval == 0:
                history.append(self.u.copy())
                times.append((n + 1) * self.dt)

        return np.array(times), history


def demo_heat_2d():
    """2D ์—ด๋ฐฉ์ •์‹ ๋ฐ๋ชจ"""
    # ๋ฌธ์ œ ์„ค์ •
    solver = HeatEquation2D_FTCS(Lx=1.0, Ly=1.0, alpha=0.01, nx=51, ny=51, T=0.5)

    # ์ดˆ๊ธฐ์กฐ๊ฑด: ๊ฐ€์šฐ์‹œ์•ˆ ์—ด์ 
    def ic(X, Y):
        x0, y0 = 0.5, 0.5
        sigma = 0.1
        return np.exp(-((X-x0)**2 + (Y-y0)**2) / (2*sigma**2))

    solver.set_initial_condition(ic)
    solver.set_boundary_conditions(0)

    # ํ’€์ด
    times, history = solver.solve(save_interval=20)

    # ์‹œ๊ฐํ™”
    fig, axes = plt.subplots(2, 3, figsize=(15, 10))

    # ์„ ํƒ๋œ ์‹œ๊ฐ„์—์„œ์˜ ํ•ด
    plot_indices = [0, len(times)//4, len(times)//2, 3*len(times)//4, -1]
    for idx, i in enumerate(plot_indices[:5]):
        if idx < 5:
            row = idx // 3
            col = idx % 3
            ax = axes[row, col]

            c = ax.contourf(solver.X, solver.Y, history[i], levels=30, cmap='hot')
            plt.colorbar(c, ax=ax)
            ax.set_xlabel('x')
            ax.set_ylabel('y')
            ax.set_title(f't = {times[i]:.3f}')
            ax.set_aspect('equal')

    # ๋นˆ subplot ์ฒ˜๋ฆฌ
    if len(plot_indices) < 6:
        axes[1, 2].axis('off')

    plt.tight_layout()
    plt.savefig('heat_2d.png', dpi=150, bbox_inches='tight')
    plt.show()

    # ์ค‘์‹ฌ์ ์—์„œ์˜ ์‹œ๊ฐ„ ๋ณ€ํ™”
    fig2, ax = plt.subplots(figsize=(10, 5))
    center_values = [h[solver.ny//2, solver.nx//2] for h in history]
    ax.plot(times, center_values, 'b-', linewidth=2)
    ax.set_xlabel('์‹œ๊ฐ„ t')
    ax.set_ylabel('u(0.5, 0.5, t)')
    ax.set_title('์ค‘์‹ฌ์ ์—์„œ์˜ ์˜จ๋„ ๋ณ€ํ™”')
    ax.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.savefig('heat_2d_center.png', dpi=150, bbox_inches='tight')
    plt.show()

    return solver, times, history

# solver, times, history = demo_heat_2d()

5.3 2D Crank-Nicolson (ADI ๋ฐฉ๋ฒ•)

๋Œ€๊ทœ๋ชจ 2D ๋ฌธ์ œ์—์„œ๋Š” ADI (Alternating Direction Implicit) ๋ฐฉ๋ฒ•์ด ํšจ์œจ์ ์ž…๋‹ˆ๋‹ค.

from scipy.sparse import diags
from scipy.sparse.linalg import spsolve

class HeatEquation2D_ADI:
    """
    2D ์—ด๋ฐฉ์ •์‹ ADI (Alternating Direction Implicit) ๋ฐฉ๋ฒ•

    ๊ฐ ์‹œ๊ฐ„ ์Šคํ…์„ ๋‘ ๋ฐ˜์Šคํ…์œผ๋กœ ๋‚˜๋ˆ”:
    1. x-๋ฐฉํ–ฅ ์•”์‹œ์ , y-๋ฐฉํ–ฅ ๋ช…์‹œ์ 
    2. y-๋ฐฉํ–ฅ ์•”์‹œ์ , x-๋ฐฉํ–ฅ ๋ช…์‹œ์ 

    ๋ฌด์กฐ๊ฑด ์•ˆ์ • + 2์ฐจ ์ •ํ™•๋„
    """

    def __init__(self, Lx=1.0, Ly=1.0, alpha=0.01, nx=51, ny=51, T=0.5, nt=100):
        self.Lx = Lx
        self.Ly = Ly
        self.alpha = alpha
        self.nx = nx
        self.ny = ny
        self.T = T
        self.nt = nt

        # ๊ฒฉ์ž ์ƒ์„ฑ
        self.dx = Lx / (nx - 1)
        self.dy = Ly / (ny - 1)
        self.dt = T / nt
        self.x = np.linspace(0, Lx, nx)
        self.y = np.linspace(0, Ly, ny)
        self.X, self.Y = np.meshgrid(self.x, self.y)

        self.rx = alpha * self.dt / (2 * self.dx**2)
        self.ry = alpha * self.dt / (2 * self.dy**2)

        print(f"2D ADI ์—ด๋ฐฉ์ •์‹ ์„ค์ •")
        print(f"  ๊ฒฉ์ž: {nx} x {ny}")
        print(f"  r_x = {self.rx:.4f}, r_y = {self.ry:.4f}")

        # ํ–‰๋ ฌ ์ƒ์„ฑ
        self._build_matrices()

    def _build_matrices(self):
        """ADI ์‚ผ์ค‘๋Œ€๊ฐ ํ–‰๋ ฌ ์ƒ์„ฑ"""
        # x-๋ฐฉํ–ฅ (๊ฐ y์— ๋Œ€ํ•ด)
        mx = self.nx - 2
        main_x = (1 + 2*self.rx) * np.ones(mx)
        off_x = -self.rx * np.ones(mx - 1)
        self.Ax = diags([off_x, main_x, off_x], [-1, 0, 1], format='csr')

        # y-๋ฐฉํ–ฅ (๊ฐ x์— ๋Œ€ํ•ด)
        my = self.ny - 2
        main_y = (1 + 2*self.ry) * np.ones(my)
        off_y = -self.ry * np.ones(my - 1)
        self.Ay = diags([off_y, main_y, off_y], [-1, 0, 1], format='csr')

    def set_initial_condition(self, func):
        """์ดˆ๊ธฐ์กฐ๊ฑด ์„ค์ •"""
        self.u = func(self.X, self.Y)
        self.u0 = self.u.copy()

    def set_boundary_conditions(self, bc_value=0):
        """๋””๋ฆฌํด๋ ˆ ๊ฒฝ๊ณ„์กฐ๊ฑด"""
        self.bc_value = bc_value

    def step(self):
        """ํ•œ ์‹œ๊ฐ„ ์Šคํ… (ADI ๋‘ ๋ฐ˜์Šคํ…)"""
        u = self.u
        bc = self.bc_value

        # ์ค‘๊ฐ„ ํ•ด ๋ฐฐ์—ด
        u_half = np.zeros_like(u)
        u_new = np.zeros_like(u)

        # ๋ฐ˜์Šคํ… 1: x-์•”์‹œ์ , y-๋ช…์‹œ์ 
        for j in range(1, self.ny - 1):
            # y-๋ช…์‹œ์  ๋ถ€๋ถ„ (์šฐ๋ณ€)
            b = u[j, 1:-1] + self.ry * (u[j+1, 1:-1] - 2*u[j, 1:-1] + u[j-1, 1:-1])
            # ๊ฒฝ๊ณ„์กฐ๊ฑด
            b[0] += self.rx * bc
            b[-1] += self.rx * bc
            # x-์•”์‹œ์  ํ’€์ด
            u_half[j, 1:-1] = spsolve(self.Ax, b)

        # ๊ฒฝ๊ณ„์กฐ๊ฑด ์ ์šฉ
        u_half[0, :] = bc
        u_half[-1, :] = bc
        u_half[:, 0] = bc
        u_half[:, -1] = bc

        # ๋ฐ˜์Šคํ… 2: y-์•”์‹œ์ , x-๋ช…์‹œ์ 
        for i in range(1, self.nx - 1):
            # x-๋ช…์‹œ์  ๋ถ€๋ถ„ (์šฐ๋ณ€)
            b = u_half[1:-1, i] + self.rx * (u_half[1:-1, i+1] - 2*u_half[1:-1, i] + u_half[1:-1, i-1])
            # ๊ฒฝ๊ณ„์กฐ๊ฑด
            b[0] += self.ry * bc
            b[-1] += self.ry * bc
            # y-์•”์‹œ์  ํ’€์ด
            u_new[1:-1, i] = spsolve(self.Ay, b)

        # ๊ฒฝ๊ณ„์กฐ๊ฑด ์ ์šฉ
        u_new[0, :] = bc
        u_new[-1, :] = bc
        u_new[:, 0] = bc
        u_new[:, -1] = bc

        self.u = u_new

    def solve(self, save_interval=None):
        """์ „์ฒด ์‹œ๊ฐ„ ๊ตฌ๊ฐ„ ํ’€์ด"""
        if save_interval is None:
            save_interval = max(1, self.nt // 50)

        history = [self.u.copy()]
        times = [0]

        for n in range(self.nt):
            self.step()
            if (n + 1) % save_interval == 0:
                history.append(self.u.copy())
                times.append((n + 1) * self.dt)

        return np.array(times), history


def compare_2d_methods():
    """2D FTCS vs ADI ๋น„๊ต"""
    Lx = Ly = 1.0
    alpha = 0.01
    nx = ny = 41
    T = 0.3

    # ์ดˆ๊ธฐ์กฐ๊ฑด
    def ic(X, Y):
        return np.sin(np.pi * X) * np.sin(np.pi * Y)

    # FTCS
    ftcs = HeatEquation2D_FTCS(Lx, Ly, alpha, nx, ny, T, safety=0.2)
    ftcs.set_initial_condition(ic)
    ftcs.set_boundary_conditions(0)
    times_ftcs, history_ftcs = ftcs.solve()

    # ADI
    adi = HeatEquation2D_ADI(Lx, Ly, alpha, nx, ny, T, nt=30)
    adi.set_initial_condition(ic)
    adi.set_boundary_conditions(0)
    times_adi, history_adi = adi.solve()

    # ํ•ด์„ํ•ด: u = sin(ฯ€x)sin(ฯ€y)exp(-2ฮฑฯ€ยฒt)
    u_exact = np.sin(np.pi * adi.X) * np.sin(np.pi * adi.Y) * \
              np.exp(-2 * alpha * np.pi**2 * T)

    print(f"\n๋น„๊ต ๊ฒฐ๊ณผ (t = {T}):")
    print(f"  FTCS ์‹œ๊ฐ„ ์Šคํ…: {ftcs.nt}")
    print(f"  ADI ์‹œ๊ฐ„ ์Šคํ…: {adi.nt}")
    print(f"  FTCS ์ตœ๋Œ€ ์˜ค์ฐจ: {np.max(np.abs(history_ftcs[-1] - u_exact)):.2e}")
    print(f"  ADI ์ตœ๋Œ€ ์˜ค์ฐจ: {np.max(np.abs(history_adi[-1] - u_exact)):.2e}")

    # ์‹œ๊ฐํ™”
    fig, axes = plt.subplots(1, 3, figsize=(15, 4))

    c1 = axes[0].contourf(adi.X, adi.Y, history_ftcs[-1], levels=30, cmap='hot')
    plt.colorbar(c1, ax=axes[0])
    axes[0].set_title(f'FTCS (dt={ftcs.dt:.5f})')
    axes[0].set_aspect('equal')

    c2 = axes[1].contourf(adi.X, adi.Y, history_adi[-1], levels=30, cmap='hot')
    plt.colorbar(c2, ax=axes[1])
    axes[1].set_title(f'ADI (dt={adi.dt:.4f})')
    axes[1].set_aspect('equal')

    c3 = axes[2].contourf(adi.X, adi.Y, u_exact, levels=30, cmap='hot')
    plt.colorbar(c3, ax=axes[2])
    axes[2].set_title('ํ•ด์„ํ•ด')
    axes[2].set_aspect('equal')

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

    plt.tight_layout()
    plt.savefig('heat_2d_comparison.png', dpi=150, bbox_inches='tight')
    plt.show()

# compare_2d_methods()

6. ๋‹ค์–‘ํ•œ ๊ฒฝ๊ณ„์กฐ๊ฑด ์ฒ˜๋ฆฌ

6.1 ๋…ธ์ด๋งŒ ๊ฒฝ๊ณ„์กฐ๊ฑด

class HeatEquation1D_Neumann:
    """
    ๋…ธ์ด๋งŒ ๊ฒฝ๊ณ„์กฐ๊ฑด์„ ๊ฐ€์ง„ 1D ์—ด๋ฐฉ์ •์‹

    โˆ‚u/โˆ‚x|_{x=0} = flux_left
    โˆ‚u/โˆ‚x|_{x=L} = flux_right
    """

    def __init__(self, L=1.0, alpha=0.01, nx=51, T=1.0, safety=0.4):
        self.L = L
        self.alpha = alpha
        self.nx = nx
        self.T = T

        self.dx = L / (nx - 1)
        self.dt = safety * self.dx**2 / alpha
        self.nt = int(np.ceil(T / self.dt))
        self.dt = T / self.nt

        self.x = np.linspace(0, L, nx)
        self.r = alpha * self.dt / self.dx**2

        print(f"๋…ธ์ด๋งŒ BC ์—ด๋ฐฉ์ •์‹: r = {self.r:.4f}")

    def set_initial_condition(self, func):
        self.u = func(self.x)

    def set_boundary_conditions(self, flux_left=0, flux_right=0):
        """๋…ธ์ด๋งŒ ๊ฒฝ๊ณ„์กฐ๊ฑด ์„ค์ •"""
        self.flux_left = flux_left
        self.flux_right = flux_right

    def step(self):
        """ํ•œ ์‹œ๊ฐ„ ์Šคํ… (๋…ธ์ด๋งŒ BC ํฌํ•จ)"""
        u_new = self.u.copy()

        # ๋‚ด๋ถ€์ 
        u_new[1:-1] = self.u[1:-1] + self.r * (
            self.u[2:] - 2*self.u[1:-1] + self.u[:-2]
        )

        # ์™ผ์ชฝ ๋…ธ์ด๋งŒ BC: โˆ‚u/โˆ‚x = flux_left
        # ๊ณ ์ŠคํŠธ ๋…ธ๋“œ ๋ฐฉ๋ฒ•: u[-1] = u[1] - 2*dx*flux_left
        # u_new[0] = u[0] + r*(u[1] - 2*u[0] + u[-1])
        #          = u[0] + r*(u[1] - 2*u[0] + u[1] - 2*dx*flux_left)
        #          = u[0] + r*(2*u[1] - 2*u[0] - 2*dx*flux_left)
        u_new[0] = self.u[0] + self.r * (
            2*self.u[1] - 2*self.u[0] - 2*self.dx*self.flux_left
        )

        # ์˜ค๋ฅธ์ชฝ ๋…ธ์ด๋งŒ BC: โˆ‚u/โˆ‚x = flux_right
        u_new[-1] = self.u[-1] + self.r * (
            2*self.u[-2] - 2*self.u[-1] + 2*self.dx*self.flux_right
        )

        self.u = u_new

    def solve(self):
        history = [self.u.copy()]
        times = [0]

        save_interval = max(1, self.nt // 100)

        for n in range(self.nt):
            self.step()
            if (n + 1) % save_interval == 0:
                history.append(self.u.copy())
                times.append((n + 1) * self.dt)

        return np.array(times), np.array(history)


def demo_neumann_bc():
    """๋…ธ์ด๋งŒ ๊ฒฝ๊ณ„์กฐ๊ฑด ๋ฐ๋ชจ: ๋‹จ์—ด ์–‘๋‹จ"""
    solver = HeatEquation1D_Neumann(L=1.0, alpha=0.01, nx=51, T=5.0)

    # ์ดˆ๊ธฐ์กฐ๊ฑด: ์™ผ์ชฝ ๋ฐ˜์€ ๋œจ๊ฒ๊ณ  ์˜ค๋ฅธ์ชฝ ๋ฐ˜์€ ์ฐจ๊ฐ€์›€
    solver.set_initial_condition(lambda x: np.where(x < 0.5, 1.0, 0.0))

    # ์–‘๋‹จ ๋‹จ์—ด (flux = 0)
    solver.set_boundary_conditions(flux_left=0, flux_right=0)

    times, history = solver.solve()

    # ์—๋„ˆ์ง€ ๋ณด์กด ํ™•์ธ
    energies = [np.trapz(h, solver.x) for h in history]

    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    # ์˜จ๋„ ๋ถ„ํฌ
    ax1 = axes[0]
    for i in range(0, len(times), len(times)//5):
        ax1.plot(solver.x, history[i], label=f't = {times[i]:.2f}')
    ax1.set_xlabel('x')
    ax1.set_ylabel('u')
    ax1.set_title('๋‹จ์—ด ๊ฒฝ๊ณ„์กฐ๊ฑด (โˆ‚u/โˆ‚x = 0)')
    ax1.legend()
    ax1.grid(True, alpha=0.3)

    # ์ด ์—๋„ˆ์ง€
    ax2 = axes[1]
    ax2.plot(times, energies, 'b-', linewidth=2)
    ax2.axhline(y=energies[0], color='r', linestyle='--', label='์ดˆ๊ธฐ ์—๋„ˆ์ง€')
    ax2.set_xlabel('์‹œ๊ฐ„ t')
    ax2.set_ylabel('์ด ์—๋„ˆ์ง€ (โˆซu dx)')
    ax2.set_title('์—๋„ˆ์ง€ ๋ณด์กด ํ™•์ธ')
    ax2.legend()
    ax2.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig('heat_neumann.png', dpi=150, bbox_inches='tight')
    plt.show()

    print(f"์ดˆ๊ธฐ ์—๋„ˆ์ง€: {energies[0]:.6f}")
    print(f"์ตœ์ข… ์—๋„ˆ์ง€: {energies[-1]:.6f}")
    print(f"์—๋„ˆ์ง€ ๋ณ€ํ™”: {(energies[-1] - energies[0]) / energies[0] * 100:.4f}%")

# demo_neumann_bc()

6.2 ๋กœ๋นˆ ๊ฒฝ๊ณ„์กฐ๊ฑด

def demo_robin_bc():
    """๋กœ๋นˆ ๊ฒฝ๊ณ„์กฐ๊ฑด: ๋Œ€๋ฅ˜ ์—ด์ „๋‹ฌ"""
    L = 1.0
    alpha = 0.01
    nx = 51
    T = 2.0

    dx = L / (nx - 1)
    dt = 0.4 * dx**2 / alpha
    nt = int(np.ceil(T / dt))
    dt = T / nt
    r = alpha * dt / dx**2

    x = np.linspace(0, L, nx)

    # ์ดˆ๊ธฐ์กฐ๊ฑด: ๊ท ์ผ ์˜จ๋„
    u = np.ones(nx)

    # ๋กœ๋นˆ BC ํŒŒ๋ผ๋ฏธํ„ฐ
    # -kยทโˆ‚u/โˆ‚x = hยท(u - T_inf) at x = 0
    # ์—ฌ๊ธฐ์„œ k=์—ด์ „๋„๋„, h=์—ด์ „๋‹ฌ๊ณ„์ˆ˜, T_inf=์ฃผ๋ณ€์˜จ๋„
    k = 1.0
    h = 10.0  # ์—ด์ „๋‹ฌ๊ณ„์ˆ˜
    T_inf = 0.0  # ์ฃผ๋ณ€ ์˜จ๋„
    Bi = h * dx / k  # Biot ์ˆ˜

    history = [u.copy()]
    times = [0]

    for n in range(nt):
        u_new = u.copy()

        # ๋‚ด๋ถ€์ 
        u_new[1:-1] = u[1:-1] + r * (u[2:] - 2*u[1:-1] + u[:-2])

        # ์™ผ์ชฝ ๋กœ๋นˆ BC: h(u - T_inf) + kยทโˆ‚u/โˆ‚x = 0
        # (u[1] - u[0])/dx = (h/k)(u[0] - T_inf)
        # ๊ณ ์ŠคํŠธ ๋…ธ๋“œ: u[-1] = u[1] - 2*dx*(h/k)*(u[0] - T_inf)
        u_new[0] = u[0] + r * (2*u[1] - 2*u[0] - 2*dx*(h/k)*(u[0] - T_inf))

        # ์˜ค๋ฅธ์ชฝ: ๋””๋ฆฌํด๋ ˆ (๊ณ ์ • ์˜จ๋„)
        u_new[-1] = 1.0

        u = u_new

        if (n + 1) % (nt // 50) == 0:
            history.append(u.copy())
            times.append((n + 1) * dt)

    # ์‹œ๊ฐํ™”
    fig, ax = plt.subplots(figsize=(10, 6))

    for i in range(0, len(times), len(times)//5):
        ax.plot(x, history[i], label=f't = {times[i]:.2f}')

    ax.set_xlabel('x')
    ax.set_ylabel('u')
    ax.set_title(f'๋กœ๋นˆ ๊ฒฝ๊ณ„์กฐ๊ฑด (๋Œ€๋ฅ˜ ์—ด์ „๋‹ฌ)\nBiot ์ˆ˜ = {Bi:.2f}')
    ax.legend()
    ax.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig('heat_robin.png', dpi=150, bbox_inches='tight')
    plt.show()

# demo_robin_bc()

7. ์š”์•ฝ

์Šคํ‚ด ๋น„๊ตํ‘œ

์Šคํ‚ด ์ •ํ™•๋„ ์•ˆ์ •์„ฑ ๊ณ„์‚ฐ ๋น„์šฉ ํŠน์ง•
FTCS O(ฮ”t, ฮ”xยฒ) ์กฐ๊ฑด๋ถ€ (rโ‰ค0.5) ๋‚ฎ์Œ ๋‹จ์ˆœ, ๋ช…์‹œ์ 
BTCS O(ฮ”t, ฮ”xยฒ) ๋ฌด์กฐ๊ฑด ์ค‘๊ฐ„ ์•”์‹œ์ , ํ–‰๋ ฌ ํ’€์ด
Crank-Nicolson O(ฮ”tยฒ, ฮ”xยฒ) ๋ฌด์กฐ๊ฑด ์ค‘๊ฐ„ 2์ฐจ ์ •ํ™•๋„
ADI (2D) O(ฮ”tยฒ, ฮ”xยฒ) ๋ฌด์กฐ๊ฑด ์ค‘๊ฐ„ ํšจ์œจ์ ์ธ 2D

CFL ์กฐ๊ฑด

1D FTCS: r = ฮฑยทฮ”t/ฮ”xยฒ โ‰ค 0.5
2D FTCS: r_x + r_y โ‰ค 0.5

๋‹ค์Œ ๋‹จ๊ณ„

  1. 10์žฅ: ํŒŒ๋™๋ฐฉ์ •์‹ - ์Œ๊ณก์„ ํ˜• PDE
  2. 11์žฅ: ๋ผํ”Œ๋ผ์Šค/ํฌ์•„์†ก - ํƒ€์›ํ˜• PDE
  3. 12์žฅ: ์ด๋ฅ˜๋ฐฉ์ •์‹ - 1์ฐจ ์Œ๊ณก์„ ํ˜• PDE

์—ฐ์Šต๋ฌธ์ œ

์—ฐ์Šต 1: FTCS ์•ˆ์ •์„ฑ ์‹คํ—˜

r = 0.3, 0.5, 0.6์—์„œ FTCS๋ฅผ ์‹คํ–‰ํ•˜๊ณ  ์•ˆ์ •/๋ถˆ์•ˆ์ • ๋™์ž‘์„ ํ™•์ธํ•˜์‹œ์˜ค.

์—ฐ์Šต 2: ์ˆ˜๋ ด ์ฐจ์ˆ˜ ํ™•์ธ

Crank-Nicolson์˜ ์‹œ๊ฐ„ ์ •ํ™•๋„๊ฐ€ 2์ฐจ์ž„์„ ์ˆ˜์น˜์ ์œผ๋กœ ํ™•์ธํ•˜์‹œ์˜ค.

์—ฐ์Šต 3: ๋น„๊ท ์งˆ ๊ฒฝ๊ณ„์กฐ๊ฑด

u(0,t) = 0, u(L,t) = 100์ธ ๊ฒฝ์šฐ์˜ ์ •์ƒ์ƒํƒœ ํ•ด๋ฅผ ๊ตฌํ•˜์‹œ์˜ค.

์—ฐ์Šต 4: 2D ์—ด๋ฐฉ์ •์‹

๊ฐ€์šฐ์‹œ์•ˆ ์ดˆ๊ธฐ์กฐ๊ฑด์ด ์•„๋‹Œ ์‚ฌ๊ฐํ˜• ์—ด์  ์ดˆ๊ธฐ์กฐ๊ฑด์œผ๋กœ 2D ์—ด๋ฐฉ์ •์‹์„ ํ’€์–ด๋ณด์‹œ์˜ค.


์ฐธ๊ณ  ์ž๋ฃŒ

  1. ๊ต์žฌ: LeVeque, "Finite Difference Methods for Ordinary and Partial Differential Equations"
  2. Python: scipy.sparse, numpy
  3. ์‹œ๊ฐํ™”: matplotlib.animation
to navigate between lessons