はじめに
前職で、移動体(船や車など)の位置を監視するシステムを、途中から引き継いだことがあります。当時の動作確認は、位置データをDBに直接登録・削除するだけの「点」の確認で、「警戒区域に侵入した瞬間」「ルートを外れた瞬間」みたいな、境界をまたぐ「動き」の判定はどうやってもテストできませんでした。
別に誰に頼まれたわけでもないんですが、相手が実際に動く乗り物や施設だと考えると、さすがに「たぶん動きます」では通せません。現場に行く担当でもなかったので、動作確認は結局自分でどうにかするしかないなと観念して作ることにしました。
幸い、システムにはルートとして扱えるマスタがありました。「これをなぞって動く疑似的な位置情報を作れば、実機なしで境界をまたぐ動きも再現できるのでは」と思い立ち、1週間ほどかけて作りました。作ってから「登録ルートと違うルートを流せば、そのまま異常系のテストにも使えるじゃん」と気づいたのが、個人的には一番の収穫でした。
当時のツールは、そのシステム固有のルートマスタやAPIに依存したものでした。でもこの考え方自体は監視システムに限らず、位置情報を使うアプリ全般に使えるはず。そう思って、外部データに頼らず地図上で自らルートを作れて、単体で完結する形に作り直したのが、今回紹介する GnssSimulator です。
GnssSimulatorでできること
地図上でルートを設定し、そのルート上を任意の速度で移動する疑似的な位置情報を生成して、TCP/UDPで外部アプリに送信するWPFデスクトップアプリです。
- 地図(Leaflet + OpenStreetMap)をクリックしてルートの頂点を追加し、ルートを作成
- 速度・送信間隔を指定してシミュレートを実行
- 送信フォーマットはNMEA(GGA/RMC)、緯度経度(カンマ区切り/JSON)、平面直角座標(カンマ区切り/JSON)から選択
- TCP/UDP(ブロードキャスト)で送信
- ルート座標はCSVで取込/出力も可能
例えば監視システムのテストなら、正常な巡回ルートAとは別に、あえて監視エリアに侵入するルートBを地図上でサクッと作成してシミュレートするだけで「警報が正しく鳴るか」を確認できます。ナビアプリなら、案内ルートから外れるルートを用意してシミュレートすれば「ルート逸脱時の再探索」が正しく動くかをその場で確認できます。実機を持って現地でわざと道を外れる必要はありません。
送信フォーマットにNMEAだけでなくシンプルなカンマ区切り/JSONも用意しているのは、対象アプリが自社独自のフォーマットしか受け付けない場合でも、このシンプルな出力を中継して変換する簡易ブリッジアプリを一つ挟めば、位置情報を必要とするあらゆるアプリのデバッグに応用できるようにするためです。
縁の下の力持ち:ルートのCSV入出力
GnssSimulatorは、地図をクリックして作ったルートをCSVとして出力したり、逆にCSVから取り込んだりできます。単なるバックアップ機能に見えるかもしれませんが、実はここが結構刺さる機能だと思っています。
そのシステムには、CSV取込機能はあったんですが、肝心の出力機能がなかったんですよね。なので自作したCSV出力機能を使って、その取込機能自体の動作確認をしたり、警戒エリアのデータを別のDBスキーマにコピーしたりするのに活用していました。中身が「緯度,経度」の単純な行形式だったので、こういう用途にもそのまま使い回せたわけです。
最近Copilotと壁打ちしていて気づいたのですが、これは出力だけでなく取込側にも同じ理屈が効きます。検証したい機器やアプリの側に、自分の位置情報や走行ログをCSVで出力する機能があれば、それをGnssSimulatorのルート登録画面に取り込むだけで、地図上にそのまま表示されます。つまり相手アプリが出力した位置情報が、本当に想定通りの正しい位置を指しているかを、地図を見ながら目で確認できるということです。もちろんそのままシミュレートに回すこともできますが、登録した時点で地図に可視化されること自体が、一番効くポイントだと今更ながら気づきました。
一度作った「監視エリア侵入ルート」「ルート逸脱シナリオ」のようなテストケースをCSVとして保存しておけば何度でも使い回せますし、検証対象アプリ側の出力をそのまま取り込んでの回帰確認にも使えます。目立たない機能ですが、実は用途の幅を一番広げているのはここかもしれません。
実装で工夫したところ:シミュレーションの「再開」
一通り作り終えたあとに欲しくなったのが、「シミュレーションを停止したあと、途中から再開したい」という機能でした。停止した状態を再現したり、速度や速度単位を途中で変えたいといったニーズを想定しています。
単純に考えると「最後に送信した座標に一番近い点から再開すればいい」となりますが、これをそのまま実装すると、カーブしているルートで問題が起きます。
ルートは頂点間を一定間隔で補間した点の列としてあらかじめ生成しているのですが、カーブの内側などでは「直線距離としては手前の点の方が近いが、進行方向的にはもう少し先の点から再開すべき」という状況が起こり得ます。単純な最近傍探索だけだと、進行方向を無視して手前の点を拾ってしまい、再開した瞬間に位置が後退して見えることがありました。
これを避けるために、最近傍点を求めたあと、その次の点との内積を使って「現在位置が最近傍点よりも進行方向側にあるか」を判定し、後ろ向きになる場合は次の点から再開するようにしています。
// 現在位置に最も近い座標のインデックスを算出し、そこから再開する。
var nearest = SimulationData
.Select((position, i) => (Position: position, Index: i))
.MinBy(t => Math.Pow(t.Position.X - CurrentPosition.X, 2) + Math.Pow(t.Position.Y - CurrentPosition.Y, 2));
var index = nearest.Index;
// 最も近い座標が現在位置より後ろ(進行方向基準)なら、一つ先から再開する。
if (index + 1 < SimulationData.Count)
{
var next = SimulationData[index + 1];
var dot = (CurrentPosition.X - nearest.Position.X) * (next.X - nearest.Position.X)
+ (CurrentPosition.Y - nearest.Position.Y) * (next.Y - nearest.Position.Y);
if (dot > 0) index++;
}
平面直角座標系(メートル単位の直交座標)に変換した上でX/Yの二乗距離を使っているので、緯度経度のまま計算するより単純な式で済んでいます。小さな機能ですが、実際に手を動かしてみないと気づきにくい類の問題だったので、印象に残っています。
技術構成
-
CommunityToolkit.Mvvm:
[ObservableProperty]/[RelayCommand]でMVVMを実装。ViewとViewModelの間はAction/Funcのデリゲートで繋ぎ、ViewModel側からViewの具体的な実装を意識しないようにしています。 - WebView2 + Leaflet:地図描画はWebView2で表示したHTML内でLeafletを動かし、WPF側とはJavaScriptのメッセージングでやり取りしています。
- 平面直角座標系変換:緯度経度と平面直角座標(1系〜19系)を相互変換し、距離・方位角の計算やシミュレーションの補間に使っています。
参考にした記事
- 平面直角座標系と緯度経度の相互変換の実装にあたり、以下の記事を参考にさせていただきました。
おわりに
実機やフィールドテストに頼らず、位置情報系アプリの検証を机の上で完結させたいというモチベーションで作りました。興味があれば触ってみてください。そのまま使うだけでなく、cloneして自分が検証したいシステムに合わせて改造してもらっても全然構いません。むしろそういう使われ方を想定してGitHubにMITライセンスでソースを公開しています。