3D curved surfaces

  • 자주 있는 일은 아니지만 3차원 곡면을 그릴 때가 있습니다.
  • 어떤 분은 원자를 표현하느라, 또는 쇠구슬을 표현하느라 구가 필요할지도 모릅니다.
  • 저는 업무상 태양이 하늘에 떠 있는 지점을 고민할 때가 많아서 반구가 필요합니다.
  • 과거에는 원자의 3차원 에너지를 표현하느라 이런 그림이 필요했습니다.

1. 데이터 준비

wikipedia: Spherical coordinate system

wikipedia: 구면 좌표계

  • 구나 반구를 그리려면 구면 좌표계로 정의된 데이터가 필요합니다.
  • 간단한 삼각함수를 사용해서 구면 데이터를 만듭니다.
  • 방위각(azimuthal angle, $\theta$)과 극고도각(polar angle, $\varphi$)을 나열한 뒤 여기에 아래 수식을 적용합니다.

$$
\begin{aligned}
x &= r \cos{\varphi} \sin{\theta}\\
y &= r \sin{\varphi} \sin{\theta}\\
z &= r \cos{\theta}
\end{aligned}$$

  • 코드로는 이렇게 정리됩니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    # angles
    polars = np.linspace(0, 180, 19)
    azimuths = np.linspace(0, 360, 37)

    # points
    from itertools import product

    df = pd.DataFrame(product(polars, azimuths), columns=["azi", "polar"])
    df["x"] = df.apply(lambda x: np.cos(np.deg2rad(x[1]))*np.sin(np.deg2rad(x[0])), axis=1)
    df["y"] = df.apply(lambda x: np.sin(np.deg2rad(x[1]))*np.sin(np.deg2rad(x[0])), axis=1)
    df["z"] = df.apply(lambda x: np.cos(np.deg2rad(x[0])), axis=1)

    df
    • 실행 결과

2. 3D 곡면 도형 그리기

matplotlib: 3D surface (colormap)
matplotlib: mpl_toolkits.mplot3d.axes3d.Axes3D

  • 주어진 데이터로 만들어지는 3D 표면 형상을 만드는데는 .plot_surface()가 적격입니다.
  • 앞서 만든 데이터로 구(sphere)와 반구(hemisphere)를 만들어봅시다.

