Refine sensor integration strategy
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user