Pythonプログラミング(その他のトピック・KdV方程式とソリトン)

このページでは、代表的なソリトンの例であるKdV方程式のシミュレーションを試みてみよう。併せて、時間変化する現象の可視化方法についても、何通りか試してみることにする。(ここはこれから)

津波を記述する方程式

底面が平らなプールに水が満たされているとき、その表面に立つ波を考えてみる。 波の波長がプールの深さよりも十分に長く、その振幅はプールの深さより十分に小さい状況で、 非圧縮(水の密度がどこでも一定)で渦はなく、粘性も無視できるなどの仮定を置くと、いわゆる浅水波を記述する方程式が得られる。 プールの縦の断面を取って、横方向をx軸として、水面の平均的な高さを$h$とし、平均水面からの高さの変動を$\eta$で表すことにする。 そのような場合、表面が上下に揺れると位相速度$c_0 = \sqrt{gh}$の「重力波」($g$は重力加速度)が生じること知られている。 海洋を伝搬する津波は、水深に比べて十分に長い波長を持つことが知られ、まだその振幅は水深よりも十分に小さいため、「浅い波」とみなすことができる。 海洋を津波が高速で伝搬するのは、水深$h$が大きいためである。

表面に立つ波の性質をさらに詳しく調べ、流体力学的な非線形効果まで考慮すると、波の振る舞い方が異なってくることが Korteweg-de Vriesらによって 明らかになった(再発見された)。 座標を線形波の速さで動いている座標系に取り直すと($x \leftarrow x - c_0 t$)、 波動を表す式は $$ \frac{\partial \eta(x,t)}{\partial t} + \frac{3 c_0}{2h} \eta(x,t) \frac{\partial \eta(x,t)}{\partial x} + \frac{c_0 h^2}{6} \frac{\partial^3 \eta(x,t)}{\partial x^3} = 0 $$ となり、これは(再)発見者の名前にちなんでKorteweg-de Vries (KdV)方程式と呼ばれている。

変数のスケールを取り直すことによって、各項の係数を調整することができる。 標準的な形として $$ \frac{\partial u(x,t)}{\partial t} + 6 u(x,t) \frac{\partial u(x,t)}{\partial x} + \frac{\partial^3 u(x,t)}{\partial x^3} = 0 $$ がよく登場するので、以下ではこれを考えることにする。

(書きかけ)

KdV方程式とソリトン

KdV方程式には様々な(無限の)解が存在することが知られている。 ひと山のパルスが伝搬する解は1-ソリトン解と呼ばれ、位相速度を$c = 4 \kappa^2$と置くと、 $$ u(x,t) = 2 \kappa^2 \textrm{sech}^2\left(\kappa(x-c t + \delta) \right) $$ と表現される。$c$は正であるから、波動はxが正の方向にのみ伝搬することがわかる。 また、山の高さ(振幅)が大きい波ほど、伝搬速度が大きくなるのは、線形波動と大きくことなる点である。

解の導出は専門書に譲って、これが本当に方程式の解になっているかどうか、式に代入して確認してみることにしよう。 以下はsympyを使って、上記の解をKdV方程式に代入し、方程式が成り立つかどうか(結果が0になるかどうか)を調べるコードの例である:

from sympy import *

x,t,u,kdveq = symbols("x, t, u, kdveq")
k,c,d = symbols("k, c, d")

c = 4*k**2
u = 2 * k**2 * sech(k*(x-c*t+d))**2

kdveq = diff(u,t) + 6 * u * diff(u,x) + diff(u,x,3)

print(simplify(kdveq))

sympyには代表的な関数はすでに定義されており、双曲線関数 $\textrm{sech}(x) = \frac{2}{e^x + e^{-x}}$ もそのまま記述することができる。

(書きかけ)

離散フーリエ変換による微分の計算

以下では、Numerical Solution of the KdVに述べられている方法を参考に、 KdV方程式を数値的に解くための具体的なコードを考えてみよう。

KdV方程式のシミュレーションには、一階および三階の空間微分を精度よく行う必要がある。 そのためには、スペクトル精度を持つFFTを使った微分計算が有効である。 具体的、関数 $u(x)$の空間微分は以下の手順で求めることができる。

