この記事で学べること#

  • JSBSim NED座標系の定義と特徴
  • Plotly ENU座標系の定義と特徴
  • NED ↔ ENU座標変換の方法
  • 地理座標(lat/lon/alt)から直交座標(x/y/z)への変換
  • 単位不整合による描画エラーの回避
  • 実用的な座標変換コードの実装

対象読者#

  • B-8「JSBSim座標系の基礎」を読んだ方
  • B-9「座標変換の基礎」を読んだ方
  • D-3「Plotly.js 3D軌道プロット」を読んだ方
  • JSBSimデータをPlotlyで可視化したい中級者

本記事では、JSBSimのNED座標系とPlotlyのENU座標系の違いを理解し、正しい座標変換を実装する方法を解説します。


座標系の基礎#

JSBSim: NED座標系#

NED (North-East-Down)座標系は、航空宇宙分野で標準的に使用されます。

X軸: 北方向 (North) [m]
Y軸: 東方向 (East) [m]
Z軸: 下方向 (Down) [m]

特徴:

  • 原点: 機体の初期位置
  • 座標系: 右手座標系
  • Z軸方向: 下向き(重力方向が+Z)

図解:

        North (X+)
           ↑
           |
           |
           ●-------→ East (Y+)
          /
         /
        ↓
      Down (Z+)

右手の法則:
- 親指: North (X+)
- 人差し指: East (Y+)
- 中指: Down (Z+)

Plotly: ENU座標系#

ENU (East-North-Up)座標系は、地理情報システムや可視化で一般的に使用されます。

X軸: 東方向 (East) [m]
Y軸: 北方向 (North) [m]
Z軸: 上方向 (Up) [m]

特徴:

  • 原点: 軌跡の中心(または基準点)
  • 座標系: 右手座標系
  • Z軸方向: 上向き(高度増加方向が+Z)

図解:

        Up (Z+)
         ↑
         |
         |
         ●-------→ East (X+)
        /
       /
      ↙
   North (Y+)

右手の法則:
- 親指: East (X+)
- 人差し指: North (Y+)
- 中指: Up (Z+)

NED ↔ ENU 座標変換#

変換式#

NED → ENU:

X_enu = Y_ned   (East ← East)
Y_enu = X_ned   (North ← North)
Z_enu = -Z_ned  (Up ← -Down)

ENU → NED:

X_ned = Y_enu   (North ← North)
Y_ned = X_enu   (East ← East)
Z_ned = -Z_enu  (Down ← -Up)

Python実装#

import numpy as np

def ned_to_enu(x_ned, y_ned, z_ned):
    """
    NED座標系からENU座標系への変換

    Args:
        x_ned: North [m]
        y_ned: East [m]
        z_ned: Down [m]

    Returns:
        (x_enu, y_enu, z_enu): East, North, Up [m]
    """
    x_enu = y_ned     # East ← East
    y_enu = x_ned     # North ← North
    z_enu = -z_ned    # Up ← -Down

    return x_enu, y_enu, z_enu


def enu_to_ned(x_enu, y_enu, z_enu):
    """
    ENU座標系からNED座標系への変換

    Args:
        x_enu: East [m]
        y_enu: North [m]
        z_enu: Up [m]

    Returns:
        (x_ned, y_ned, z_ned): North, East, Down [m]
    """
    x_ned = y_enu     # North ← North
    y_ned = x_enu     # East ← East
    z_ned = -z_enu    # Down ← -Up

    return x_ned, y_ned, z_ned

行列形式#

変換行列 T_NED→ENU:

┌       ┐   ┌         ┐ ┌       ┐
│ X_enu │   │ 0  1  0 │ │ X_ned │
│ Y_enu │ = │ 1  0  0 │ │ Y_ned │
│ Z_enu │   │ 0  0 -1 │ │ Z_ned │
└       ┘   └         ┘ └       ┘

Python実装:

def transform_ned_to_enu_matrix(coords_ned):
    """
    行列を使ったNED→ENU変換

    Args:
        coords_ned: (N, 3) array, [[x_ned, y_ned, z_ned], ...]

    Returns:
        coords_enu: (N, 3) array, [[x_enu, y_enu, z_enu], ...]
    """
    T = np.array([
        [0,  1,  0],
        [1,  0,  0],
        [0,  0, -1]
    ])

    coords_enu = np.dot(coords_ned, T.T)

    return coords_enu

