PyEphemによる人工衛星の軌道計算と可視化 — ISSの追跡からパス予測まで

「今、国際宇宙ステーション(ISS)は地球のどこの上空を飛んでいるのだろう?」「今夜、自分の家からISSを肉眼で見られるタイミングはいつだろう?」 こうした疑問に答えるには、人工衛星の軌道計算を数値的に実行する仕組みが必要です。衛星は約90分で地球を一周し、その間に地球自身も自転しているため、地上から見た衛星の位置は刻一刻と変化します。手計算で追いかけるのは現実的ではありません。

Pythonには、こうした天体・衛星の位置計算を手軽に実行できるライブラリがいくつか存在します。その中でも歴史が長く、教育目的にも適しているのがPyEphemephem)です。PyEphemは、米国の天文計算ソフトウェア XEphem をベースに開発されたライブラリで、TLE(Two-Line Element)データを入力するだけで、任意の時刻における衛星の緯度・経度・高度を計算できます。さらに、地上のある観測地点から見た衛星の仰角・方位角を計算し、いつ衛星が見えるか(パス予測)まで自動で求められます。

PyEphemによる衛星の軌道計算を理解すると、以下のような応用が見えてきます。

  • アマチュア無線の衛星通信: 通信衛星の上空通過タイミングを予測し、アンテナの方向を自動制御する
  • 天体観測の計画: ISSや明るい衛星の可視パスを事前に把握し、写真撮影の計画を立てる
  • 衛星コンステレーションの運用: 複数の衛星を同時に追跡し、地上局のスケジューリングを最適化する
  • 教育・研究: ケプラー軌道やSGP4モデルの理解を深め、軌道力学の実践的な演習に活用する

本記事の内容

  • TLEデータの構造と読み方
  • PyEphemの基本的な使い方 — 衛星オブジェクトの作成と位置計算
  • ISSのリアルタイム位置計算
  • 地上軌跡(Ground Track)の可視化
  • 観測地点からの衛星パス予測
  • 仰角・方位角の計算とポーラープロット
  • 複数衛星の同時追跡
  • Skylieldとの比較 — モダンな代替ライブラリ

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

軌道計算の背景 — ケプラー軌道とSGP4

人工衛星の軌道計算を理解するために、まず理論的な枠組みを簡単に振り返っておきましょう。

二体問題とケプラー軌道

地球の周りを回る人工衛星を考えるとき、最も単純なモデルは二体問題です。地球と衛星の2つの天体だけが存在し、他の重力源を無視するモデルです。このとき、衛星の運動方程式は次のように書けます。

$$ \ddot{\bm{r}} = -\frac{\mu}{r^3} \bm{r} $$

ここで $\bm{r}$ は地球中心から衛星への位置ベクトル、$r = |\bm{r}|$ はその大きさ、$\mu = GM_{\oplus} \approx 3.986 \times 10^{14} \, \text{m}^3/\text{s}^2$ は地球の標準重力パラメータです。

この方程式の解はケプラー軌道と呼ばれ、6つの軌道要素(半長径 $a$、離心率 $e$、軌道傾斜角 $i$、昇交点赤経 $\Omega$、近地点引数 $\omega$、真近点離角 $\nu$)で完全に記述されます。ケプラー軌道では衛星は楕円(または円、放物線、双曲線)の上を永遠に同じパターンで動き続けます。

実際の軌道は摂動で変化する

しかし、現実の衛星軌道はケプラー軌道から少しずつずれていきます。その主な原因は次の通りです。

  • 地球の非球形: 地球は完全な球ではなく、赤道方向に膨らんだ回転楕円体です。特に $J_2$ 項と呼ばれる扁平率の影響が大きく、昇交点赤経 $\Omega$ や近地点引数 $\omega$ が時間とともに変化します
  • 大気抵抗: 低軌道(高度 1,000 km 以下)の衛星は、希薄な大気との摩擦で徐々にエネルギーを失い、高度が下がります
  • 太陽・月の重力: 特に高軌道の衛星では無視できない三体効果です
  • 太陽輻射圧: 太陽光の光子が衛星表面に与える微小な圧力です

これらの摂動を考慮して衛星の位置を精密に計算するモデルがSGP4(Simplified General Perturbations 4)です。SGP4は米国宇宙軍(旧NORAD)が開発した解析的な軌道伝播モデルで、$J_2$ をはじめとする地球重力場の主要な摂動項や大気抵抗の効果を含んでいます。

PyEphemの内部でもSGP4が動いている

PyEphemで衛星の位置を計算するとき、内部では実はこのSGP4アルゴリズムが実行されています。ユーザーはTLEデータを入力し、計算したい時刻を指定するだけで、SGP4による軌道伝播が自動的に行われ、衛星の位置(緯度・経度・高度)や観測地点から見た方位角・仰角が返されます。つまり、PyEphemは「SGP4をPythonから簡単に使うためのラッパー」としての側面を持っているのです。

SGP4の理論的な背景が理解できたところで、次にSGP4の入力データであるTLEの構造を詳しく見ていきましょう。

TLEデータの構造と読み方

TLEとは何か

TLE(Two-Line Element)は、人工衛星の軌道情報を2行のテキストで表現する標準フォーマットです。「たった2行のテキストで衛星の軌道がわかるのか?」と思うかもしれませんが、実はこの2行には、SGP4で衛星の位置を伝播するために必要な情報がすべて詰まっています。

TLEデータは、米国宇宙軍の18th Space Defense Squadronが公開しており、CelesTrakなどのウェブサイトで誰でも入手できます。ISSのTLEは以下のような形式です。

ISS (ZARYA)
1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993
2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075

1行目は衛星の名前(オプション)で、その後に続く2行が実際のTLEデータです。

1行目の構造

TLEの1行目(Line 1)には、衛星の識別情報と時刻関連のパラメータが格納されています。フィールドごとに分解してみましょう。

列位置 フィールド名 上の例での値 意味
1 行番号 1 Line 1を示す
3-7 カタログ番号 25544 NORADが割り当てた衛星番号
8 分類 U U=非機密
10-17 国際識別符号 98067A 打上年(98)・番号(067)・部品(A)
19-32 エポック 24045.51782528 年(24)+通日(045.51782528)
34-43 平均運動の1次微分 .00011186 1日あたりの変化率 (rev/day$^2$)
45-52 平均運動の2次微分 00000-0 通常ゼロ
54-61 B*抵抗係数 20208-3 大気抵抗パラメータ
63 暦番号タイプ 0 SGP4を示す
65-68 要素セット番号 999 更新回数
69 チェックサム 3 行末の検査合計

エポック(Epoch)はTLEの中で最も重要なフィールドの一つです。24045.51782528 は「2024年の第45.51782528日」を意味し、2024年2月14日12時25分40秒(UTC)頃に相当します。SGP4はこのエポックを基準時刻として、指定した時刻まで軌道を伝播します。

B*(ビースター)抵抗係数は大気抵抗の効果を表すパラメータで、20208-3 は $2.0208 \times 10^{-3}$ を意味します。この表記法はTLE独特のもので、先頭5桁が仮数、末尾2桁が指数(10のべき乗)を表しています。

2行目の構造

TLEの2行目(Line 2)には、軌道の幾何学的パラメータが格納されています。

列位置 フィールド名 上の例での値 意味
1 行番号 2 Line 2を示す
3-7 カタログ番号 25544 1行目と同じ
9-16 軌道傾斜角 $i$ 51.6412
18-25 昇交点赤経 $\Omega$ 208.6063
27-33 離心率 $e$ 0005140 先頭に 0. を補って 0.0005140
35-42 近地点引数 $\omega$ 43.4286
44-51 平均近点離角 $M$ 70.0747
53-63 平均運動 $n$ 15.49562695 rev/day
64-68 通算周回数 44107 エポック時点の周回数

ここでいくつか注意すべき点があります。離心率のフィールドは小数点以下のみが記録されており、0005140 は $e = 0.0005140$ を意味します。ISSの離心率がほぼゼロ(ほぼ円軌道)であることがわかります。

平均運動 $n = 15.49562695 \, \text{rev/day}$ は、ISSが1日に約15.5周していることを示しています。これから軌道周期を求めると次のようになります。

