衛星測位が使えないとき:Levenberg-Marquardt で Bluetooth RSSI の混乱に対抗する

衛星測位は建物内で使えなくなるが、著者はBLEビーコンとLevenberg-Marquardt三辺測位をiOS上で組み合わせ、誤差を50cm以内に抑えた。

日本語
コピー
题图:BeaconIL 室内定位示意图,三个 BLE 信标的覆盖圈交叉出定位点,左下是 EMA 滤波曲线,右下是 Levenberg-Marquardt 的损失函数公式

衛星測位は建物の前までなら連れて行ってくれる。だが中に入った途端、見放される。屋外で位置を数メートル以内まで三角測量できる信号も、屋根一枚は貫けない。コンクリートと鉄筋が信号を遮断する。倉庫、病院、商業施設、空港ターミナル——人類が打ち上げたあらゆる衛星コンステレーションから見て、あなたはそこにいない。これが屋内測位問題であり、実際に難しい。厄介な工学パズル、という種類の難しさではない。物理法則がこちらに牙を剥いている、という種類の難しさだ。BeaconIL という iOS アプリを作った。Bluetooth Low Energy ビーコンと、数値計算から移植した非線形最適化アルゴリズムでこの問題に挑むものだ。以下は、数学で電波物理に立ち向かった過程で学んだことだ。

まず背景として、このプロジェクトがなぜ存在するのかを話しておく。2020 年時点で、私はデスクトップ開発とマイコン組み込みの仕事に 12 年携わっていた。デスクトップの Qt と C++ からベアメタルファームウェアまで一通りやった。所属していた会社の R&D 部門はマイコンベースの製品を作っていた。同じ仕事は二つとない。地下鉄の有害ガス監視ファームウェア。産業インフラの監視。それからスマートペット首輪——後に全国展開の製品に育ったが、これについてはすでに書いた。そして本稿に関係する仕事が来る。工場での量産を前提としたスマートウォッチで、機能リストに屋内測位が入っていた。屋根の下では衛星測位が使えないからだ。ここには安全上の要件もあった。工場のフロアで動く機械は 50 センチの誤差を許してくれない。だから位置は近距離で信頼できなければならず、平均的に正確であればいいという話ではなかった。

ウォッチが主要な成果物だった。測距の対象を与えるために、iBeacon も自作した。ハードウェアは同僚が設計し、ファームウェアは私の担当だった。だがこの仕事の核心は数学にある。ノイズ混じりの電波の読み取り値を位置推定に変換し、それをマイコン上で、最終的には純粋な C で動かす。とはいえ、電波の厄介ごとを潰していくには、手元にスマートフォンがあるのが一番手軽だった。iOS には使える BLE スタックと、CoreLocation の測距コールバック、そして推定位置を描画できる画面があった。どれもデバッグ不要で、そのまま使える。マイコン上では、数学を検証できる状態に持っていくまでに、これらの層をすべて自分で作らなければならない。だからまずアルゴリズムを iOS に載せ、それが BeaconIL になった。実機でアルゴリズムを検証した後、Go に移植して独立したクリーンなライブラリにした。その Go コードは、マイコン向けの最終的な C 書き直しの参照用であり、今度はすでに踏んだバグを避けるためのものだ。つまり BeaconIL が動き出した時点で、この数学はまだ存在していなかった。終着点はベアメタルのマイコンだが、スマートフォンのおかげで数学だけに集中できた。もちろん、向こうにも OS の抽象化層と制約がある。この実験は、問われていた以上のことを明らかにした。余談だが、iOS でここまで深くやったことが私の進路を決めた。このプロジェクトの後、私はモバイルを長く取り組むべき職業として選び、そこでリリースしたアプリが注目を集め、上司からはその後エンタープライズ向けのものも作るよう言われた。

主な目標は磨き上げられた製品を作ることではなく、数学が成立するかを検証することだった。EMA フィルタ、パスロス距離モデル、Levenberg-Marquardt による三辺測量が、実際の iOS ハードウェアと実際の BLE ビーコン上で安定して使える位置推定を返せるか。この目標は達成した。4×6 メートルの部屋に校正済みのビーコンを 3 つ配置した環境で、画面上のマーカーは実際に立っている位置と常に 50 センチ以内の誤差に収まった。App Store への申請、2 回の審査通過、そして後のオープンソース化は副次的なものだ。その後に培ったモバイルの経験を踏まえて今同じものを作り直すなら、アーキテクチャは違うものになるだろう。よりクリーンで、iOS の流儀に沿い、組み込みエンジニアが初めて作ったモバイルプロジェクトらしさは薄れるはずだ。だがアーキテクチャが粗いからといってコードが信頼できないわけではない。あの初期のアプリは実使用に耐え、本当に重要なエッジケースは処理されていた。数学は——これが本題だが——成立している。

このアプリは近くの iBeacon 互換 BLE ビーコンをスキャンし、各ビーコンの信号強度を測定して距離推定に変換し、三辺測量アルゴリズムで位置を計算し、その結果をフロアプラン上にリアルタイムで描画する。2 回の App Store 審査を経て無事に公開された。コードはhttps://github.com/Vitaliy69/BeaconILにある。

図 1 — BeaconIL のメイン画面。上部:major/minor 識別子、dBm 単位の RSSI、推定距離を表示するリアルタイムのビーコン一覧。下部:SpriteKit による可視化で、ビーコン(青いマーカー)と計算されたデバイス位置(赤いマーカー)がフロアプラン上に重ねて表示されている。

図 1 — BeaconIL のメイン画面。上部:major/minor 識別子、dBm 単位の RSSI、推定距離を表示するリアルタイムのビーコン一覧。下部:SpriteKit による可視化で、ビーコン(青いマーカー)と計算されたデバイス位置(赤いマーカー)がフロアプラン上に重ねて表示されている。

電波の厄介ごと

Bluetooth Low Energy ビーコンは電池駆動の小型送信機で、一定間隔——通常は毎秒 10 回——でアドバタイズパケットをブロードキャストする。各パケットには UUID、major 値、minor 値が含まれ、ビーコン本体とそのグループを識別する。iPhone は CoreLocation でこれらのパケットを受け取る。CoreLocation はそれらを溜め込み、およそ毎秒 1 回 didRange をコールバックする。コールバックには、見えているすべてのビーコンとその現在の RSSI 値の配列が入っている。RSSI の単位は dBm(デシビーミリワット)だ。

RSSI は負の値を取る。ゼロに近いほど信号が強い。スマートフォンに密着したビーコンなら -40 dBm 程度、10 メートル離れると -75 dBm 程度まで落ちる。理屈の上では、これが距離を推定する手段になる。信号が弱いほど距離が遠い、というわけだ。