長さ$L$の区間を$\Delta x = L/N$の幅に等分して($N$は分割数)、$\ell$番目の区画の座標を $x(\ell) = \Delta x \, \ell$ と置く。 説明の便宜上$\ell=0$を原点に対応させるが、座標がシフトしても以下の議論は全く同様である。 $x$の関数$u(x)$を考え、その離散フーリエ変換を $$ U_k = \sum_{\ell=0}^{N-1} u_\ell \exp\left( -\frac{2 \pi k x(\ell)}{L} i\right) $$ と定義することにする。ここで、座標が離散的に指定されていることを明示するため、同じことであるが、 $$ U_k = \sum_{\ell=0}^{N-1} u_\ell \exp\left( -\frac{2 \pi k \ell}{N} i\right) $$ と書いておく。 その逆変換は、 $$ u_{\ell} = \frac{1}{N} \sum_{k=0}^{N-1} U_k \exp\left( \frac{2 \pi k x(\ell)}{L} i\right) $$ となる。

これを使うと、一階微分は $$ \begin{eqnarray} \frac{\partial u(x)}{\partial x} & = & \frac{1}{N} \sum_{k} \left(\frac{2 \pi i k}{L} \right) U_k \exp\left( \frac{2 \pi k x(\ell)}{L} i\right) \\ & = & \frac{1}{N} \sum_{k} \left(\frac{2 \pi i k}{\Delta x \, N} \right) U_k \exp\left( \frac{2 \pi i k \ell}{N} i\right) \end{eqnarray} $$ のように、元のフーリエ成分に $\frac{2 \pi i k}{\Delta x \, N}$という因子を乗じると、微分のフーリエ成分が得られることがわかる。 ただし、フーリエ空間の折返し構造から、乗ずる因子を、 $k \le N/2$では $$ \frac{2 \pi i k}{\Delta x \, N} $$ $k \gt N/2$ については $$ \frac{2 \pi i (k-N)}{\Delta x \, N} $$ とする必要がある。 そのあたりの事情については、チューリングパターンのページで説明したので参照のこと。

全く同様に、$k \le N/2$については $\left( \frac{2 \pi i k}{\Delta x \, N} \right)^3$、 $k \gt N/2$ では $\left(\frac{2 \pi i (k-N)}{\Delta x \, N}\right)^3$を乗じれば、3階微分のフーリエ成分が得られる。

微分方程式の時間発展の記述

解きたいKdV方程式は $$ \frac{\partial u}{\partial t} + 3 \frac{\partial (u^2)}{\partial x} + \frac{\partial^3 u}{\partial x^3} = 0 $$ と書き直すことができる。 この式全体を$x$についてフーリエ変換すると $$ \frac{\partial U(q,t)}{\partial t} + 3 i q U_2(q,t) - i q^3 U(q,t) = 0 $$ となる。ただし、ここでは $$ q(k) = \begin{cases} \frac{2 \pi k}{L}, & \text{if } k \leq N/2\\ \frac{2 \pi (k-N)}{L}, & \text{otherwise} \end{cases} $$ と置いて、(離散)フーリエ変換を $$ U(q,t) = \sum_\ell u(x(\ell),t) \exp\left( - i q(k) x(\ell) \right) $$ $$ U_2(q,t) = \sum_\ell \left\{ u(x(\ell),t) \right\}^2 \exp\left( - i q(k) x(\ell) \right) $$ で定義した。

フーリエ変換したKdV方程式は$t$についての常微分方程式と見なせるから、これをオイラー差分で近似すると、$u(x,t)$のフーリエモードの時間発展を表す差分方程式 $$ U(q,t+\Delta t) = U(q,t) + \left[ - 3 i q U_2(q,t) + i q^3 U(q,t) \right] \Delta t $$ が得られる。 $U(q,t)$と$U_2(q,t)$が含まれているように見えるが、 $U_2(q,t)$は、時間ステップ毎に$U(q,t)$を逆フーリエ変換して$u(x,t)$を得てから、それを二乗した関数をフーリエ変換することで得られるので、 閉じた関係式を形成できる(その過程で、異なる波数の波の相互作用が生じる)。