$$ T = \frac{1}{n} = \frac{1}{15.496} \approx 0.0645 \, \text{day} \approx 92.8 \, \text{分} $$

ISSが約93分で地球を一周するという、よく知られた事実と一致しています。また、平均運動からケプラーの第三法則を使って半長径を逆算できます。

平均運動 $n$ をラジアン毎秒に変換すると、

$$ n_{\text{rad}} = \frac{2\pi \times n}{86400} = \frac{2\pi \times 15.496}{86400} \approx 1.127 \times 10^{-3} \, \text{rad/s} $$

ケプラーの第三法則 $n^2 a^3 = \mu$ より、半長径 $a$ は次のように計算できます。

$$ a = \left(\frac{\mu}{n_{\text{rad}}^2}\right)^{1/3} = \left(\frac{3.986 \times 10^{14}}{(1.127 \times 10^{-3})^2}\right)^{1/3} \approx 6{,}794 \, \text{km} $$

地球の平均半径が約 6,371 km ですから、ISSの平均高度は $6{,}794 – 6{,}371 = 423 \, \text{km}$ となり、これもよく知られた値と合致します。

TLEの構造が理解できたところで、次はこのTLEデータをPyEphemに渡して、実際に衛星の位置計算を行ってみましょう。

PyEphemとは — ライブラリの概要とインストール

PyEphemの概要

PyEphem(パイエフェム)は、天体暦の計算を行うPythonライブラリです。もともとはElihu Burbachが開発したXEphemという天文計算ソフトウェアのC言語コアをPythonからアクセスできるようにラッピングしたものです。人工衛星だけでなく、太陽・月・惑星・恒星の位置も計算できますが、本記事では人工衛星の軌道計算に焦点を当てます。

PyEphemの主な特徴は以下の通りです。

  • TLEデータから衛星位置を計算: SGP4アルゴリズムによる軌道伝播
  • 観測者の視点からの計算: 任意の地上地点から見た衛星の仰角・方位角
  • パス予測: 衛星がいつ地平線の上に昇り、いつ沈むかを自動計算
  • 時刻管理: Pythonの datetime オブジェクトとシームレスに連携
  • 軽量: 純粋なC拡張で、依存関係が少なくインストールが簡単

インストール

PyEphemのインストールは pip で一行で完了します。

pip install ephem

Pythonのコード中では ephem としてインポートします。

import ephem

なお、PyEphemのPythonパッケージ名は ephem であり、pyephem ではない点に注意してください。古いドキュメントでは pip install pyephem と書かれている場合がありますが、現在は ephem に統一されています。

時刻の扱い

PyEphemでは、時刻はユリウス日をベースにした独自の Date 型で管理されます。Pythonの datetime オブジェクトからの変換も簡単です。

import ephem
from datetime import datetime

# 文字列から生成(UTC)
d1 = ephem.Date("2026/4/25 12:00:00")

# datetimeから生成(UTC)
d2 = ephem.Date(datetime(2026, 4, 25, 12, 0, 0))

# 現在時刻を取得
d3 = ephem.now()

print(f"d1 = {d1}")
print(f"d2 = {d2}")
print(f"d3 = {d3}")

# ephem.Date -> datetime に変換
dt = ephem.Date(d1).datetime()
print(f"datetime型: {dt}")

このコードを実行すると、PyEphemの Date 型が「年/月/日 時:分:秒」の形式で表示されることが確認できます。PyEphemの内部ではすべてUTC(協定世界時)で処理されるため、日本時間(JST = UTC+9)で考える場合は9時間の差分を意識する必要があります。datetime 型との相互変換が容易なので、最終的な表示時にタイムゾーン変換を行えばよいでしょう。

ライブラリのインストールと時刻の扱いがわかったところで、いよいよTLEデータを使って衛星オブジェクトを作成し、位置計算を行ってみましょう。

基本的な使い方 — 衛星オブジェクトの作成と位置計算

TLEから衛星オブジェクトを作成する

PyEphemでは、ephem.readtle() 関数にTLEの3行(名前 + Line1 + Line2)を渡すことで、衛星オブジェクトを作成できます。まずはISSのTLEデータを使ってみましょう。

import ephem

# ISSのTLEデータ(サンプル)
tle_name = "ISS (ZARYA)"
tle_line1 = "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993"
tle_line2 = "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"

# 衛星オブジェクトを作成
iss = ephem.readtle(tle_name, tle_line1, tle_line2)

# 特定の時刻で位置を計算
iss.compute("2024/2/15 12:00:00")

# 結果を表示
print(f"衛星名: {iss.name}")
print(f"緯度:   {iss.sublat}")          # 直下点の緯度
print(f"経度:   {iss.sublong}")         # 直下点の経度
print(f"高度:   {iss.elevation / 1000:.1f} km")  # 高度 (mからkmに変換)
print(f"赤経:   {iss.ra}")              # 赤経(天球座標)
print(f"赤緯:   {iss.dec}")             # 赤緯(天球座標)

このコードでは、まず ephem.readtle() でTLEを読み込み、次に compute() メソッドに時刻を渡して位置計算を実行しています。compute() を呼ぶと、SGP4アルゴリズムがTLEのエポックから指定時刻まで軌道を伝播し、結果が衛星オブジェクトの属性として格納されます。

sublatsublong は衛星の直下点(地球表面への投影点)の緯度・経度を表し、度-分-秒の形式で返されます。elevation は地球中心からではなく、地球表面からの高度をメートル単位で返します。

属性の一覧

compute() を呼んだ後に利用できる主な属性をまとめます。

属性 説明
sublat Angle 直下点の緯度
sublong Angle 直下点の経度
elevation float 地表からの高度 (m)
ra Angle 赤経(J2000)
dec Angle 赤緯(J2000)
range float 観測者からの距離 (m)(Observer設定時)
range_velocity float 視線方向速度 (m/s)(Observer設定時)
alt Angle 仰角(Observer設定時)
az Angle 方位角(Observer設定時)
eclipsed bool 地球の影にいるか

ここで重要なのは、alt(仰角)と az(方位角)は観測者(Observer)を設定した場合にのみ意味を持つという点です。衛星の絶対的な位置(直下点の緯度・経度・高度)は compute() だけで得られますが、特定の地上地点から見た相対的な位置(方位角・仰角)を知るには、観測者の情報が必要です。

角度の扱い

PyEphemは角度を独自の Angle 型で返します。ラジアン値として格納されていますが、float() でラジアン値を取得できますし、str() で度-分-秒の文字列を取得できます。

import ephem
import math

iss = ephem.readtle(
    "ISS (ZARYA)",
    "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
    "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
)
iss.compute("2024/2/15 12:00:00")

# ラジアン値
lat_rad = float(iss.sublat)
lon_rad = float(iss.sublong)

# 度に変換
lat_deg = math.degrees(lat_rad)
lon_deg = math.degrees(lon_rad)

print(f"緯度: {iss.sublat} = {lat_deg:.4f}°")
print(f"経度: {iss.sublong} = {lon_deg:.4f}°")

角度の変換は math.degrees() を使うだけなので簡単です。後ほどmatplotlibで地上軌跡を描画する際にも、この変換を頻繁に使います。

衛星オブジェクトの作成と位置計算の基本がわかったところで、次はもう少し実践的に、ISSの位置を時系列で追跡してみましょう。

ISSの現在位置を計算する

時刻を変えながら連続計算

PyEphemの compute() は何度でも呼び直せます。時刻を少しずつ変えながら繰り返し呼ぶことで、衛星の軌道上の位置を時系列で追跡できます。以下のコードでは、1分間隔でISSの位置を1周回分(約93分)計算しています。

import ephem
import math
from datetime import datetime, timedelta

# ISSのTLEデータ
iss = ephem.readtle(
    "ISS (ZARYA)",
    "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
    "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
)

# 計算期間: エポック付近の1周回(93分間)
start = datetime(2024, 2, 15, 0, 0, 0)
dt_minutes = 1  # 1分間隔
n_steps = 93    # 93ステップ(約1周回)

lats = []
lons = []
alts = []
times = []

for i in range(n_steps):
    t = start + timedelta(minutes=i * dt_minutes)
    iss.compute(t)
    lats.append(math.degrees(float(iss.sublat)))
    lons.append(math.degrees(float(iss.sublong)))
    alts.append(iss.elevation / 1000)  # km
    times.append(t)