2.1. sphere

  • 우리가 만든 좌표가 이미 구의 좌표입니다.

  • 방위각 $\varphi$를 0도에서 360도, 극고도각 $\theta$를 0도에서 180도까지 변화시켰고, 이는 3차원 공간의 모든 방향에 해당되기 때문입니다.

  • 그렇다면 남은 일은, DataFrame에 1차원으로 들어있는 각각의 좌표를 차원에 맞게 2차원으로 바꿔주는 것 뿐입니다.

  • 극고도각과 방위각의 경우의 수가 각각 19가지, 37가지이므로 .reshape((19, 37)을 적용합니다.

  • matplotlib은 구면좌표계가 아니라 직교좌표계를 사용합니다. x, y, z로 변환합니다.

    1
    2
    3
    4
    5
    6
    fig, ax = plt.subplots(figsize=(5, 5), 
    subplot_kw={"projection":"3d"})
    ax.plot_surface(df["x"].values.reshape((19, 37)),
    df["y"].values.reshape((19, 37)),
    df["z"].values.reshape((19, 37)),
    alpha=0.3)


  • 구가 그려졌습니다.

  • 그런데 z축 방향으로 조금 찌그러져서 납작한 느낌이 나네요.

  • aspect ratio를 1:1:1로 맞춰줍시다.

  • matplotlib 3차원 Axes(Axes3D)에서 aspect ratio를 지정하는 명령은 .set_box_aspect()입니다.

    1
    2
    3
    4
    5
    6
    7
    fig, ax = plt.subplots(figsize=(5, 5), 
    subplot_kw={"projection":"3d"})
    ax.plot_surface(df["x"].values.reshape((19, 37)),
    df["y"].values.reshape((19, 37)),
    df["z"].values.reshape((19, 37)),
    alpha=0.3)
    ax.set_box_aspect((1, 1, 1))


2.2. partial sphere

  • 극좌표계를 사용해서 구의 일부를 쪼개볼 수도 있습니다.
  • plot_surface()안에 넣는 x, y, z에다 slicing을 추가하면 됩니다.
  • 극고도각은 전체 범위를 사용하고 방위각은 마지막 9개를 사용하지 않겠다고 하면 이런 그림이 그려집니다.
    1
    2
    3
    4
    5
    6
    7
    fig, ax = plt.subplots(figsize=(5, 5), 
    subplot_kw={"projection":"3d"})
    ax.plot_surface(df["x"].values.reshape((19, 37))[:,:-9],
    df["y"].values.reshape((19, 37))[:,:-9],
    df["z"].values.reshape((19, 37))[:,:-9],
    alpha=0.3)
    ax.set_box_aspect((1, 1, 1))

2.3. 선 색깔 조정

  • 우리는 지금 을 그리고 있습니다.

  • 따라서 edgecolor (ec), linestyle (ls), linewidth (lw)가 적용됩니다.

  • 불필요한 axis도 지워버립시다. 명령은 똑같이 ax.axis(False)입니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    fig, ax = plt.subplots(figsize=(5, 5), 
    constrained_layout=True,
    subplot_kw={"projection":"3d"})
    ax.plot_surface(df["x"].values.reshape((19, 37))[:,:-9],
    df["y"].values.reshape((19, 37))[:,:-9],
    df["z"].values.reshape((19, 37))[:,:-9],
    ec="k", lw=0.2, ls=":",
    alpha=0.3)
    ax.set_box_aspect((1, 1, 1))
    ax.axis(False)


  • 아까보다 한결 깔끔해 보입니다.

2.4. 면 색깔 조정

  • 면 색깔을 지정하는 매개변수는 color입니다.

  • colors="g"처럼 색상을 의미하는 약어나 숫자를 넣으면 적용됩니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    fig, ax = plt.subplots(figsize=(5, 5), 
    constrained_layout=True,
    subplot_kw={"projection":"3d"})
    ax.plot_surface(df["x"].values.reshape((19, 37))[:,:-9],
    df["y"].values.reshape((19, 37))[:,:-9],
    df["z"].values.reshape((19, 37))[:,:-9],
    ec="k", lw=0.2, ls=":",
    color="g",
    alpha=0.3)
    ax.set_box_aspect((1, 1, 1))
    ax.axis(False)


  • 조명 방향도 바꿀 수 있습니다.

  • matplotlib.colorsLightSource 클래스를 사용해 조명 방향을 지정합니다.

  • 두 개의 인자로 방위각과 극고도각을 도(degree) 단위로 입력합니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    from matplotlib.colors import LightSource

    fig, axs = plt.subplots(ncols=3,
    figsize=(15, 5),
    constrained_layout=True,
    subplot_kw={"projection":"3d"})

    lightsources = [LightSource(30, 30), LightSource(-30, 60), LightSource(0, 0)]
    titles = ["azi: 30, polar: 30", "azi: -30, polar: 60", "azi: 0, polar: 0"]
    for ax, ls, title in zip(axs, lightsources, titles):
    ax.plot_surface(df["x"].values.reshape((19, 37))[:,:-9],
    df["y"].values.reshape((19, 37))[:,:-9],
    df["z"].values.reshape((19, 37))[:,:-9],
    ec="none", lw=0, ls="-",
    color="w", lightsource=ls,
    alpha=1)
    ax.set_title(title, fontsize="large")
    ax.set_box_aspect((1, 1, 1))
    ax.axis(False)


  • 멋진 그라데이션을 입히고 싶다면 cmap 매개변수를 사용할 수 있습니다.

  • z축 방향 값에 따른 그라데이션이 매겨집니다.

  • Matplotlib이 제공하는 gradation 이름을 집어넣습니다.

  • 다만 cmaplightsource함께 쓰일 수 없으니 유의해야 합니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    fig, ax = plt.subplots(figsize=(5, 5), 
    constrained_layout=True,
    subplot_kw={"projection":"3d"})
    ax.plot_surface(df["x"].values.reshape((19, 37))[:,:-9],
    df["y"].values.reshape((19, 37))[:,:-9],
    df["z"].values.reshape((19, 37))[:,:-9],
    ec="k", lw=0.2, ls=":",
    cmap="inferno",
    alpha=0.3)
    ax.set_box_aspect((1, 1, 1))
    ax.axis(False)


3. 응용

3.1. 구와 반구

stackoverflow: Axes3D 여백 없애기

  • 구와 반구를 동시에 그려봅니다.

  • 구는 위에서 만든 코드를 그대로 사용하면 되고,

  • 반구는 극고도각을 절반만 사용하면 됩니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    fig, axs = plt.subplots(ncols=2, 
    figsize=(10, 5),
    constrained_layout=True,
    subplot_kw={"projection":"3d"})

    # sphere
    axs[0].plot_surface(df["x"].values.reshape((19, 37)),
    df["y"].values.reshape((19, 37)),
    df["z"].values.reshape((19, 37)),
    cmap="viridis",
    alpha=0.3)
    axs[0].set_box_aspect((1, 1, 1))
    axs[0].set_title("sphere", pad=12)

    # hemisphere
    axs[1].plot_surface(df["x"].iloc[:370].values.reshape((10, 37)),
    df["y"].iloc[:370].values.reshape((10, 37)),
    df["z"].iloc[:370].values.reshape((10, 37)),
    cmap="viridis",
    alpha=0.3)

    axs[1].set_box_aspect((1, 1, 0.5))
    axs[1].set_title("hemisphere", pad=12)

    for ax in axs:
    ax.set_xlabel("X")
    ax.set_ylabel("Y")
    ax.set_zlabel("Z")


  • X, Y, Z축 범위가 -1~1을 약간 벗어나 있습니다.

  • matplotlib 기본 설정 문제입니다.

  • 이를 해결하려면 위 코드에서 fig, axs를 생성한 뒤에 짧은 코드를 추가합니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    from mpl_toolkits.mplot3d.axis3d import Axis
    if not hasattr(Axis, "_get_coord_info_old"):
    def _get_coord_info_new(self, renderer):
    mins, maxs, centers, deltas, tc, highs = self._get_coord_info_old(renderer)
    mins += deltas / 4
    maxs -= deltas / 4
    return mins, maxs, centers, deltas, tc, highs
    Axis._get_coord_info_old = Axis._get_coord_info
    Axis._get_coord_info = _get_coord_info_new


  • 동서남북, 그리고 상하를 잇는 수직선을 그립니다.

  • 좌표 원점이라고 봐도 됩니다.

  • 여기서 한 가지 주의할 점이 있습니다.

  • matplotlib이 3D 도형과 2D 도형의 위치관계를 깔끔하기 처리하지 못합니다.

  • 3D 도형에 가려질 부분과 가려지지 않을 부분을 따로 그리고 표현해줘야 합니다.

  • 동서남북을 나타내는 N, E, W, S도 적당한 위치에 넣어줍니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    fig, axs = plt.subplots(ncols=2, 
    figsize=(10, 5),
    subplot_kw={"projection":"3d"})

    # Axes3D 딱 붙이기
    # https://stackoverflow.com/questions/16488182/removing-axes-margins-in-3d-plot
    from mpl_toolkits.mplot3d.axis3d import Axis
    if not hasattr(Axis, "_get_coord_info_old"):
    def _get_coord_info_new(self, renderer):
    mins, maxs, centers, deltas, tc, highs = self._get_coord_info_old(renderer)
    mins += deltas / 4
    maxs -= deltas / 4
    return mins, maxs, centers, deltas, tc, highs
    Axis._get_coord_info_old = Axis._get_coord_info
    Axis._get_coord_info = _get_coord_info_new

    # sphere
    axs[0].plot_surface(df["x"].values.reshape((19, 37)),
    df["y"].values.reshape((19, 37)),
    df["z"].values.reshape((19, 37)),
    cmap="viridis", ec="k", lw=0.1,
    alpha=0.3)
    axs[0].set_box_aspect((1, 1, 1))
    axs[0].set_title("sphere", pad=12)

    # hemisphere
    axs[1].plot_surface(df["x"].iloc[:370].values.reshape((10, 37)),
    df["y"].iloc[:370].values.reshape((10, 37)),
    df["z"].iloc[:370].values.reshape((10, 37)),
    cmap="viridis", ec="k", lw=0.1,
    alpha=0.3)

    axs[1].set_box_aspect((1, 1, 0.5))
    axs[1].set_title("hemisphere", pad=12)

    for ax in axs:
    ax.set_xlabel("X")
    ax.set_ylabel("Y")
    ax.set_zlabel("Z")
    ax.view_init(azim=235)
    # make margins
    ax.margins(0)
    # center axis
    zmin = -1 if ax == axs[0] else 0
    ax.plot([-1.1, 1.1], [0, 0], [0, 0], c="k", zorder=-10)
    ax.plot([0, 0, 0], [-1.1, 1.1, 0], c="k", zorder=-10)
    ax.plot([0, 0], [0, 0], [zmin, 1], c="k", zorder=-10)
    ax.plot([0, 0], [0, 0], [1, 1.1], c="k", zorder=3)

    if ax == axs[0]:
    ax.plot([-1, -1.1], [0, 0], [0, 0], c="k", zorder=3)
    ax.plot([0, 0], [-1, -1.1], [0, 0], c="k", zorder=3)

    # 동서남북 표시
    font_text = {"fontweight":"bold", "fontsize":15, "ha":"center", "va":"center"}
    ax.text(-1.4, 0, 0, "W", transform=ax.transData, fontdict=font_text)
    ax.text(1.4, 0, 0, "E", transform=ax.transData, fontdict=font_text)
    ax.text(0, 1.4, 0, "N", transform=ax.transData, fontdict=font_text, zorder=-10)
    ax.text(0, -1.4, 0, "S", transform=ax.transData, fontdict=font_text)
    ax.axis(False)


3.2. magnetocrystalline anisotropy

wikipedia: magnetocrystalline anisotropy

  • 결정자기이방성(magnetocrystalline anisotropy)이라는 말을 들어보신 분은 거의 없으실 겁니다.
  • 자성(magnetism)을 공부할 때 나오는 용어인데, 방위에 따라 다른 에너지라고 대충 넘어가셔도 좋습니다.
  • 중요한 것은 이번엔 구나 반구가 아니라 울퉁불퉁한 모양을 만들 것이라는 점입니다.
  • 원리는 간단합니다.
  • 이 글의 처음에서 구면좌표계는 다음과 같은 식으로 표현된다고 했습니다.

$$
\begin{aligned}
x &= r \cos{\varphi} \sin{\theta}\\
y &= r \sin{\varphi} \sin{\theta}\\
z &= r \cos{\theta}
\end{aligned}$$

  • 앞에서는 $r = 1$로 두었지만, 여기서는 $r = f(\varphi, \theta)$입니다.
  • 방위각과 극고도각을 이용해서 물질마다 다르게 r을 정의합니다.
  • 모양이 울퉁불퉁한만큼 아까 10도 단위로 쪼갠 공간을 이번엔 5도 단위로 쪼갭니다.
  • 자세한 설명은 생략하고 결과 그림과 코드만 간략히 보여드리겠습니다.
  • 5도 단위로 공간 분할

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # angles
    polars = np.linspace(0, 179, 37)
    azimuths = np.linspace(0, 360, 73)

    # points
    from itertools import product

    df = pd.DataFrame(product(polars, azimuths), columns=["azi", "polar"])
    df["x"] = df.apply(lambda x: np.cos(np.deg2rad(x[1]))*np.sin(np.deg2rad(x[0])), axis=1)
    df["y"] = df.apply(lambda x: np.sin(np.deg2rad(x[1]))*np.sin(np.deg2rad(x[0])), axis=1)
    df["z"] = df.apply(lambda x: np.cos(np.deg2rad(x[0])), axis=1)
  • 방향에 따른 $r$ 계산: 자기이방성 에너지

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    # 자기이방성 계수 K1, K2
    K1_Co = 45
    K2_Co = 15

    K1_Fe = 4.8
    K2_Fe = 0.5

    K1_Ni = -0.5
    K2_Ni = -0.2

    # 방향에 따른 자기이방성 에너지 계산.
    # 그림을 그리는 것이 목적이므로 보기 좋도록 적절히 스케일링
    def calc_uni(K1, K2, df):
    return K1*(df["x"]**2 + df["y"]**2)

    def calc_cubic(K1, K2, df):
    return K1*(df["x"]**2 * df["y"]**2 + \
    df["y"]**2 * df["z"]**2 + \
    df["z"]**2 * df["x"]**2) + \
    K2*(df["x"]**2 * df["y"]**2 * df["z"]**2)

    df["E_Co"] = df.apply(lambda x: calc_uni(K1_Co, K2_Co, x), axis=1) + 1
    df["E_Fe"] = df.apply(lambda x: calc_cubic(K1_Fe, K2_Fe, x), axis=1) + 1
    df["E_Ni"] = df.apply(lambda x: calc_cubic(K1_Ni, K2_Ni, x), axis=1)*3 +1

    # 극좌표계를 직교좌표계로 변환
    df["x_Co"] = df["E_Co"] * df["x"]
    df["y_Co"] = df["E_Co"] * df["y"]
    df["z_Co"] = df["E_Co"] * df["z"]

    df["x_Fe"] = df["E_Fe"] * df["x"]
    df["y_Fe"] = df["E_Fe"] * df["y"]
    df["z_Fe"] = df["E_Fe"] * df["z"]

    df["x_Ni"] = df["E_Ni"] * df["x"]
    df["y_Ni"] = df["E_Ni"] * df["y"]
    df["z_Ni"] = df["E_Ni"] * df["z"]
  • 세 가지 물질(Co, Fe, Ni)의 자기이방성 에너지 시각화

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    fig, axs = plt.subplots(ncols=3, 
    figsize=(15, 6),
    constrained_layout=True,
    subplot_kw={"projection":"3d"})
    for ax, mat in zip(axs, ["Co", "Fe", "Ni"]):
    ax.plot_surface(df[f"x_{mat}"].values.reshape((37, 73))[:,:-19],
    df[f"y_{mat}"].values.reshape((37, 73))[:,:-19],
    df[f"z_{mat}"].values.reshape((37, 73))[:,:-19],
    ec="k", lw=0.2,
    color="w", lightsource=LightSource(0, 10),
    alpha=1)
    ax.plot(df[f"x_{mat}"].values.reshape((37, 73))[:,-20],
    df[f"y_{mat}"].values.reshape((37, 73))[:,-20],
    df[f"z_{mat}"].values.reshape((37, 73))[:,-20],
    c="k", alpha=1)
    ax.plot(df[f"x_{mat}"].values.reshape((37, 73))[:,0],
    df[f"y_{mat}"].values.reshape((37, 73))[:,0],
    df[f"z_{mat}"].values.reshape((37, 73))[:,0],
    c="k", alpha=1)
    ax.set_box_aspect((1, 1, 1))
    ax.axis(False)
    ax.set_title(mat, fontsize="xx-large")

    fig.suptitle("crystalline anisotropy energy surface\n",
    color="blue", fontsize="xx-large")


  • 제 박사학위 주제와 밀접한 그림입니다.

  • 교과서 그림을 모방해 학위논문(2011)에도 같은 그림을 그려서 넣었습니다.

  • mathematica를 사용해서 그린 그림입니다.



도움이 되셨나요? 카페인을 투입하시면 다음 포스팅으로 변환됩니다

Share