Refine sensor integration strategy

This commit is contained in:
2026-03-25 17:42:16 +08:00
parent a19342d61f
commit f7d4499e36
11 changed files with 1509 additions and 8 deletions

View File

@@ -6,4 +6,7 @@ export type TelemetryEvent =
| { type: 'session_state_updated'; at: number; status: GameSessionStatus; startedAt: number | null; endedAt: number | null }
| { type: 'target_updated'; controlId: string | null; point: LonLatPoint | null }
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
| { type: 'accelerometer_updated'; at: number; x: number; y: number; z: number }
| { type: 'gyroscope_updated'; at: number; x: number; y: number; z: number }
| { type: 'device_motion_updated'; at: number; alpha: number | null; beta: number | null; gamma: number | null }
| { type: 'heart_rate_updated'; at: number; bpm: number | null }

View File

@@ -11,8 +11,46 @@ import {
import { type GameSessionState } from '../core/gameSessionState'
import { type TelemetryEvent } from './telemetryEvent'
import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
import { EMPTY_TELEMETRY_STATE, type TelemetryState } from './telemetryState'
import {
EMPTY_TELEMETRY_STATE,
type DevicePose,
type HeadingConfidence,
type TelemetryState,
} from './telemetryState'
const SPEED_SMOOTHING_ALPHA = 0.35
const DEVICE_HEADING_SMOOTHING_ALPHA = 0.28
const ACCELEROMETER_SMOOTHING_ALPHA = 0.2
const DEVICE_POSE_FLAT_ENTER_Z = 0.82
const DEVICE_POSE_FLAT_EXIT_Z = 0.7
const DEVICE_POSE_UPRIGHT_ENTER_Z = 0.42
const DEVICE_POSE_UPRIGHT_EXIT_Z = 0.55
const DEVICE_POSE_UPRIGHT_AXIS_ENTER = 0.78
const DEVICE_POSE_UPRIGHT_AXIS_EXIT = 0.65
const HEADING_CONFIDENCE_HIGH_TURN_RATE_RAD = 0.35
const HEADING_CONFIDENCE_MEDIUM_TURN_RATE_RAD = 1.05
function normalizeHeadingDeg(headingDeg: number): number {
const normalized = headingDeg % 360
return normalized < 0 ? normalized + 360 : normalized
}
function normalizeHeadingDeltaDeg(deltaDeg: number): number {
let normalized = deltaDeg
while (normalized > 180) {
normalized -= 360
}
while (normalized < -180) {
normalized += 360
}
return normalized
}
function interpolateHeadingDeg(currentDeg: number, targetDeg: number, factor: number): number {
return normalizeHeadingDeg(currentDeg + normalizeHeadingDeltaDeg(targetDeg - currentDeg) * factor)
}
function getApproxDistanceMeters(
a: { lon: number; lat: number },
@@ -76,6 +114,99 @@ function smoothSpeedKmh(previousSpeedKmh: number | null, nextSpeedKmh: number):
return previousSpeedKmh + (nextSpeedKmh - previousSpeedKmh) * SPEED_SMOOTHING_ALPHA
}
function resolveDevicePose(
previousPose: DevicePose,
accelerometer: TelemetryState['accelerometer'],
): DevicePose {
if (!accelerometer) {
return previousPose
}
const magnitude = Math.sqrt(
accelerometer.x * accelerometer.x
+ accelerometer.y * accelerometer.y
+ accelerometer.z * accelerometer.z,
)
if (!Number.isFinite(magnitude) || magnitude <= 0.001) {
return previousPose
}
const normalizedX = Math.abs(accelerometer.x / magnitude)
const normalizedY = Math.abs(accelerometer.y / magnitude)
const normalizedZ = Math.abs(accelerometer.z / magnitude)
const verticalAxis = Math.max(normalizedX, normalizedY)
const withinFlatEnter = normalizedZ >= DEVICE_POSE_FLAT_ENTER_Z
const withinFlatExit = normalizedZ >= DEVICE_POSE_FLAT_EXIT_Z
const withinUprightEnter = normalizedZ <= DEVICE_POSE_UPRIGHT_ENTER_Z && verticalAxis >= DEVICE_POSE_UPRIGHT_AXIS_ENTER
const withinUprightExit = normalizedZ <= DEVICE_POSE_UPRIGHT_EXIT_Z && verticalAxis >= DEVICE_POSE_UPRIGHT_AXIS_EXIT
if (previousPose === 'flat') {
if (withinFlatExit) {
return 'flat'
}
if (withinUprightEnter) {
return 'upright'
}
return 'tilted'
}
if (previousPose === 'upright') {
if (withinUprightExit) {
return 'upright'
}
if (withinFlatEnter) {
return 'flat'
}
return 'tilted'
}
if (withinFlatEnter) {
return 'flat'
}
if (withinUprightEnter) {
return 'upright'
}
return 'tilted'
}
function resolveHeadingConfidence(
headingDeg: number | null,
pose: DevicePose,
gyroscope: TelemetryState['gyroscope'],
): HeadingConfidence {
if (headingDeg === null || pose === 'flat') {
return 'low'
}
if (!gyroscope) {
return pose === 'upright' ? 'medium' : 'low'
}
const turnRate = Math.sqrt(
gyroscope.x * gyroscope.x
+ gyroscope.y * gyroscope.y
+ gyroscope.z * gyroscope.z,
)
if (turnRate <= HEADING_CONFIDENCE_HIGH_TURN_RATE_RAD) {
return pose === 'upright' ? 'high' : 'medium'
}
if (turnRate <= HEADING_CONFIDENCE_MEDIUM_TURN_RATE_RAD) {
return 'medium'
}
return 'low'
}
function getHeartRateTone(
heartRateBpm: number | null,
telemetryConfig: TelemetryConfig,
@@ -257,7 +388,17 @@ export class TelemetryRuntime {
}
reset(): void {
this.state = { ...EMPTY_TELEMETRY_STATE }
this.state = {
...EMPTY_TELEMETRY_STATE,
accelerometer: this.state.accelerometer,
accelerometerUpdatedAt: this.state.accelerometerUpdatedAt,
accelerometerSampleCount: this.state.accelerometerSampleCount,
gyroscope: this.state.gyroscope,
deviceMotion: this.state.deviceMotion,
deviceHeadingDeg: this.state.deviceHeadingDeg,
devicePose: this.state.devicePose,
headingConfidence: this.state.headingConfidence,
}
}
configure(config?: Partial<TelemetryConfig> | null): void {
@@ -353,6 +494,64 @@ export class TelemetryRuntime {
return
}
if (event.type === 'accelerometer_updated') {
const previous = this.state.accelerometer
this.state = {
...this.state,
accelerometer: previous === null
? {
x: event.x,
y: event.y,
z: event.z,
}
: {
x: previous.x + (event.x - previous.x) * ACCELEROMETER_SMOOTHING_ALPHA,
y: previous.y + (event.y - previous.y) * ACCELEROMETER_SMOOTHING_ALPHA,
z: previous.z + (event.z - previous.z) * ACCELEROMETER_SMOOTHING_ALPHA,
},
accelerometerUpdatedAt: event.at,
accelerometerSampleCount: this.state.accelerometerSampleCount + 1,
}
this.recomputeDerivedState()
return
}
if (event.type === 'gyroscope_updated') {
this.state = {
...this.state,
gyroscope: {
x: event.x,
y: event.y,
z: event.z,
},
}
this.recomputeDerivedState()
return
}
if (event.type === 'device_motion_updated') {
const nextDeviceHeadingDeg = event.alpha === null
? this.state.deviceHeadingDeg
: (() => {
const nextHeadingDeg = normalizeHeadingDeg(360 - event.alpha * 180 / Math.PI)
return this.state.deviceHeadingDeg === null
? nextHeadingDeg
: interpolateHeadingDeg(this.state.deviceHeadingDeg, nextHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA)
})()
this.state = {
...this.state,
deviceMotion: {
alpha: event.alpha,
beta: event.beta,
gamma: event.gamma,
},
deviceHeadingDeg: nextDeviceHeadingDeg,
}
this.recomputeDerivedState()
return
}
if (event.type === 'heart_rate_updated') {
this.syncCalorieAccumulation(event.at)
this.state = {
@@ -374,12 +573,20 @@ export class TelemetryRuntime {
const averageSpeedKmh = elapsedMs > 0
? (this.state.distanceMeters / (elapsedMs / 1000)) * 3.6
: null
const devicePose = resolveDevicePose(this.state.devicePose, this.state.accelerometer)
const headingConfidence = resolveHeadingConfidence(
this.state.deviceHeadingDeg,
devicePose,
this.state.gyroscope,
)
this.state = {
...this.state,
elapsedMs,
distanceToTargetMeters,
averageSpeedKmh,
devicePose,
headingConfidence,
}
}

View File

@@ -1,6 +1,9 @@
import { type LonLatPoint } from '../../utils/projection'
import { type GameSessionStatus } from '../core/gameSessionState'
export type DevicePose = 'upright' | 'tilted' | 'flat'
export type HeadingConfidence = 'low' | 'medium' | 'high'
export interface TelemetryState {
sessionStatus: GameSessionStatus
sessionStartedAt: number | null
@@ -15,6 +18,14 @@ export interface TelemetryState {
lastGpsPoint: LonLatPoint | null
lastGpsAt: number | null
lastGpsAccuracyMeters: number | null
accelerometer: { x: number; y: number; z: number } | null
accelerometerUpdatedAt: number | null
accelerometerSampleCount: number
gyroscope: { x: number; y: number; z: number } | null
deviceMotion: { alpha: number | null; beta: number | null; gamma: number | null } | null
deviceHeadingDeg: number | null
devicePose: DevicePose
headingConfidence: HeadingConfidence
heartRateBpm: number | null
caloriesKcal: number | null
calorieTrackingAt: number | null
@@ -34,6 +45,14 @@ export const EMPTY_TELEMETRY_STATE: TelemetryState = {
lastGpsPoint: null,
lastGpsAt: null,
lastGpsAccuracyMeters: null,
accelerometer: null,
accelerometerUpdatedAt: null,
accelerometerSampleCount: 0,
gyroscope: null,
deviceMotion: null,
deviceHeadingDeg: null,
devicePose: 'upright',
headingConfidence: 'low',
heartRateBpm: null,
caloriesKcal: null,
calorieTrackingAt: null,