print(f"計算点数: {len(lats)}")
print(f"緯度範囲: {min(lats):.2f}° ~ {max(lats):.2f}°")
print(f"経度範囲: {min(lons):.2f}° ~ {max(lons):.2f}°")
print(f"高度範囲: {min(alts):.1f} km ~ {max(alts):.1f} km")

このコードの出力結果から、いくつかの重要な特徴が読み取れます。まず、緯度の範囲は約 $-51.6\degree$ から $+51.6\degree$ の間に収まります。これはISSの軌道傾斜角が $51.6\degree$ であるためです。軌道傾斜角は衛星が到達しうる最大緯度に等しいという性質が、数値的にも確認できます。また、高度は約 420 km 前後でわずかに変動しますが、離心率がほぼゼロ($e \approx 0.0005$)であるため、変動幅は数 km 程度にとどまります。

高度の時間変化

ISSの軌道がほぼ円軌道であることを、高度の時間変化プロットで確認してみましょう。

import ephem
import math
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, timedelta

# ISSのTLE
iss = ephem.readtle(
    "ISS (ZARYA)",
    "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
    "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
)

start = datetime(2024, 2, 15, 0, 0, 0)
minutes = np.arange(0, 93, 0.5)  # 0.5分刻みで1周回
alts = []

for m in minutes:
    t = start + timedelta(minutes=float(m))
    iss.compute(t)
    alts.append(iss.elevation / 1000)

plt.figure(figsize=(10, 4))
plt.plot(minutes, alts, color="cyan", linewidth=1.5)
plt.xlabel("Time [min]")
plt.ylabel("Altitude [km]")
plt.title("ISS Altitude over One Orbit")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

このグラフを見ると、ISSの高度はおおよそ 415 km から 425 km の範囲で緩やかに変動していることがわかります。この変動幅は約 10 km で、平均高度 420 km に対してわずか 2.4% 程度です。離心率 $e = 0.0005$ という非常に小さな値が、ほぼ完全な円軌道を形成していることを数値的に裏付けています。近地点と遠地点の高度差は理論的に $\Delta h \approx 2ae$ で見積もれるので、$\Delta h \approx 2 \times 6794 \times 0.0005 \approx 6.8 \, \text{km}$ であり、グラフの変動幅とおおむね一致しています。

ISSの位置を時系列で計算できるようになったので、次はこのデータを地図上にプロットして地上軌跡(Ground Track)を可視化してみましょう。

地上軌跡(Ground Track)の可視化

地上軌跡とは

衛星の地上軌跡(Ground Track)とは、衛星の直下点(衛星から地球中心に向かって引いた直線が地表と交わる点)を時間順に結んだ線のことです。地球が自転しているため、衛星が同じ軌道面内を周回しても、地上軌跡は周回ごとに西にずれていきます。

ISSのような傾斜角 $51.6\degree$ の衛星の場合、地上軌跡は赤道を挟んで南北に $\pm 51.6\degree$ の範囲で蛇行する正弦波のような曲線を描きます。1周回ごとに西に約 $22.5\degree$ ずれるのは、ISSの軌道周期が約93分であるのに対し、地球が93分間に $360\degree \times 93/1440 \approx 23.25\degree$ 回転するためです。

matplotlibによる地上軌跡の描画

まずは、シンプルに複数周回分の地上軌跡をプロットしてみます。

import ephem
import math
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, timedelta

# ISSのTLE
iss = ephem.readtle(
    "ISS (ZARYA)",
    "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
    "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
)

# 3周回分(約280分)を計算
start = datetime(2024, 2, 15, 0, 0, 0)
minutes = np.arange(0, 280, 0.5)

lats = []
lons = []

for m in minutes:
    t = start + timedelta(minutes=float(m))
    iss.compute(t)
    lats.append(math.degrees(float(iss.sublat)))
    lons.append(math.degrees(float(iss.sublong)))

lats = np.array(lats)
lons = np.array(lons)

# 経度の不連続点を処理(-180/+180の境界)
# 大きなジャンプがある箇所にNaNを挿入
lon_diff = np.abs(np.diff(lons))
jump_idx = np.where(lon_diff > 300)[0]
for idx in sorted(jump_idx, reverse=True):
    lats = np.insert(lats, idx + 1, np.nan)
    lons = np.insert(lons, idx + 1, np.nan)

# 描画
fig, ax = plt.subplots(figsize=(14, 7))
ax.plot(lons, lats, color="orange", linewidth=1.0, label="ISS Ground Track")

# 開始点をマーク
ax.plot(lons[0], lats[0], "o", color="lime", markersize=8, label="Start")

# 地図のグリッドと範囲
ax.set_xlim(-180, 180)
ax.set_ylim(-90, 90)
ax.set_xlabel("Longitude [deg]")
ax.set_ylabel("Latitude [deg]")
ax.set_title("ISS Ground Track (3 orbits)")
ax.set_aspect("equal")
ax.grid(True, alpha=0.3)

# 赤道線
ax.axhline(y=0, color="white", linewidth=0.5, alpha=0.5)

# 傾斜角の限界線
ax.axhline(y=51.64, color="cyan", linewidth=0.5, linestyle="--", alpha=0.5, label="Inclination limit")
ax.axhline(y=-51.64, color="cyan", linewidth=0.5, linestyle="--", alpha=0.5)

ax.legend(loc="lower left")
plt.tight_layout()
plt.show()

このグラフから、ISSの地上軌跡に関する複数の特徴が確認できます。第一に、軌跡は南北方向に $\pm 51.6\degree$ の範囲で蛇行しており、シアンの破線で示した傾斜角の限界線にぴったり接しています。第二に、周回ごとに約 $23\degree$ 西にずれており、3周回では地球表面をほぼ均等に横断する帯状のパターンが形成されています。第三に、経度 $\pm 180\degree$ の境界で線が途切れているのは、NaN挿入による不連続処理が正しく機能しているためです。この処理を入れないと、画面を横断する直線が描画されてしまいます。

Cartopyを使った本格的な地図投影

より見栄えの良い地上軌跡を描くには、Cartopyライブラリを使って実際の地図上にプロットする方法があります。Cartopyは地図投影や海岸線の描画を簡単に行えるライブラリです。

import ephem
import math
import numpy as np
import matplotlib.pyplot as plt

try:
    import cartopy.crs as ccrs
    import cartopy.feature as cfeature
    HAS_CARTOPY = True
except ImportError:
    HAS_CARTOPY = False
    print("Cartopyがインストールされていません。pip install cartopy でインストールしてください。")

from datetime import datetime, timedelta

if HAS_CARTOPY:
    # ISSのTLE
    iss = ephem.readtle(
        "ISS (ZARYA)",
        "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
        "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
    )

    start = datetime(2024, 2, 15, 0, 0, 0)
    minutes = np.arange(0, 280, 0.5)
    lats, lons = [], []

    for m in minutes:
        t = start + timedelta(minutes=float(m))
        iss.compute(t)
        lats.append(math.degrees(float(iss.sublat)))
        lons.append(math.degrees(float(iss.sublong)))

    lats = np.array(lats)
    lons = np.array(lons)

    fig = plt.figure(figsize=(14, 7))
    ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
    ax.set_global()

    # 地図の装飾
    ax.add_feature(cfeature.LAND, facecolor="darkgreen", alpha=0.3)
    ax.add_feature(cfeature.OCEAN, facecolor="darkblue", alpha=0.3)
    ax.add_feature(cfeature.COASTLINE, linewidth=0.5, edgecolor="gray")
    ax.gridlines(draw_labels=True, alpha=0.3)

    # 地上軌跡をプロット(セグメント分割で不連続対応)
    segments = []
    seg_lats, seg_lons = [lats[0]], [lons[0]]
    for i in range(1, len(lons)):
        if abs(lons[i] - lons[i-1]) > 300:
            segments.append((seg_lons, seg_lats))
            seg_lats, seg_lons = [], []
        seg_lats.append(lats[i])
        seg_lons.append(lons[i])
    segments.append((seg_lons, seg_lats))

    for seg_lon, seg_lat in segments:
        ax.plot(seg_lon, seg_lat, color="orange", linewidth=1.5,
                transform=ccrs.Geodetic())

    ax.plot(lons[0], lats[0], "o", color="lime", markersize=10,
            transform=ccrs.PlateCarree(), label="Start")

    ax.set_title("ISS Ground Track on World Map")
    ax.legend(loc="lower left")
    plt.tight_layout()
    plt.show()