理論上は。

実際には、RSSI は人生で出会う中で最もノイズの多い信号のひとつだ。ビーコンから出た電波が、きれいに直線を描いてスマホに届くなんてことはない。壁、床、天井、金属ラック、人体で反射する。水に吸収される(人体の大半は水だ)。マルチパスで自分自身と干渉する——同じパケットが複数の経路を通り、わずかにずれた時間で届き、それらのコピーが重なり合ったり打ち消し合ったりする。誰かが自分とビーコンの間を横切れば、RSSI は一瞬で 10 dBm 落ち、その人が離れれば元に戻る。

微動だにせず、スマホをしっかり構えていても、単一のビーコンの RSSI は隣り合う 2 回の読み取りの間で 3〜8 dBm 揺れる。RSSI から距離への変換は対数関係なので、3 メートル地点での 6 dBm の揺れは、およそ −1.5〜+3 メートルの距離変動に相当する。この非対称性は対数曲線そのものから来ている。位置推定はビー玉のようにあちこち跳ね回る。

これが最初にぶつかる壁だ。位置計算をひとつでも行う前に、これを何とかしなければならない。

EMA:指数移動平均

解決策は信号のフィルタリングだ。生の RSSI 値をそのまま使うことはできない。揺れが激しすぎるので、まず平滑化が必要になる。ただし平滑化もやりすぎると、システムが鈍くなり、位置が実際の動きについていけなくなる。

選んだのは指数移動平均、つまり EMA だ。考え方は単純で、新しい RSSI の読み取りが来るたびに、平滑値をまるごと置き換えるのではなく、そちらへ少しだけ動かす。その一歩の大きさを決めるのが平滑化係数で、これはウィンドウサイズから導出される N

α = 2 / (N + 1)
ema_new = α × rssi_new + (1 - α) × ema_old

IndoorMath.swift の実装では、beacon ごとに RSSI 履歴バッファを 1 つ持つ。新しい読み取りが届いたら、まずバッファが EMA ウィンドウを満たしているかを確認する。満たしていなければ生の値をそのまま追加する。データが足りず、平滑化も何もあったものではないからだ。バッファが埋まった時点で EMA の式が効き始める:

ウィンドウはスライド式だ。新しい値が入り、古い値が先頭から落ちていく。デフォルトのウィンドウサイズは 10 で、対応する平滑化係数はおよそ 0.18。つまり新しい読み取りが平滑値に寄与するのは約 18% で、残りの 82% は履歴から来る。

ウィンドウサイズはユーザーが設定でき、範囲は 5 から 100 まで。ウィンドウが小さいほど応答が速く(動けばすぐ位置が更新される)、そのぶんノイズも多く入り込む。大きいほど曲線は滑らかになるが、遅れが目立つ。実際の部屋で本物の beacon を使い、何度もテストしたうえでデフォルトは 10 に落ち着いた。画面上の位置マーカーが乱れず、それでいてシロップの中を泳ぐような鈍さもない、ちょうどいい塩梅だ。

もうひとつ、期限切れのチェックがある。ある beacon から 12 秒以上信号が届かなければ、バッファから丸ごと追い出す。beacon のカバレッジから外に出た場合の処理だ。見えなくなった beacon の古いデータが三辺測量を汚染し続けるのは困る。

RSSI から距離へ:パスロスモデル

平滑化した RSSI が手に入ったら、次はそれを距離に変換する。使うモデルは対数距離パスロス式の簡略版だ:

distance = 10 ^ ((txCalibratedPower - rssi) / (10 × n))

ここで txCalibratedPower はビーコンからちょうど 1 メートルの地点で測った RSSI。基準点であり、抽象的な dBm の数値を物理的な距離に結びつける錨だ。n はパスロス指数で、自由空間では信号電力が距離の 2 乗で減衰するから、n = 2 が Friis の伝送方程式における 20·log₁₀(d) の項に対応する。この自由空間の 2 が、app の最初のリリースで採用された値だ。実在の建物がこれに当てはまることはほとんどなく、この記事の後半で扱う校正の話はまさにそこに効いてくる。周波数は指数ではなく、1 メートル基準値のほうに織り込まれている。

実装は関数ひとつだけだ:

コード中の除数 10.0 * pathLossExponent が、上の式の 10 × n にあたる。指数はこのコミットでパラメータになり、デフォルト値は既存の 2 か所の呼び出し元に対して 2021 年当時の挙動をそのまま保っている。以下で説明する校正を実際のフロアに適用するとき、n がエリアごとにフィットさせるべき数値になる。

txCalibratedPower が -59 dBm(Estimote や Kontakt.io のビーコンでよくある値)で、平滑化した RSSI が -70 dBm なら、自由空間のデフォルトでは距離はおよそ 10^(11/20) ≈ 3.5 メートル。同じ読み取りを、現場でフィットさせた指数 2.8 で計算すると、推定値は 10^(11/28) ≈ 2.5 メートルまで縮む。同じ電波、同じ算術で、1 メートルの差はまるごと指数が生んでいる。単純な話だ。

だが txCalibratedPower は普遍的な定数ではない。ビーコンごとに少しずつ違う。製造公差、アンテナの向き、電池残量、筐体の材質——これらすべてが 1 メートル基準値を数 dBm ずらす。あるビーコンの真値が -65 なのに -59 として計算すれば、距離推定は系統的に 10^(6/20) ≈ 2 倍ずれる。2 メートル先のビーコンを 4 メートル先と見積もることになる。システム全体が破綻する。

だから校正が必要なのだ。

1 メートル基準値

BeaconIL には校正モードが組み込まれている。iPhone をビーコンからちょうど 1 メートル、同じ高さに置き、「Start calibration」を押す。app は RSSI の測定値を集め(デフォルトで 30 個)、平均して、そのビーコン固有の txCalibratedPower を求める。

校正中は RSSI 値がリアルタイムで表示されるので、いつ安定したかがわかる。サンプル数が十分になると、平均値がその beacon の onMeterRSSI として Core Data に保存される。校正のデータ量は設定でき、10 から 150 サンプルまで選べる。サンプルが多いほど平均は正確になるが、校正にかかる時間も延びる。30 は実用的なデフォルトだ。CoreLocation のコールバックはおよそ毎秒 1 回なので、所要時間は 30 秒ほどで、得られる基準値としては十分に安定している。アプリへの適用や通知など、一連の流れ全体は後ほど登場する。

図 2 — 単一 beacon の設定とキャリブレーション画面。ユーザーは平面図上で X/Y 座標、1 メートル地点の RSSI 値、名前を設定する。キャリブレーションボタンを押すとリアルタイムサンプリングが始まる。サンプリング中は RSSI フィールドがリアルタイムで更新される。

