AirTagの位置測位データは取り出せる！② ~測位データ抽出編~

## はじめに
+ 本記事は[前回の記事](https://qiita.com/MtGuchi/items/78af8d72eda93d454acb)の後編となります
+ 前回はAirTagの位置測位データを含む生データのダンプを行いました
+ 上記の動作確認ができていない方はまず前編をご覧ください

## 本記事のゴール
+ AirTagの位置測位データを抽出します！
    + <img width="500px" alt="demo" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/611618/cea12fc8-b70c-89ff-3c5c-96b43c531ec0.gif">
+ 本記事を読み進めれば，最終的に以下のようなデータを得ることができます
    +
    |timestamp  |distance  |azimuth  |elevation  |
    |---|---|---|---|
    |16466.153469| 1.526608| -90.042649| -8.601441|
    |16466.273513| 1.543757| -90.120226| -1.454071|
    |16466.395865| 1.551355| -89.938739| 9.001518|
    |16466.553616| 1.547471| -89.873304| 15.544009|
    |16466.673660| 1.547786| -88.254214| 15.305837|
    |16466.793704| 1.542401| -88.072648| 8.913718|
    |16466.913749| 1.529785| -89.061435| -3.331561|
    |16467.033793| 1.533102| -90.601608| -17.429219|
    |16467.173845| 1.525131| -93.156616| -31.547601|
    |16467.293889| 1.537368| -96.305077| -43.633401|
    |...|...|...|...|

## 測位データの保存
+ 有線・無線によらず前回の記事と同様の手順を踏んでデータをダンプできるようにする
+ 改めてデータ抽出用のシェルスクリプトを作成
```extract_location.sh
#!/bin/bash

idevicesyslog -m nearbyd | \
# 位置測位結果「Part 1 of 2」の文字列に注目
sed -n '/Part 1 of 2/,/}/p' | \
awk '
    # r1 timestamp (sec) の行を検出して4番目の数値に相当するフィールドを格納
    /r1 timestamp \(sec\)/ { timestamp = $4 }

    # r1 range (m) の行を検出して4番目の数値に相当するフィールドを格納
    /r1 range \(m\)/       { distance  = $4 }

    # r1 azimuth PDOA (deg) の行を検出して4番目の数値に相当するフィールドを格納
    /azimuth PDOA \(deg\)/ { azimuth   = $4 }

    # r1 elevation PDOA (deg) の行を検出して4番目の数値に相当するフィールドを格納
    /elevation PDOA \(deg\)/ { elevation = $4 }

    # }（閉じカッコ）の行が来たときに全ての変数が揃っていれば CSV として出力
    /}/ {
        if (timestamp && distance && azimuth && elevation) {
            # フィールド末尾に付いているカンマを削除
            gsub(/,/, "", timestamp);
            gsub(/,/, "", distance);
            gsub(/,/, "", azimuth);
            gsub(/,/, "", elevation);

            # CSV 出力
            printf "%s,%s,%s,%s\n", timestamp, distance, azimuth, elevation;
            timestamp=distance=azimuth=elevation=""
        }
    }
' > | (echo "timestamp,distance,azimuth,elevation" && cat) > nearbyd_location.csv
```
+ ``chmod +x extract_nearbyd_data.sh``を実行してスクリプトに実行権限を付与
+ ``./extract_nearbyd_data.sh``を実行してスクリプトを実行
    + 位置測位に関連するデータのみをテキストマッチングで抽出してcsv保存
+ iPhone上でFind Myを起動してAirTagを探す
    + AirTagを探している最中にlogが蓄積される
    + ある程度動かせたらアプリを終了
+ Ctrl + Cでコマンドを終了
    + 自動的にデータnearbyd_location.csvが保存される
+ これで得られる位置測位データがこちら
    +
    |timestamp  |distance  |azimuth  |elevation  |
    |---|---|---|---|
    |2253.56| 1.80104| invalid| invalid|
    |2253.62| 1.81335| 56.000000| 5.000000|
    |2253.69| 1.80134| invalid| invalid|
    |2253.75| 1.79833| 70.000000| 4.000000|
    |2253.81| 1.82596| invalid| invalid|
    |2253.88| 1.80494| 72.000000| -5.000000|
    |2253.94| 1.84428| invalid| invalid|
    |2254| 1.80434| 81.000000| -13.000000|
    |2254.07| 1.8671| invalid| invalid|
    |2254.13| 1.82206| 82.000000| -7.000000|
    |...|...|...|...|

## 測位データの抽出
+ 得られるデータは16Hzくらい
+ さらにいえばデータの半分がinvalidとなるため実質8Hzくらい
+ データ数を増やすためにアップサンプリングを実施
+ たとえば以下のPythonコードを作成
```upsample_location.py
import pandas as pd
import numpy as np

# CSVファイルパス
input_file = "nearbyd_location.csv"
output_file = "nearbyd_location_upsampled.csv"

# CSVデータの読み込み
df = pd.read_csv(input_file)

# 列名を確認
print("読み込んだ列名:", df.columns)

# スペースが含まれている場合を考慮して列名を修正
df.columns = df.columns.str.strip()

# timestamp列を数値型に変換
df['timestamp'] = pd.to_numeric(df['timestamp'], errors='coerce')

# distance, azimuth, elevation 列も数値型に変換し、invalid を NaN に置換
df['distance'] = pd.to_numeric(df['distance'], errors='coerce')
df['azimuth'] = pd.to_numeric(df['azimuth'], errors='coerce')
df['elevation'] = pd.to_numeric(df['elevation'], errors='coerce')

# タイムスタンプ範囲で60Hzのアップサンプリングを実行
start_time = df['timestamp'].min()
end_time = df['timestamp'].max()
new_timestamps = np.arange(start_time, end_time, 1/60.0)  # 1/60秒間隔のタイムスタンプ

# アップサンプリング結果を格納するデータフレームを作成
df_upsampled = pd.DataFrame({'timestamp': new_timestamps})

# distanceをアップサンプリング (全データ)
df_upsampled['distance'] = np.interp(
    df_upsampled['timestamp'],
    df['timestamp'],
    df['distance']
)

# azimuth, elevationはinvalidを除外してアップサンプリング
valid_azimuth = df.dropna(subset=['azimuth'])
valid_elevation = df.dropna(subset=['elevation'])

df_upsampled['azimuth'] = np.interp(
    df_upsampled['timestamp'],
    valid_azimuth['timestamp'],
    valid_azimuth['azimuth']
)

df_upsampled['elevation'] = np.interp(
    df_upsampled['timestamp'],
    valid_elevation['timestamp'],
    valid_elevation['elevation']
)

# 結果をCSVに保存
df_upsampled.to_csv(output_file, index=False)

print(f"Saved upsampled data as {output_file}. ")
```
+ ``python upsample_location.py``を実行することで冒頭のようなデータをGET!!

## [細かいデータを見たい方へ] 生データの保存
+ 有線・無線によらず[前回の記事](https://qiita.com/MtGuchi/items/78af8d72eda93d454acb)と同様の手順を踏んでデータをダンプできるようにする
+ 改めて以下のコマンドを実行
    + ```idevicesyslog -m nearbyd | sed -n '/Part 1 of 2/,/}/p; /Part 2 of 2/,/}/p' > nearbyd_extracted.log```
    + 位置情報の部分だけをテキストマッチングで抽出してlogデータに保存
+ iPhone上でFind Myを起動してAirTagを探す
    + AirTagを探している最中にlogが蓄積される
    + ある程度動かせたらアプリを終了
+ Ctrl + Cでコマンドを終了
    + 自動的にデータnearbyd_extracted.logが保存される

## [細かいデータを見たい方へ] 生データの抽出例
+ 生データを我々が観察しやすい形で抽出
+ 下記データがUWBパケット1発に基づいて出てくる
+ このデータがパケットごとに何サイクルもlogデータに格納されていく
+ 各行のデータが何を表しているかは行末に記述
```
[Solution Provider] Range Result (Part 1 of 2): // 測定結果のPart 1（全2部構成）
{
    'ticket id': 19, // セッションの識別子（そのAirTagがこれまでに何回探されてきたか）
    'r1 sess id': 0, // 測定セッションのID（通常は0で，新規セッションを表す）
    'r1 cycle_idx': 2, // 測定サイクルのインデックス（logデータ内でこのデータが何サイクル目か）
    'r1 mac_addr': 0x17427d0ca2dba300, // 測定対象デバイスのMACアドレス
    'mach abs time (sec)': 2946.41, // スリープ時間を含まない，iPhoneが起動してからの時間（秒）
    'mach cont time (sec)': 2990.097107, // スリープ時刻を含む，iPhoneが起動してからの時間（秒）
    'r1 timestamp (sec)': 2784.71, // AirTag側のタイムスタンプ（秒、基準時刻の取り方は不明）
    'r1 sess status': SUCCESS, // 測定セッションのステータス
    'r1 sess type': PointToPoint, // 測定セッションの種類（iPhoneとAirTagの一対一接続）
    'r1 winning_tx_antenna_mask': 0x01, // iPhoneで用いた送信アンテナのマスク値（0x01と0x04の2種類あり）
    'r1 UWB channel': CHANNEL9, // 使用されたUWB通信チャネル
    'r1 Band select': N/A, // 帯域選択（今回の測定では未使用）
    'r1 NB channel': N/A, // ナローバンドチャネル（今回の測定では未使用）
    'r1 range (m)': 0.134222, // 測定された距離（m）
    'ME range unc (m)': 0.010000, // 距離測定の不確実性（m）
    'r1 azimuth PDOA (deg)': -90.000000, // 方位角の到着位相差 (PDoA, deg)
    'r1 elevation PDOA (deg)': -141.000000, // 仰角のPDoA (deg)
    'r1 SOI RSSI (dBm)': -84.25, // 測定対象信号 (SOI) の受信信号強度 (dBm)
    'r1 anchor time offset raw (r1 ticks)': 230151, // アンカ時間オフセットの生データ（タイムスタンプの生値）
    'r1 anchor time offset (ps)': 5.99352e+09, // アンカー時間オフセット（ピコ秒単位での調整量）
    'r1 AOA first path SNR - center (dB)': 32.000000, // 中央軸方向の到達経路の信号対雑音比 (dB)
    'r1 AOA first path SNR - vertical (dB)': 30.000000, // 垂直方向の信号対雑音比 (dB)
    'r1 AOA first path SNR - horizontal (dB)': 41.000000, // 水平方向の信号対雑音比 (dB)
    'r1 carrier freq offset (ppb)': 1503.44, // キャリア周波数のオフセット (ppb単位での周波数誤差)
    'r1 AOA first path index - center (dB)': 118.000000, // 中央軸方向の到達経路インデックス値
    'r1 AOA first path index - vertical (dB)': 119.320000, // 垂直方向の到達経路インデックス値
    'r1 AOA first path index - horizontal (dB)': 118.850000, // 水平方向の到達経路インデックス値
}
[Solution Provider] Range Result (Part 2 of 2): // 測定結果のPart 2（全2部構成）
{
    'ticket id': 19, // セッションの識別子（Part 1と一致）
    'r1 sess id': 0, // 測定セッションのID（Part 1と一致）
    'r1 cycle_idx': 2, // 測定サイクルのインデックス（Part 1と一致）
    'r1 mac_addr': 0x17427d0ca2dba300, // 測定対象デバイスのMACアドレス（Part 1と一致）
    'r1 SOI RSSI 2 (dBm)': invalid, // 測定対象信号の第2RSSI値（データ無効）
    'r1 OC SOI RSSI 2 (dBm)': invalid, // 補正後RSSI値（データ無効）
    'r1 mms val status':  N/A, // 測定データの有効性（該当データなし）
    'r1 nb demod chain: ':  N/A, // ナローバンドのデモジュレーションチェーン（該当データなし）
    'r1 nb rx0 elna hg: ':  N/A, // RX0のアンプゲイン（該当データなし）
    'r1 nb rx1 elna hg: ':  N/A, // RX1のアンプゲイン（該当データなし）
    'r1 nb tx antenna: ':  N/A, // ナローバンド送信アンテナ（該当データなし）
    'r1 cycle skipping':  N/A, // サイクルスキップ情報（該当データなし）
    'toa peak snr (dB)':N/A, // 到達時間（TOA）ピークの信号対雑音比（該当データなし）
    'toa peak index high res':N/A, // 高解像度のTOAピークインデックス（該当データなし）
    'toa peak signal level high res (dB)':N/A, // 高解像度TOAピーク信号レベル（dB、該当データなし）
}
```

## 補足
+ 測位データのazimuthやelevationが1行おきにinvalidとなる理由
    + 今回の実験ではUWBアンテナを2本有するiPhone 12 Proを使用
    + 2本のアンテナを交互に用いることで測位データを抽出
    + DistanceはiPhone・AirTag間のTwo Way Ranging (TWR) などでアンテナごとに取得可能
    + Azimuth, elevationといった角度は2アンテナから算出されるので片方はinvalidとなっている
+ 本稿で抽出している生データも完全なログデータというわけではない
    + 真のログデータは''idevicesyslog -m nearbyd''を実行すれば出てくる
    + ただその中身については著者もまだ把握できていない
    + こちらで進展があれば記事を更新いたします

## まとめ
+ 前回の記事から更新が遅くなり申し訳ございません！
+ AirTagを用いた位置測位が皆様のお手元で実現されていれば幸いです...
+ 誤植や不備等がございましたら遠慮なくご指摘いただきたく存じます