Cartopyを使った地図では、海岸線や大陸の輪郭が表示されるため、ISSがどの国の上空を通過しているかが直感的にわかります。ISSの軌道傾斜角 $51.6\degree$ は、ロシアのバイコヌール宇宙基地(北緯 $45.6\degree$)から打ち上げるために選ばれた値であり、地上軌跡が主要国の上空を広くカバーしていることが地図から確認できます。

地上軌跡の描画ができたところで、次はより実用的な機能 ── 特定の観測地点から衛星がいつ見えるかを予測する「パス予測」に進みましょう。

観測地点からの衛星パス予測

パス予測の考え方

衛星の「パス(Pass)」とは、地上の観測者から見て衛星が地平線の上に見える期間のことです。ISSのような低軌道衛星は、高速で移動しているため、1回のパスは通常5~10分程度です。アマチュア無線の衛星通信や、ISSの肉眼観測を行う際には、このパスのタイミングを正確に予測する必要があります。

パス予測の基本的な考え方はシンプルです。観測者の位置(緯度・経度・標高)を設定した上で、衛星の仰角(Elevation angle, 水平面からの角度)を時々刻々計算します。仰角が $0\degree$ を超えた瞬間がAOS(Acquisition of Signal, 衛星の出現)、最大仰角に達した瞬間がTCA(Time of Closest Approach, 最接近)、そして仰角が再び $0\degree$ に戻った瞬間がLOS(Loss of Signal, 衛星の消失)です。

PyEphemには、この計算を自動で行ってくれる next_pass() メソッが用意されています。

next_pass() の使い方

import ephem
from datetime import datetime

# ISSのTLE
iss = ephem.readtle(
    "ISS (ZARYA)",
    "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
    "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
)

# 観測地点を設定(東京)
observer = ephem.Observer()
observer.lat = "35.6762"    # 北緯 35.6762°
observer.lon = "139.6503"   # 東経 139.6503°
observer.elevation = 40     # 標高 40 m
observer.date = "2024/2/15 00:00:00"  # UTC

# 次のパスを予測
pass_info = observer.next_pass(iss)

# 結果を展開
rise_time, rise_az, max_alt_time, max_alt, set_time, set_az = pass_info

print("=== 次回のISSパス予測(東京)===")
print(f"AOS (出現):   {ephem.Date(rise_time).datetime()} UTC")
print(f"  方位角:     {rise_az}  ({float(rise_az) * 180 / 3.14159:.1f}°)")
print(f"TCA (最接近): {ephem.Date(max_alt_time).datetime()} UTC")
print(f"  最大仰角:   {max_alt}  ({float(max_alt) * 180 / 3.14159:.1f}°)")
print(f"LOS (消失):   {ephem.Date(set_time).datetime()} UTC")
print(f"  方位角:     {set_az}  ({float(set_az) * 180 / 3.14159:.1f}°)")
print(f"パス時間:     {(set_time - rise_time) * 24 * 60:.1f} 分")

next_pass() は6つの値をタプルで返します。出現時刻(AOS)とその方位角、最大仰角の時刻(TCA)と仰角、消失時刻(LOS)とその方位角です。これだけで、衛星がいつ、どの方角から現れ、どれくらいの高さまで上がり、どの方角に沈むかがわかります。

パス予測の精度は、TLEのエポックからの経過時間に依存します。TLEのエポックから数日以内であれば十分な精度が得られますが、1~2週間以上離れると誤差が大きくなるため、最新のTLEデータを使うことが重要です。

複数パスの予測

1回のパスだけでなく、ある期間内の全パスを列挙することもできます。next_pass() を繰り返し呼び、observer.date を更新していくだけです。

import ephem
import math

# ISSのTLE
iss = ephem.readtle(
    "ISS (ZARYA)",
    "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
    "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
)

# 観測地点(東京)
observer = ephem.Observer()
observer.lat = "35.6762"
observer.lon = "139.6503"
observer.elevation = 40

# 24時間以内のパスをすべて列挙
observer.date = "2024/2/15 00:00:00"
end_date = ephem.Date("2024/2/16 00:00:00")

passes = []
while observer.date < end_date:
    try:
        pass_info = observer.next_pass(iss)
        rise_time, rise_az, max_alt_time, max_alt, set_time, set_az = pass_info

        if rise_time is None:
            break

        passes.append({
            "rise_time": ephem.Date(rise_time).datetime(),
            "max_alt_deg": math.degrees(float(max_alt)),
            "set_time": ephem.Date(set_time).datetime(),
            "duration_min": (set_time - rise_time) * 24 * 60,
            "rise_az_deg": math.degrees(float(rise_az)),
            "set_az_deg": math.degrees(float(set_az)),
        })

        # 次のパスを探すために時刻を進める
        observer.date = set_time + ephem.minute
    except Exception:
        break

print(f"24時間以内のパス数: {len(passes)}\n")
for i, p in enumerate(passes, 1):
    print(f"パス {i}:")
    print(f"  出現: {p['rise_time'].strftime('%H:%M:%S')} UTC  "
          f"方位角: {p['rise_az_deg']:.0f}°")
    print(f"  最大仰角: {p['max_alt_deg']:.1f}°")
    print(f"  消失: {p['set_time'].strftime('%H:%M:%S')} UTC  "
          f"方位角: {p['set_az_deg']:.0f}°")
    print(f"  継続時間: {p['duration_min']:.1f} 分")
    print()

この出力から、ISSは24時間の間に5~7回程度、東京上空を通過することがわかります。ただし、全てのパスが肉眼観測に適しているわけではありません。最大仰角が低い(10度未満の)パスは地平線付近で大気の影響を受けやすく、見つけにくいです。また、日の出前や日没後の限られた時間帯でないと、ISSは太陽光を反射して輝かないため、肉眼では見えません。実用的には、最大仰角が $20\degree$ 以上で、かつ薄明の時間帯に発生するパスを選ぶのが一般的です。

パスの予測ができるようになったので、次はパス中の衛星の動きをより詳細に追跡し、仰角・方位角の時間変化をポーラープロット(極座標プロット)で可視化してみましょう。

仰角・方位角の計算と可視化

仰角と方位角の意味

地上の観測者から見た衛星の位置は、仰角(Elevation / Altitude angle)方位角(Azimuth)の2つの角度で表されます。仰角は水平面からの角度で、$0\degree$ が地平線、$90\degree$ が天頂です。方位角は北を $0\degree$ として時計回りに測り、東が $90\degree$、南が $180\degree$、西が $270\degree$ です。

衛星追跡アンテナの制御や、肉眼観測で「今どこを見ればいいか」を知るためには、この仰角・方位角の時間変化が重要です。

パス中の仰角・方位角の追跡

1つのパスについて、仰角と方位角を高時間分解能で計算し、ポーラープロットで可視化してみましょう。ポーラープロットは、天頂を中心、地平線を円の外周とする表現で、衛星が空をどのように横切るかを直感的に示します。

import ephem
import math
import numpy as np
import matplotlib.pyplot as plt
from datetime import timedelta

# ISSのTLE
iss = ephem.readtle(
    "ISS (ZARYA)",
    "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
    "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
)

# 観測地点(東京)
observer = ephem.Observer()
observer.lat = "35.6762"
observer.lon = "139.6503"
observer.elevation = 40
observer.date = "2024/2/15 00:00:00"

# 次のパスを取得
pass_info = observer.next_pass(iss)
rise_time, rise_az, max_alt_time, max_alt, set_time, set_az = pass_info

# パス期間中の仰角・方位角を10秒間隔で計算
dt_sec = 10
duration_sec = (set_time - rise_time) * 86400  # 秒に変換
n_points = int(duration_sec / dt_sec) + 1

az_list = []
el_list = []
time_list = []

for i in range(n_points):
    t = rise_time + i * dt_sec / 86400.0  # ephem.Dateの単位は日
    observer.date = t
    iss.compute(observer)
    az_list.append(math.degrees(float(iss.az)))
    el_list.append(math.degrees(float(iss.alt)))
    time_list.append(ephem.Date(t).datetime())