地理座標から直交座標への変換#

問題: 単位不整合#

Plotly 3Dでは地理座標を直接プロットできない:

# ❌ 間違い: 単位が混在
fig.add_trace(go.Scatter3d(
    x=df['longitude_deg'],  # degrees (経度)
    y=df['latitude_deg'],   # degrees (緯度)
    z=df['altitude_m'],     # meters (高度)
))

問題点:

  • X軸: 0.002° ≈ 200m
  • Y軸: 4.0° ≈ 440km
  • Z軸: 60m
  • スケール比 2200:1:0.3 → 描画不能

解決策: ローカル直交座標系(ENU)#

ステップ1: 基準点の設定

# 軌跡の中心を基準点とする
lat_center = (df['latitude_deg'].min() + df['latitude_deg'].max()) / 2
lon_center = (df['longitude_deg'].min() + df['longitude_deg'].max()) / 2
alt_base = df['altitude_m'].min()  # 最低高度を基準

ステップ2: 度→メートル変換係数

import numpy as np

# 緯度1度 = 約111km(全緯度で一定)
DEG_TO_M_LAT = 111000  # m/degree

# 経度1度 = 約111km × cos(緯度)(緯度により変化)
DEG_TO_M_LON = 111000 * np.cos(np.radians(lat_center))

日本付近(北緯35°)での値:

  • DEG_TO_M_LAT = 111000 m/deg
  • DEG_TO_M_LON = 111000 × cos(35°) ≈ 90900 m/deg

ステップ3: 地理座標 → ローカルENU座標

# 地理座標から相対座標への変換
x_enu = (df['longitude_deg'] - lon_center) * DEG_TO_M_LON  # East [m]
y_enu = (df['latitude_deg'] - lat_center) * DEG_TO_M_LAT   # North [m]
z_enu = df['altitude_m'] - alt_base                        # Up [m]

結果: 全座標がメートル単位の相対座標になる


完全な座標変換コード#

JSBSimデータ→Plotly ENU座標#

"""
JSBSim地理座標からPlotly ENU座標への変換
"""
import pandas as pd
import numpy as np
import plotly.graph_objects as go

# ===== データ読み込み =====
df = pd.read_csv('flight_data.csv')

# ===== ステップ1: 基準点の設定 =====
lat_center = (df['latitude_deg'].min() + df['latitude_deg'].max()) / 2
lon_center = (df['longitude_deg'].min() + df['longitude_deg'].max()) / 2
alt_base = df['altitude_m'].min()

print(f"基準点: Lat={lat_center:.6f}°, Lon={lon_center:.6f}°, Alt={alt_base:.1f}m")

# ===== ステップ2: 変換係数の計算 =====
DEG_TO_M_LAT = 111000  # m/degree(緯度)
DEG_TO_M_LON = 111000 * np.cos(np.radians(lat_center))  # m/degree(経度)

print(f"変換係数: LAT={DEG_TO_M_LAT} m/deg, LON={DEG_TO_M_LON:.0f} m/deg")

# ===== ステップ3: 地理座標 → ENU座標 =====
x_enu = (df['longitude_deg'] - lon_center) * DEG_TO_M_LON  # East [m]
y_enu = (df['latitude_deg'] - lat_center) * DEG_TO_M_LAT   # North [m]
z_enu = df['altitude_m'] - alt_base                        # Up [m]

# ===== 検証: 座標範囲を確認 =====
print(f"\nENU座標範囲:")
print(f"  East (X): {x_enu.min():.1f} ~ {x_enu.max():.1f} m")
print(f"  North (Y): {y_enu.min():.1f} ~ {y_enu.max():.1f} m")
print(f"  Up (Z): {z_enu.min():.1f} ~ {z_enu.max():.1f} m")

# ===== Plotlyで3Dプロット =====
fig = go.Figure(data=[
    go.Scatter3d(
        x=x_enu,
        y=y_enu,
        z=z_enu,
        mode='lines',
        line=dict(color='blue', width=4),
        name='Trajectory'
    )
])

