40行でPoisson方程式を解く


Haskellで配列を使ってみたいなーと思っていて、Poisson方程式を数値計算してみたら40行だったので1人で感動しちゃったので残しておきます。
Haskellの表現力の強さを知ってもらえればうれしいです。

# 注意
- とりあえず実装したかったので、処理速度とかは一切考慮してないです。
- 同じ理由で、精度も可視化していい感じになることを目指しています。
- Haskell、数値計算とも初学者なので、間違いとかよりよい作法がありましたら教えていただけると大変うれしいです。

# はじめに
この記事では、Poisson方程式の概要→差分方程式→アルゴリズム→Haskellで書いたコードという順で説明していきます。

# Poisson方程式とは
Poisson方程式は二階の偏微分方程式で、次の様に表されるもののことを言います。

$$\Delta\phi=f$$

ここで、$\Delta$は微分演算子で

```math
\Delta=\nabla \cdot \nabla \\
\nabla=(\frac{\partial}{\partial x},\frac{\partial}{\partial y},\frac{\partial}{\partial z})
```
となります。また、$\phi,f$はスカラー関数です。

この記事では簡単のため二次元の系を扱うことにして、Poisson方程式として次の電位と電荷密度に関する方程式を解いてみます。3次元への拡張も簡単にできるので是非やってみて下さい。
$$\Delta\phi=-\frac{\rho}{\epsilon_0}$$

ただし、$\phi(x,y)$は座標$(x,y)$における電位、$\rho(x,y)$は電荷密度、$\epsilon_0$は真空中の誘電率を表します。

# 差分方程式を導出する
解きたい方程式
$$\Delta\phi=-\frac{\rho}{\epsilon_0}$$

は次のようにも表せます。

$$\frac{\partial^2\phi}{\partial x^2}+\frac{\partial^2\phi}{\partial y^2}=-\frac{\rho}{\epsilon_0}$$

ここで、二階の偏導関数は格子幅$\Delta x$を用いて、

$$\frac{\partial^2\phi}{\partial x^2}\simeq\frac{\phi(x+\Delta x,y)-2\phi(x,y)+\phi(x-\Delta x,y)}{\Delta x ^2}$$

と近似できることが知られています。このような差分の取り方を中心差分と言います。
これを用いて偏導関数を置き換えると、次を得ます。
$$\frac{\phi(x+\Delta x,y)-2\phi(x,y)+\phi(x-\Delta x,y)}{\Delta x ^2} + \frac{\phi(x,y+\Delta y)-2\phi(x,y)+\phi(x,y-\Delta y)}{\Delta y ^2}=-\frac{\rho(x,y)}{\epsilon_0}$$
問題を簡単にするために、$\Delta x=\Delta y=\delta$として、$i,j\in\mathbb{Z},\phi(x+i\delta,y+j\delta)$を単に$\phi_{i,j}$と表記することにします。そうすると、次の差分方程式を得ます!

$$\phi_{i,j}=\frac{1}{4}(\phi_{i+1,j} + \phi_{i-1,j}+\phi_{i,j+1}+\phi_{i,j-1}+\delta^2\frac{\rho_{i,j}}{\epsilon_0})$$

つまり、ある一点$(i,j)$における電位$\phi_{i,j}$を求めるには周囲の4点での電位を平均した値に$\delta^2\frac{\rho_{i,j}}{4\epsilon_0}$を足せばよいと、この方程式は言っています。

# アルゴリズム
得られた差分方程式を用いて具体的に解く手法を考えます。

まず、対象とする$x,y$方向の区間を$n$分割し、格子幅を$δ$とします。すると$xy$平面は次のように分割されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489648/66d4d054-aad1-08f2-68b3-e736031a599f.png)
この場合、対象とする区間は$\forall x\in[0,1],\forall y\in[0,1]$です。
それぞれの点には物理量$\phi,\rho$がありこれらの値を差分方程式から求めれば良いのですが、$i=0,n$や$j=0,n$の時は周囲の値を参照することができないので、境界条件として

```math
\forall i\forall j,\phi_{0,j}=\phi_{n,j}=\phi_{i,0}=\phi_{i,n}=0
```
を設けてやります。

そうすると、具体的には次のような連立方程式を解けば良いことになります。

