この記事で学べること#
- 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/degDEG_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]。