ただし、このスキームをそのまま適用すると、誤差が累積して短時間で計算が破綻してしまう。 そこで、こちらのサイトでは、 $$ U(q,t+\Delta t) = U(q,t) + 3 i q^3 U(q,t) \Delta t $$ の部分が線形で厳密に解けることから $$ U_1(q,t+\Delta t) = U(q,t) \exp\left(i q^3 \Delta t\right) $$ という途中段階を経てから $$ U(q,t+\Delta t) = U_1(q,t+\Delta t) - 3 i q U_2(q,t+\Delta t) \Delta t $$ を得る方法を提案している。 ここで、$U_2(q,t+\Delta t)$は、$U_1(q,t+\Delta t)$を逆フーリエ変換して得た$u_1(x,t+\Delta t)$を自乗してフーリエ変換した $$ \begin{eqnarray} u_1(x,t+\Delta t) & \leftarrow & \mathcal{F}^{-1}[U_1(q,t+\Delta t)] \\ U_2(q,t+\Delta t) & \leftarrow & \mathcal{F}\left[ \left\{ u_1(x,t+\Delta t) \right\}^2 \right] \end{eqnarray} $$ を用いると良いとされている($\mathcal{F}[\cdot]$はフーリエ変換、$\mathcal{F}^{-1}[\cdot]$はその逆変換)。

Pythonによるコーディングの例

以下は $L=20$の区間(周期境界)で、初期条件 $$ u(x,0) = 12 \exp\left(- x^2 \right) $$ のもとで計算し、アニメーション表示するコードの例である。

時間方向の積分は関数nextstep()の中に書かれており、 前節のアルゴリズムそのままではなく、中点法を使って $$ \begin{eqnarray} U_1(q,t+\Delta t) & \leftarrow & U(q,t) \exp\left(i q^3 \Delta t\right) \\ K_1 & \leftarrow & - 3 i q \mathcal{F}[u^2] \\ U_2(q,t+\Delta t/2) & \leftarrow & U_1(q,t+\Delta t) + K_1 \Delta t/2 \\ u_2(x,t+\Delta t/2) & \leftarrow & \mathcal{F}^{-1}[U_2(q,t+\Delta t/2)] \\ K_2 & \leftarrow & - 3 i q * \mathcal{F}[u_2(x,t+\Delta t/2)^2] \\ U(q,t+\Delta t) & \leftarrow & U_1(q,t+\Delta t) + K_2 \Delta t \\ \end{eqnarray} $$ によって行っている。

このコードはGoogle Colaboratoryで実行することを想定。

単体で実行する場合は、末尾付近のplt.close()の行はplt.show()に変更すること。

import numpy as np
import random
import math
import matplotlib.pyplot as plt
from matplotlib import animation, rc
from IPython.display import HTML

def prep_3iq(n,dx):
    A = np.zeros((n,),dtype=np.complex64)
    for k in range(0,n):
        q = 2*math.pi*k/(dx*n) if k<=n//2 else 2*math.pi*(k-n)/(dx*n)
        A[k] = 3*1j*q
    return A

def prep_iq3(n,dx):
    A = np.zeros((n,),dtype=np.complex64)
    for k in range(0,n):
        q = 2*math.pi*k/(dx*n) if k<=n//2 else 2*math.pi*(k-n)/(dx*n)
        A[k] = 1j*q*q*q
    return A

def nextstep():
    global u,t
    U = np.fft.fft(u)    
    for cnt in range(500):
        U1 = U * np.exp(OP_iq3 * dt)
        K1 = - OP_3iq * np.fft.fft(u*u)
        U2 = U1 + K1 * dt/2
        u2 = np.fft.ifft(U2).real
        K2 = - OP_3iq * np.fft.fft(u2*u2)
        U = U1 + K2 * dt
        u = np.fft.ifft(U).real
        t = t + dt

###
N = 256
L = 20
dx = L/N

OP_3iq = prep_3iq(N,dx)
OP_iq3 = prep_iq3(N,dx)

x = np.linspace(-L/2,L/2,N)
u = np.exp(-x*x)*12

HSCALE=4
VSCALE=10
H = 800

fig, ax = plt.subplots()
images = []

t = 0
dt = 0.00001

ax.set_xlabel('x')
ax.set_ylabel('u')

while t<0.1:
    img = ax.plot(x,u,color='red')
    label = plt.text(0.5,1, 't='+'{:.4f}'.format(t), ha='left',va='bottom',color='black', transform=ax.transAxes)
    images.append([img[0],label])
    nextstep()

anim = animation.ArtistAnimation(fig, images, interval=100)
rc('animation', html='jshtml')
plt.close()
anim

上のコードをGoogle Colaboratoryのノートブック上で実行している様子。

上のコード例は、計算結果をプロットした画像をあらかじめ蓄えておいて、紙芝居のように表示するようにしているので、長時間にわたる表示には向いていない。