az_arr = np.array(az_list)
el_arr = np.array(el_list)

# ポーラープロット
fig, ax = plt.subplots(figsize=(8, 8), subplot_kw={"projection": "polar"})

# 方位角をラジアンに変換(北を上にするために調整)
az_rad = np.radians(az_arr)

# 仰角を「天頂を中心」にするため、90 - elevation をプロット
r = 90 - el_arr

# カラーマップで時間経過を表現
colors = np.linspace(0, 1, len(az_rad))
scatter = ax.scatter(az_rad, r, c=colors, cmap="cool", s=15, zorder=5)

# 開始と終了をマーク
ax.plot(az_rad[0], r[0], "^", color="lime", markersize=12, label="AOS", zorder=10)
ax.plot(az_rad[-1], r[-1], "v", color="red", markersize=12, label="LOS", zorder=10)

# 天頂を中心、地平線を外周に設定
ax.set_theta_zero_location("N")  # 北を上に
ax.set_theta_direction(-1)       # 時計回り
ax.set_ylim(0, 90)
ax.set_yticks([0, 15, 30, 45, 60, 75, 90])
ax.set_yticklabels(["90°", "75°", "60°", "45°", "30°", "15°", "0°"])
ax.set_title(f"ISS Pass - Max El: {max(el_arr):.1f}°\n"
             f"AOS: {time_list[0].strftime('%H:%M:%S')} UTC  "
             f"LOS: {time_list[-1].strftime('%H:%M:%S')} UTC",
             pad=20)
ax.legend(loc="lower right")

plt.colorbar(scatter, ax=ax, label="Time progression", shrink=0.8)
plt.tight_layout()
plt.show()

ポーラープロットでは、プロットの中心が天頂(仰角 $90\degree$)、外縁が地平線(仰角 $0\degree$)に対応しています。カラーグラデーションは時間の経過を表しており、緑の三角形(AOS)から赤の三角形(LOS)に向かって衛星が移動する軌跡が描かれています。衛星が天頂に近いほど中心近くを通過し、低仰角のパスであれば外縁近くを弧を描くように通過します。

この表示形式は、アマチュア無線の衛星通信において指向性アンテナの向きを手動で追尾する際に非常に便利です。また、自動追尾システムのデバッグにも活用できます。方位角と仰角のデータをそのままローテーター(アンテナ回転装置)の制御コマンドに変換することで、自動追尾が実現できます。

仰角の時間変化プロット

ポーラープロットに加えて、仰角の時間変化を通常のグラフで表示すると、パスの構造がより明確になります。

import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import numpy as np

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))

# 仰角の時間変化
ax1.plot(time_list, el_arr, color="cyan", linewidth=2)
ax1.axhline(y=10, color="yellow", linestyle="--", alpha=0.5, label="Min usable el (10°)")
ax1.fill_between(time_list, el_arr, alpha=0.2, color="cyan")
ax1.set_ylabel("Elevation [deg]")
ax1.set_title("ISS Pass - Elevation over Time")
ax1.legend()
ax1.grid(True, alpha=0.3)
ax1.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))

# 方位角の時間変化
ax2.plot(time_list, az_arr, color="orange", linewidth=2)
ax2.set_ylabel("Azimuth [deg]")
ax2.set_xlabel("Time [UTC]")
ax2.set_title("ISS Pass - Azimuth over Time")
ax2.grid(True, alpha=0.3)
ax2.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))

plt.tight_layout()
plt.show()

上のグラフからは、仰角が滑らかな山型の曲線を描くことがわかります。パスの中央付近で最大仰角に達し、両端で $0\degree$ に戻ります。黄色の破線は仰角 $10\degree$ を示しており、この角度以下では大気減衰が大きく、通信品質や肉眼観測の条件が悪くなります。方位角のグラフは、衛星の通過方向に応じて様々なパターンを示します。天頂付近を通過するパスでは方位角が急激に変化する区間が生じ、アンテナ追尾における制御上の課題となることもあります。

仰角・方位角の可視化ができたところで、次は実用的な応用として複数の衛星を同時に追跡する方法を紹介します。

複数衛星の同時追跡

なぜ複数衛星の追跡が必要か

実際の運用場面では、1つの衛星だけでなく複数の衛星を同時に管理する必要があることが多いです。たとえば、衛星コンステレーション(Starlinkなど)の運用管理、複数のアマチュア無線衛星の通信スケジュール計画、あるいは宇宙状況認識(Space Situational Awareness)のために多数の物体を追跡するケースなどが挙げられます。

PyEphemでは、複数の衛星オブジェクトを作成して独立に計算するだけなので、追跡の仕組みは非常にシンプルです。

複数衛星の地上軌跡を同時描画

ISSに加えて、いくつかの代表的な衛星を同時にプロットしてみましょう。ここでは、異なる軌道傾斜角を持つ衛星を選び、地上軌跡の違いを比較します。

import ephem
import math
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, timedelta

# 複数衛星のTLEデータ(サンプル)
satellites = {
    "ISS": (
        "ISS (ZARYA)",
        "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
        "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
    ),
    "NOAA 19": (
        "NOAA 19",
        "1 33591U 09005A   24045.48691712  .00000182  00000-0  11544-3 0  9991",
        "2 33591  99.1625 39.0956 0013367 205.5765 154.4621 14.12591605781721"
    ),
    "Hubble": (
        "HST",
        "1 20580U 90037B   24045.17305220  .00001408  00000-0  73018-4 0  9991",
        "2 20580  28.4711 256.5033 0002576 110.3019 249.7904 15.09425786431641"
    ),
}

# 各衛星の地上軌跡を計算(3周回分)
start = datetime(2024, 2, 15, 0, 0, 0)
minutes = np.arange(0, 280, 0.5)

colors = {"ISS": "orange", "NOAA 19": "cyan", "Hubble": "lime"}

fig, ax = plt.subplots(figsize=(14, 7))

for name, (tle_name, line1, line2) in satellites.items():
    sat = ephem.readtle(tle_name, line1, line2)
    lats, lons = [], []

    for m in minutes:
        t = start + timedelta(minutes=float(m))
        sat.compute(t)
        lats.append(math.degrees(float(sat.sublat)))
        lons.append(math.degrees(float(sat.sublong)))

    lats = np.array(lats)
    lons = np.array(lons)

    # 経度の不連続点をNaNで処理
    lon_diff = np.abs(np.diff(lons))
    jump_idx = np.where(lon_diff > 300)[0]
    for idx in sorted(jump_idx, reverse=True):
        lats = np.insert(lats, idx + 1, np.nan)
        lons = np.insert(lons, idx + 1, np.nan)

    ax.plot(lons, lats, color=colors[name], linewidth=1.0,
            label=f"{name} (i={line2.split()[2]}°)", alpha=0.8)

ax.set_xlim(-180, 180)
ax.set_ylim(-90, 90)
ax.set_xlabel("Longitude [deg]")
ax.set_ylabel("Latitude [deg]")
ax.set_title("Multi-Satellite Ground Tracks (3 orbits)")
ax.set_aspect("equal")
ax.grid(True, alpha=0.3)
ax.legend(loc="lower left")
plt.tight_layout()
plt.show()

このグラフから、軌道傾斜角の違いが地上軌跡にどう影響するかが一目でわかります。ISS($i \approx 51.6\degree$)は中緯度を広くカバーし、NOAA 19($i \approx 99.2\degree$)は南北両極を通過する太陽同期軌道を描いています。ハッブル宇宙望遠鏡($i \approx 28.5\degree$)は赤道帯に限定された狭い範囲を周回しています。この傾斜角 $28.5\degree$ は、ケネディ宇宙センター(北緯 $28.5\degree$)からのスペースシャトル打ち上げに由来するものです。

太陽同期軌道のNOAA 19は、軌道傾斜角が $90\degree$ を超える逆行軌道($i > 90\degree$)であるため、東から西に向かって飛んでいるように見えます。これは $J_2$ 摂動による昇交点赤経の歳差運動速度を地球の公転角速度に一致させるための設計であり、毎日同じ地方太陽時に同じ地点上空を通過するという特性を持ちます。気象衛星や地球観測衛星に広く採用されている軌道です。