図 2 — 単一 beacon の設定とキャリブレーション画面。ユーザーは平面図上で X/Y 座標、1 メートル地点の RSSI 値、名前を設定する。キャリブレーションボタンを押すとリアルタイムサンプリングが始まる。サンプリング中は RSSI フィールドがリアルタイムで更新される。

キャリブレーション中にアプリをバックグラウンドへ切り替えないよう、アプリは警告を出す。アプリがフォアグラウンドにないと iOS は BLE の測距を止めてしまうからだ。そうなると読み取れるデータは古いか欠けているかのどちらかで、キャリブレーション結果はゴミになる。

もう一点、正直に書いておくべきことがある。距離の式に登場する指数 2 は自由空間の値であり、実際の建物は自由空間ではない。実測される屋内の指数は、開放的なホールで約 1.6、コンクリートに遮られた環境では 4 以上に達する。キャリブレーションは曲線を 1 メートル地点に固定するので、近距離の推定は信頼できる。指数を 2 に固定したままだと、曲線の遠い側が系統的にずれる。4 メートル×6 メートルの部屋ならこのずれは許容できるが、倉庫では通用しない。

キャリブレーションを実際のフロアへ広げるのは別の作業であり、部屋と工場のあいだの隔たりこそがその出発点になる。実用的な進め方:

まず現場を歩いてから、何かを設置する。金属製の棚、冷蔵庫への通路、動く機械、人の流れの向きを記録する。これらが指数のゾーニングを決める。公表されている測定結果では、開放空間は 2 に近く、オフィスは 2.7 から 3.5、雑然とした工業プラントなら 4 以上になる。4 メートル×6 メートルの実験室で測った数字をそのまま持ち込むことはできない。

1 メートルのキャリブレーションは、図を描きやすい場所ではなく、beacon を実際に設置する位置で行う。そのうえで基準線に沿って参照測定値を集める。既知の距離——2、4、8 メートル——に立って RSSI を記録する。ゾーンごとに 2 点あれば局所的な指数をフィットできる。対数距離グラフ上で測定した点の組に直線を当てはめ、その傾きが n になる。この n をそのゾーンの beacon の calculateRealDistance に入れる。これが上のコードリストにある pathLossExponent パラメータそのものだ。コンクリート壁の奥のゾーンは独自の指数を持つべきで、開放的な場所と同じ値を使い回してはいけない。

計画に沿って再キャリブレーションする。棚は動き、在庫は回転し、レイアウトは変わる。公表されている導入ガイドラインは、高精度が求められるアプリでは四半期ごとの再キャリブレーションを推奨している。そして環境がどうにも手に負えないときは、物理モデルを捨ててしまえばいい。代替はフィンガープリンティングだ。既知の参照点で RSSI パターンを記録し、リアルタイムの読み取り値をそのマップに照合する。モデルを実測の現実で置き換える手法で、広く不規則な空間ではこれが定石になる。

図 3 —— キャリブレーション手法を 1 枚で見る。左: 4 組の模擬基準線測定値(1、2、4、8 メートル、txPower -59 dBm、真の指数 2.8、シャドウイングノイズ 2 dB)と、対数距離グラフ上の最小二乗直線。その傾きは指数にマイナス 10 を掛けたものになる。右: 指数を間違えると何を払うか。自由空間のデフォルト値 2 では、推定値が距離とともに対角線から外れていく(8 メートルが約 23 メートルと読まれる)。一方、フィットした指数は真の距離についていく。ここではフィット値が 3 付近に落ち、真の値は 2.8。ノイズを含む 4 点はわずかに過大評価しており、だからこそこの手法は単一のグローバルな数字ではなく、ゾーンごとのフィットを要求する。

図 3 —— キャリブレーション手法を 1 枚で見る。左: 4 組の模擬基準線測定値(1、2、4、8 メートル、txPower -59 dBm、真の指数 2.8、シャドウイングノイズ 2 dB)と、対数距離グラフ上の最小二乗直線。その傾きは指数にマイナス 10 を掛けたものになる。右: 指数を間違えると何を払うか。自由空間のデフォルト値 2 では、推定値が距離とともに対角線から外れていく(8 メートルが約 23 メートルと読まれる)。一方、フィットした指数は真の距離についていく。ここではフィット値が 3 付近に落ち、真の値は 2.8。ノイズを含む 4 点はわずかに過大評価しており、だからこそこの手法は単一のグローバルな数字ではなく、ゾーンごとのフィットを要求する。

線形三辺測量が通用しない理由

ここまでは展開規模のキャリブレーションの話だった。ここからは 1 つの部屋、1 台のスマートフォンに戻る。少なくとも 3 つの beacon までの距離が手元にあり、それぞれの beacon が平面図上のどこにあるかも分かっている。次は位置推定だ。

教科書どおりのやり方は三辺測量である。各 beacon を中心に、測定した距離を半径とする円を描き、3 つの円の交点が自分の位置になる。簡潔でエレガント、そして現実世界ではまったく使えない。

問題は、距離の推定が決して正確ではないことにある。ノイズを含む無線信号から計算した近似値であり、信号はフィルタリングされても浄化されてはいない。だから実際には、3 つの円が 1 点で交わることはほとんどない。重なってぐちゃぐちゃな領域になるか、まったく重ならないかのどちらかだ。2 つの円は 2 点で交わるかもしれないが、3 つ目の円はどこにも触れないかもしれない。きれいな幾何学的解は存在しない。

交点を平均するとか、重なり領域の重心を取るとかいった手も思いつく。こうした場当たり的な方法はうまく機能しない。一部の距離推定は他よりも信頼できるという事実を無視しているからだ。信号が強く距離が近い beacon の推定は、ノイズフロアに埋もれた遠い beacon の推定よりも優れている。

正しいアプローチは、これを幾何の問題として扱うのをやめ、最適化の問題として扱うことだ。距離測定が不完全な状況で、最適な位置を求めるのである。

Levenberg-Marquardt: 非線形最小二乗

完璧な交点を探す代わりに、任意の候補位置がどれだけ悪いかを測るコスト関数を定義し、それを最小化する。コスト関数は残差平方和で、距離の二乗の差として書ける:

f(x) = Σ ( ‖x - pᵢ‖² - dᵢ² )²

