Issue #38(P6)Track #48(P6-10、軌跡データの保存・一覧・再生・端末外への吸い出し)で採用する、軌跡記録の永続化ファイル形式の仕様。P6-9で実装したJSON Lines形式(track/trajectory.jsonl)を置き換える。
P6-9では1行1JSON({"atMs":...,"lat":...,"lon":...,"segmentId":...})を単一ファイルへ追記する方式を採用したが、P6-10で以下の要件が加わり形式を見直した。
V1・V2に共通する設計方針・ファイル配置・共通処理をまとめる。バージョンごとに異なるヘッダ・レコード構造は「V1フォーマット」「V2フォーマット」の各章を参照。
segmentIdはレコードごとに繰り返さず、ヘッダに1回だけ記録する。flush()し、クラッシュ耐性を保つ。将来必要になれば、セグメント確定後(記録終了時)にファイル全体を圧縮する「compress-on-finalize」を検討する(このドキュメントでは仕様化しない)。<外部ストレージ>/track/records/{プロファイル名(サニタイズ済み)}_{yyyyMMdd-HHmmss}.dtrk
FileDiagnosticRecorderが診断セッションディレクトリ名に使っている書式(yyyyMMdd-HHmmss)と統一する。/ \ : * ? " < > |等)を除去・置換してから使う。segmentId)を作る。ヘッダの先頭6byte(magic+version)は、将来バージョンがいくつ増えてもオフセット・サイズ・意味を変更しない。ここだけを読めば、他のどのフィールドも解釈せずに「このファイルがDTRK形式かどうか」「続くバイト列をどのバージョンの定義で読むべきか」を判定できる必要があるため(version自体をどのレイアウトで読むかが分からなければ、バージョン判定ができず堂々巡りになる)。全フィールドはbig-endian(既存のSessionLogWriter/SessionLogReaderと統一)。
| オフセット | サイズ | 型 | 名前 | 内容 |
|---|---|---|---|---|
| 0 | 4byte | ASCII | magic | "DTRK"(0x44 0x54 0x52 0x4B)。他形式ファイルの誤読を防ぐ識別子。 |
| 4 | 2byte | UInt16 | version | フォーマットバージョン。この値を読んでから、対応するバージョン(「V1フォーマット」「V2フォーマット」)の章の定義に従って残りを解釈する。 |
オフセット6以降(reserved/headerLengthを含むヘッダの残り・レコード全体)は、各バージョンの章で個別に定義する。位置・サイズが変わらないことが保証されるのはoffset 0-5だけで、offset 6以降の意味はversionによって変わりうる(実際、offset 6-7はV1ではreserved、V2ではheaderLengthと意味が変わる。詳細は各章を参照)。V1とV2でオフセット8以降のレイアウトが一致している部分があるが、それもV2がV1の定義を流用したという設計判断であり、恒久的な保証ではない。
V1・V2とも、レコード内のdLat/dLonはInt24で表現する。DataOutputStream/DataInputStreamに24bit整数の読み書きは無いため、以下の変換で扱う。
0xFFで埋める)。表現範囲: 符号付き24bit = ±8,388,607。1e-7度(≒1.11cm)を掛けると 約±93km(初期値からの距離として)。
SessionLogReaderが可変長形式で行っている「壊れた末尾を切り捨てそれまでは有効とする」方針と同じ考え方。P6-9→P6-10で実装済み、実機記録実績あり。
先頭6byte(オフセット0-5)は「基本フォーマット」で定義した自己記述領域(magic/version)で、V1ではversionが1になる。オフセット6以降がV1固有のフィールド(reservedは未使用領域で、V2のheaderLengthとは異なる)。全フィールドはbig-endian(既存のSessionLogWriter/SessionLogReaderと統一)。
| オフセット | サイズ | 型 | 名前 | 内容 |
|---|---|---|---|---|
| 0 | 4byte | ASCII | magic | "DTRK"(0x44 0x54 0x52 0x4B)。他形式ファイルの誤読を防ぐ識別子。 |
| 4 | 2byte | UInt16 | version | フォーマットバージョン。V1では1(V2では2になる。詳細は「V2フォーマット」章を参照)。 |
| 6 | 2byte | UInt16 | reserved | 0固定(未使用)。V2で同じ位置に導入されるheaderLengthとは無関係。実機記録済みのV1ファイルはこの位置に0が書かれているため、V1ファイルの読み込みではこの値を長さとして解釈してはならない(後述のデコード方法を参照)。 |
| 8 | 4byte | Int32 | segmentId | このファイルが属するセグメントID。ファイル内で共通。 |
| 12 | 8byte | Int64 | initialAtMs | セッション開始点(このファイルの最初のレコード)の絶対epoch ms。 |
| 20 | 8byte | Float64 (IEEE754) | initialLat | セッション開始点の絶対緯度(度)。量子化なしのフル精度。 |
| 28 | 8byte | Float64 (IEEE754) | initialLon | セッション開始点の絶対経度(度)。量子化なしのフル精度。 |
ヘッダ合計: 4+2+2+4+8+8+8 = 36byte。
ファイルの最初の点(セッション開始点そのもの)はヘッダのinitialAtMs/initialLat/initialLonが担うため、レコード列は2点目以降を格納する。
| オフセット(レコード内) | サイズ | 型 | 名前 | 内容 |
|---|---|---|---|---|
| 0 | 4byte | Int32 | elapsedMs | initialAtMsからの経過ms(常に正の値、直前のレコードからではなく常にヘッダ基準)。 |
| 4 | 3byte | Int24(符号付き) | dLat | initialLatからの緯度オフセット(1e-7度単位)。 |
| 7 | 3byte | Int24(符号付き) | dLon | initialLonからの経度オフセット(1e-7度単位)。 |
レコード合計: 4+3+3 = 10byte。
セッション開始点(そのファイルの最初の点)をp0 = (atMs0, lat0, lon0)として:
ヘッダ書き込み:
magic = "DTRK"
version = 1
reserved = 0
segmentId = <現在のセグメントID>
initialAtMs = atMs0
initialLat = lat0
initialLon = lon0
2点目以降の各点p = (atMs, lat, lon)に対し、レコードを書き込む:
elapsedMs = atMs - atMs0 // Int32
dLat = round((lat - lat0) / 1e-7) // Int24(符号付き)
dLon = round((lon - lon0) / 1e-7) // Int24(符号付き)
各点を受信するたびに1レコードを追記し、即座にflush()する(クラッシュ耐性、P6-9からの既存方針を踏襲)。
範囲超過時の扱い: dLat/dLonが±8,388,607を超える場合(セッション開始点から93kmを超えて移動した場合)、そのまま整数へ詰めるとオーバーフロー(ラップアラウンド)し、無音のまま位置が大きく狂ったレコードになる。これを避けるため、範囲の最大/最小値へクランプして記録する(位置に誤差は生じるが、無音の暴走地点にはならない)。同様にelapsedMsが31bit符号付き範囲(約24.8日)を超える場合もクランプする(通常の1セグメントの継続時間ではまず起こらない)。
ヘッダを読み、magic/versionを検証する。一致しなければこのファイルは扱わない(空リストを返す)。
segmentId, initialAtMs, initialLat, initialLonを読む(reservedは無視する)。
points = [ (initialAtMs, initialLat, initialLon, segmentId) ] // ファイル先頭の1点
残りバイト数 = ファイルサイズ - 36 // V1のヘッダ長は固定36。実機記録済みファイルのreserved領域は`0`のため、ここを長さとして読んではならない(V2との違いは「V1との後方互換」節を参照)
レコード数 N = floor(残りバイト数 / 10) // 末尾の不完全なレコード(破損・書き込み途中断)は自動的に切り捨てられる
N回繰り返し、各レコードについて:
elapsedMs, dLat, dLon を読む
atMs = initialAtMs + elapsedMs
lat = initialLat + dLat * 1e-7
lon = initialLon + dLon * 1e-7
points に (atMs, lat, lon, segmentId) を追加
実測GGA受信レート(P6-9で確認、約9〜10Hz)を前提に、1日の稼働時間ごとの概算:
| 稼働時間 | 点数(9.5Hz換算) | サイズ(10byte/点 + ヘッダ36byte) |
|---|---|---|
| 1時間 | 約34,200点 | 約342KB |
| 6時間 | 約205,200点 | 約1.96MB |
| 8時間 | 約273,600点 | 約2.61MB |
JSON形式(P6-9、1点あたり約90〜100byte)比で約9〜10倍の削減。
Track #49(P6-11)で、走行軌跡へアンテナ―作業機オフセットを反映するために策定した拡張。P6 UI仕様の「アンテナ―作業機オフセット」節も参照。
以下をすべて実装済み(testDebugUnitTest/assembleReleaseとも成功)。
NmeaParser.decodeRmcがCOG(courseDegrees)・対地速度(speedKnots)をデコードし、FixRepositoryがノット→m/s変換の上でTrackPoint.courseDeg/speedMpsへ付与する。DtrkFormat/DtrkFileWriter/DtrkFileReaderがV1・V2両方を読み書きする(新規記録は常にV2、既存V1ファイルはversionで分岐して読み続ける)。Profileにアンテナ―作業機オフセット(offsetForwardCm/offsetLateralCm)を追加し、レイヤー1のプロファイル編集画面から設定できる。記録開始時点の値がV2ヘッダへ書き込まれる。TrajectoryTurnDetectionSettings(COG信頼最低速度・旋回ヨーレート/速度閾値)を追加し、設定画面から調整できる。TrajectoryOffsetTransform(描画専用の変換ロジック、trackパッケージ)がメカニズム1(方位の穴埋め)・メカニズム2(旋回区間の除外)を実装し、updateTrajectoryLine経由で測位系画面の地図・再生画面の両方に適用される(記録一覧のサムネイルは既定値のまま=オフセット無しで暫定)。未実装・未検証のまま残るもの(Track #49で明記した既知の残課題、このサイクルでも解消していない):
P6-9〜P6-10のV1形式は、軌跡点(アンテナ位置)の座標だけを持ち、方位(COG)を持たない。トラクターの実際の耕耘位置(ロータリー中心)はアンテナからオフセットしているため、表示・再生時にオフセット変換をかける必要があるが、そのためには各点の方位が要る。オフセット適用は記録時ではなく描画・再生時に行う方針とした(旋回中は実際には作業していないため軌跡表示に意味がないという利用者の実運用知見により、旋回区間の判定・除外ロジックを後から何度でも調整できるようにするため。記録時に焼き込む方式だと、ロジックを見直すたびに記録済みデータの再収集が必要になってしまう)。このためV1で持っていたアンテナ生位置の記録方式自体は維持し、方位(COG)・速度を新たにレコードへ追加する。
先頭6byte(オフセット0-5)は「基本フォーマット」の自己記述領域(magic/version)で、versionが2になる。オフセット6-7は、V1ではreserved(未使用)だった位置に、V2で新たにheaderLength(ヘッダ全体のbyte数、値40)を導入する。オフセット8-35(segmentId/initialAtMs/initialLat/initialLon)はV1のフィールド定義をそのまま流用した(V2固有の設計判断であり、自己記述領域のような「変更しない」保証があるわけではない)。末尾に以下を追加する。
| オフセット | サイズ | 型 | 名前 | 内容 |
|---|---|---|---|---|
| 0〜5 | 6byte | (基本フォーマット参照) | magic/version | 自己記述領域。versionは2になる |
| 6 | 2byte | UInt16 | headerLength | ヘッダ全体のbyte数。V2では40固定。V1でreserved(常に0)だった位置に、V2で新たに意味を持たせたフィールド。 |
| 8〜35 | 28byte | (V1のレイアウトを流用) | segmentId/initialAtMs/initialLat/initialLon | V1の定義をそのまま流用(V2固有の設計判断) |
| 36 | 2byte | Int16 | offsetForwardCm | このセグメント記録開始時点の、プロファイルの前後オフセット(cm) |
| 38 | 2byte | Int16 | offsetLateralCm | 同、左右オフセット(cm) |
ヘッダ合計: 6 + 2 + 28 + 2 + 2 = 40byte。
headerLengthを設けた理由: ヘッダとレコード列(データ部)の境界を、バージョンごとのヘッダサイズを実装側にハードコードせずに判定できるようにするため。V1にはこのフィールドが無い(オフセット6-7は常に0のreserved)ため、この恩恵を受けられるのはV2以降で新たに書かれたファイルのみ。V1ファイルの読み込みでは、version==1と判定した時点でヘッダ長を固定値36として扱う(「V1との後方互換」節を参照)。
オフセットはセグメント(ファイル)ごとに1回だけ記録する(監査目的: 後からプロファイルのオフセット設定を変更しても、既存ファイルがどの値で記録されたかを追跡できる)。ヘッダが担うセッション開始点(1点目)には、COG・速度を持たせない。そのためだけに専用フィールドを設けるより、他の「COGが無効な点」と同じフォールバック経路(後述)で扱う方が単純であり、影響も数万点中の1点・約100ms未満に限られるため。
V1レコード(10byte、elapsedMs/dLat/dLon)の定義をそのまま流用し(V2固有の設計判断)、末尾に追加する。
| オフセット(レコード内) | サイズ | 型 | 名前 | 内容 |
|---|---|---|---|---|
| 0〜9 | 10byte | (V1のレイアウトを流用) | elapsedMs/dLat/dLon | 変更なし |
| 10 | 2byte | UInt16 | cogDeciDeg | この点のCOG(進行方向)。0.1°単位(0〜3599)。0xFFFF=無効(受信機がRMCのCOGフィールドを空欄で送ってきた場合、NMEA仕様上あり得る) |
| 12 | 1byte | UInt8 | speedByte | この点の対地速度。0.05m/s刻み(0〜254 = 0〜12.7m/s)。0xFF(255)=無効 |
レコード合計: 10 + 2 + 1 = 13byte。
0xFFFF/0xFFを「無効」のセンチネル値にするのは、RMCのCOG・速度フィールド自体がNMEA仕様上「値が無ければ空欄」になり得るためで、これにより描画側で「この点はCOG/速度が無効」と明確に判別できる。単純な「低速・停止=継続表示/旋回=非表示」という二分法ではなく、2つの別レイヤーの処理として整理する。詳細な検討経緯はP6 UI仕様の「アンテナ―作業機オフセット」節を参照。
cogDeciDegが0xFFFF)、または速度(speedByte)が閾値未満で瞬間的なCOG値の信頼性が低いと判断される間は、オフセット変換に使う「向き」として直前の有効な方位を保持する。これはオフセットの計算方向だけの話であり、その点を線として描画するかどうかとは別問題。versionフィールドで分岐する。version==1なら旧形式(36byteヘッダ+10byteレコード、COG/速度/オフセットは「情報なし」として扱う=アンテナ生位置のみの表示になる)、version==2なら新形式(40byteヘッダ+13byteレコード)として読む。これにより、既に実機で記録済みのV1ファイルも引き続き読める。
ヘッダ長の扱いはバージョンで非対称になる点に注意。version==1ではオフセット6-7のreservedは常に0(実機記録済みファイルの実際の値)であり長さの情報を持たないため、ヘッダ長は固定値36をハードコードする。version==2以降はheaderLengthフィールド(オフセット6-7)を実際に読んで使う。つまり「ヘッダ・レコード列の境界判定にバージョンごとの定数をハードコードしなくてよい」というheaderLength本来の利点が効くのはV2以降のファイルに対してのみで、V1ファイルは今まで通りバージョンごとの固定値に頼る。
6時間稼働(実測レート約9.5Hz換算、約20.5万点)の試算で、V1の約1.96MBに対しV2は約2.67MB(約+36%)。書き出し・保存の運用上は無視できる差と判断している。
耕耘あと(作業機幅を反映した帯)表示のため、作業機(ロータリー)の全体幅(implementWidthCm)を追加した拡張。「PCで先に耕耘あと表示を確立し、同じロジックをタブレットのリアルタイム描画にそのまま反映する」という開発方針(Track #54)の一環で、まず作業機幅をProfile・記録ファイルの両方に持たせる。
V2のヘッダ(40byte、headerLengthは40)の末尾にimplementWidthCm(2byte)を追加する。それに伴いheaderLengthは42になる。オフセット0-35(magic/version/headerLength/segmentId/initialAtMs/initialLat/initialLon)・36-39(offsetForwardCm/offsetLateralCm)はV2の定義をそのまま流用する。
| オフセット | サイズ | 型 | 名前 | 内容 |
|---|---|---|---|---|
| 0〜5 | 6byte | (基本フォーマット参照) | magic/version | 自己記述領域。versionは3になる |
| 6 | 2byte | UInt16 | headerLength | ヘッダ全体のbyte数。V3では42固定 |
| 8〜35 | 28byte | (V1のレイアウトを流用) | segmentId/initialAtMs/initialLat/initialLon | V1の定義をそのまま流用 |
| 36 | 2byte | Int16 | offsetForwardCm | V2から変更なし |
| 38 | 2byte | Int16 | offsetLateralCm | V2から変更なし |
| 40 | 2byte | Int16 | implementWidthCm | このセグメント記録開始時点の、プロファイルの作業機(ロータリー)全体幅(cm)。負値は想定しないが、型はV2のオフセット2フィールドと揃えてInt16にした |
ヘッダ合計: 6 + 2 + 28 + 2 + 2 + 2 = 42byte。
V2導入時に見込んでいた通り、headerLengthを実読みする設計のおかげで、この拡張はヘッダサイズのハードコードを新たに増やすことなく行えた(V2/V3のどちらもheaderLengthを読んでヘッダ・レコード列の境界を判定する。「V1/V2との後方互換」節を参照)。
作業機幅はセグメント(ファイル)ごとに1回、ヘッダにのみ記録する(点ごとに変化しない値のため)。レコード構造はV2のレイアウト(elapsedMs/dLat/dLon/cogDeciDeg/speedByte、13byte)をそのまま流用する。
Track #54時点での初弾スコープ:
TrajectoryOffsetTransform(アンテナ―作業機オフセット適用・旋回区間除外済み)の出力をそのまま使う。implementWidthCm/2ずつオフセットした矩形を作り、連結する単純な方式(自己交差・オーバーラップの処理は初弾では行わない)。track-core)に実装し、PC向けCLI(pc-tool)と:app(タブレット)の両方から同一コードとして参照する。判定ロジックをPC/タブレットで別言語に翻訳することはしない(Track #54の議論を参照)。versionフィールドで3方向に分岐する。version==1は36byteヘッダ+10byteレコード(COG/速度/オフセット/作業機幅は「情報なし」)、version==2は40byteヘッダ+13byteレコード(作業機幅のみ「情報なし」=0)、version==3は42byteヘッダ+13byteレコードとして読む。V1ファイルのみヘッダ長を固定値36としてハードコードし、V2・V3はheaderLengthフィールドを実読みする(「V1との後方互換」節を参照、V2→V3でこの非対称性は変わらない)。