複数衛星の同時追跡で軌道の多様性を確認できました。ここからは、PyEphemの代替として近年登場したSkyfieldライブラリと比較し、それぞれの長所と短所を整理してみましょう。

太陽同期軌道の可視化 — 実用例

太陽同期軌道とは

ここでは、PyEphemを使った具体的な応用例として、太陽同期軌道(Sun-Synchronous Orbit, SSO)の特性を可視化してみます。太陽同期軌道は地球観測衛星で最も広く使われている軌道種別であり、「毎回同じ照明条件で地表を撮影できる」という実用上の大きな利点があります。

太陽同期軌道が成り立つ条件は、地球の扁平率($J_2$ 項)による昇交点赤経 $\Omega$ の歳差運動速度が、地球の公転角速度(年間 $360\degree$)と一致することです。数式で表すと、

$$ \dot{\Omega} = -\frac{3}{2} n J_2 \left(\frac{R_{\oplus}}{a}\right)^2 \frac{\cos i}{(1 – e^2)^2} $$

ここで $n$ は平均運動、$J_2 \approx 1.0827 \times 10^{-3}$ は地球の動的形状係数、$R_{\oplus}$ は地球の赤道半径、$a$ は半長径、$e$ は離心率、$i$ は軌道傾斜角です。

この歳差運動速度 $\dot{\Omega}$ を地球の公転角速度 $\dot{\Omega}_{\text{sun}} \approx 0.9856 \, \degree/\text{day}$ に等しくする、というのが太陽同期条件です。つまり、

$$ \dot{\Omega} = \dot{\Omega}_{\text{sun}} \approx 0.9856 \, \degree/\text{day} $$

この条件を満たすには $\cos i < 0$(すなわち $i > 90\degree$)が必要で、典型的には $i \approx 96\degree \sim 99\degree$ の逆行軌道になります。

NOAA 19の地上軌跡の特性を可視化

太陽同期軌道の特徴を視覚的に確認するため、NOAA 19の1日分の地上軌跡を描画してみましょう。

import ephem
import math
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, timedelta

# NOAA 19のTLE
noaa19 = ephem.readtle(
    "NOAA 19",
    "1 33591U 09005A   24045.48691712  .00000182  00000-0  11544-3 0  9991",
    "2 33591  99.1625 39.0956 0013367 205.5765 154.4621 14.12591605781721"
)

# 1日分の地上軌跡を計算
start = datetime(2024, 2, 15, 0, 0, 0)
minutes = np.arange(0, 1440, 0.5)  # 24時間
lats, lons = [], []

for m in minutes:
    t = start + timedelta(minutes=float(m))
    noaa19.compute(t)
    lats.append(math.degrees(float(noaa19.sublat)))
    lons.append(math.degrees(float(noaa19.sublong)))

lats = np.array(lats)
lons = np.array(lons)

# 昇交点の経度を抽出(赤道を南→北に横切る点)
ascending_lons = []
for i in range(1, len(lats)):
    if lats[i-1] < 0 and lats[i] >= 0:
        ascending_lons.append(lons[i])

# 経度の不連続点をNaNで処理
lon_diff = np.abs(np.diff(lons))
jump_idx = np.where(lon_diff > 300)[0]
lats_plot = lats.copy()
lons_plot = lons.copy()
for idx in sorted(jump_idx, reverse=True):
    lats_plot = np.insert(lats_plot, idx + 1, np.nan)
    lons_plot = np.insert(lons_plot, idx + 1, np.nan)

fig, ax = plt.subplots(figsize=(14, 7))
ax.plot(lons_plot, lats_plot, color="cyan", linewidth=0.8, alpha=0.7)

# 昇交点をマーク
for asc_lon in ascending_lons:
    ax.plot(asc_lon, 0, "o", color="yellow", markersize=6)

ax.set_xlim(-180, 180)
ax.set_ylim(-90, 90)
ax.set_xlabel("Longitude [deg]")
ax.set_ylabel("Latitude [deg]")
ax.set_title("NOAA 19 Ground Track (24 hours) - Sun-Synchronous Orbit")
ax.set_aspect("equal")
ax.grid(True, alpha=0.3)
ax.axhline(y=0, color="white", linewidth=0.5, alpha=0.5)
plt.tight_layout()
plt.show()

print(f"昇交点の数: {len(ascending_lons)}")
if len(ascending_lons) >= 2:
    lon_spacings = [ascending_lons[i+1] - ascending_lons[i]
                    for i in range(len(ascending_lons)-1)]
    print(f"昇交点間の経度間隔: {np.mean(lon_spacings):.1f}° (平均)")

地上軌跡を見ると、NOAA 19は1日で地球全面をほぼ均等にカバーしていることがわかります。黄色の点で示した昇交点(赤道を南から北に横切る点)は経度方向にほぼ等間隔に並んでおり、間隔は約 $25.5\degree$ です。これは軌道周期 $T \approx 102$ 分に対して、地球が $360\degree \times 102/1440 \approx 25.5\degree$ 回転する計算と一致しています。

太陽同期軌道の最大の特徴は、連日にわたって同じ地方太陽時に同じ地点を通過することです。これにより、異なる日に撮影した画像を比較する際に照明条件がほぼ同じになり、変化検出や時系列分析が容易になります。NOAA 19の場合、昇交点通過地方太陽時は午後2時頃に設定されており、午後の気象観測に最適化されています。

太陽同期軌道の可視化を通じて、PyEphemが実用的な軌道解析にも十分活用できることがわかりました。次に、PyEphemと同様の機能を提供するモダンなライブラリ Skyfield との比較を行い、使い分けの指針を示します。

Skylield との比較 — モダンな代替ライブラリ

Skyfield の概要

Skyfield は、PyEphemの開発者の一人である Brandon Rhodes が、PyEphemの設計上の課題を踏まえてゼロから書き直した天文計算ライブラリです。2014年に初版がリリースされ、現在も活発に開発が続いています。PyEphemとの主な違いは以下の通りです。

項目 PyEphem Skyfield
SGP4実装 独自C拡張 sgp4 パッケージに委譲
座標系 J2000 / apparent ICRS / ITRS を明示的に区別
時刻管理 独自 Date Timescale による厳密な時刻管理
ベクトル化 なし(ループ必須) NumPy配列を直接渡せる
API設計 暗黙的な状態変更 関数型、副作用なし
惑星暦 内蔵(低精度) JPL DE430/DE440をダウンロード
依存関係 ほぼなし sgp4, numpy, certifi
保守状況 安定(大きな変更なし) 活発に更新中

Skyfield での基本的な衛星追跡

Skylieldでの衛星追跡の基本的なコードを示します。PyEphemとの違いを比較してみてください。

from sgp4.api import Satrec, WGS72
from skyfield.api import load, EarthSatellite, wgs84

# Timescaleオブジェクトを作成
ts = load.timescale()

# TLEデータ
tle_line1 = "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993"
tle_line2 = "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"

# 衛星オブジェクトを作成
satellite = EarthSatellite(tle_line1, tle_line2, "ISS (ZARYA)", ts)

# 時刻を指定して位置を計算
t = ts.utc(2024, 2, 15, 12, 0, 0)
geocentric = satellite.at(t)

# 直下点の緯度・経度・高度
subpoint = wgs84.subpoint(geocentric)
print(f"緯度: {subpoint.latitude.degrees:.4f}°")
print(f"経度: {subpoint.longitude.degrees:.4f}°")
print(f"高度: {subpoint.elevation.km:.1f} km")

# 観測地点からの仰角・方位角
tokyo = wgs84.latlon(35.6762, 139.6503, elevation_m=40)
difference = satellite - tokyo
topocentric = difference.at(t)
alt, az, distance = topocentric.altaz()

print(f"\n東京からの観測:")
print(f"仰角:   {alt.degrees:.2f}°")
print(f"方位角: {az.degrees:.2f}°")
print(f"距離:   {distance.km:.1f} km")

コードの構造を比較すると、PyEphemが「オブジェクトの内部状態を .compute() で更新する」スタイルなのに対し、Skylieldは「引数を渡して結果を受け取る」関数型のスタイルであることがわかります。Skylieldのアプローチは、同じ衛星を異なる時刻で計算する際に状態の混乱が起きにくく、バグの原因になりにくいという利点があります。