ここで x は候補位置、pᵢ は beacon i の既知の位置、dᵢ はその beacon までの測定距離である。beacon ごとに、候補位置からその beacon までのユークリッド距離の二乗を計算し、測定距離の二乗を引き、差を二乗して、すべての beacon について合計する。この和を最小にする位置が最良の推定値になる。この書き方(距離の差ではなく距離の二乗の差)は、厳密解が存在する場合には直接形式と同じ最小点を持つ。ノイズを含むデータでは両者がわずかに異なることがある。その代わり、二乗形式はより単純な Jacobian を与える。座標に関する偏導関数は線形であり(2x - 2pᵢ)、LMA の各反復の計算が安く済む。

これは非線形最小二乗問題であり、標準的な解法はLevenberg-Marquardtアルゴリズムだ。LMAは勾配降下法とガウス・ニュートン法を組み合わせ、現在どれだけ解から離れているかに応じて挙動を変える。最小値から遠いときは勾配降下法のように振る舞う。遅いが確実で、最急降下方向に沿って小さく歩を進める。最小値に近づくとガウス・ニュートン法に切り替わる。収束が速く、ステップは大きく、より正確だ。この切り替えは減衰パラメータλで制御され、そのステップがコストを下げたかどうかに応じて毎回の反復で調整される。

完全な実装はLMAMath.swiftにある。771行のSwiftで、外部依存は一切ない。Javaで書かれたApache Commons MathのLevenberg-Marquardtオプティマイザの移植版だ。同じアルゴリズムはオープンソースのGoライブラリhttps://github.com/Vitaliy69/lmamathにも存在する。その最初のコミットは2021年6月28日で、同じ日にSwiftコアが強化アップデートを受け取り、追加のチェックと3D空間のサポートが加わった。同じ数学の独立した2つの実装が、それ以来互いを検証し合っている。

この記事を書くにあたり、共有テストベクトル上でこの2つの移植版を突き合わせた。厳密解が存在する入力では、両者が返す座標は小数点以下12桁まで完全に一致した。ノイズのある入力では、結果の差は1マイクロメートル未満で、残りは浮動小数点の丸めによるもので、ロジックの違いではない。

この記事のカバー画像も同じ方法で作られている。画像上の3つのビーコンは固定座標にある。円は測定された距離で、実際のRSSI値からパスロス式で導出したものだ。赤い星は飾りではない。その正確なシナリオに対してソルバーが出した答えで、カバーを描画する際に計算された。シーンを生成するのに使った実際の位置は数センチしか離れていない。ノイズのある円が推定値を幾何学的な理想から引き離すからだ。このずれこそがこの記事の物語のすべてであり、1枚の画像に凝縮されている。この3つの円をどちらの移植版に入れても、ほぼ寸分違わない点に着地する。

どちらの移植版にも、コマンド1つで実行できるテストスイートが付属している。Swiftパッケージはテスト実行ファイルでソルバーをビルドし、Goモジュールにはgo testがある。両者は同じテストベクトルを共有する。厳密な入力、ノイズ入りの入力、より高次元のアンカー、そして拒否されるべき不正な入力だ。拒否の側では、2つの実装がそれぞれ自前の防御を稼ぎ出している。Swiftは2021年6月のコミットで次元チェックを加えた。Goがそれを補ったのは数年後で、混合次元のアンカーで自身のテストスイートがpanicしたのがきっかけだった。まさにSwift版が2年前に直したバグだ。修正はpanicが現れた当日にリポジトリに入った。移植版は、同じ失敗の仕方をして初めて完成する。

Goライブラリの1回の実行が、物語全体を1つのシーンに圧縮する。4つのアンカーと4つのノイズ入り距離を食わせると、重心から出発し、コスト関数がどちらへ傾くかを感じ取り、慎重に減衰させた歩みを推定値が動かなくなるまで進める。以下のシーンはその実行を、ソルバー自身のループから追跡したものだ。半透明の球はそれぞれノイズ入りの距離を表す。赤い経路は推定値が下っていく軌跡。アスタリスクは最終的に止まった位置で、真値から約0.5メートル離れている。右のパネルは同じ実行を内側から見せている。コストが崩壊し、減衰が一度だけ上がって悪いステップにブレーキをかける。減衰がゼロに落ちると、ソルバーは純粋なGauss-Newtonステップを踏む。

図4 —— Go移植版が3次元のケースを解く様子を、ソルバー自身のループから追跡した。4つのアンカーは異なる高さにあり、これが2021年6月のコミットが解き放った次元だ。距離はノイズ入り(±0.35 m)。収束経路は重心の初期推定から推定値まで進む。真値との差0.45 m、7回の反復。右図:各反復のコストと減衰因子λ。λは一度上がって悪いステップを減衰させ、その後ゼロに落ちる。ソルバーが最小値付近でGauss-Newtonステップに切り替えるからだ。ノイズ源はこの記事で繰り返し出てくるものと同じだ。B2の経路上に立つ人体、同一周波数帯のWi-Fiアクセスポイント。(読みやすさのため、Z軸は1.25倍に引き伸ばしている。)

図4——Go移植版が3次元のケースを解く様子で、軌跡はソルバー自身のループから直接取っている。4つのアンカーは異なる高さにあり、これが2021年6月のコミットが解き放った次元だ。距離はノイズ入り(±0.35 m)。収束経路は重心の初期推定から推定値まで進む。真値との差は0.45 m、反復は7回。右図:各反復のコストと減衰因子λ。λは一度上がって悪いステップを抑え込み、その後、ソルバーが最小値付近でGauss-Newtonステップに切り替わるにつれてゼロに落ちる。ノイズ源はこの記事で繰り返し出てくるものと同じだ。B2の経路上に立つ人体、同一周波数帯のWi-Fiアクセスポイント。(読みやすさのため、Z軸は1.25倍に引き伸ばしている。)

Swiftのエントリポイントはsolve(positions:distances:)だ。

あのコメントは符号が逆になっている。target[i]は常にゼロだ。最適点での実際の式は(x0-xi)^2 + (y0-yi)^2 - ri^2 = target[i]で、上のコスト関数と一致する。だがコメントには+xi+ri^2と書かれている。無害だ。コメントは実行されない。下のvalue()jacobian()の関数は正しく計算する。公開済みコードに残った小さな傷だと思えばいい。

いくつか設計上の判断に触れておく価値がある。

初期点。 オプティマイザには開始時の推定が必要だ。私は重心、つまりすべてのビーコン位置の平均を使う。これは妥当な出発点だ。デバイスはビーコンのカバー範囲内のどこかにあり、重心はその領域の中央にあるからだ。より良い初期推定は、収束に必要な反復回数が少なくなることを意味する。

逆二乗重み付け。 各ビーコンのコスト関数への寄与に重み 1/d² を掛ける。つまり、より近いビーコン(距離推定の信頼性が高い)ほど解への影響が大きくなる。距離1メートルのビーコンの重みは、距離5メートルの25倍だ。これが逆二乗則であり、物理的な事実に対応している。信号強度は距離の二乗に比例して減衰するため、信号源に近い測定ほど本質的に信頼できる。

