GPS-filter mot jitter: fartgrind (Doppler), stillastående-filter, baslinje-reset
All checks were successful
release / build-release (push) Successful in 4m19s

Användarens idé i sensorriktig variant: GPS:ens egen Doppler-fartmätning
(stabilare än positionerna) används som referens —
- Fartgrind: positionshopp som kräver > faktor × Doppler-fart förkastas
  (t.ex. 100 m-hopp vid gångfart). Efter 4 förkastade i rad sätts ny
  baslinje (tunnel/underfart) utan att distans räknas.
- Stillastående-filter: vandring < max(2 m, halva noggrannheten) räknas
  varken som distans eller spårpunkt — ingen fejkdistans när man står still.
- Allt loggas i tekniska loggen (hopp, tillåten fart, förkastanden).
Justerbart i Inställningar → GPS-spårning: noggrannhetsgräns (35 m),
fartfaktor (2,0×), fartgolv (12 km/h). Version 0.8.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
This commit is contained in:
2026-08-26 07:25:35 +02:00
parent edbd16464d
commit 33a8e71ed1
5 changed files with 180 additions and 10 deletions

View File

@@ -34,6 +34,12 @@ data class AppSettings(
val isFemale: Boolean? = null,
/** Visa teknisk logg på spårskärmen (felsökning) */
val trackLogEnabled: Boolean = false,
/** GPS-filter: förkasta fixar med sämre noggrannhet än detta (meter) */
val gpsAccuracyLimitM: Int = 35,
/** GPS-filter: max tillåten positionsfart som multipel av Doppler-farten */
val gpsSpeedFactor: Double = 2.0,
/** GPS-filter: fartgolv (km/h) när Doppler-fart saknas/är noll */
val gpsSpeedFloorKmh: Int = 12,
)
class SettingsStore(private val context: Context) {
@@ -61,9 +67,24 @@ class SettingsStore(private val context: Context) {
birthYear = prefs[KEY_BIRTH_YEAR],
isFemale = prefs[KEY_IS_FEMALE]?.toBooleanStrictOrNull(),
trackLogEnabled = prefs[KEY_TRACK_LOG]?.toBooleanStrictOrNull() ?: false,
gpsAccuracyLimitM = prefs[KEY_GPS_ACC] ?: 35,
gpsSpeedFactor = prefs[KEY_GPS_FACTOR]?.toDoubleOrNull() ?: 2.0,
gpsSpeedFloorKmh = prefs[KEY_GPS_FLOOR] ?: 12,
)
}
suspend fun setGpsAccuracyLimit(value: Int) {
context.settingsDataStore.edit { it[KEY_GPS_ACC] = value.coerceIn(10, 100) }
}
suspend fun setGpsSpeedFactor(value: Double) {
context.settingsDataStore.edit { it[KEY_GPS_FACTOR] = value.coerceIn(1.2, 5.0).toString() }
}
suspend fun setGpsSpeedFloor(value: Int) {
context.settingsDataStore.edit { it[KEY_GPS_FLOOR] = value.coerceIn(4, 40) }
}
suspend fun setTrackLogEnabled(value: Boolean) {
context.settingsDataStore.edit { it[KEY_TRACK_LOG] = value.toString() }
}
@@ -140,5 +161,8 @@ class SettingsStore(private val context: Context) {
private val KEY_BIRTH_YEAR = intPreferencesKey("birth_year")
private val KEY_IS_FEMALE = stringPreferencesKey("is_female")
private val KEY_TRACK_LOG = stringPreferencesKey("track_log_enabled")
private val KEY_GPS_ACC = intPreferencesKey("gps_accuracy_limit_m")
private val KEY_GPS_FACTOR = stringPreferencesKey("gps_speed_factor")
private val KEY_GPS_FLOOR = intPreferencesKey("gps_speed_floor_kmh")
}
}

View File