ベクトル化による高速計算

Skylieldの大きなアドバンテージの一つが、時刻配列をまとめて渡せるベクトル化です。

from skyfield.api import load, EarthSatellite, wgs84
import numpy as np

ts = load.timescale()

tle_line1 = "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993"
tle_line2 = "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"

satellite = EarthSatellite(tle_line1, tle_line2, "ISS (ZARYA)", ts)

# 1分間隔で1日分の時刻配列を一括で生成
minutes = np.arange(0, 1440)
t_array = ts.utc(2024, 2, 15, 0, minutes)

# 全時刻の位置を一括計算(ループ不要)
geocentric = satellite.at(t_array)
subpoints = wgs84.subpoint(geocentric)

lats = subpoints.latitude.degrees   # NumPy配列
lons = subpoints.longitude.degrees  # NumPy配列
alts = subpoints.elevation.km       # NumPy配列

print(f"計算点数: {len(lats)}")
print(f"緯度範囲: {lats.min():.2f}° ~ {lats.max():.2f}°")

PyEphemではPythonのforループで1点ずつ計算する必要がありましたが、Skylieldではscipy配列を一括で渡せるため、数千~数万点の計算が大幅に高速化されます。データ量が多いシミュレーションやコンステレーション全体の解析では、この差が顕著になります。

どちらを使うべきか

両ライブラリの選択指針をまとめます。

PyEphemが適するケース: – 手軽に衛星位置を計算したい場合 – 依存関係を最小限にしたい場合 – 既存のPyEphemベースのコードを保守する場合 – 太陽・月・惑星の位置も同じAPIで扱いたい場合

Skylieldが適するケース: – 大量の時刻点に対する高速計算が必要な場合 – 座標系を厳密に管理したい場合(ICRS/ITRS/TOD等の区別) – 高精度の惑星暦(JPL DE440等)を使いたい場合 – 新規プロジェクトで長期保守を見据える場合

PyEphemは教育目的や小規模なスクリプトには今でも十分実用的ですが、本番環境や大規模な解析にはSkylieldの方が適しています。PyEphemの公式ドキュメントでも、Skylieldへの移行が推奨されています。ただし、両者のSGP4実装はほぼ同等の精度を持つため、少数の衛星を少数の時刻で計算する限り、結果に実用上の差はありません。

ライブラリの選択肢を理解したうえで、最後に本記事の内容をまとめ、次のステップへの道筋を示しましょう。

ISSの可視パス判定 — 実用的なフィルタリング

肉眼観測の条件

ここまでのパス予測では、ISSが地平線の上にあるパスをすべて列挙しましたが、肉眼で実際に観測するには追加の条件が必要です。ISSが肉眼で見えるのは、以下の3つの条件が同時に成り立つ場合だけです。

  1. ISSが太陽光を反射している: ISSが地球の影(Eclipse)に入っていないこと
  2. 観測地が暗い: 地上が日没後の薄明(Twilight)または夜間であること
  3. 仰角が十分高い: 大気の散乱や減光の影響を受けにくい仰角(通常 $10\degree$ 以上)であること

つまり、「地上は暗いけれどISSにはまだ太陽光が当たっている」という、日没後から完全な夜になるまでの短い時間帯(または日の出前の同様の時間帯)がチャンスです。PyEphemでは eclipsed 属性でISSが地球の影にいるかどうかを判定でき、太陽の位置も計算できるため、これらの条件を組み合わせたフィルタリングが可能です。

可視パスのフィルタリング実装

import ephem
import math
from datetime import datetime

# ISSのTLE
iss = ephem.readtle(
    "ISS (ZARYA)",
    "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
    "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
)

# 観測地点(東京)
observer = ephem.Observer()
observer.lat = "35.6762"
observer.lon = "139.6503"
observer.elevation = 40

# 太陽オブジェクト
sun = ephem.Sun()

# 3日間の可視パスを探索
observer.date = "2024/2/15 00:00:00"
end_date = ephem.Date("2024/2/18 00:00:00")

visible_passes = []

while observer.date < end_date:
    try:
        pass_info = observer.next_pass(iss)
        rise_time, rise_az, max_alt_time, max_alt, set_time, set_az = pass_info

        if rise_time is None:
            break

        # 最大仰角が10度未満のパスはスキップ
        if math.degrees(float(max_alt)) < 10:
            observer.date = set_time + ephem.minute
            continue

        # パスの中間時刻で太陽の位置を確認
        mid_time = (rise_time + set_time) / 2
        observer.date = mid_time
        sun.compute(observer)
        sun_alt_deg = math.degrees(float(sun.alt))

        # ISSが影にいるか確認
        iss.compute(observer)
        is_eclipsed = iss.eclipsed

        # 可視条件: 太陽が地平線下6~18度(市民薄明~天文薄明)
        # かつ ISSが太陽に照らされている
        is_twilight = -18 < sun_alt_deg < -6

        if is_twilight and not is_eclipsed:
            visible_passes.append({
                "rise_time": ephem.Date(rise_time).datetime(),
                "set_time": ephem.Date(set_time).datetime(),
                "max_alt_deg": math.degrees(float(max_alt)),
                "sun_alt_deg": sun_alt_deg,
                "duration_min": (set_time - rise_time) * 24 * 60,
            })

        observer.date = set_time + ephem.minute
    except Exception:
        observer.date += ephem.minute * 10

print(f"3日間の可視パス数: {len(visible_passes)}\n")
for i, p in enumerate(visible_passes, 1):
    print(f"パス {i}:")
    print(f"  出現: {p['rise_time'].strftime('%Y-%m-%d %H:%M:%S')} UTC")
    print(f"  消失: {p['set_time'].strftime('%Y-%m-%d %H:%M:%S')} UTC")
    print(f"  最大仰角: {p['max_alt_deg']:.1f}°")
    print(f"  太陽高度: {p['sun_alt_deg']:.1f}°(パス中間時)")
    print(f"  継続時間: {p['duration_min']:.1f} 分")
    print()

このコードでは、各パスの中間時刻において太陽の高度とISSの日照状態を確認しています。太陽高度が $-18\degree$ から $-6\degree$ の範囲にあるとき(天文薄明から市民薄明の間)、地上は十分暗く、しかし高高度にあるISSにはまだ太陽光が届いている可能性があります。eclipsed 属性が False であれば、ISSは実際に太陽光を反射しており、肉眼で明るい点として観測できます。

3日間で検索すると、可視パスは通常3~6回程度見つかります。全パスの中から可視パスだけを抽出しているため、数は大幅に減りますが、これが実際に「目で見える」パスです。最大仰角が高いほどISSは明るく見え、$60\degree$ を超えるパスでは金星よりも明るく輝くことがあります。

このように、PyEphemは単なる位置計算だけでなく、日照条件を考慮した実用的な観測計画にも活用できます。パス予測と可視条件のフィルタリングを組み合わせることで、スマートフォンのISS追跡アプリのような機能をPythonスクリプトで実現できるのです。

TLEの鮮度と精度について

TLEには有効期限がある

ここまでの例ではすべて固定のTLEデータを使ってきましたが、実運用ではTLEの鮮度が精度に大きく影響します。TLEのエポック(基準時刻)からの経過日数が増えるほど、SGP4による伝播誤差は急速に蓄積されます。

おおまかな目安として、低軌道衛星(ISS等)のTLEは以下のような精度劣化を示します。

エポックからの経過日数 典型的な位置誤差
0日(エポック時点) 数 km 以下
1~3日 数 km ~ 十数 km
1週間 数十 km
2週間 100 km 以上
1ヶ月 使用不可(数百 km 以上)

位置誤差の主な原因は大気抵抗の不確実性です。高層大気の密度は太陽活動(11年周期の太陽黒点数変動)や地磁気嵐によって大きく変動するため、SGP4の大気抵抗モデルでは数日先までしか精密に予測できません。

最新TLEの入手方法