収束判定。 コストの相対的な減少が1e-10を下回るか、ステップ幅がパラメータノルムの1e-10倍を下回ると、オプティマイザは停止する。この許容値は非常に厳しいので、早期に停止することはない。安全網として1000回の反復と1000回の関数評価というハードリミットもあるが、実際には5〜20回の反復で収束する。

QR分解。 各反復で、ヤコビ行列(残差の位置パラメータに対する偏導関数行列)を列ピボット付きQR分解で分解する。この手法は数値的に安定しており、ランク落ちのケースも問題なく処理できる。実装には完全なQR分解ルーチンが含まれており、Householder反射で変換を行い、列ピボットで数値安定性を確保し、Givens回転でLMパラメータを決定する。

この問題のヤコビ行列は単純だ。残差の各座標に対する偏導関数は 2x - 2pᵢ になる:

残差関数は距離の二乗の差を計算する:

これらを組み合わせると、ノイズの多い距離に対して十分ロバストな位置推定が得られる。3つの円が1点で交わることはアルゴリズムは要求しない(実際には決して交わらない)。代わりに、既存のデータに最もよく合う位置を、各測定値の信頼度で重み付けしながら見つけ出す。視野に4つ目、5つ目のビーコンがあれば、システムは自動的にそれらも使う。ビーコンが増えれば制約が増え、フィット精度が上がる。アルゴリズムは「3つの円」の幾何学的関係など気にしていない。ただ誤差を最小化しているだけだ。

スレッド分離:UIを滑らかに保つ

BLEスキャンは絶えず更新を生成する。CoreLocationは毎秒1回 didRange をコールバックし、各コールバックは可視ビーコンとその最新RSSI値を含む配列を運んでくる。5つのビーコンが見えれば、毎秒5つの更新された CLBeacon オブジェクトを受け取ることになる。この負荷は大きくないが、メインスレッドで雑に処理すればUIは影響を受ける。テーブルビューはカクつき、SpriteKitシーンはフレームを落とし、アプリ全体が壊れているように感じられる。

解決策はスレッドを明示的に管理することだ。BeaconScan が測距コールバックを処理し(既知ビーコンのフィルタリング、距離計算、diffable data sourceスナップショットの構築)、その後明示的にメインスレッドに切り替えてスナップショットを適用し、可視化を更新する:

通知は ScanControllercalculateLocation() を発火させ、それが IndoorMath.updateVisibleBeacons()IndoorMath.getLocation() を実行する。これらはすべて純粋な計算(EMAフィルタ、距離変換、LMAソルバー)であり、メインスレッドに置いているのは結果がすぐにSpriteKitシーンに消費されるからだ。MVPとしては意図的なトレードオフだ。ビーコンが3〜5個なら、ソルバーは数マイクロ秒で収束する。バックグラウンドキューに移すとコンテキストスイッチのオーバーヘッドが増えるだけで、実際の利益は見えない。実測した。現在のMacデスクトップで、最適化ビルドの LMAMath.swift を使い、2つの固定シナリオ(ビーコン3個と5個)で繰り返し解いたところ、3個で平均約5マイクロ秒、5個で約10マイクロ秒だった。スマホはもっと遅いが、それでもマイクロ秒のオーダーだ。Geekbenchのシングルコアスコアで言えば、iPhone 11はこのMacより約2.5倍遅く、5個のビーコンの求解でも数十マイクロ秒に過ぎない。結論は変わらない。

スナップショットが何かUIに触れる前に完全に構築される、これが要点だ。DispatchQueue.main.async はその後、測距コールバックがどのキューから来たかに関わらず、メインスレッドで適用されることを保証する。AppDelegatetableShouldUpdate フラグも保持しており、ScanControllerviewDidDisappear でそれを false に設定するので、ユーザーがスキャン画面を見ていないときはスキャンコントローラが処理を完全に停止する。誰も見ていないのに三辺測量でCPUを焼く意味はない。

SpriteKitでフロアプランを描画する

可視化レイヤにはSpriteKitを使った。Appleの2Dゲームエンジンだ。ツール系アプリとしては奇妙に見える選択かもしれない。UIKitを使う方が一般的だろう。だがSpriteKitは座標系、z軸ソート、組み込みアニメーションを備えたシーングラフを提供してくれ、動く物体を含むフロアプランの描画に役立つ。

VisualizationScene クラスがシーンを管理する。ビーコンは青い四角のスプライト、デバイス位置は赤いマーカーとして描画される。フロアプランは写真ライブラリから読み込み、50%の不透明度で背景レイヤとして配置できる:

zPosition システムが背景を常にビーコンと位置マーカーの後ろに保つことを保証する。シーンが更新されると、既存のビーコンノードと位置ノードはすべて削除され、新しい位置で再追加される。最も効率的な方法ではない(既存ノードをその場で更新することもできる)が、少数のオブジェクトなら十分速く、ノードプールの複雑さも省ける。

シーンの座標系は (0, 0) を中心とし、「View Area Size」設定に応じて外側に広がる。ビーコン座標はこの中心点に対して保存される。あるビーコンのXを5.0、Yを-3.0に設定すれば、シーン中心の右5単位、下3単位に表示される。スケール係数がシーン単位をピクセルにマッピングし、領域サイズの変化に応じて調整される。

ちょっとした工夫:SKViewpreferredFramesPerSecond は1に設定されている。これは正しくないように聞こえる。ゲームエンジンでなぜ1 fpsなのか。だがこれはゲームではない。シーンは60 fpsで連続的にレンダリングする必要はなく、新しい位置データが来たとき、つまり測距コールバックが到着したときにだけ再描画すればいい。フレームレートを1に設定すると、SpriteKitは毎秒1回シーンをtickし、背景とノード位置を新鮮に保つのに十分であり、不要なレンダリングで電力を消費しない。実際の位置更新は updateVisualisation() を通じてオンデマンドで発生し、ノードツリーを直接操作する。

図 5 — グローバル設定画面。UUID フィールドはどの iBeacon リージョンをスキャンするかを決める。View Area Size はシーンのズーム倍率を制御する。Calibration Data Size はキャリブレーション時に平均する RSSI サンプル数を設定する。EMA Filter Size は平滑化ウィンドウを制御する。iCloud 同期はオン/オフを切り替えられる(アプリの再起動が必要)。

図 5 — グローバル設定画面。UUID フィールドはどの iBeacon リージョンをスキャンするかを決める。View Area Size はシーンのズーム倍率を制御する。Calibration Data Size はキャリブレーション時に平均する RSSI サンプル数を設定する。EMA Filter Size は平滑化ウィンドウを制御する。iCloud 同期はオン/オフを切り替えられる(アプリの再起動が必要)。

