问题引入¶
前面研究的是标量在时间维度上的动力学,例如地球温度如何随时间变化。
但地球并不具有单一、均匀的温度;更确切地说,在某一特定时刻,地球上不同地方的温度各不相同,这些不同的温度由于多种机制而随时间变化。
本讲将研究两种基本机制:平流和扩散。考虑海洋中的温度。
- 由于海洋是运动中的流体,一团温暖的海水可以因水本身的物理运动而流到新的位置;这就是平流 (advection)。
- 即使水不运动,温度或溶解在流体中的某种物质的较高浓度也可以通过分子机制扩散;这就是扩散 (diffusion)。
这些因素的引入将问题从一维(只有时间)扩展到多维(时间和空间)。
准备工作¶
import numpy as np
import matplotlib.pyplot as plt
from ipywidgets import interact, Checkbox, FloatSlider, IntSlider
plt.rcParams['font.family'] = ['Noto Sans', 'Noto Sans CJK SC']
时间与空间共同演进¶
本讲将限制在一维空间,把温度 $T$ 视为以下函数:
$$T(t, x)$$
依赖于两个独立变量:
- 时间,$t$
- 空间,$x$
需要为每一对可能的值 $(t, x)$ 计算温度 $T$ 的值,即对于所有时间($>0$)和所有位置。
给定点的温度将因不同的物理过程而变化。需要通过写出描述每个物理过程及其对温度影响的方程来建模这一点。由于现在有两个独立变量 $t$ 和 $x$,可以预期最终会得到关于这两个变量的导数,因此给定点的温度时间变化率也依赖于温度在空间中的梯度。这将导出一个偏微分方程 (partial differential equation, PDE),关联 $T$ 的偏导数。
在气候建模的背景下,可以把 $x$ 视为纬度,假设同一纬度的所有点具有相同温度。这样就可以建模极地寒冷、赤道温暖的事实,以及热量如何从热处流向冷处。
然而,显然无法以此方式建模实际的洋流,那将需要两个甚至三个空间维度。
温度剖面与离散化¶
常微分方程需要每个变量的初始值。偏微分方程则需要一个初始函数 $T_0(x)$ 给出每个位置 $x$ 处的温度。假设位置限制在区间 $[0, L_x]$ 内。
为了在计算机上表示连续函数 $T_0(x)$,需要以某种方式进行离散化 (discretization),即将连续函数近似为计算机中的有限数值集合。
最简单的离散化方法是离散网格点(或节点)$x_i$($i = 1, \dots, N_x$)处对函数进行采样。为简单起见,取等间距排列,间距 $x_{i+1} - x_i = \delta x = L_x / N_x$。
可以将网格点取在每个区间的中心,这样就有 $N_x$ 个区间和 $N_x$ 个网格点,从 $x_1 = \delta x/2$ 开始,到 $x_N = L_x - \delta x / 2$ 结束。
def T0_func(x):
return np.sin(2 * np.pi * x) + 2 * np.cos(4 * np.pi * x) + 0.2
Nx = 20
Lx = 1.0
dx = Lx / Nx
xs = np.arange(dx / 2, Lx, dx)
print(f'网格点数 Nx = {Nx}')
print(f'空间步长 δx = {dx}')
print(f'网格点 xs = {np.round(xs, 4)}')
x_fine = np.linspace(0, Lx, 1000)
T0_fine = T0_func(x_fine)
T0_sampled = T0_func(xs)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6),
gridspec_kw={'height_ratios': [4, 1]})
ax1.plot(x_fine, T0_fine, lw=2, label='T₀(x)')
ax1.scatter(xs, T0_sampled, c='red', zorder=5, label='采样点')
ax1.scatter(xs, np.zeros_like(xs), c='black', alpha=0.5, s=15, label='x 节点')
for i in range(len(xs)):
ax1.plot([xs[i] - dx/2, xs[i] + dx/2], [T0_sampled[i], T0_sampled[i]],
c='green', lw=4)
ax1.plot([xs[i] - dx/2, xs[i] - dx/2], [0, T0_sampled[i]],
c='green', lw=1, ls='--', alpha=0.3)
ax1.plot([xs[i] + dx/2, xs[i] + dx/2], [0, T0_sampled[i]],
c='green', lw=1, ls='--', alpha=0.3)
for x in xs:
ax1.plot([x, x], [0, T0_func(x)], ls='--', c='black', alpha=0.3)
ax1.axhline(0, ls='--', c='gray', alpha=0.5)
ax1.set_ylabel('T₀(x)')
ax1.legend(loc='upper right')
ax2.imshow(T0_sampled.reshape(1, -1), aspect='auto', cmap='RdBu_r',
extent=[xs[0]-dx/2, xs[-1]+dx/2, 0, 1],
vmin=-3, vmax=3)
ax2.set_yticks([])
ax2.set_xlabel('x')
ax2.set_ylabel('热图')
plt.tight_layout()
plt.show()
网格点数 Nx = 20 空间步长 δx = 0.05 网格点 xs = [0.025 0.075 0.125 0.175 0.225 0.275 0.325 0.375 0.425 0.475 0.525 0.575 0.625 0.675 0.725 0.775 0.825 0.875 0.925 0.975]
平流¶
现在把这个剖面 (profile) 视为代表流体中每个小体积或“流体微团”的温度。假设流体以恒定、均匀的速度 $U$ 向右运动(均匀意味着流体各部分的速度相同)。那么温度剖面也应该随流体移动。这种随流体一起运动的量(如温度)被称为示踪物 (tracer)。
如果将注意力固定在空间中的某个点,比如网格点 $x_i$,那里的温度将随时间变化,因为流体从旁边流过。温度如何随时间变化取决于相邻网格点的值,因为它们决定了有多少热量被传输进入和离开当前单元。
补充知识:将注意力固定在空间某一点的观点称为欧拉观点。另一种选择是跟随流体微团在空间中运动;这称为拉格朗日观点。
可视化流体中的通量¶
随着流体流过网格点(更确切地说,是以网格点为中心的单元),可视化会发生什么。可视化在流体中运动的示踪粒子:
N_particles = 5000
np.random.seed(42)
xx_particles = np.abs(-2 + 4 * np.random.rand(N_particles))**2 - 1.5
yy_particles = np.random.rand(N_particles)
delta_p = 0.8
U_p = 0.2
fig, ax = plt.subplots(figsize=(10, 3))
ax.fill([0, 1, 1, 0, 0], [0, 0, 1, 1, 0], alpha=0.5, color='lightblue')
ax.scatter(xx_particles, yy_particles, s=1.5, alpha=0.1, c='gray')
ax.plot([-1.5, 2], [0, 0], c='black')
ax.plot([-1.5, 2], [1, 1], c='black')
ax.set_xlim(-2, 2)
ax.set_ylim(-0.1, 1.1)
ax.set_xlabel('x')
ax.set_title('流体中的示踪粒子(t = 0)')
plt.tight_layout()
plt.show()
def plot_particles(t=0.0, show_particles=False):
fig, ax = plt.subplots(figsize=(10, 3))
ax.fill([0, 1, 1, 0, 0], [0, 0, 1, 1, 0], alpha=0.5, color='lightblue')
new_xx = xx_particles + U_p * t
ax.scatter(new_xx, yy_particles, s=1.5, alpha=0.1, c='gray')
if show_particles:
cs = np.zeros(N_particles, dtype=int)
for i in range(N_particles):
x = xx_particles[i]
if -U_p * delta_p < x < 0:
cs[i] = 1
elif 1 - U_p * delta_p < x < 1:
cs[i] = 2
mask = cs != 0
ax.scatter(new_xx[mask], yy_particles[mask], s=1.5, alpha=0.5, c=cs[mask])
ax.plot([-1.5, 2], [0, 0], c='black')
ax.plot([-1.5, 2], [1, 1], c='black')
ax.set_xlim(-2, 2)
ax.set_ylim(-0.1, 1.1)
ax.set_xlabel('x')
ax.set_title(f'流体中的示踪粒子(t = {t:.2f})')
plt.tight_layout()
plt.show()
interact(plot_particles,
t=FloatSlider(min=0, max=2, step=0.01, value=0, description='t'),
show_particles=Checkbox(value=False, description='显示进入/离开的粒子'));
interactive(children=(FloatSlider(value=0.0, description='t', max=2.0, step=0.01), Checkbox(value=False, descr…
时间步进¶
希望建模温度剖面如何因流体流动而随时间变化。方法是观察每个单元,询问在给定时间步长 $\delta t$ 内有多少热量进入和离开该单元。
将 $T^n_i$ 称为位置 $x_i$ 处、第 $n$ 个时间步 $t_n$ 的近似(未知)平均值,即 $T(t_n, x_i)$ 的近似,其中 $t_n = n \, \delta t$。
于是 $T^{n+1}_i \simeq T(t_n + \delta t, x_i)$,$T^{n}_{i+1} \simeq T(t_n, x_i + \delta x)$。
注意:这些算法中的上标 $n$ 并不表示幂;它只是时间步的标签。
假设流体以速度 $U$ 向右运动。在持续 $\delta t$ 的时间步内,单元 $i$ 的温度 $T^n_i$ 因两个原因而改变:
- 一些热量进入单元 $i$
- 一些热量离开单元 $i$
要计算有多少热量进入和离开,注意只有在距单元边界距离 $U \, \delta t$ 以内的流体区域中的热量才会穿越边界。因此,单元 $i$ 中热量的比例为 $(U \, \delta t) / \delta x$ 会穿越边界。
因此,有 $T^n_i (U \delta t) / \delta x$ 的量将离开单元 $i$ 并进入单元 $i+1$(右侧单元)。类似地,$T^n_{i-1} (U \delta t) / \delta x$ 的量将从左侧相邻单元 $i-1$ 进入单元 $i$。
由此得到:
$$T^{n+1}_i = T^{n}_i + (T^n_{i-1} - T^n_{i})\, U \, \delta t / \delta x.$$
注意:等式右边是时间步 $n$ 的量,左边是时间步 $n+1$ 的量。于是得到了如何从第 $n$ 步更新到第 $n+1$ 步的方法。
连续极限:平流方程 PDE¶
重新排列前一个方程得到
$$\frac{T^{n+1}_i - T^{n}_i}{\delta t} = \frac{T^n_{i-1} - T^n_{i}}{\delta x}\, U.$$
当 $\delta t \to 0$ 和 $\delta x \to 0$ 时取连续极限,可以看到是偏导数的定义。用 $\partial$ 表示这些偏导数,得到平流方程:
$$\frac{\partial T(t, x)}{\partial t} = -U \frac{\partial T(t, x)}{\partial x},$$
简写为
$$\frac{\partial T}{\partial t} = -U \frac{\partial T}{\partial x}.$$
由于 $T$ 是 $x$ 和 $t$ 的函数,且此方程涉及关于两个独立变量的偏导数,因此这是一个偏微分方程(PDE)。它描述了函数 $T(t, x)$ 如何随时间和空间连续变化。
虽然有一些解析方法可以求解 PDE,但通常需要使用数值方法。这里将研究求解此类方程的简单数值方法。
平流方程的数值方法¶
回到以下形式的方程,其中下一时间步的值已被分离:
$$T^{n+1}_i = T^{n}_i - \left( U \frac{\delta t}{\delta x} \right) (T^n_{i} - T^n_{i-1}).$$
在右边最后一项中,需要同一时间步中不同位置 $T$ 值的某种组合。
最简单的方法是直接转录向量的第 $i$ 个分量的方程。将 T 称为当前向量,即 $\mathbf{T}^n := (T^n_i)_{i=1, \ldots, N_x}$,T_new 为下一个时间步的新向量:
T_new[i] = T[i] + δt * U * (T[i-1] - T[i]) / δx
但这时出现一个问题:当 $i=0$ 时怎么办?这将试图访问向量 T 的索引 $-1$,该索引不存在。
边界条件¶
这说明必须选择边界条件来指定区域边缘发生什么。
为简单起见,选择使用周期性边界条件。这是一种方便的数学处理,通过将系统环绕在一个环面上,使所有单元处于同等地位,从而使单元 $i=0$ 和 $i=N_x-1$ 成为相邻单元。
于是可以写成如下形式,单独写 $i=0$ 的情况(也可以考虑取模)。
dt = 0.001
U_adv = 0.2
def advection(T, dt, dx, U):
N = len(T)
T_new = np.zeros_like(T)
for i in range(1, N):
T_new[i] = T[i] - dt * U * (T[i] - T[i-1]) / dx
T_new[0] = T[0] - dt * U * (T[0] - T[N-1]) / dx
return T_new
这是平流方程的单次时间步;它接受当前的 $T$ 向量并返回更新后的新 $T$ 向量。
注意,这就像求解 ODE 的欧拉法的一步,但同时更新许多空间坐标。本质上在求解一个耦合的 ODE 组。
def temperature_heatmap(x, T, ax=None, vmin=-3, vmax=3):
if ax is None:
fig, ax = plt.subplots(figsize=(10, 1))
ax.imshow(np.array(T).reshape(1, -1), aspect='auto', cmap='RdBu_r',
extent=[x[0]-dx/2, x[-1]+dx/2, 0, 1],
vmin=vmin, vmax=vmax)
ax.set_yticks([])
return ax
def evolve(method, xs, dt, params, t_final=10.0, f0=T0_func):
T = np.array([f0(x) for x in xs])
t = 0.0
ts = [t]
results = [T.copy()]
while t < t_final - dt/2:
T_new = method(T, dt, dx, params)
results.append(T_new.copy())
t += dt
ts.append(t)
T = T_new.copy()
return np.array(ts), results
ts_adv, evolution_adv = evolve(advection, xs, dt, U_adv, t_final=2.0)
n_snapshot = len(evolution_adv) // 2
def plot_advection(n=n_snapshot):
n = min(n, len(evolution_adv) - 1)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 5),
gridspec_kw={'height_ratios': [4, 1]})
ax1.scatter(xs, evolution_adv[n], c='blue', zorder=5)
ax1.plot(xs, evolution_adv[n], c='blue', alpha=0.5)
ax1.set_xlim(0, 1)
ax1.set_ylim(-3.1, 3.1)
ax1.set_ylabel('T')
ax1.set_title(f'平流(上游风格):t = {ts_adv[n]:.2f}')
temperature_heatmap(xs, evolution_adv[n], ax2)
ax2.set_xlabel('x')
plt.tight_layout()
plt.show()
plot_advection()
interact(plot_advection,
n=IntSlider(min=1, max=len(evolution_adv)-1, step=1, value=1, description='时间步'));
interactive(children=(IntSlider(value=1, description='时间步', max=2000, min=1), Output()), _dom_classes=('widget…
遗憾的是,这种方法并不能按预期工作:随着时间的推移,剖面形状没有保持不变,而是在衰减。这是由于近似方式造成的。
一种更好的空间导数离散化方法是使用以下中心差分:
$$\frac{\partial T(t_n, x_i)}{\partial x} \simeq \frac{T^n_{i+1} - T^n_{i-1}}{2 \delta x}$$
def advection2(T, dt, dx, U):
N = len(T)
T_new = np.zeros_like(T)
for i in range(1, N-1):
T_new[i] = T[i] - dt * U * (T[i+1] - T[i-1]) / (2 * dx)
T_new[0] = T[0] - dt * U * (T[1] - T[N-1]) / (2 * dx)
T_new[N-1] = T[N-1] - dt * U * (T[0] - T[N-2]) / (2 * dx)
return T_new
ts_adv2, evolution_adv2 = evolve(advection2, xs, dt, 0.1, t_final=2.0)
n_snapshot2 = len(evolution_adv2) // 2
def plot_advection2(n=n_snapshot2):
n = min(n, len(evolution_adv2) - 1)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 5),
gridspec_kw={'height_ratios': [4, 1]})
ax1.scatter(xs, evolution_adv2[n], c='blue', zorder=5)
ax1.plot(xs, evolution_adv2[n], c='blue', alpha=0.5)
ax1.set_xlim(0, 1)
ax1.set_ylim(-3.1, 3.1)
ax1.set_ylabel('T')
ax1.set_title(f'平流(中心差分):t = {ts_adv2[n]:.2f}')
temperature_heatmap(xs, evolution_adv2[n], ax2)
ax2.set_xlabel('x')
plt.tight_layout()
plt.show()
plot_advection2()
interact(plot_advection2,
n=IntSlider(min=1, max=len(evolution_adv2)-1, step=1, value=1, description='时间步'));
interactive(children=(IntSlider(value=1, description='时间步', max=2000, min=1), Output()), _dom_classes=('widget…
连续极限:热传导方程 PDE¶
引入 $\delta x$ 作为空间离散化,$\delta t$ 作为时间步长,得到
$$T^{n+1}_i = \kappa \frac{\delta t}{\delta x^2} (T^n_{i-1} - 2 T^n_i + T^n_{i+1}).$$
连续极限是以下热传导方程或扩散方程:
$$\frac{\partial T}{\partial t} = \kappa \frac{\partial^2 T}{\partial x^2}.$$
其中 $\kappa$ 是热扩散率,描述热量扩散的速度。在质量扩散的背景下,等价的是扩散系数 $D$。
要获得求解此方程的数值方法,再次需要离散化,特别是二阶导数。一种可能的离散化是
$$\frac{\partial^2 T}{\partial x^2}(t_n, x_i) \simeq \frac{T^n_{i+1} - 2 T^n_i + T^n_{i-1}}{\delta x^2}.$$
def diffusion(T, dt, dx, D):
N = len(T)
T_new = np.zeros_like(T)
for i in range(1, N-1):
T_new[i] = T[i] + dt * D * (T[i+1] - 2*T[i] + T[i-1]) / (dx**2)
T_new[0] = T[0] + dt * D * (T[1] - 2*T[0] + T[N-1]) / (dx**2)
T_new[N-1] = T[N-1] + dt * D * (T[0] - 2*T[N-1] + T[N-2]) / (dx**2)
return T_new
ts_diff, evolution_diff = evolve(diffusion, xs, dt, 0.01, t_final=5.0)
n_snapshot_diff = len(evolution_diff) // 2
def plot_diffusion(n=n_snapshot_diff):
n = min(n, len(evolution_diff) - 1)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 5),
gridspec_kw={'height_ratios': [4, 1]})
ax1.scatter(xs, evolution_diff[n], c='blue', zorder=5)
ax1.plot(xs, evolution_diff[n], c='blue', alpha=0.5)
ax1.set_xlim(0, 1)
ax1.set_ylim(-3.1, 3.1)
ax1.set_ylabel('T')
ax1.set_title(f'扩散:t = {ts_diff[n]:.2f}')
temperature_heatmap(xs, evolution_diff[n], ax2)
ax2.set_xlabel('x')
plt.tight_layout()
plt.show()
plot_diffusion()
interact(plot_diffusion,
n=IntSlider(min=1, max=len(evolution_diff)-1, step=1, value=1, description='时间步'));
interactive(children=(IntSlider(value=1, description='时间步', max=5000, min=1), Output()), _dom_classes=('widget…
平流-扩散 PDE¶
最后,可以将两种机制结合起来,描述一个同时被平流(以恒定速度运动)和扩散的示踪物。这基本上利用了平流和扩散函数的复合:
def advection_diffusion(T, dt, dx, params):
U, D = params
temp = advection2(T, dt, dx, U)
return diffusion(temp, dt, dx, D)
UU_default = 0.5
DD_default = 0.01
ts_ad, results_ad = evolve(advection_diffusion, xs, dt,
(UU_default, DD_default), t_final=2.0)
n_snapshot_ad = len(results_ad) // 2
def plot_advection_diffusion(U=UU_default, D=DD_default, n=n_snapshot_ad):
ts_local, results_local = evolve(advection_diffusion, xs, dt,
(U, D), t_final=2.0)
n = min(n, len(results_local) - 1)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 5),
gridspec_kw={'height_ratios': [4, 1]})
ax1.scatter(xs, results_local[n], c='blue', zorder=5)
ax1.plot(xs, results_local[n], c='blue', alpha=0.5)
ax1.set_xlim(0, 1)
ax1.set_ylim(-3.1, 3.1)
ax1.set_ylabel('T')
ax1.set_title(f'平流-扩散(U={U:.2f}, D={D:.3f}):t = {ts_local[n]:.2f}')
temperature_heatmap(xs, results_local[n], ax2)
ax2.set_xlabel('x')
plt.tight_layout()
plt.show()
plot_advection_diffusion()
interact(lambda n: plot_advection_diffusion(n=n),
n=IntSlider(min=1, max=2000, step=1, value=1, description='时间步'));
interactive(children=(IntSlider(value=1, description='时间步', max=2000, min=1), Output()), _dom_classes=('widget…
interact(plot_advection_diffusion,
U=FloatSlider(min=-1, max=1, step=0.01, value=0.5, description='U'),
D=FloatSlider(min=-0.2, max=0.2, step=0.001, value=0.01, description='D'),
n=IntSlider(min=1, max=2000, step=1, value=1, description='时间步'));
interactive(children=(FloatSlider(value=0.5, description='U', max=1.0, min=-1.0, step=0.01), FloatSlider(value…
本讲小结¶
本讲从 ODE(常微分方程)迈向 PDE(偏微分方程),引入了空间维度的离散化与建模。从计算思维的角度:
1. 抽象与维度的扩展
此前,温度 $T$ 仅是时间 $t$ 的函数(ODE)。本讲将其扩展为 $T(t, x)$,依赖时间和空间两个独立变量,由此引入偏微分方程。这是计算思维中"抽象层次提升"的典型例子:同一个时间步进框架,从处理标量变为处理向量(空间网格上的离散值),代码结构几乎不变。
2. 离散化(Discretization)
连续函数 $T(x)$ 无法直接在计算机中表示。本讲采用网格点采样的方法,将空间区间 $[0, L_x]$ 均分为 $N_x$ 个单元,在每个单元中心存储一个温度值。这种空间离散化与第17讲的时间离散化(欧拉法)相结合,形成了求解 PDE 的基础:
- 空间离散化:上游差分 vs 中心差分,以及二阶导数的离散化
- 时间离散化:前向欧拉法从 ODE 推广到 PDE
3. 分解与组合(Decomposition & Composition)
平流和扩散是两个独立的物理过程,各自对应一个 PDE:
- 平流方程:$\partial T / \partial t = -U \, \partial T / \partial x$
- 扩散方程:$\partial T / \partial t = \kappa \, \partial^2 T / \partial x^2$
代码中分别实现了 advection2() 和 diffusion() 函数,然后通过函数复合 advection_diffusion() 将两者组合。这种"先分解、再组合"的策略使得代码模块化,且易于扩展新的物理过程。
4. 建模:从物理直觉到代码
- 平流:流体携带着温度移动——对应上游差分格式
- 扩散:连接到第13讲的随机游走——随机游走的连续极限就是扩散方程
这两个物理过程的数值实现都直接来源于对物理机制的理解,体现了"物理 → 数学 → 代码"的建模路径。
扩展:根据本讲内容建立地球的一维能量平衡模型,计算得出赤道热、两极冷的温度分布。