# 軸ラベル設定
fig.update_scenes(
    xaxis=dict(title="East [m]"),
    yaxis=dict(title="North [m]"),
    zaxis=dict(title="Up [m]"),
    aspectmode='data'  # データの比率を保持
)

fig.update_layout(
    title="Flight Trajectory (ENU Coordinates)",
    height=700,
    width=1000
)

fig.write_html('trajectory_enu.html')
print("\n[OK] Saved: trajectory_enu.html")

JSBSim NEDデータから直接変換#

方法1: distance-from-start プロパティ#

JSBSimが提供する組み込みプロパティを使用(推奨):

import jsbsim

fdm = jsbsim.FGFDMExec()
# ... (初期化・実行)

# NED座標を直接取得(meters)
distance_north_m = fdm["position/distance-from-start-lat-mt"]
distance_east_m = fdm["position/distance-from-start-lon-mt"]
altitude_m = fdm["position/h-sl-meters"]

# NED座標
x_ned = distance_north_m  # North [m]
y_ned = distance_east_m   # East [m]
z_ned = -altitude_m       # Down [m](高度の符号反転)

# NED → ENU変換
x_enu = y_ned     # East
y_enu = x_ned     # North
z_enu = -z_ned    # Up

注意: distance-from-start-*-mtプロパティはJSBSimバージョンにより利用可否が異なります。

方法2: NED速度の積分#

速度を積分して位置を計算(確実な方法):

class NEDCoordinateTracker:
    """NED座標系で位置を追跡"""

    def __init__(self):
        self.position_north_m = 0.0
        self.position_east_m = 0.0
        self.prev_time = 0.0

    def update(self, fdm, current_time):
        """
        位置を更新

        Args:
            fdm: JSBSim FDM インスタンス
            current_time: 現在時刻 [s]

        Returns:
            (x_enu, y_enu, z_enu): ENU座標系での位置 [m]
        """
        # 時間差分
        dt = current_time - self.prev_time

        if dt > 0:
            # NED速度を取得(ft/s → m/s)
            v_north = fdm["velocities/v-north-fps"] * 0.3048
            v_east = fdm["velocities/v-east-fps"] * 0.3048

            # 台形則で積分
            self.position_north_m += v_north * dt
            self.position_east_m += v_east * dt

        self.prev_time = current_time

        # 高度取得
        altitude_m = fdm["position/h-sl-meters"]

        # NED座標
        x_ned = self.position_north_m  # North [m]
        y_ned = self.position_east_m   # East [m]
        z_ned = -altitude_m            # Down [m]

        # NED → ENU変換
        x_enu = y_ned     # East
        y_enu = x_ned     # North
        z_enu = -z_ned    # Up

        return x_enu, y_enu, z_enu

使用例:

tracker = NEDCoordinateTracker()

# シミュレーションループ
for step in range(num_steps):
    fdm.run()
    current_time = fdm['simulation/sim-time-sec']

    # ENU座標を取得
    x_enu, y_enu, z_enu = tracker.update(fdm, current_time)

    # CSVに保存
    writer.writerow([current_time, x_enu, y_enu, z_enu])

座標系の可視化#

NED座標系の可視化#

import plotly.graph_objects as go

# NED座標軸を表示
fig = go.Figure()

# North軸(X+、赤)
fig.add_trace(go.Scatter3d(
    x=[0, 100], y=[0, 0], z=[0, 0],
    mode='lines+text',
    line=dict(color='red', width=5),
    name='North (X+)',
    text=['', 'N'], textposition='top center'
))

# East軸(Y+、緑)
fig.add_trace(go.Scatter3d(
    x=[0, 0], y=[0, 100], z=[0, 0],
    mode='lines+text',
    line=dict(color='green', width=5),
    name='East (Y+)',
    text=['', 'E'], textposition='top center'
))

# Down軸(Z+、青)
fig.add_trace(go.Scatter3d(
    x=[0, 0], y=[0, 0], z=[0, 100],
    mode='lines+text',
    line=dict(color='blue', width=5),
    name='Down (Z+)',
    text=['', 'D'], textposition='top center'
))