永続化:Core Data、Codable、CloudKit

Beacon の設定(座標、キャリブレーション後の RSSI、名前)は、アプリを再起動しても保持され、複数のデバイス間で同期される必要がある。ここは三層のアーキテクチャになっている。

シリアライズ。BeaconCoordinates はごく普通の Codable 構造体だ:

各 beacon の設定は JSON にシリアライズされ、文字列として Beacons という名前の Core Data エンティティに保存される。このエンティティには JSON 文字列を保持する data という属性が一つだけある。計算プロパティ dataAccessor がエンコードとデコードを透過的に処理する:

このやり方はあまり一般的ではない。普通は各フィールドを個別の Core Data 属性としてモデリングするもので、JSON を文字列に押し込んだりはしない。だがこれには実際的な理由がある:CloudKit だ。Core Data が CloudKit 経由で同期するとき、各属性は CloudKit スキーマの独立したフィールドになり、スキーマの変更にはマイグレーションが必要になる。設定を単一の JSON 文字列として保存すれば、CloudKit スキーマはシンプルで安定したままでいられる。BeaconCoordinates に新しいフィールドを追加するのは Codable レベルの変更で済む:マイグレーションも CloudKit スキーマの更新も要らない。

ローカル永続化。BeaconKnown は CRUD 層だ。初期化時にすべての beacon を取り出し、addBeacondeleteBeacon メソッドを提供し、NSManagedObjectContext を通じて保存する。context は automaticallyMergesChangesFromParent を有効にしているので、CloudKit 同期による変更はそのまま反映され、マージ処理を自分で書く必要はない。

クラウド同期。AppDelegate は persistent container を遅延初期化する。iCloud 同期が有効なら(デフォルトで有効)、通常の NSPersistentContainer ではなく NSPersistentCloudKitContainer を使う:

CloudKit を有効にすると、Core Data は変更を自動的に iCloud に同期し、ユーザーの複数のデバイス間で状態を保つ。iPad で設定した beacon が iPhone にも現れる。entitlements ファイルは CloudKit コンテナ識別子と各サービスを宣言している。同期をオフにすると、アプリはローカル保存のみに戻り、persistent history tracking を有効にする(CloudKit を使わない場合、変更を正しく扱うにはこれが必要だ)。

キャリブレーションの流れ全体

ここからは、一つの beacon をキャリブレーションするときに実際に何が起きるのかを最初から最後まで追う。ほぼすべてのコンポーネントが関わってくるからだ。

リスト内の beacon をタップすると、BeaconSettingsController への遷移がトリガーされる。画面には X、Y、RSSI、名前のフィールドがある。beacon の間取り図上の物理座標、たとえば (5.0, 3.0) を入力し、「B1」のような名前を付ける。RSSI フィールドのデフォルトは -59 で、よくある工場出荷値だ。

「Start calibration」を押す。スマートフォンを beacon から 1 メートル、同じ高さに置くよう促すダイアログが出る。確認する。calibrationInProgress フラグが true になり、RSSI フィールドが無効化され、通知が送られる:

BeaconScan がこの通知を受け取り、キャリブレーション中の beacon を保存する。次の測距コールバックで、可視 beacon の中から major/minor が一致するものを絞り込み、その生の RSSI を返す:

BeaconSettingsController は RSSI 値を受け取るたびにカウンタを進め、合計に加算する。リアルタイム RSSI が表示され、読み取り値が次々と入ってくるのが見える:-61、-58、-63、-60、-59……カウントが設定されたキャリブレーションデータ数(デフォルト 30)に達すると、平均を計算して保存する:

calibrationInProgressdidSet が発火し、停止通知を送り、最終値を表示し、フィールドを再び有効化し、アキュムレータをリセットする。Save を押すと、BeaconKnown.addBeacon がキャリブレーション済みの beacon を Core Data に永続化する。CloudKit が有効なら、他のデバイスにも同期される。

以降、この beacon がスキャンで見えるとき、IndoorMath は距離計算の txCalibratedPower としてキャリブレーション後の RSSI を使う。1 メートル基準値はもう推測ではなく、この特定の物理 beacon を、この特定の環境で、この特定のスマートフォンで測った値だ。

App Store への道

このアプリは今は App Store にないが、かつては掲載されていた。「Beacon Indoor Location」の名でリリースされ、App Store ID は 1561643830、バージョン 1.0 と 1.2 が相次いで Apple の審査を通過した。1.2 は App Store Review を最後まで通り、最終ステータスは「Ready for Distribution」だった。その後取り下げられたのは、アプリ自体に問題があったからではなく、背後にあった商用開発者アカウントが一時的に凍結されたためだ。その時点で BeaconIL はやるべきことを終えていた:Apple に承認された、実際の消費者向け iOS アプリの中で、生の BLE 信号から間取り図上の一点までの全経路を通し、コンセプトをエンドツーエンドで検証したのだ。残ったのは、さらに拡張でき、米国の市場インフラの標準にも適合できるアーキテクチャだ——もしその道を行くのなら。コードは残っており、MIT ライセンスでオープンソースになっている。

図 6 — App Store 上の BeaconIL。このアプリは App Store Review を 2 回通過してリリースされた。現在リスティングは非アクティブで、アーキテクチャはより大規模な展開に向けた準備を進めている。

図 6 — App Store 上の BeaconIL。このアプリは App Store Review を 2 回通過してリリースされた。現在リスティングは非アクティブで、アーキテクチャはより大規模な展開に向けた準備を進めている。

数学の進化:1 つのバグと 1 度の一般化

git の履歴そのものが物語っている。この数学がどうやって本当に成熟したかも見て取れる。

最初のコミットは 2021 年 4 月 22 日。コードベース全体、71 ファイル、3,947 行が「First public commit」として一度にコミットされた。個人プロジェクトを非公開で開発し、後からリモートに push するのは珍しくない。珍しいのはこの後に起きたことだ。2 か月間、何も動かなかった。そして 6 月 28 日、2 回目のコミットは 2 つのファイル、LMAMath.swiftIndoorMath.swift だけに触れた。コミットメッセージはこの変更を、追加のチェックを入れ、2 次元以上をサポートするものだと説明している。

このコミットは、目立たないが深刻な問題を 1 つ潰した。元のコードでは、反復カウンタと評価カウンタが 0 と 1 ではなく、上限値そのもの(1000 と 1000)で初期化されていた。