```math
\phi_{1,1}=\frac{1}{4}(\phi_{2,1} + \phi_{0,1}+\phi_{1,2}+\phi_{1,0}+\delta^2\frac{\rho_{1,1}}{\epsilon_0}) \\
\phi_{1,2}=\frac{1}{4}(\phi_{2,2} + \phi_{0,2}+\phi_{1,3}+\phi_{1,1}+\delta^2\frac{\rho_{1,2}}{\epsilon_0}) \\
\vdots \\
\phi_{n-1,n-1}=\frac{1}{4}(\phi_{n,n-1} + \phi_{n-2,n-1}+\phi_{n-1,n}+\phi_{n-1,n-2}+\delta^2\frac{\rho_{n-1,n-1}}{\epsilon_0})
```
しかし、実は困ったことに$\phi_{i,j} \ (1\le i\le n-1, 1\le j\le n-1)$の値も分からないのですが、上記の連立方程式はヤコビ法やガウス-ザイデル法と呼ばれる連立方程式の反復解法を用いることができる形になっています。(詳しくは[ヤコビ法](https://ja.wikipedia.org/wiki/%E3%83%A4%E3%82%B3%E3%83%93%E6%B3%95)を参照してください。)
今回はヤコビ法を用いることにして、適当に$\phi_{i,j}^{(0)}$の値を設定し、$\vec{x}^{(k+1)}=D^{-1}\lbrace\vec{b}-(L+U)\vec{x}^{(k)}\rbrace$という形の漸化式を上記の方程式に対して繰り返し適用し、
$$max \\ |\phi_{i,j}^{(k+1)}-\phi_{i,j}^{(k)}|\lt\epsilon$$
という条件を満たした時、$\phi_{i,j}^{(k+1)}$を解として出力します。ただし、関数$max$は$i,j$の組み合わせを全て走査してその中の最大値を返す関数で、$\epsilon$は小さな数でプログラムの停止基準になります。

# 問題設定
今回実際に数値計算してみる系として次のようなものを考えます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489648/2d5c753a-d645-e606-dc3c-690dfc53f425.png)
平面$1[m^2]$を対象として境界を含んで外側は接地されていて$0[V]$で、中心に半径$10[cm]$で電荷密度$1.0\times10^{-8}$の電荷を設置した状況で、各点の電位を求めます。

# Haskellプログラムの説明
それでは、Haskellでゴリゴリ書いていきましょう!
最初はもちろんライブラリのインポートです。

```haskell:Haskell
module PoissonEqSolver where
import Data.Array
import Text.Printf
```
今回は主なデータ構造として配列を用いるので`Data.Array`を、出力する時に簡単に整形できるので`Text.Printf`をインポート
しています。(Arrayはboxedでimmutableな配列です。)

続いて、定数たちを宣言します。

```haskell:Haskell
ε0 = 8.85E-12 --真空中の誘電率
n = 100 --区間はn分割される
δ = 1 / fromIntegral n --格子幅
```
上から、真空中の誘電率、分割数、格子幅です。

ところで、Haskellの配列は`array`関数を用いて次の形で宣言します。

```haskell:Haskell
arr = array (インデックスの最小値,インデックスの最大値) [(インデックス,要素) | インデックスの生成]
```
`arr`の型は`Array i e`で`i`はインデックスの型、`e`は要素の型となります。
ちなみに、インデックスへのアクセスは`!`関数でやります。
これを使って仮の電位分布と電荷分布を二次元配列で宣言します。

```haskell:Haskell
φ0 :: Array (Int, Int) Double
φ0 = array ((0,0),(n,n)) [((x,y), 0) | x <- [0..n], y <- [0..n]] --仮の電位分布

ρ0 :: Array (Int, Int) Double
ρ0 = array ((0,0),(n,n)) [((x,y), f x y) | x <- [0..n], y <- [0..n]] --電荷密度の分布
    where
        f x y | (δ*fromIntegral (x - div n 2))^2 + (δ*fromIntegral (y - div n 2))^2 <= 0.1^2 = 1.0E-08 --円状に電荷密度を分布させる
              | otherwise = 0
```
仮の電位分布`φ0`は全ての要素を0にセットしておいて、電荷分布`ρ0`には中心に円状に電荷を分布をさせています。(`fromIntegral`関数を多用するとコードが汚くなっちゃうのはしょうがないのでしょうか...?)

それでは、漸化式を記述する関数`next`を実装します。

```haskell:Haskell
next :: Array (Int, Int) Double -> Array (Int, Int) Double -> Array (Int, Int) Double
next ρ φ = φ // [((x,y),f x y) | x <- [1..n-1], y <- [1..n-1]]
    where
        f x y  = (/4) $ φ ! (x+1,y) + φ ! (x-1,y) + φ ! (x,y+1) + φ ! (x,y-1) + δ^2 * ρ ! (x,y) / ε0
```
`where`句で宣言されている関数`f`はインデックス`x,y`から周囲の物理量にアクセスして平均した値を返します。`f`をインデックス全体に渡って`map`してあげることで次のステップの状態に移ります。

得られた電位をgnuplotで表示できるように、雑に外部出力関数を作ります。

```Haskell:haskell
output :: Array (Int,Int) Double -> IO ()
output φ = do
    let filePath = "./result.dat"
    writeFile filePath ""
    mapM_ (appendFile filePath) $ do
        x <- [0..n]
        y <- [0..n]
        return $ printf "%d %d %.15f\n" x y (φ ! (x,y))
```
`filePath`に出力先のパスを宣言、`writeFile`関数でとりあえずファイルを開きます。
その後、`mapM_`でリスト全体に`append`関数を適用することで、`filePath`に`x y φ(x,y)`の順で逐次書き込みます。(初めてリストモナドを使ってみました、かっこいいですね。)

最後に、実際に系を解く関数を用意しておしまいです。

```haskell:Haskell
solve :: Double -> Array (Int,Int) Double -> Array (Int,Int) Double -> IO ()
solve ε ρ φ | error < ε = do
                putStrLn "Write result to ./result.dat \nWait..."
                output φ'
                printf "Finished.\n"
            | otherwise = solve ε ρ φ'
    where
        φ' = next ρ φ
        error = maximum [abs ( φ ! (x,y) - φ' ! (x,y) ) | x <- [0..n], y <- [0..n]]
```
この関数を端的に説明すると、現時点での解と後の解との誤差を`error`で評価して`ε`未満ならば、`output`関数に電位を渡す、そうでなければ、次のステップの電位を自身に引数として渡す、ということをします。`where`句では、次のステップの電位である`φ'`と誤差`error`が定義されています。

# 40行でPoisson方程式を解く
まとめとして、ソースコード全体を載せておきます。ぴったり40行だよ、やったね!

```haskell:Haskell
module PoissonEqSolver where
import Data.Array
import Text.Printf

ε0 = 8.85E-12 --真空中の誘電率
n = 100 --区間はn分割される
δ = 1 / fromIntegral n --格子幅

φ0 :: Array (Int, Int) Double
φ0 = array ((0,0),(n,n)) [((x,y), 0) | x <- [0..n], y <- [0..n]] --仮の電位分布

ρ0 :: Array (Int, Int) Double
ρ0 = array ((0,0),(n,n)) [((x,y), f x y) | x <- [0..n], y <- [0..n]] --電荷密度の分布
    where
        f x y | (δ*fromIntegral (x - div n 2))^2 + (δ*fromIntegral (y - div n 2))^2 <= 0.1^2 = 1.0E-08 --円状に電荷密度を分布させる
              | otherwise = 0

next :: Array (Int, Int) Double -> Array (Int, Int) Double -> Array (Int, Int) Double
next ρ φ = φ // [((x,y),f x y) | x <- [1..n-1], y <- [1..n-1]]
    where
        f x y  = (/4) $ φ ! (x+1,y) + φ ! (x-1,y) + φ ! (x,y+1) + φ ! (x,y-1) + δ^2 * ρ ! (x,y) / ε0

output :: Array (Int,Int) Double -> IO ()
output φ = do
    let filePath = "./result.dat"
    writeFile filePath ""
    mapM_ (appendFile filePath) $ do
        x <- [0..n]
        y <- [0..n]
        return $ printf "%d %d %.15f\n" x y (φ ! (x,y))

solve :: Double -> Array (Int,Int) Double -> Array (Int,Int) Double -> IO ()
solve ε ρ φ | error < ε = do
                putStrLn "Write result to ./result.dat \nWait..."
                output φ'
                printf "Finished.\n"
            | otherwise = solve ε ρ φ'
    where
        φ' = next ρ φ
        error = maximum [abs ( φ ! (x,y) - φ' ! (x,y) ) | x <- [0..n], y <- [0..n]]
```
Haskellは楽しい!

# gnuplotで表示する
`ghci`で`solve 1e-05 ρ0 φ0`を入力しEnterキーを押して待ちます。
`result.dat`が吐き出されます。

出力された`result.dat`をgnuplotで可視化してみました。z軸が電位[V]の大きさを表しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489648/0fa7d803-798c-252f-4441-ca094c171b44.png)

いい感じですね。

# 感想
今回は配列を用いてPoisson方程式を解いてみました、楽しかったです。
ただ、想像以上に処理に時間がかかり、如何にして速度を出すかが課題だと思いました。(IOUArrayでSOR法で解いてみたい、Vectorが早いと風のうわさで聞いたので使ってみたい。)
本当は解析解との比較などもやりたかったのですが、それは他の強い文献にまかせて自粛させていただきます。

ここまで見てくれた人、ありがとうございます!

# 最後に
PoissonがPoisonになってないかめっちゃ確認しました。