diff --git a/src/components/DevOverlay.test.tsx b/src/components/DevOverlay.test.tsx index 79cc0bb34..0496a3fc5 100644 --- a/src/components/DevOverlay.test.tsx +++ b/src/components/DevOverlay.test.tsx @@ -2,15 +2,17 @@ import { act, fireEvent, render } from '@testing-library/react-native'; import * as Application from 'expo-application'; import { useAtomValue } from 'jotai'; import { Dimensions, StyleSheet } from 'react-native'; -import type { Station } from '~/@types/graphql'; +import { LineType, type Station } from '~/@types/graphql'; import { MAX_PERMIT_ACCURACY } from '~/constants/location'; import { BAD_ACCURACY_THRESHOLD } from '~/constants/threshold'; import * as remoteConfigModule from '~/lib/remoteConfig'; import { etaAnchorAtom } from '~/store/atoms/etaFallback'; import { + accuracyHistoryAtom, backgroundLocationTrackingAtom, locationAtom, rawLocationAtom, + smoothingDecisionAtom, } from '~/store/atoms/location'; import { autoModeEnabledAtom } from '~/store/atoms/navigation'; import { isLEDThemeAtom } from '~/store/atoms/theme'; @@ -106,6 +108,8 @@ describe('DevOverlay', () => { autoModeEnabled = false, etaPhase = null, etaAnchor = null, + filterAccuracyHistory = [15], + smoothingDecision = { skipSmoothing: false, lineType: null }, }: { location?: unknown; rawLocation?: unknown; @@ -113,6 +117,8 @@ describe('DevOverlay', () => { autoModeEnabled?: boolean; etaPhase?: unknown; etaAnchor?: unknown; + filterAccuracyHistory?: number[]; + smoothingDecision?: { skipSmoothing: boolean; lineType: unknown }; } = {}) => { mockGetEtaPhaseNow.mockReturnValue(etaPhase as never); mockUseAtomValue.mockImplementation((atom) => { @@ -131,6 +137,12 @@ describe('DevOverlay', () => { if (atom === etaAnchorAtom) { return etaAnchor as never; } + if (atom === accuracyHistoryAtom) { + return filterAccuracyHistory as never; + } + if (atom === smoothingDecisionAtom) { + return smoothingDecision as never; + } if (atom === isLEDThemeAtom) { return false as never; } @@ -330,6 +342,25 @@ describe('DevOverlay', () => { ); }); + // filterのskipSmoothingとlineTypeは、判定時に1つのatomへまとめて書かれた組を + // そのまま出す。片方をstationAtomから読み直すと、測位と無関係な路線の + // 切り替わりで持ち出し時の値だけが進み、両者で検算できなくなる。 + it('平滑化の判定は結果と入力を同じ組のまま出力する', async () => { + setupAtomValues({ + smoothingDecision: { skipSmoothing: true, lineType: LineType.Subway }, + }); + const { getByTestId } = render(); + + fireEvent.press(getByTestId('dev-overlay-copy-button')); + await act(async () => {}); + + const copied = JSON.parse(mockCopyTextToClipboard.mock.calls[0][0]); + expect(copied.filter).toMatchObject({ + skipSmoothing: true, + lineType: LineType.Subway, + }); + }); + it('押した直後はCOPIED表示になり、一定時間で戻る', async () => { jest.useFakeTimers(); try { diff --git a/src/components/DevOverlay.tsx b/src/components/DevOverlay.tsx index 83072c378..5a6d78539 100644 --- a/src/components/DevOverlay.tsx +++ b/src/components/DevOverlay.tsx @@ -26,9 +26,11 @@ import { useTelemetryEnabled } from '~/hooks/useTelemetryEnabled'; import { getMaxPermitAccuracy, isEtaAssistEnabled } from '~/lib/remoteConfig'; import { etaAnchorAtom } from '~/store/atoms/etaFallback'; import { + accuracyHistoryAtom, backgroundLocationTrackingAtom, locationAtom, rawLocationAtom, + smoothingDecisionAtom, } from '~/store/atoms/location'; import { autoModeEnabledAtom } from '~/store/atoms/navigation'; import { copyTextToClipboard } from '~/utils/clipboard'; @@ -463,6 +465,13 @@ const DevOverlay: React.FC = ({ unrotated = false }) => { const isBackgroundLocationTracking = useAtomValue( backgroundLocationTrackingAtom ); + // 平滑化の要否を決めている履歴と、その判定結果。チャート用のchartHistoryとは + // 別物なので、診断の持ち出しでは両方を出す。 + // 判定結果と、その判定に使ったlineTypeは同じatomから取る。lineTypeを + // stationAtomから読むと、測位と無関係な路線の切り替わりで持ち出し時の値だけが + // 進み、判定時のskipSmoothingと食い違う。 + const filterAccuracyHistory = useAtomValue(accuracyHistoryAtom); + const smoothingDecision = useAtomValue(smoothingDecisionAtom); // ETA補助の診断表示。有効フラグ(リモート設定/手動トグル)は非リアクティブなgetter、 // アンカーはatomから購読する。推定フェーズは常駐タイマーで公開されなくなったため、 // DevOverlay自身の1秒ティック(nowTick)を評価時刻としてオンデマンド計算する。 @@ -555,6 +564,9 @@ const DevOverlay: React.FC = ({ unrotated = false }) => { rawLocation, filteredLocation: simulatedLocation, accuracyHistory: chartHistory, + filterAccuracyHistory, + skipSmoothing: smoothingDecision.skipSmoothing, + lineType: smoothingDecision.lineType, effectiveSpeedMps: effectiveSpeed, hasMeasuredSpeed: hasEverMeasuredSpeed, maxPermitAccuracy, diff --git a/src/store/atoms/location.test.ts b/src/store/atoms/location.test.ts index 2b4f905a5..ea46b7cc6 100644 --- a/src/store/atoms/location.test.ts +++ b/src/store/atoms/location.test.ts @@ -9,6 +9,7 @@ import { resetLocationState, setLocation, setRawLocation, + smoothingDecisionAtom, } from './location'; import stationState from './station'; @@ -118,6 +119,101 @@ describe('setLocation', () => { }); }); + describe('地下鉄分岐を通ったかの記録', () => { + // 診断の持ち出し(DevOverlay)がこの値を読む。同じ条件を外で組み直すと、 + // 判定と表示が別々に育って食い違うため、setLocationが下した結果そのものを固定する。 + it('地下鉄かつ精度が不安定なら真になる', () => { + setStationLineType(LineType.Subway); + store.set(accuracyHistoryAtom, [10, 300, 20, 400]); + + setLocation(makeLocation(35.0, 139.0, 500, 1000)); + + expect(store.get(smoothingDecisionAtom)).toEqual({ + skipSmoothing: true, + lineType: LineType.Subway, + }); + }); + + it('地上路線なら偽になる', () => { + setStationLineType(LineType.Normal); + store.set(accuracyHistoryAtom, [10, 300, 20, 400]); + + setLocation(makeLocation(35.0, 139.0, 500, 1000)); + + expect(store.get(smoothingDecisionAtom)).toEqual({ + skipSmoothing: false, + lineType: LineType.Normal, + }); + }); + + it('地下鉄でも精度履歴が安定していれば偽になる', () => { + setStationLineType(LineType.Subway); + store.set(accuracyHistoryAtom, [30, 35, 28, 32]); + + setLocation(makeLocation(35.0, 139.0, 30, 1000)); + + expect(store.get(smoothingDecisionAtom)).toEqual({ + skipSmoothing: false, + lineType: LineType.Subway, + }); + }); + + it('駅が無ければ路線種別はnullで記録する', () => { + setStationLineType(null); + store.set(accuracyHistoryAtom, [10, 300, 20, 400]); + + setLocation(makeLocation(35.0, 139.0, 500, 1000)); + + expect(store.get(smoothingDecisionAtom)).toEqual({ + skipSmoothing: false, + lineType: null, + }); + }); + + // 回帰: 結果と入力を別々のatom(あるいは片方をstationAtomの直読み)で持つと、 + // 測位と無関係な路線の切り替わりで入力側だけが進み、持ち出した診断の + // skipSmoothingとlineTypeが別の瞬間の値になって検算できなくなる。 + it('判定後に路線が変わっても判定時の組を保つ', () => { + setStationLineType(LineType.Subway); + store.set(accuracyHistoryAtom, [10, 300, 20, 400]); + setLocation(makeLocation(35.0, 139.0, 500, 1000)); + + setStationLineType(LineType.Normal); + + expect(store.get(smoothingDecisionAtom)).toEqual({ + skipSmoothing: true, + lineType: LineType.Subway, + }); + }); + + // 判定が変わらない限り同じオブジェクトを保つ。測位のたびに新しい参照を入れると、 + // 購読しているDevOverlayが1秒ごとに再レンダーする。 + it('判定が変わらなければ参照を作り直さない', () => { + setStationLineType(LineType.Subway); + store.set(accuracyHistoryAtom, [10, 300, 20, 400]); + setLocation(makeLocation(35.0, 139.0, 500, 1000)); + const first = store.get(smoothingDecisionAtom); + + setLocation(makeLocation(35.0001, 139.0001, 500, 2000)); + + expect(store.get(smoothingDecisionAtom)).toBe(first); + }); + + it('リセットで初期値へ戻る', () => { + setStationLineType(LineType.Subway); + store.set(accuracyHistoryAtom, [10, 300, 20, 400]); + setLocation(makeLocation(35.0, 139.0, 500, 1000)); + expect(store.get(smoothingDecisionAtom).skipSmoothing).toBe(true); + + resetLocationState(); + + expect(store.get(smoothingDecisionAtom)).toEqual({ + skipSmoothing: false, + lineType: null, + }); + }); + }); + describe('地下鉄路線', () => { it('精度が不安定な場合はスムージングをスキップする', () => { setStationLineType(LineType.Subway); diff --git a/src/store/atoms/location.ts b/src/store/atoms/location.ts index 1edb9341c..04b6b210d 100644 --- a/src/store/atoms/location.ts +++ b/src/store/atoms/location.ts @@ -106,6 +106,28 @@ export const backgroundLocationTrackingAtom = atom(false); // 下流の処理が「現在位置を信用できない=走行中」と扱えるようにする。 export const locationAccuracyOutlierAtom = atom(false); +// 直近のsetLocationが下した平滑化の判定。診断表示専用で、パイプラインの判定には使わない。 +// 地下の挙動を調べるとき、locationAtomの値だけではどちらの経路を通ったか分からず、 +// 条件を外から組み直すと判定と食い違うため、結果そのものを残す。 +// +// 結果(skipSmoothing)と入力(lineType)を1つのオブジェクトで持つ。別々のatomにすると、 +// lineTypeは測位と無関係に変わる(stationAtomの更新)ため、判定後に路線が変わった状態で +// 持ち出したときに「判定時のskipSmoothing」と「持ち出し時のlineType」という別の瞬間の +// 値が並び、両者で検算できなくなる。 +export type SmoothingDecision = { + skipSmoothing: boolean; + lineType: LineType | null; +}; + +const INITIAL_SMOOTHING_DECISION: SmoothingDecision = { + skipSmoothing: false, + lineType: null, +}; + +export const smoothingDecisionAtom = atom( + INITIAL_SMOOTHING_DECISION +); + // EMAスムージングの基準として使う「最後にフィルタ処理を通過した位置」 // 地下鉄モード中は更新しないため、モード復帰後にノイジーなprevで誤棄却されるのを防ぐ const lastFilteredLocationAtom = atom(null); @@ -128,6 +150,7 @@ export const resetLocationState = () => { store.set(lastFilteredLocationAtom, null); store.set(lastRawLocationAtom, null); store.set(locationAccuracyOutlierAtom, false); + store.set(smoothingDecisionAtom, INITIAL_SMOOTHING_DECISION); consecutiveSpeedRejections = 0; resetEtaBoundHold(); }; @@ -261,6 +284,21 @@ export const setLocation = (location: Location.LocationObject) => { const currentLineType = store.get(stationState).station?.line?.lineType; const skipSmoothing = currentLineType === LineType.Subway && !isAccuracyStable(updatedHistory); + // 判定の結果と入力を、同じ瞬間の組として残す。DevOverlayの診断表示から + // 「いま地下鉄分岐に入っているか」と「その根拠」を読めるようにするためで、 + // 同じ条件を呼び出し側で組み直すと判定と表示が別々に育って食い違う。 + // 値が変わらないときは書かない。毎回新しいオブジェクトを入れると、購読側が + // 測位のたびに再レンダーする。 + const prevDecision = store.get(smoothingDecisionAtom); + if ( + prevDecision.skipSmoothing !== skipSmoothing || + prevDecision.lineType !== (currentLineType ?? null) + ) { + store.set(smoothingDecisionAtom, { + skipSmoothing, + lineType: currentLineType ?? null, + }); + } // ETAが許す進行量を超えた測位は、どちらの経路へも通さない if (isImplausibleByEta(location)) { diff --git a/src/utils/devDiagnosticsSnapshot.test.ts b/src/utils/devDiagnosticsSnapshot.test.ts index 9cd9ca299..b251ec54a 100644 --- a/src/utils/devDiagnosticsSnapshot.test.ts +++ b/src/utils/devDiagnosticsSnapshot.test.ts @@ -37,6 +37,9 @@ const baseInput: DevDiagnosticsInput = { rawLocation: makeLocation(35.732538, 139.670653, 312, 1_700_000_000_000), filteredLocation: makeLocation(35.7325, 139.6706, 312, 1_700_000_000_000), accuracyHistory: [20, 45, 310], + filterAccuracyHistory: [20, 45, 310, 620], + skipSmoothing: true, + lineType: 'Subway', effectiveSpeedMps: 12.5, hasMeasuredSpeed: true, maxPermitAccuracy: 1500, @@ -89,6 +92,22 @@ describe('buildDevDiagnosticsSnapshot', () => { expect(snapshot.location.accuracyHistory).toEqual([20, 45, 310]); }); + it('平滑化の判定材料と結果を持つ', () => { + // locationAtomの値だけでは、平滑化を掛けたのか生の座標を入れたのかが区別できない。 + // チャート用の精度履歴とは別に、判定に使われた履歴と結果を持ち出す + const snapshot = buildDevDiagnosticsSnapshot(baseInput); + + expect(snapshot.filter).toEqual({ + skipSmoothing: true, + lineType: 'Subway', + accuracyHistory: [20, 45, 310, 620], + }); + // チャート用とは別物であることを固定する(取り違えると地下鉄分岐の説明が付かない) + expect(snapshot.filter.accuracyHistory).not.toEqual( + snapshot.location.accuracyHistory + ); + }); + it('測位が無い場合もnullで表現して壊れない', () => { const snapshot = buildDevDiagnosticsSnapshot({ ...baseInput, diff --git a/src/utils/devDiagnosticsSnapshot.ts b/src/utils/devDiagnosticsSnapshot.ts index b1e3f64f5..296d73f59 100644 --- a/src/utils/devDiagnosticsSnapshot.ts +++ b/src/utils/devDiagnosticsSnapshot.ts @@ -30,8 +30,25 @@ export type DevDiagnosticsInput = { rawLocation: Location.LocationObject | null; /** フィルタ・スムージングを通した、アプリが現在地として使っている値 */ filteredLocation: Location.LocationObject | null; - /** DevOverlay のチャートが持つ精度履歴(古い順) */ + /** + * DevOverlay のチャートが持つ精度履歴(古い順)。1秒ごとのサンプリングで、 + * 測位が無い間は NaN が積まれる(JSONでは null になる)。見た目の推移用。 + */ accuracyHistory: number[]; + /** + * 平滑化の要否(isAccuracyStable)を決めている accuracyHistoryAtom の中身。 + * 測位を受理するたびに積まれ、無効値は捨てられるのでチャート用とは別物。 + * 地下鉄分岐に入っているかを説明できるのはこちらなので、必ず一緒に持ち出す。 + */ + filterAccuracyHistory: number[]; + /** + * 直近の測位が地下鉄分岐(平滑化スキップ)を通ったか。次の lineType と + * 対で受け取ること。どちらも smoothingDecisionAtom が同じ判定時に書いた値で、 + * 片方を stationAtom から読み直すと別の瞬間の値が混ざる。 + */ + skipSmoothing: boolean; + /** 上の判定に使った路線種別(判定時の値) */ + lineType: string | null; /** 表示に使っている速度(m/s)と、それが実測かどうか */ effectiveSpeedMps: number; hasMeasuredSpeed: boolean; @@ -112,6 +129,13 @@ export const buildDevDiagnosticsSnapshot = (input: DevDiagnosticsInput) => ({ // 変位から算出した値か、測位が運んできた実測かを区別する speedIsMeasured: input.hasMeasuredSpeed, }, + // どちらの経路を通ったかと、その判定材料。locationAtomの値だけでは + // 平滑化を掛けたのか生の座標を入れたのかが区別できない。 + filter: { + skipSmoothing: input.skipSmoothing, + lineType: input.lineType, + accuracyHistory: input.filterAccuracyHistory, + }, eta: { phase: input.etaPhase, anchor: input.etaAnchor,