@@ -69,6 +69,13 @@ class TrackingService : Service(), LocationListener {
private var weightKg: Double? = null
private var activeSinceElapsed = 0L
private var lastLocation: Location? = null
// GPS-filter (justerbara i inställningarna, skickas med vid start)
private var accuracyLimitM = 35f
private var speedFactor = 2.0
private var speedFloorMs = 12.0 / 3.6
// Glidande Doppler-fart (m/s) — GPS:ens egen fartmätning, stabilare än positionerna
private var dopplerEmaMs = 0.0
private var rejectStreak = 0
// Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras
private val altitudeWindow = ArrayDeque<Double>()
private var smoothedAltitude: Double? = null
@@ -99,6 +106,11 @@ class TrackingService : Service(), LocationListener {
private fun startTracking(intent: Intent) {
val distanceBased = intent.getBooleanExtra(EXTRA_DISTANCE_BASED, false)
weightKg = intent.getDoubleExtra(EXTRA_WEIGHT_KG, -1.0).takeIf { it > 0 }
accuracyLimitM = intent.getIntExtra(EXTRA_GPS_ACC_LIMIT, 35).toFloat()
speedFactor = intent.getDoubleExtra(EXTRA_GPS_SPEED_FACTOR, 2.0)
speedFloorMs = intent.getIntExtra(EXTRA_GPS_SPEED_FLOOR, 12) / 3.6
dopplerEmaMs = 0.0
rejectStreak = 0
_state.value = TrackingState(
isActive = true,
@@ -116,6 +128,12 @@ class TrackingService : Service(), LocationListener {
"start: ${_state.value.typeName} (distans=$distanceBased, vikt=${weightKg ?: "?"} kg)" +
if (distanceBased) " — väntar på GPS-fix" else ""
)
if (distanceBased) {
TrackLog.log(
"filter: acc≤${accuracyLimitM.toInt()} m, fartgrind ${speedFactor}× Doppler " +
"(golv ${"%.0f".format(speedFloorMs * 3.6)} km/h)"
)
}
val type = if (distanceBased) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
@@ -217,11 +235,45 @@ class TrackingService : Service(), LocationListener {
// Position för kartan uppdateras alltid, oavsett kvalitet
_state.value = _state.value.copy(currentPosition = location.latitude to location.longitude)
// Spårpunkter kräver hygglig noggrannhet
if (location.hasAccuracy() && location.accuracy > 35f) {
TrackLog.log("${location.provider}: förkastad för spåret, acc=${"%.0f".format(acc)} m (>35)")
// Grind 1: spårpunkter kräver hygglig noggrannhet
if (location.hasAccuracy() && location.accuracy > accuracyLimitM) {
TrackLog.log("${location.provider}: förkastad, acc=${"%.0f".format(acc)} m (>${accuracyLimitM.toInt()})")
return
}
// Uppdatera glidande Doppler-fart (GPS:ens fartmätning via frekvensskift)
if (location.hasSpeed()) {
dopplerEmaMs = if (dopplerEmaMs == 0.0) location.speed.toDouble()
else 0.7 * dopplerEmaMs + 0.3 * location.speed
}
// Grind 2 (fartgrinden): en ny position som skulle kräva högre fart än
// speedFactor × Doppler-farten är orimlig — GPS-hopp, inte förflyttning.
lastLocation?.let { prev ->
val dtSec = (location.elapsedRealtimeNanos - prev.elapsedRealtimeNanos) / 1e9
if (dtSec > 0.3) {
val jump = prev.distanceTo(location).toDouble()
val impliedMs = jump / dtSec
val allowedMs = maxOf(speedFloorMs, dopplerEmaMs * speedFactor)
if (impliedMs > allowedMs) {
rejectStreak++
TrackLog.log(
"${location.provider}: fartgrind — hopp ${"%.0f".format(jump)} m på ${"%.1f".format(dtSec)} s " +
"= ${"%.1f".format(impliedMs * 3.6)} km/h (tillåtet ${"%.1f".format(allowedMs * 3.6)}), förkastad ($rejectStreak)"
)
// Många orimliga i rad = det är NYA positionen som är sann
// (t.ex. efter tunnel) — sätt ny baslinje utan att räkna distans.
if (rejectStreak >= 4) {
TrackLog.log("fartgrind: ny baslinje efter $rejectStreak förkastade")
lastLocation = location
rejectStreak = 0
}
return
}
}
}
rejectStreak = 0
TrackLog.log(
"${location.provider}: fix acc=${"%.0f".format(acc)} m" +
" alt=${if (location.hasAltitude()) "%.0f".format(location.altitude) else "?"}" +
@@ -232,10 +284,13 @@ class TrackingService : Service(), LocationListener {
var distance = s.distanceMeters
var elevGain = s.elevationGainMeters
var acceptedAsMovement = true
lastLocation?.let { prev ->
val d = prev.distanceTo(location).toDouble()
// Ignorera mikrobrus när man står stilla
if (d >= 2.0) distance += d
// Grind 3 (stillastående): vandring mindre än halva noggrannheten
// är brus, inte förflyttning — räkna varken distans eller spårpunkt.
val minMove = maxOf(2.0, (if (location.hasAccuracy()) location.accuracy else 5f) * 0.5)
if (d >= minMove) distance += d else acceptedAsMovement = false
}
if (location.hasAltitude()) {
@@ -257,7 +312,9 @@ class TrackingService : Service(), LocationListener {
_state.value = cur.copy(
distanceMeters = distance,
elevationGainMeters = elevGain,
points = cur.points + (location.latitude to location.longitude),
points = if (acceptedAsMovement || cur.points.isEmpty()) {
cur.points + (location.latitude to location.longitude)
} else cur.points,
currentSpeedKmh = if (location.hasSpeed()) location.speed * 3.6 else null,
gpsFix = true,
)
@@ -333,12 +390,22 @@ class TrackingService : Service(), LocationListener {
const val EXTRA_MET = "met"
const val EXTRA_DISTANCE_BASED = "distanceBased"
const val EXTRA_WEIGHT_KG = "weightKg"
const val EXTRA_GPS_ACC_LIMIT = "gpsAccLimit"
const val EXTRA_GPS_SPEED_FACTOR = "gpsSpeedFactor"
const val EXTRA_GPS_SPEED_FLOOR = "gpsSpeedFloor"
private val _state = MutableStateFlow(TrackingState())
/** Läses av UI:t — servicen äger sanningen. */
val state: StateFlow<TrackingState> = _state.asStateFlow()
fun start(context: Context, type: ActivityType, weightKg: Double?) {
fun start(
context: Context,
type: ActivityType,
weightKg: Double?,
gpsAccuracyLimitM: Int = 35,
gpsSpeedFactor: Double = 2.0,
gpsSpeedFloorKmh: Int = 12,
) {
val intent = Intent(context, TrackingService::class.java).apply {
putExtra(EXTRA_TYPE_ID, type.id)
putExtra(EXTRA_TYPE_KEY, type.key)
@@ -347,6 +414,9 @@ class TrackingService : Service(), LocationListener {
putExtra(EXTRA_MET, type.met)
putExtra(EXTRA_DISTANCE_BASED, type.isDistanceBased)
weightKg?.let { putExtra(EXTRA_WEIGHT_KG, it) }
putExtra(EXTRA_GPS_ACC_LIMIT, gpsAccuracyLimitM)
putExtra(EXTRA_GPS_SPEED_FACTOR, gpsSpeedFactor)
putExtra(EXTRA_GPS_SPEED_FLOOR, gpsSpeedFloorKmh)
}
context.startForegroundService(intent)
}

View File

@@ -73,6 +73,16 @@ class SettingsViewModel(
fun setTrackLog(value: Boolean) = viewModelScope.launch { store.setTrackLogEnabled(value) }
fun adjustGpsAccuracy(delta: Int) = viewModelScope.launch {
store.setGpsAccuracyLimit(settings.value.gpsAccuracyLimitM + delta)
}
fun adjustGpsSpeedFactor(delta: Double) = viewModelScope.launch {
store.setGpsSpeedFactor(settings.value.gpsSpeedFactor + delta)
}
fun adjustGpsSpeedFloor(delta: Int) = viewModelScope.launch {
store.setGpsSpeedFloor(settings.value.gpsSpeedFloorKmh + delta)
}
fun setAutoRelogin(value: Boolean) = viewModelScope.launch {
store.setAutoRelogin(value)
// Stängs funktionen av slängs de sparade uppgifterna direkt.
@@ -147,6 +157,41 @@ fun SettingsScreen(
}
}
SettingsCard(title = "GPS-spårning") {
Text(
"Filter mot GPS-brus i aktivitetsspårningen. Fartgrinden förkastar " +
"positionshopp som skulle kräva högre fart än faktorn × GPS:ens " +
"egen fartmätning (Doppler).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
StepperRow(
label = "Noggrannhetsgräns",
value = "${settings.gpsAccuracyLimitM} m",
onMinus = { viewModel.adjustGpsAccuracy(-5) },
onPlus = { viewModel.adjustGpsAccuracy(5) },
)
StepperRow(
label = "Fartgrind (× uppmätt fart)",
value = "${settings.gpsSpeedFactor}×",
onMinus = { viewModel.adjustGpsSpeedFactor(-0.5) },
onPlus = { viewModel.adjustGpsSpeedFactor(0.5) },
)
StepperRow(
label = "Fartgolv",
value = "${settings.gpsSpeedFloorKmh} km/h",
onMinus = { viewModel.adjustGpsSpeedFloor(-2) },
onPlus = { viewModel.adjustGpsSpeedFloor(2) },
)
Text(
"Gäller från nästa startade aktivitet. Standard: 35 m / 2,0× / 12 km/h.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
SettingsCard(title = "Felsökning") {
Row(
modifier = Modifier
@@ -378,6 +423,30 @@ private fun SettingsCard(title: String, content: @Composable () -> Unit) {
}
}
@Composable
private fun StepperRow(
label: String,
value: String,
onMinus: () -> Unit,
onPlus: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
FilledTonalButton(onClick = onMinus) { Text("") }
Text(
value,
modifier = Modifier.padding(horizontal = 10.dp),
style = MaterialTheme.typography.titleSmall,
)
FilledTonalButton(onClick = onPlus) { Text("+") }
}
}
@Composable
private fun RadioRow(label: String, description: String, selected: Boolean, onClick: () -> Unit) {
Row(

View File

@@ -67,6 +67,7 @@ import eu.brassepc.fitnessdroid.ui.appContainer
import eu.brassepc.fitnessdroid.ui.common.compact
import eu.brassepc.fitnessdroid.ui.common.openAppSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.osmdroid.config.Configuration
@@ -80,7 +81,7 @@ import java.time.Instant
class TrackViewModel(
private val gymApi: GymApi,
private val repo: GymRepository,
settingsStore: eu.brassepc.fitnessdroid.data.SettingsStore,
private val settingsStore: eu.brassepc.fitnessdroid.data.SettingsStore,
) : ViewModel() {
val tracking = TrackingService.state
/** Teknisk logg visas bara om den slagits på i inställningarna. */
@@ -110,7 +111,13 @@ class TrackViewModel(
fun start(context: android.content.Context) {
val type = pendingType.value ?: return
viewModelScope.launch {
TrackingService.start(context, type, repo.bodyWeightKg())
val cfg = settingsStore.settings.first()
TrackingService.start(
context, type, repo.bodyWeightKg(),
gpsAccuracyLimitM = cfg.gpsAccuracyLimitM,
gpsSpeedFactor = cfg.gpsSpeedFactor,
gpsSpeedFloorKmh = cfg.gpsSpeedFloorKmh,
)
}
}