fig.update_scenes(
    xaxis=dict(title="North [m]"),
    yaxis=dict(title="East [m]"),
    zaxis=dict(title="Down [m]"),
    aspectmode='cube'
)

fig.update_layout(title="NED Coordinate System")
fig.write_html('ned_axes.html')

ENU座標系の可視化#

# ENU座標軸を表示
fig = go.Figure()

# East軸(X+、緑)
fig.add_trace(go.Scatter3d(
    x=[0, 100], y=[0, 0], z=[0, 0],
    mode='lines+text',
    line=dict(color='green', width=5),
    name='East (X+)',
    text=['', 'E'], textposition='top center'
))

# North軸(Y+、赤)
fig.add_trace(go.Scatter3d(
    x=[0, 0], y=[0, 100], z=[0, 0],
    mode='lines+text',
    line=dict(color='red', width=5),
    name='North (Y+)',
    text=['', 'N'], textposition='top center'
))

# Up軸(Z+、スカイブルー)
fig.add_trace(go.Scatter3d(
    x=[0, 0], y=[0, 0], z=[0, 100],
    mode='lines+text',
    line=dict(color='skyblue', width=5),
    name='Up (Z+)',
    text=['', 'U'], textposition='top center'
))

fig.update_scenes(
    xaxis=dict(title="East [m]"),
    yaxis=dict(title="North [m]"),
    zaxis=dict(title="Up [m]"),
    aspectmode='cube'
)

fig.update_layout(title="ENU Coordinate System")
fig.write_html('enu_axes.html')

よくある問題と対処法#

問題1: 軌跡が潰れて見える#

# ❌ 原因: 地理座標を直接プロット
fig.add_trace(go.Scatter3d(
    x=df['longitude_deg'],
    y=df['latitude_deg'],
    z=df['altitude_m']
))

対策: 地理座標→ローカルENU座標に変換

# ✅ 正しい: ローカル座標に変換
x_enu = (df['longitude_deg'] - lon_center) * DEG_TO_M_LON
y_enu = (df['latitude_deg'] - lat_center) * DEG_TO_M_LAT
z_enu = df['altitude_m'] - alt_base

問題2: Z軸が逆向きに表示される#

# ❌ 原因: NEDのZ軸(Down)をそのままプロット
z_plotly = z_ned  # Downがそのまま+Z方向

対策: NED→ENU変換でZ軸を反転

# ✅ 正しい: Z軸を反転
z_enu = -z_ned  # Up = -Down

問題3: North/East軸が入れ替わって見える#

# ❌ 原因: NED座標をENU座標と誤認
x_plotly = x_ned  # North(X_ned)を East(X_plotly)として表示
y_plotly = y_ned  # East(Y_ned)を North(Y_plotly)として表示

対策: NED→ENU変換でX/Y軸を入れ替え

# ✅ 正しい: X/Y軸を入れ替え
x_enu = y_ned  # East ← East (Y_ned)
y_enu = x_ned  # North ← North (X_ned)

まとめ#

本記事では、JSBSimのNED座標系とPlotlyのENU座標系の対応関係を解説しました。

重要なポイント:

  • JSBSim: NED座標系(North-East-Down)、右手座標系
  • Plotly: ENU座標系(East-North-Up)、右手座標系
  • NED→ENU変換: X_enu=Y_ned, Y_enu=X_ned, Z_enu=-Z_ned
  • 地理座標: degrees→metersに変換してからプロット
  • 変換係数: DEG_TO_M_LAT=111000, DEG_TO_M_LON=111000×cos(lat)
  • ローカル座標: 軌跡中心を原点とする相対座標
  • 単位統一: 全軸をメートル単位に統一することが必須

次のステップとして、機体姿勢の可視化(Euler角からの回転行列変換)や、複数機体の同時表示に挑戦してみましょう。


参照資料#

本記事の執筆にあたり、以下の資料を参照しました [@jsbsim_refman_2024; @cartesian_coordinate_policy_2025; @coordinate_system_specification_2025; @nelson_flight_stability_1998]。