そこで、pygletを使って、画面に直接描画出力するコードの例も以下に示しておく。

このコードを実行するには pyglet(バージョン1.5.27以前) が必要

pyglet version 2をお使いの方は kdv-eq-pyglet2.pyをダウンロードしてお使いください。

# coding: utf-8

import numpy as np
import random
import math
import pyglet
from pyglet.gl import *

def prep_3iq(n,dx):
    A = np.zeros((n,),dtype=np.complex64)
    for k in range(0,n):
        q = 2*math.pi*k/(dx*n) if k<=n//2 else 2*math.pi*(k-n)/(dx*n)
        A[k] = 3*1j*q
    return A

def prep_iq3(n,dx):
    A = np.zeros((n,),dtype=np.complex64)
    for k in range(0,n):
        q = 2*math.pi*k/(dx*n) if k<=n//2 else 2*math.pi*(k-n)/(dx*n)
        A[k] = 1j*q*q*q
    return A

###
N = 256
L = 20
dx = L/N

OP_3iq = prep_3iq(N,dx)
OP_iq3 = prep_iq3(N,dx)

x = np.linspace(-L/2,L/2,N)
u = np.exp(-x*x)*12

t = 0
dt = 0.00001

HSCALE=4
VSCALE=10
H = 800
config = pyglet.gl.Config(double_buffer=True)
win = pyglet.window.Window(N*HSCALE,H,config=config,resizable=False)
win.set_caption('KdV Equation')

def nextstep():
    global u,t
    U = np.fft.fft(u)    
    for cnt in range(500):
        U1 = U * np.exp(OP_iq3 * dt)
        K1 = - OP_3iq * np.fft.fft(u*u)
        U2 = U1 + K1 * dt/2
        u2 = np.fft.ifft(U2).real
        K2 = - OP_3iq * np.fft.fft(u2*u2)
        U = U1 + K2 * dt
        u = np.fft.ifft(U).real
        t = t + dt
    

@win.event
def on_draw():
    glClear(GL_COLOR_BUFFER_BIT)
    glClearColor(0,0,0,1)
    glColor3d(0.2,0.2,0.3)
    glLineWidth(2.0)    
    for j in range(-100,100,10):
        glBegin(GL_LINES)
        glVertex2d(0, H/2 + VSCALE*j)
        glVertex2d(N*HSCALE, H/2 + VSCALE*j)
        glEnd()
        str = '{0}'.format(j)
        label_y = pyglet.text.Label(str,font_name='Helvetica', color=(255,255,255,255), font_size=12, x=20, y=H/2+VSCALE*j-6)
        label_y.draw()
    
    glColor3d(1.0, 0.5, 0.1)
    glLineWidth(4.0)
    glBegin(GL_LINE_STRIP)    
    for i in range(N):
        glVertex2d(i*HSCALE, H/2+u[i]*VSCALE)
    glEnd()

    str = '{0}'.format(-L/2)
    label_x0 = pyglet.text.Label(str,font_name='Helvetica', color=(255,255,255,255), font_size=12, x=0, y=H/2-25)
    label_x0.draw()

    str = '{0}'.format(L/2)
    label_x1 = pyglet.text.Label(str,font_name='Helvetica', color=(255,255,255,255), font_size=12, x=N*HSCALE-40, y=H/2-25)
    label_x1.draw()

    str = 'T={0:.3f}'.format(t)
    label_time = pyglet.text.Label(str,font_name='Helvetica', color=(255,255,255,255), font_size=12, x=10, y=10)
    label_time.draw()

def update(deltat):
    nextstep()    


pyglet.clock.schedule_interval(update, 1/20.0)
pyglet.app.run()

上のコードを実行した様子が以下の動画である(YouTube)。 初期条件として単峰の山(ガウス曲線)を与えると、それが複数の孤立波(ソリトン)に分裂し、進行する様子が観察できる。 振幅の大きな波ほど伝搬速度(位相速度)が大きいことも判る。

icon-pc 練習:積分スキームの比較

上のコードの関数 nextstep()中の積分のスキームを

  1. オイラー差分(1次)
  2. 4次のルンゲクッタ

に変更し、それぞれの動作や安定性を確認してみなさい。

icon-teacher 解説: Waterfall型のプロット

下図のように、波動の伝搬の様子を時空間的に可視化し、アニメーション表示するコードの例を示す。 時間が「上」から「下」に流れるように表示されるので waterfall plotと呼ぶことがある。 ソリトンが衝突し、再び分裂して進行する様子がよく確認できる(YouTube動画)。