ループ内にはこれらの上限をチェックする処理が一切なかった。カウンタは存在するのに、一度も機能していなかった。ひとたびオプティマイザが収束しなくなると(ビーコンの幾何分布が極端に悪い、あるいは距離データが荒唐無稽なほど矛盾している場合など)、while true ループは回り続け、アプリが固まる。上限は安全網のように見えて、それを強制するものが何もなかった。

6 月の更新は両方の問題を直した。カウンタは 0 と 1 に正しく初期化され、ループ内に実際の上限チェックが入った。

maxIterationsmaxEvaluations もインスタンス変数から static let 定数に変わった。ちょっとした Swift のリファクタリングで、各インスタンスが個別に持つ値ではなく、型のコンパイル時定数になった。

このコミットのもう 1 つの変更は戻り値の型だ。元の solve 関数は固定の 2 次元タプルを返していた。

ファイル冒頭には "Solves a formulation of n-D space trilateration problem (2D in this application)" と書かれていた。更新後のバージョンは括弧内の説明を削り、戻り値の型を汎用配列に変えた。

これでソルバーは、入力位置と同じ次元数の座標配列を返す。IndoorMath.getLocation() の呼び出し側は依然として最初の 2 要素しか展開しない。アプリ内のフロアプランが平面だからだが、ソルバー自体は 2 軸に縛られていない。ヤコビ行列、残差関数、QR 分解はすべて任意の次元で動作するので、同じコードを変更なしに多層の小売店レイアウトに使える。

このコミットでは次元の検証も追加された。入力位置の次元が揃っていなければ、ソルバーはゴミを計算する代わりに空配列を返す。測定データも同じ扱いだ。距離がゼロ、負、非有限値の場合、アンカー座標が非有限値の場合、いずれも最初に弾かれる。理由は重み付けにある。逆二乗重み付けは距離ゼロを無限大の重みに変えてしまい、NaN は QR 分解を静かに通り抜けて、返される位置に紛れ込む。アプリ自身はこうした値を生成しない。距離はパスロス式から来るからだ。だがソルバーは公開 API であり、その入力は出力と同じくらい厳しく見られるべきだ。入力に問題があれば空配列を返す。これは 2 つの実装が共通して守る約束だ。

物語は 2026 年も続く。まさにこの記事を書いている最中に。距離関数が 2021 年に登場したとき、除数には指数 2 がハードコードされていた。4 メートル × 6 メートルのテストルームでは、この選択が問題になることは一度もなかった。大規模会場のキャリブレーション手法を書いているときに、このハードコードが展開規模でどれほどの代償になるかが露呈した。そこで指数はチェックリストにある pathLossExponent パラメータになり、デフォルト値は 2 だ。これで元の 2 か所の呼び出し地点は以前とまったく同じ動作をする。5 年越しの 1 コミットが、ラボの測定と工場の要求の間のループを閉じた。

iBeacon の実運用:電波物理が教えてくれること

BLE beacon で本当に屋内測位をやるなら、物理法則がいくつかの実践的な教訓を引き出す。これらはアプリケーションに依存しない。RSSI で距離を推定するあらゆるシステムに当てはまる。

iBeacon のパケット形式

iBeacon のアドバタイズパケットは、Apple が定義した特定のペイロード構造を持つ BLE アドバタイズだ。パケットには次が含まれる。

  • UUID(16 バイト)——beacon のグループが共有する領域識別子。例えば 07070707-0405-0607-0809-0A0B0C0D0E00。スマートフォンはこの UUID で beacon をフィルタリングするので、通常は同じ展開内のすべての beacon がこれを共有する。
  • Major(2 バイト、0〜65535)——サブグループ識別子。例えば「2 階」や「B 棟」。
  • Minor(2 バイト、0〜65535)——サブグループ内の個々の beacon 識別子。例えば「2 階の 14 番の beacon」。
  • TX Power(1 バイト、符号付き)——出荷時に測定された 1 メートル地点の RSSI キャリブレーション値。これが距離の式で使う txCalibratedPower だ。あくまで出発点にすぎない。前述したように現場で再キャリブレーションすべきだ。出荷時の値はあなたの環境を考慮していない。

beacon はこのパケットを設定可能な間隔でブロードキャストする。通常は 100 ミリ秒(10 Hz)。間隔が短いほどデータが増え、応答も速くなるが、電池寿命は縮む。100 ミリ秒間隔で動くコイン電池の beacon は数か月もつ。1 秒間隔なら 1 年以上もつ。測位に使うなら短い間隔を選ぶ。応答速度が電池寿命より重要であり、長期展開では beacon はたいてい壁電源につながれる。

配置の幾何学

三辺測量で位置を推定するには、対象地点から少なくとも3つのビーコンが見えていなければならない。当たり前に聞こえるが、実際にこれを満たすには事前の計画が要る。

商業小売店舗、オフィス、企業の物流倉庫に iBeacon を配備するとき、配置の幾何学が測位品質を決める。平面図の上では妥当に見える配置でも、幾何関係が悪ければ実際の性能は散々なものになりうる。

最悪の配置は一直線だ。廊下に沿ってビーコンを3つ並べると、幾何条件は極端に悪くなる。三辺測量の問題は不良設定になり、わずかな距離誤差がこの線に垂直な軸方向へ巨大な位置誤差として増幅される。ソルバーはそれでも収束するが、結果は不安定になる。

最良の配置は三角形だ。3つのビーコンを正三角形の頂点に置き、想定されるデバイス位置をほぼ重心付近に取る。こうすればソルバーは複数の角度からバランスの取れた拘束を得られる。長方形の部屋なら、4隅のうち3つにビーコンを置くとうまくいく。廊下なら、すべてを同じ側の壁に並べるのではなく、両側の壁に互い違いに配置する。

より広い空間では、重なり合う三角形の単位で考える。測位したい領域のどの部分でも、少なくとも3つのビーコンがおおむね三角形を成して見えているべきだ。4つ目のビーコンを足せば冗長性が生まれ精度は上がるが、適切に置かれた3つは、ひどい位置に置かれた5つに勝る。

高さがものを言う

屋内測位の配備で最も見落とされやすい要素だ。パスロスモデルは、ビーコンと受信機が同じ高さにあると仮定する。両者の高さが違えば、実際の距離は斜辺——水平距離より長い——であり、RSSI が反映するのはこの斜辺距離だ。平面図がそれを計算に入れていようといまいと関係ない。

ビーコンを3メートルの天井に取り付け、スマートフォンを1.5メートルで持つなら、垂直方向のずれは1.5メートルある。水平距離が2メートルなら、真の空間距離は √(2² + 1.5²) ≈ 2.5メートルだ。RSSI が反映するのは2.5メートルで、二次元の三辺測量が仮定しているのは2メートル。高さが合っていないというだけで25%の誤差が持ち込まれる。