最新のTLEデータは以下のソースから入手できます。

  • CelesTrak (https://celestrak.org/): 最も広く使われているTLEの配布サイト。NORADカタログの衛星TLEがカテゴリ別に整理されている
  • Space-Track (https://www.space-track.org/): 米国宇宙軍の公式サイト。登録(無料)が必要だが、最も信頼性の高いソース
  • AMSAT (https://www.amsat.org/): アマチュア無線衛星に特化したTLEを配布

PyEphemのスクリプトで定期的にTLEを更新するには、CelesTrakのAPIを利用するのが便利です。

import ephem
import urllib.request

# CelesTrakからISSの最新TLEを取得
url = "https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=TLE"

try:
    response = urllib.request.urlopen(url)
    tle_data = response.read().decode("utf-8").strip().split("\n")

    if len(tle_data) >= 3:
        tle_name = tle_data[0].strip()
        tle_line1 = tle_data[1].strip()
        tle_line2 = tle_data[2].strip()

        iss = ephem.readtle(tle_name, tle_line1, tle_line2)
        iss.compute(ephem.now())

        print(f"衛星名: {iss.name}")
        print(f"TLEエポック: {tle_line1[18:32]}")
        print(f"現在の緯度: {iss.sublat}")
        print(f"現在の経度: {iss.sublong}")
        print(f"現在の高度: {iss.elevation/1000:.1f} km")
    else:
        print("TLEデータの取得に失敗しました")
except Exception as e:
    print(f"ネットワークエラー: {e}")

このコードは、CelesTrakのAPIから最新のISS TLEをダウンロードし、リアルタイムの位置を計算しています。実際のアプリケーションでは、取得したTLEをローカルファイルにキャッシュし、1日1回程度の頻度で更新するのが一般的です。TLEのエポック日を確認して、古すぎる場合(3日以上経過など)は自動的に再取得する仕組みを組み込むと、信頼性が向上します。

TLEのエポック日は Line 1 の 19~32 列に記録されています。先ほどのサンプルでは 24045.51782528 で、2024年の第45日(2月14日頃)がエポックでした。計算対象の時刻がエポックに近いほど精度が高いことを常に意識しておく必要があります。

TLEの取り扱いに関する注意点を押さえたところで、最後にこの記事全体の内容をまとめましょう。

軌道面の3D可視化

軌道を三次元で描く

ここまでは地上軌跡(2D投影)を中心に見てきましたが、衛星の軌道を3次元空間で可視化すると、軌道面の傾きや地球との位置関係がより直感的に理解できます。PyEphemで計算した緯度・経度・高度から、3次元直交座標(ECEF: Earth-Centered, Earth-Fixed)に変換してプロットしてみましょう。

地心直交座標への変換式は次の通りです。緯度 $\phi$、経度 $\lambda$、地表からの高度 $h$ から、ECEF座標 $(X, Y, Z)$ への変換は、

$$ X = (R_{\oplus} + h) \cos\phi \cos\lambda $$

$$ Y = (R_{\oplus} + h) \cos\phi \sin\lambda $$

$$ Z = (R_{\oplus} + h) \sin\phi $$

ここで $R_{\oplus} \approx 6{,}371 \, \text{km}$ は地球の平均半径です。$\cos\phi$ の因子により、高緯度では $X$-$Y$ 平面上の円の半径が小さくなり、赤道面での半径が最大になります。$Z$ 成分は $\sin\phi$ に比例するため、軌道傾斜角が大きいほど $Z$ 方向の振幅が大きくなります。

import ephem
import math
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from datetime import datetime, timedelta

# ISSのTLE
iss = ephem.readtle(
    "ISS (ZARYA)",
    "1 25544U 98067A   24045.51782528  .00011186  00000-0  20208-3 0  9993",
    "2 25544  51.6412 208.6063 0005140  43.4286  70.0747 15.49562695441075"
)

# 1周回分の軌道を計算
start = datetime(2024, 2, 15, 0, 0, 0)
R_earth = 6371  # km

minutes = np.arange(0, 93, 0.3)
xs, ys, zs = [], [], []

for m in minutes:
    t = start + timedelta(minutes=float(m))
    iss.compute(t)
    lat = float(iss.sublat)
    lon = float(iss.sublong)
    alt_km = iss.elevation / 1000
    r = R_earth + alt_km

    xs.append(r * math.cos(lat) * math.cos(lon))
    ys.append(r * math.cos(lat) * math.sin(lon))
    zs.append(r * math.sin(lat))

# 地球の球面を描画
u = np.linspace(0, 2 * np.pi, 50)
v = np.linspace(0, np.pi, 25)
xe = R_earth * np.outer(np.cos(u), np.sin(v))
ye = R_earth * np.outer(np.sin(u), np.sin(v))
ze = R_earth * np.outer(np.ones(np.size(u)), np.cos(v))

fig = plt.figure(figsize=(10, 10))
ax = fig.add_subplot(111, projection="3d")

# 地球
ax.plot_surface(xe, ye, ze, alpha=0.15, color="blue")

# 軌道
ax.plot(xs, ys, zs, color="orange", linewidth=2, label="ISS Orbit")
ax.plot([xs[0]], [ys[0]], [zs[0]], "o", color="lime", markersize=8, label="Start")

# 赤道面のリング
theta = np.linspace(0, 2 * np.pi, 100)
ax.plot(R_earth * np.cos(theta), R_earth * np.sin(theta),
        np.zeros_like(theta), color="white", linewidth=0.5, alpha=0.5)

ax.set_xlabel("X [km]")
ax.set_ylabel("Y [km]")
ax.set_zlabel("Z [km]")
ax.set_title("ISS Orbit in 3D (1 orbit)")
ax.legend()

# アスペクト比を均等に
max_range = 7500
ax.set_xlim(-max_range, max_range)
ax.set_ylim(-max_range, max_range)
ax.set_zlim(-max_range, max_range)

plt.tight_layout()
plt.show()

3Dプロットでは、ISSの軌道面が赤道面に対して約 $51.6\degree$ 傾いている様子が明確に見えます。青い半透明の球が地球、オレンジの曲線がISSの軌道を表しています。軌道が地球表面からわずかに浮いた薄い殻の上を走っていることが見て取れ、ISSの高度(約420 km)が地球半径(6,371 km)のわずか 6.6% にすぎないことが視覚的に実感できます。宇宙空間とは思えないほど地球に近い軌道であることが、この3Dビューからよくわかります。

まとめ

本記事では、PythonライブラリPyEphemを使った人工衛星の軌道計算と可視化について解説しました。

  • TLEデータ: 人工衛星の軌道情報を2行のテキストで表現する標準フォーマット。エポック日、軌道傾斜角、離心率、平均運動など、SGP4に必要なパラメータがすべて含まれている
  • PyEphemの基本操作: ephem.readtle() でTLEを読み込み、compute() で任意の時刻の衛星位置(緯度・経度・高度)を計算できる。内部ではSGP4アルゴリズムが実行されている
  • 地上軌跡の描画: 時系列で位置を計算し、matplotlibやCartopyで可視化。軌道傾斜角が衛星の到達可能な緯度範囲を決定すること、周回ごとに地球の自転分だけ地上軌跡が西にずれることを確認した
  • パス予測: next_pass() メソッドで、観測地点から見た衛星の出現・最接近・消失の時刻と方位角・仰角を自動計算。肉眼観測には太陽の位置と日照条件のフィルタリングが必要
  • ポーラープロット: 仰角・方位角の時間変化を極座標で可視化することで、衛星がどの方角からどの高さを通過するかが一目でわかる
  • 複数衛星の比較: 異なる軌道傾斜角を持つ衛星の地上軌跡を比較し、軌道設計の目的(全球カバレッジ、太陽同期、特定緯度帯のカバー)が地上軌跡のパターンに直結することを確認した
  • Skylieldとの比較: モダンな代替としてSkylieldを紹介。ベクトル化による高速計算、厳密な座標系管理、関数型APIが利点。新規プロジェクトではSkylieldの採用を検討すべき
  • TLEの鮮度: エポックから離れるほど精度が劣化するため、最新TLEの定期的な取得が不可欠

PyEphemは軌道計算の入門ライブラリとして今でも十分な実用性を持っています。TLEの読み方、SGP4の位置づけ、パス予測のロジックなど、ここで学んだ概念はSkylieldやGMATなど他のツールに移行しても共通して役立ちます。

次のステップとして、以下の記事も参考にしてください。