計算部分は上記の例と全く同様であるが、計算結果を配列に格納・操作し、それを画像として表示することで、画面の更新をいくばくか高速化している。

NumPyの配列を画像として扱う際に、画像の横軸が「列」(axis=1)、画像の縦軸が「行」(axis=0)に対応する点に注意が必要である。

このコードを実行するには pyglet(version 1.5以下)が必要

pyglet version 2をお使いの方は kdv-eq-scroll-pyglet2.pyをダウンロードしてお使いください。

#coding utf-8

import numpy as np
import random
import math
import pyglet
from pyglet.gl import *

def prep_3iq(n,dx):
    A = np.zeros((n,),dtype=np.complex64)
    for k in range(0,n):
        q = 2*math.pi*k/(dx*n) if k<=n//2 else 2*math.pi*(k-n)/(dx*n)
        A[k] = 3*1j*q
    return A

def prep_iq3(n,dx):
    A = np.zeros((n,),dtype=np.complex64)
    for k in range(0,n):
        q = 2*math.pi*k/(dx*n) if k<=n//2 else 2*math.pi*(k-n)/(dx*n)
        A[k] = 1j*q*q*q
    return A

###
N = 256
L = 20
dx = L/N

OP_3iq = prep_3iq(N,dx)
OP_iq3 = prep_iq3(N,dx)

x = np.linspace(-L/2,L/2,N)
u = np.exp(-x*x)*12

t = 0
dt = 0.00001

SCALE=1
H = 400

IMG_DATA = np.zeros(shape=(H,N*SCALE,3),dtype=np.uint8)

config = pyglet.gl.Config(double_buffer=True)
win = pyglet.window.Window(N*SCALE,H,config=config,resizable=False)
win.set_caption('KdV Equation')
def nextstep():
    global u,t,umem
    U = np.fft.fft(u)
    for cnt in range(100):
        U1 = U * np.exp(OP_iq3 * dt)
        K1 = - OP_3iq * np.fft.fft(u*u)
        U2 = U1 + K1 * dt/2
        u2 = np.fft.ifft(U2).real
        K2 = - OP_3iq * np.fft.fft(u2*u2)
        U = U1 + K2 * dt
        u = np.fft.ifft(U).real                
        t = t + dt

@win.event
def on_draw():
    glClear(GL_COLOR_BUFFER_BIT)
    glClearColor(0,0,0,1)
    global IMG_DATA,u
    IMG_DATA = np.roll(IMG_DATA,1,axis=0)
    for i in range(N):
        r = int((math.sin(u[i]*0.1)+1)/2*255)
        g = int((math.sin(2*u[i]*0.1)+1)/2*255)
        b = int((1.0+math.cos(u[i]*0.1))/2*255)
        IMG_DATA[0,i*SCALE:(i+1)*SCALE,0] = r
        IMG_DATA[0,i*SCALE:(i+1)*SCALE,1] = g
        IMG_DATA[0,i*SCALE:(i+1)*SCALE,2] = b
    pixels = IMG_DATA.ravel()
    rawData =  (GLubyte * len(pixels))(*pixels)
    imageData = pyglet.image.ImageData(N*SCALE, H, 'RGB', rawData)
    imageData.blit(0, 0)

    glColor3d(1,1,1)
    glLineWidth(1)
    glBegin(GL_LINE_STRIP)
    glVertex2d(1, H-10)
    glVertex2d(1, H-20)
    glVertex2d(N*SCALE, H-20)
    glVertex2d(N*SCALE, H-10)        
    glEnd()
        
    str = '{0}'.format(-L/2)
    label_x0 = pyglet.text.Label(str,font_name='Helvetica', color=(255,255,255,255), font_size=10, x=0, y=H-30)
    label_x0.draw()

    str = '{0}'.format(L/2)
    label_x1 = pyglet.text.Label(str,font_name='Helvetica', color=(255,255,255,255), font_size=10, x=N*SCALE-30, y=H-30)
    label_x1.draw()

    str = 'T={0:.3f}'.format(t)
    label_time = pyglet.text.Label(str,font_name='Helvetica', color=(255,255,255,255), font_size=10, x=N*SCALE/2-30, y=H-12)
    label_time.draw()

def update(deltat):
    nextstep()    

pyglet.clock.schedule_interval(update, 1/20.0)
pyglet.app.run()

(これから)