BeaconIL のような二次元測位システムでは、解決策はビーコンをスマートフォンとほぼ同じ高さ——1.5〜2メートル——に、天井ではなく壁に固定して取り付けることだ。常に可能とは限らないが、これができる最大の精度改善になる。天井取り付けを避けられない場合、選択肢は2つある。第一に、ソフトウェア側で高さの差を補正し、三平方の定理で距離推定から垂直成分を差し引く。第二に、高さを明示的にモデル化する。2021年6月のアップデートで数学部分が任意次元の座標を受け取れるようになったので、各ビーコンが自分の設置高を持てるようになり、垂直方向のずれは誤差ではなくモデルの一部になる。

環境要因

BLE が使う2.4 GHz 帯は Wi-Fi、電子レンジ、その他大量の家電と同じだ。注意すべき干渉源:

  • Wi-Fi アクセスポイント——特に2.4 GHz 帯の802.11n/ac/ax。これらのチャネルは BLE のアドバタイズ周波数と重なる。ビーコンを Wi-Fi AP から1メートル以内に設置しないこと。
  • 金属面——冷蔵庫、サーバーラック、エレベーターシャフト、金属製の棚。強力なマルチパス反射を生む。金属棚の後ろに置いたビーコンは、事実上存在しないのと同じだ。
  • 人体——人体はおよそ60%が水で、水は2.4 GHz を吸収する。混雑した環境の RSSI は空いた環境より3〜5 dBm 低くなる。これはランダムではなく系統的で、距離推定を大きめにずらす。簡単な解決策はない。EMA フィルタは変動を滑らかにするが、バイアスは残ったままだ。
  • ガラスと間仕切り——ガラスは2.4 GHz をおおむね透過するが、コーティングや着色ガラス(Low-E 窓)は信号を5〜10 dBm 減衰させる。乾式壁の間仕切りは壁1枚ごとに2〜4 dBm。コンクリート壁は10〜20 dBm 減衰し、実質的に信号を遮断する。

実務上の結論:校正は実験室ではなく、システムが実際に動く環境で行う。空いた廊下で測った1メートル基準 RSSI は、金属棚が積み上がった倉庫で測った値とは違う。BeaconIL のビーコン単位の校正はまさにこのためにある。

すべてのビーコンが大声で叫ぶ必要はない

最も楽な発想は、すべてのビーコンの送信電力を最大にすることだ。距離は伸び、カバレッジは広がり、終わり。だが工場の現場では、この発想は逆効果になる。金属だらけで、機械が動き、人が歩き回る。大声のビーコンが増幅するのはカバレッジではなく反射と干渉だ。実際の製造環境での測定では、BLE 信号はそこそこ使えるものの減衰が顕著で、生の出力よりも配置と密度のほうが重要だと分かっている。

我々の答えは電力の階段だ。

通常のビーコンが粗い測位層を担う。だが一部のデバイスは意図的に極めて弱い信号を出す。短い距離を超えるとほぼ即座に検出できなくなる。近距離では、同じビーコン単位の校正を経たうえで、距離推定は理想に近い。近づかないと聞こえないビーコンは、ひとつのことを高い信頼度で告げる。あなたはそれに近い、と。

階段の最下段は NFC に近い挙動をする近接エミッタだ。およそ10センチ以内で検出できる。スマートウォッチが管理対象の機械にこの距離まで近づくと、アラートがデバイス上で発火する。平均0.5メートルの精度はナビゲーションには十分だ。だがプレス機のそばの最後の安全境界としては不十分である。機械安全工学は人と危険区域の間の最小分離距離を考えるが、0.5メートルの誤差を伴う推定はその境界として使えない。そこでこの境界は独立したデバイス種別になった。

この教訓は工場に限らない。高い送信電力はときに敵だ。距離と引き換えに、マルチパス、干渉、根拠のない自信を手に入れる。弱い信号を意図的に使うことはむしろ特徴になる。距離の収縮を近接のシグナルとして扱うのだ。

私ならこう変える

しばらく経ってからこのコードを見返すと、変えたいと思う箇所がいくつかある。

EMA の実装は、生の RSSI 値を配列に保存し、指数平均を手計算し、更新のたびに配列をずらしている。動きはするが、必要以上に複雑だ。もっとすっきりした方法は、現在の EMA 値とウィンドウサイズだけを保持し、標準的な EMA の式 ema = α × new + (1 - α) × ema をそのまま当てはめることだ。配列ベースのやり方は初期実装時の選択で、ずっとリファクタリングしていない。

Beacons という Core Data エンティティは、設定を個別の属性に分けず JSON 文字列として保存している。CloudKit の事情は前述のとおりで、これは意図的なトレードオフだ。ただその代償として、major 値などのフィールドで Core Data の predicate を使って検索できず、全 beacon を取り出して一つずつデコードするしかない。beacon が数十個のアプリなら問題ないが、千個になると厳しい。

LMA ソルバーは Apache Commons Math の実装をそのまま移植したので、あちらのコードの複雑さもそのまま引き継いでいる。determineLMParameterdetermineLMDirection の 2 つのメソッドは密度が高く、LM パラメータを計算する Moré–Hebden アルゴリズムを実装していて、Givens 回転、ランク判定、区間を縮小するループが含まれる。ロジックは正しく、テストも十分だが、読むのも保守するのも楽ではない。より現代的なやり方なら、もっと単純な信頼領域法を使うか、線形代数を Accelerate(iOS 標準のライブラリ)に任せる手もあるだろう。

肝心なのは数学

BeaconIL の面白さは UI でも App Store での公開でもない。数学だ。屋内測位は外から見ると単純に見える(「Bluetooth の電波でどこにいるか分かる」)が、実際に取り組むと底なし沼だと分かる。EMA フィルタで無線ノイズと戦い、beacon ごとのキャリブレーションでアンカーしたパスロスモデルを使い、Levenberg-Marquardt でノイズ混じりの距離を位置に変換し、スレッド分離で BLE コールバックを UI の外に追いやる。一つひとつは複雑ではない。難しいのは、それらを実機上でリアルタイムに協調させることだ。しかもハードウェアの挙動は教科書どおりとは限らない。4 メートル×6 メートル、beacon 3 個のテスト環境で、チェーン全体は推定誤差を 0.5 メートル程度に収めている。これこそがエンジニアリング上の挑戦であり、このプロジェクトに取り組む価値がある理由だ。

コードは https://github.com/Vitaliy69/BeaconIL。三辺測量ソルバーを独立に Go へ移植したものは https://github.com/Vitaliy69/lmamath。どちらも MIT ライセンス。

出典: HackerNoon← ホームへ戻る