TL;DR
- Sky-Watcher AZ-GTi経緯台マウントを衛星追尾させる自作macOSアプリを作っています
- 設計時は「マウントに連続でレート指令(角速度指令)を送り続ける」方式を想定していました
- 実機に
AxisRatesGet・MoveAxisSetを送ったところ、両方ともUnknownが返ってきました - Windows版公式アプリの実装を参考に、「未来の座標点列を事前計算し、GoToコマンドを一定間隔で送り続ける」方式に設計を作り直しました
- この方式に切り替えて動くようにはなったものの、追尾中の「目標との角度誤差」表示が常に100度規模のおかしな値を返し続けるバグが後から見つかりました
- 原因は、地心座標(geocentric)と観測地基準座標(topocentric)という、似て非なる2つの座標系を取り違えて比較していたことでした
- ただし、この記事の内容は現時点での到達点です。macOS側の接続不安定という新しい問題が出てきており、プロトコル前提の再検証が必要になる可能性があります
はじめに
対象読者: 望遠鏡・アンテナ・ロボットアームなど、外部ハードウェアを自作クライアントから制御しようとしていて、メーカーの提供するプロトコル文書やAPI仕様書を頼りに実装を組み立てている人向けです。
あわせて、SGP4/SDP4のような軌道計算や測位計算を扱っていて、地心座標(geocentric)と観測地基準座標(topocentric)のような、似た名前の異なる座標系を取り違えた経験がある人にも参考になります。
実行環境(2026年8月時点):
- macOS 27.0(ビルド26A5388g、beta)
- Swift 6.4(Xcode 27 beta 4付属、
swift-tools-version:6.2) - ターゲット: Sky-Watcher AZ-GTi経緯台マウント(モーターボードファームウェア3.40.A5)
- 通信経路: SynScan Pro App(Sky-Watcher公式アプリ)経由、UDP/TCP 11881番ポート
作っているのは、ISSのような低軌道衛星から、ひまわり・みちびきのような静止衛星・準天頂衛星まで追尾できるmacOSアプリです。
TLEをCelestrakから取得し、SGP4/SDP4で軌道計算し、経緯台マウントに向きを指示します。
マウントに載せる機材は望遠鏡に限りません。
ISSが送信するAPRSやSSTVのような電波を受信するアンテナを載せる用途も想定しています。
この記事では、「メーカーのプロトコル文書に書かれている通りに実装すれば動くはず」という前提が実機検証でどう崩れたかを書きます。
あわせて、代替方式に切り替えて動くようになった後にも、別のところに座標系の取り違えというバグが潜んでいた話も書きます。
当初の設計: 連続レート制御方式
経緯台マウントに天体を追尾させる方法は、大きく分けて2つあります。
- 連続レート制御: マウントに「方位軸を毎秒0.005度で回転させ続けろ」のような角速度指令を送り、マウント側が指示された速度で動き続ける方式
- GoTo連打: 「次の瞬間はこの座標を向け」という単発の指令を、短い間隔で繰り返し送る方式
天体追尾用途では、連続レート制御のほうが一般的です。
天体の見かけの動きは滑らかなので、マウント側に速度だけ渡して任せるほうが自然だからです。
そのため、当初の設計では以下のようなアーキテクチャを想定していました。
protocol MountTransport {
// 当初想定していたインターフェース(実装はしていない)
func slewAtRate(azimuthDegPerSec: Double, altitudeDegPerSec: Double) async throws
}
TrackingControllerが現在時刻での目標の角速度を計算し、それをslewAtRateでマウントに渡し続ける、という設計です。
SynScan App Protocol(AZ-GTiがWi-Fi経由で公開しているコマンド体系)の文書には、軸ごとのレート取得・設定コマンドらしきものが載っていました。
これで実装できると考えていました。
実機検証で判明した誤算
設計をコードに落とす前に、実機に対してコマンドを直接投げて動作確認しました。
AxisRatesGetとMoveAxisSetがUnknownを返す
SynScan App Protocolの主経路であるUDP 11881番ポートに対して、レート関連と思われるコマンドを送ってみます。
# 実行環境: macOS 27.0、AZ-GTiはSTAモードで自宅Wi-Fiに接続済み
# SynScan Pro Appがlocalhost:11881でSynScan App Protocolサーバーとして待ち受けている
echo -n "AxisRatesGet,0" | nc -u -w2 localhost 11881
echo -n "MoveAxisSet,0,1.0" | nc -u -w2 localhost 11881
返ってきたのは、以下のような応答でした。
Unknown,AxisRatesGet
Unknown,MoveAxisSet
これは「コマンドは受け付けたが未実装(Unimplemented)」ではなく、「コマンド名自体が定義されていない(Unknown)」という応答です。
つまり、SynScan App Protocolのコマンド体系には、そもそもこの種のレート制御コマンドが存在しない可能性が高いということになります。
TCP 11882でもマウントは動かなかった
AZ-GTiにはもう1つ、TCP 11882番ポートで話す別系統のプロトコル(SynScan communication protocol)があります。
こちらは可変速軸駆動用のPコマンドを持っており、応答自体は返ってきました。
しかし、実際にコマンドを送ってもマウントは物理的に動きませんでした。
加えて、後から最新のプロトコル文書(2025年9月30日版)を確認したところ、このポートはそもそも衛星追尾用の文書のスコープ外だと明記されていることが分かりました。
ここまでで、以下が実機検証で確定しました。
- UDP/TCP 11881(SynScan App Protocol)にレート制御コマンドは存在しない
- TCP 11882(SynScan communication protocol)は衛星追尾用途の対象外であり、実際にマウントも動かない
当初の設計の前提だった「連続レート制御」は、この時点で成立しないことが分かりました。
コードを書き始める前に実機へコマンドを1つ投げてみたことが、この誤算に早い段階で気づけた理由です。
設計転換: tracepoints事前計算+GoTo逐次送信方式
代替方式を探すにあたって参考にしたのは、Sky-Watcher純正のWindows版アプリ「Satellite Tracker」の実装です。
このアプリは軌道計算エンジン「PreviSat」と組み合わせて動作し、マニュアルには以下のように書かれていました(要約)。
- PreviSatが将来の座標点列(tracepoints)をCSVファイルとして事前に書き出す
- Satellite Trackerがこの点列を、一定間隔でGoToコマンドとしてマウントに送り続ける
- 送信間隔は有線接続なら20ms、Wi-Fi接続なら200msが推奨(通信の信頼性を優先するため)
これは「連続レート制御」ではなく、「短い間隔でGoToを連打する」方式です。
SynScan App ProtocolのSlewToCoordinatesAsync(座標指定の単発GoToコマンド)は実機検証で動作が確認できていたため、この方式なら実現できます。
設計を以下のように作り直しました。
// Sources/SatTracker/Tracking/TrackingController.swift(抜粋、実装済み)
@MainActor
final class TrackingController: ObservableObject {
/// 定期送信間隔(秒)。Wi-Fi経由前提のため既定200ms
let sendIntervalSeconds: TimeInterval
private func sendGoTo(
_ target: TracepointsGenerator.Tracepoint,
transport: MountTransport,
latDeg: Double,
lonDeg: Double
) async throws {
// targetは事前計算済みのtracepoint(時刻・RA・Dec)
try await transport.gotoRaDecAsync(
rightAscensionHours: target.rightAscensionHours,
declinationDeg: target.declinationDeg,
epoch: .jNow
)
}
}
OrbitPropagator(SGP4/SDP4計算エンジン)が将来数分〜数十分先までのRA/Dec座標点列を事前に計算しておきます。
TrackingControllerはそれを200ms間隔でgotoRaDecAsyncとして送り続ける、という構成です。
Windows版公式アプリと同じ設計に揃えたことで、実績のある構成に追従できました。
さらに、マニュアルには「マウントは現在時刻より最低20秒先のtracepointへスルーし、衛星が来るのを待つ」というランデブー待受の記述もありました。
これも同じ発想で実装しています。
// ランデブー待受のマージン(秒)。Windows版マニュアル1.3.4.4「最低20秒程度」を参考値とする
let rendezvousMarginSeconds: TimeInterval
pointingErrorDegが常時115度規模の誤差を返していた
方式を切り替えたことで、マウントは実際に衛星の方向へ動くようになりました。
しかし、これで終わりではありませんでした。
追尾パネルに表示していた「目標との角度誤差」は、常に100度規模のおかしな値を返し続けていたのです。
EchoTransportスタブで理論値ゼロのはずの誤差を実測する
角度誤差の算出テストを書き、実測してみました。
以下は、送ったコマンドをそのまま折り返すEchoTransport(テスト用のスタブ)を使った検証です。
// Tests/SatTrackerTests/TrackingControllerTests.swift(抜粋)
@MainActor final class EchoTransport: MountTransport {
var lastGotoRaHours: Double?
var lastGotoDecDeg: Double?
func gotoRaDecAsync(
rightAscensionHours: Double,
declinationDeg: Double,
epoch: CoordinateEpoch
) async throws {
lastGotoRaHours = rightAscensionHours
lastGotoDecDeg = declinationDeg
}
// コマンドされたRA/Decを、そのままAz/Elに変換して折り返す
// (マウントが完璧に指示通り動いた場合の理想応答)
func getAzAlt() async throws -> AzAlt {
guard let ra = lastGotoRaHours, let dec = lastGotoDecDeg else {
return AzAlt(azimuthDeg: 0, altitudeDeg: 0)
}
// ...恒星時からAz/Elへの変換(省略)
}
}
EchoTransportは「マウントが指示通りに完璧に追従した」状態を再現するスタブです。
コマンドした座標をそのまま実測値として返すので、理屈のうえでは角度誤差はほぼ0になるはずでした。
ところが、実際にISSのTLEでこのテストを流すと、角度誤差はおよそ115度という数値を返しました。
マウントが完璧に追従しているはずのシナリオでの結果です。
geocentric座標とtopocentric座標を直接比較していた
原因は、2つの異なる座標系を同じ土俵で比較していたことでした。
-
TracepointsGeneratorが生成するRA/Decは、OrbitPropagator(SGP4/SDP4計算エンジン)が出力するgeocentric(地心)座標です。地球の中心を基準にした、衛星の「本当の」位置を表します - 角度誤差を算出する際に使っていた
SGP4CoordinateTransform.lookAnglesは、topocentric(観測地基準)座標を返します。地上の観測者から見た、見かけの方向を表します
低軌道を回るISSのような衛星では、地球の中心から見た方向と地表の観測者から見た方向は、数十度単位でずれます。
これは衛星までの距離が地球の半径(約6,378km)に対して小さいためです。
静止衛星(約36,000km上空)ではこの差はほとんど無視できます。
一方、ISS(高度約400km)では、今回実測した115度規模の差として現れます。
修正前のコードは、この2つを同じ角度として比較していました。
マウントが完璧に追従していても、比較対象そのものが食い違っています。
誤差はゼロにならず、常に数十〜100度規模の値を返し続けていたわけです。
修正は、「衛星の真の位置との誤差」ではなく、「コマンドした方向にマウントが正しく追従しているか」を指標にする方向に変えました。
// Sources/SatTracker/Tracking/TrackingController.swift(抜粋、修正後)
/// 直近に`gotoRaDecAsync`で送信したコマンドのAz/El(オフセット適用後)。
/// `pollPointingError`が実測`getAzAlt()`との角度誤差を求める基準に使う。
///
/// pointingErrorDegの基準はコマンド値であり、衛星の真の位置ではない。
/// TracepointsGeneratorが生成するRA/Decはgeocentric座標であり、
/// SGP4CoordinateTransform.lookAnglesが返すtopocentric座標とは
/// 低軌道衛星で数十度規模の差がありうるため。
private var lastCommandedAzEl: (azimuthDeg: Double, elevationDeg: Double)?
private func pollPointingError(transport: MountTransport) async {
guard let actual = try? await transport.getAzAlt() else { return }
statusStore.mountAzAlt = actual
guard statusStore.trackingState == .tracking
|| statusStore.trackingState == .rendezvous else { return }
guard let commanded = lastCommandedAzEl else { return }
statusStore.pointingErrorDeg = Self.angularSeparationDeg(
az1: commanded.azimuthDeg, el1: commanded.elevationDeg,
az2: actual.azimuthDeg, el2: actual.altitudeDeg
)
}
「衛星の真の位置」ではなく「直前に自分が送ったコマンド座標」を基準にすることで、比較対象の座標系が一致します。
同じEchoTransportのテストで確認したところ、角度誤差は1度未満に収まりました。
// テストのアサーション(抜粋)
XCTAssertNotNil(store.pointingErrorDeg)
if let error = store.pointingErrorDeg {
// EchoTransportはコマンドされたAz/Elをそのまま折り返すため、
// poll時刻の恒星時計算タイミング差による極小誤差のみが残るはず。
// geocentric/topocentric不整合があればここが数十度になる。
XCTAssertLessThan(error, 1.0)
}
このテストは今も実装に残っており、swift test --filter testPointingError_zeroWhenActualMatchesCommandedAzElで再現できます。
まとめ
「メーカーのプロトコル文書に書かれている通りに実装すれば動くはず」という前提は、実機に対して実際にコマンドを送ってみるまで検証できませんでした。
AxisRatesGetがUnknownを返した瞬間に、当初の設計は成立しないと分かりました。
さらに厄介だったのは、設計を直して「マウントが実際に動く」ようになった後にも、別の罠が潜んでいたことです。
角度誤差という数値は出ているのに、その数値自体が意味を持たない状態でした。
この種のバグは、動作確認だけでは気づきにくいものです。
「桁が明らかにおかしい」という違和感からしか見つけられません。
今回は、コマンドした座標をそのまま折り返すスタブ(EchoTransport)を用意し、「理論上ゼロになるはずの値」をテストで実測することで発見できました。
ハードウェア制御を伴う個人開発では、仕様書を読んで実装するだけでは足りません。
実機に短いコマンドを1つ投げてみること、そして期待通りの応答が返るはずの状況を作ってテストすること。
この2つの検証を早い段階に挟むことが、後からの手戻りを減らします。
「動いた」の中に、まだ壊れた指標が紛れていないか。
115度という数字は、その疑いを一つ持っておく価値を教えてくれました。
ここまでの検証は、あくまで現時点での到達点です。
実は最近、macOS側でSynScan Pro Appとマウントの接続そのものが不安定になる場面に遭遇しており、この記事で確定させたはずのプロトコル前提を改めて洗い直す必要が出てくるかもしれません。
その結果次第では、tracepoints事前計算+GoTo逐次送信という現在のアプリ構造自体に手を入れることもありえます。
仕様が完全に公開されていないハードウェアを相手にする以上、1回の実機検証で確定した設計がそのまま最後まで通用するとは限らない、ということなのだと思います。