From e6832b74de360583746132b5b4ff767d5092834c Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Thu, 5 Mar 2026 21:39:29 +0100 Subject: [PATCH 1/2] feat: add complete JSON data export/import with sensor windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export now includes sensor windows alongside steps, transitions, and cycling sessions. Import parses the same JSON format and batch-inserts all records, enabling data migration between app variants (e.g. production → dev) without raw DB access. Co-Authored-By: Claude Opus 4.6 --- app/build.gradle.kts | 5 ++ .../main/java/com/podometer/MainActivity.kt | 1 + .../data/db/ActivityTransitionDao.kt | 4 + .../podometer/data/db/CyclingSessionDao.kt | 4 + .../com/podometer/data/db/SensorWindowDao.kt | 7 ++ .../java/com/podometer/data/db/StepDao.kt | 8 ++ .../com/podometer/data/export/ExportModels.kt | 19 ++++ .../java/com/podometer/di/UseCaseModule.kt | 19 ++++ .../domain/usecase/ExportDataUseCase.kt | 13 +++ .../domain/usecase/ImportDataUseCase.kt | 88 +++++++++++++++++++ .../podometer/ui/settings/SettingsScreen.kt | 22 +++++ .../ui/settings/SettingsViewModel.kt | 27 ++++++ app/src/main/res/values/strings.xml | 3 +- .../data/repository/CyclingRepositoryTest.kt | 2 + .../data/repository/StepRepositoryTest.kt | 6 ++ .../domain/usecase/ExportDataUseCaseTest.kt | 20 ++++- .../podometer/domain/usecase/UseCaseTest.kt | 6 ++ 17 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/podometer/domain/usecase/ImportDataUseCase.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 985bf26..cff1192 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,7 +52,12 @@ android { } buildTypes { + debug { + // applicationIdSuffix = ".dev" // Temporarily disabled for one-time migration + resValue("string", "app_name", "Podometer-dev") + } release { + resValue("string", "app_name", "Podometer") isMinifyEnabled = true isShrinkResources = true signingConfig = if (System.getenv("RELEASE_KEYSTORE_PATH") != null) { diff --git a/app/src/main/java/com/podometer/MainActivity.kt b/app/src/main/java/com/podometer/MainActivity.kt index a05ca22..3379951 100644 --- a/app/src/main/java/com/podometer/MainActivity.kt +++ b/app/src/main/java/com/podometer/MainActivity.kt @@ -146,6 +146,7 @@ class MainActivity : ComponentActivity() { onSetAutoStartEnabled = viewModel::setAutoStartEnabled, onSetNotificationStyle = viewModel::setNotificationStyle, onExportData = viewModel::exportData, + onImportData = viewModel::importData, onResetExportState = viewModel::resetExportState, onNavigateToDonate = { navController.navigate(Screen.Donate.route) diff --git a/app/src/main/java/com/podometer/data/db/ActivityTransitionDao.kt b/app/src/main/java/com/podometer/data/db/ActivityTransitionDao.kt index d079ae5..ed76882 100644 --- a/app/src/main/java/com/podometer/data/db/ActivityTransitionDao.kt +++ b/app/src/main/java/com/podometer/data/db/ActivityTransitionDao.kt @@ -27,6 +27,10 @@ interface ActivityTransitionDao { @Insert suspend fun insertTransition(transition: ActivityTransition) + /** Inserts multiple [ActivityTransition] rows. */ + @Insert + suspend fun insertAllTransitions(transitions: List) + /** Updates an existing [ActivityTransition] row (matched by primary key). */ @Update suspend fun updateTransition(transition: ActivityTransition) diff --git a/app/src/main/java/com/podometer/data/db/CyclingSessionDao.kt b/app/src/main/java/com/podometer/data/db/CyclingSessionDao.kt index 2a09263..a99edf1 100644 --- a/app/src/main/java/com/podometer/data/db/CyclingSessionDao.kt +++ b/app/src/main/java/com/podometer/data/db/CyclingSessionDao.kt @@ -30,6 +30,10 @@ interface CyclingSessionDao { @Insert suspend fun insertSession(session: CyclingSession): Long + /** Inserts multiple [CyclingSession] rows. */ + @Insert + suspend fun insertAllSessions(sessions: List) + /** Updates an existing [CyclingSession] row (matched by primary key). */ @Update suspend fun updateSession(session: CyclingSession) diff --git a/app/src/main/java/com/podometer/data/db/SensorWindowDao.kt b/app/src/main/java/com/podometer/data/db/SensorWindowDao.kt index b24e99e..53995e3 100644 --- a/app/src/main/java/com/podometer/data/db/SensorWindowDao.kt +++ b/app/src/main/java/com/podometer/data/db/SensorWindowDao.kt @@ -28,6 +28,13 @@ interface SensorWindowDao { @Query("SELECT * FROM sensor_windows WHERE timestamp BETWEEN :startMs AND :endMs ORDER BY timestamp") fun getWindowsBetween(startMs: Long, endMs: Long): Flow> + /** + * Returns all sensor windows ordered by timestamp ascending. + * One-shot suspend query intended for data export — not a [Flow]. + */ + @Query("SELECT * FROM sensor_windows ORDER BY timestamp ASC") + suspend fun getAllWindows(): List + /** * Deletes all sensor windows older than [cutoffMs]. * diff --git a/app/src/main/java/com/podometer/data/db/StepDao.kt b/app/src/main/java/com/podometer/data/db/StepDao.kt index 66eb2a2..85b6840 100644 --- a/app/src/main/java/com/podometer/data/db/StepDao.kt +++ b/app/src/main/java/com/podometer/data/db/StepDao.kt @@ -137,6 +137,14 @@ interface StepDao { """) suspend fun addCyclingMinutes(date: String, minutes: Int) + /** Inserts multiple [DailySummary] rows, replacing on conflict. */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllDailySummaries(summaries: List) + + /** Inserts multiple [HourlyStepAggregate] rows, replacing on conflict. */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllHourlyAggregates(aggregates: List) + /** * Returns all [DailySummary] rows ordered by date ascending. * One-shot suspend query intended for data export — not a [Flow]. diff --git a/app/src/main/java/com/podometer/data/export/ExportModels.kt b/app/src/main/java/com/podometer/data/export/ExportModels.kt index d3afe00..974780a 100644 --- a/app/src/main/java/com/podometer/data/export/ExportModels.kt +++ b/app/src/main/java/com/podometer/data/export/ExportModels.kt @@ -21,6 +21,8 @@ data class ExportData( val activityTransitions: List, /** All cycling sessions, ordered by start time ascending. */ val cyclingSessions: List, + /** All raw sensor classifier windows, ordered by timestamp ascending. */ + val sensorWindows: List = emptyList(), ) /** @@ -104,3 +106,20 @@ data class ExportCyclingSession( /** True when the user manually created or overrode this session. */ val isManualOverride: Boolean, ) + +/** + * Export model mirroring the Room [com.podometer.data.db.SensorWindow] entity. + */ +@Serializable +data class ExportSensorWindow( + /** Database row ID. */ + val id: Long, + /** Epoch-millisecond timestamp for the start of this window. */ + val timestamp: Long, + /** Accelerometer magnitude variance (m/s²)² for this window. */ + val magnitudeVariance: Double, + /** Step cadence in Hz. */ + val stepFrequencyHz: Double, + /** Number of steps detected during this window. */ + val stepCount: Int, +) diff --git a/app/src/main/java/com/podometer/di/UseCaseModule.kt b/app/src/main/java/com/podometer/di/UseCaseModule.kt index 01a4ff7..99f1f77 100644 --- a/app/src/main/java/com/podometer/di/UseCaseModule.kt +++ b/app/src/main/java/com/podometer/di/UseCaseModule.kt @@ -5,6 +5,7 @@ import android.os.Build import androidx.room.withTransaction import com.podometer.data.db.PodometerDatabase import com.podometer.domain.usecase.ExportDataUseCase +import com.podometer.domain.usecase.ImportDataUseCase import com.podometer.domain.usecase.GetTodayCyclingSessionsUseCase import com.podometer.domain.usecase.GetTodayCyclingSessionsUseCaseImpl import com.podometer.domain.usecase.GetTodayStepsUseCase @@ -18,6 +19,10 @@ import com.podometer.domain.usecase.OverrideActivityUseCaseImpl import com.podometer.domain.usecase.RecomputeActivitySessionsUseCase import com.podometer.domain.usecase.RecomputeActivitySessionsUseCaseImpl import com.podometer.domain.usecase.TransactionRunner +import com.podometer.data.db.ActivityTransitionDao +import com.podometer.data.db.CyclingSessionDao +import com.podometer.data.db.SensorWindowDao +import com.podometer.data.db.StepDao import com.podometer.data.repository.CyclingRepository import com.podometer.data.repository.StepRepository import dagger.Binds @@ -93,10 +98,24 @@ abstract class UseCaseModule { fun provideExportDataUseCase( stepRepository: StepRepository, cyclingRepository: CyclingRepository, + sensorWindowDao: SensorWindowDao, ): ExportDataUseCase = ExportDataUseCase( stepRepository = stepRepository, cyclingRepository = cyclingRepository, + sensorWindowDao = sensorWindowDao, deviceModel = Build.MODEL, ) + + @Provides + @Singleton + fun provideImportDataUseCase( + stepDao: StepDao, + activityTransitionDao: ActivityTransitionDao, + cyclingSessionDao: CyclingSessionDao, + ): ImportDataUseCase = ImportDataUseCase( + stepDao = stepDao, + activityTransitionDao = activityTransitionDao, + cyclingSessionDao = cyclingSessionDao, + ) } } diff --git a/app/src/main/java/com/podometer/domain/usecase/ExportDataUseCase.kt b/app/src/main/java/com/podometer/domain/usecase/ExportDataUseCase.kt index 7c891da..5d029ed 100644 --- a/app/src/main/java/com/podometer/domain/usecase/ExportDataUseCase.kt +++ b/app/src/main/java/com/podometer/domain/usecase/ExportDataUseCase.kt @@ -7,6 +7,8 @@ import com.podometer.data.export.ExportDailySummary import com.podometer.data.export.ExportData import com.podometer.data.export.ExportHourlyAggregate import com.podometer.data.export.ExportMetadata +import com.podometer.data.export.ExportSensorWindow +import com.podometer.data.db.SensorWindowDao import com.podometer.data.repository.CyclingRepository import com.podometer.data.repository.StepRepository import kotlinx.serialization.json.Json @@ -26,6 +28,7 @@ import java.time.Instant class ExportDataUseCase( private val stepRepository: StepRepository, private val cyclingRepository: CyclingRepository, + private val sensorWindowDao: SensorWindowDao, private val deviceModel: String, ) { @@ -54,6 +57,7 @@ class ExportDataUseCase( val hourlyAggregates = stepRepository.getAllHourlyAggregates() val transitions = stepRepository.getAllTransitions() val sessions = cyclingRepository.getAllSessions() + val sensorWindows = sensorWindowDao.getAllWindows() return ExportData( metadata = ExportMetadata( @@ -96,6 +100,15 @@ class ExportDataUseCase( isManualOverride = session.isManualOverride, ) }, + sensorWindows = sensorWindows.map { window -> + ExportSensorWindow( + id = window.id, + timestamp = window.timestamp, + magnitudeVariance = window.magnitudeVariance, + stepFrequencyHz = window.stepFrequencyHz, + stepCount = window.stepCount, + ) + }, ) } diff --git a/app/src/main/java/com/podometer/domain/usecase/ImportDataUseCase.kt b/app/src/main/java/com/podometer/domain/usecase/ImportDataUseCase.kt new file mode 100644 index 0000000..1f7d2bc --- /dev/null +++ b/app/src/main/java/com/podometer/domain/usecase/ImportDataUseCase.kt @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.domain.usecase + +import com.podometer.data.db.ActivityTransition +import com.podometer.data.db.ActivityTransitionDao +import com.podometer.data.db.CyclingSession +import com.podometer.data.db.CyclingSessionDao +import com.podometer.data.db.DailySummary +import com.podometer.data.db.HourlyStepAggregate +import com.podometer.data.db.StepDao +import com.podometer.data.export.ExportData +import kotlinx.serialization.json.Json + +/** + * Use case that parses an exported JSON file and inserts all data into the local database. + * + * Existing rows with matching primary keys are replaced (daily summaries) or + * inserted with new auto-generated IDs (hourly aggregates, transitions, sessions). + * + * @param stepDao DAO for daily summaries and hourly aggregates. + * @param activityTransitionDao DAO for activity transitions. + * @param cyclingSessionDao DAO for cycling sessions. + */ +class ImportDataUseCase( + private val stepDao: StepDao, + private val activityTransitionDao: ActivityTransitionDao, + private val cyclingSessionDao: CyclingSessionDao, +) { + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Parses [jsonString] as an [ExportData] and inserts all records into the database. + * + * Auto-generated IDs (id = 0) are used so Room assigns fresh primary keys, + * avoiding conflicts with any existing data. + */ + suspend fun importFromJson(jsonString: String) { + val data = json.decodeFromString(ExportData.serializer(), jsonString) + + stepDao.insertAllDailySummaries( + data.dailySummaries.map { s -> + DailySummary( + date = s.date, + totalSteps = s.totalSteps, + totalDistance = s.totalDistance, + walkingMinutes = s.walkingMinutes, + cyclingMinutes = s.cyclingMinutes, + ) + }, + ) + + stepDao.insertAllHourlyAggregates( + data.hourlyAggregates.map { a -> + HourlyStepAggregate( + id = 0, + timestamp = a.timestamp, + stepCountDelta = a.stepCountDelta, + detectedActivity = a.detectedActivity, + ) + }, + ) + + activityTransitionDao.insertAllTransitions( + data.activityTransitions.map { t -> + ActivityTransition( + id = 0, + timestamp = t.timestamp, + fromActivity = t.fromActivity, + toActivity = t.toActivity, + isManualOverride = t.isManualOverride, + ) + }, + ) + + cyclingSessionDao.insertAllSessions( + data.cyclingSessions.map { s -> + CyclingSession( + id = 0, + startTime = s.startTime, + endTime = s.endTime, + durationMinutes = s.durationMinutes, + isManualOverride = s.isManualOverride, + ) + }, + ) + } +} diff --git a/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt b/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt index 5d73bed..6d9856f 100644 --- a/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt @@ -87,6 +87,7 @@ fun SettingsScreen( onSetAutoStartEnabled: (Boolean) -> Unit, onSetNotificationStyle: (String) -> Unit, onExportData: (Uri) -> Unit, + onImportData: (Uri) -> Unit, onResetExportState: () -> Unit, onNavigateToDonate: () -> Unit = {}, onOpenFeedbackUrl: () -> Unit = {}, @@ -135,6 +136,15 @@ fun SettingsScreen( } } + // SAF launcher for data import — opens the file picker for a JSON file + val openDocumentLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri: Uri? -> + if (uri != null) { + onImportData(uri) + } + } + if (showStepGoalDialog) { StepGoalDialog( currentGoal = uiState.dailyStepGoal, @@ -253,6 +263,13 @@ fun SettingsScreen( ) { Text(text = stringResource(R.string.settings_export_button)) } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { openDocumentLauncher.launch(arrayOf("application/json")) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(R.string.settings_import_button)) + } } // ── About section ────────────────────────────────────────────── @@ -643,6 +660,7 @@ private fun SettingsScreenIdlePreview() { onSetAutoStartEnabled = {}, onSetNotificationStyle = {}, onExportData = {}, + onImportData = {}, onResetExportState = {}, ) } @@ -661,6 +679,7 @@ private fun SettingsScreenExportingPreview() { onSetAutoStartEnabled = {}, onSetNotificationStyle = {}, onExportData = {}, + onImportData = {}, onResetExportState = {}, ) } @@ -685,6 +704,7 @@ private fun SettingsScreenDetailedPreview() { onSetAutoStartEnabled = {}, onSetNotificationStyle = {}, onExportData = {}, + onImportData = {}, onResetExportState = {}, ) } @@ -703,6 +723,7 @@ private fun SettingsScreenSuccessPreview() { onSetAutoStartEnabled = {}, onSetNotificationStyle = {}, onExportData = {}, + onImportData = {}, onResetExportState = {}, ) } @@ -721,6 +742,7 @@ private fun SettingsScreenErrorPreview() { onSetAutoStartEnabled = {}, onSetNotificationStyle = {}, onExportData = {}, + onImportData = {}, onResetExportState = {}, ) } diff --git a/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt index 40443c2..6eac780 100644 --- a/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.podometer.data.repository.PreferencesManager import com.podometer.domain.usecase.ExportDataUseCase +import com.podometer.domain.usecase.ImportDataUseCase import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -72,6 +73,7 @@ data class SettingsUiState( @HiltViewModel class SettingsViewModel @Inject constructor( private val exportDataUseCase: ExportDataUseCase, + private val importDataUseCase: ImportDataUseCase, private val preferencesManager: PreferencesManager, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -182,6 +184,31 @@ class SettingsViewModel @Inject constructor( } } + /** + * Imports data from a previously exported JSON file at the given SAF [uri]. + * + * Reads the file contents, deserializes the JSON, and inserts all records + * into the local database. + * + * @param uri The URI of the JSON file chosen by the user. + */ + fun importData(uri: Uri) { + viewModelScope.launch { + _exportState.value = ExportState.InProgress + try { + withContext(Dispatchers.IO) { + val inputStream = context.contentResolver.openInputStream(uri) + ?: throw IOException("Cannot open input stream for URI") + val jsonString = inputStream.use { it.readBytes().toString(Charsets.UTF_8) } + importDataUseCase.importFromJson(jsonString) + } + _exportState.value = ExportState.Success + } catch (e: Exception) { + _exportState.value = ExportState.Error(e.message ?: "Unknown error") + } + } + } + /** Resets [exportState] back to [ExportState.Idle] after the user dismisses a result. */ fun resetExportState() { _exportState.value = ExportState.Idle diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b2f777..d67d92d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - Podometer + Dashboard @@ -124,6 +124,7 @@ Export failed podometer_export.json Export all app data to a JSON file + Import Data Goals diff --git a/app/src/test/java/com/podometer/data/repository/CyclingRepositoryTest.kt b/app/src/test/java/com/podometer/data/repository/CyclingRepositoryTest.kt index 7669e9a..04cb016 100644 --- a/app/src/test/java/com/podometer/data/repository/CyclingRepositoryTest.kt +++ b/app/src/test/java/com/podometer/data/repository/CyclingRepositoryTest.kt @@ -49,6 +49,8 @@ class CyclingRepositoryTest { override suspend fun getOngoingSession(): CyclingSession? = null override suspend fun getSessionCoveringTimestamp(timestamp: Long): CyclingSession? = null + + override suspend fun insertAllSessions(sessions: List) { } } // ─── getTodaySessions ──────────────────────────────────────────────────── diff --git a/app/src/test/java/com/podometer/data/repository/StepRepositoryTest.kt b/app/src/test/java/com/podometer/data/repository/StepRepositoryTest.kt index 287d695..2b77d9f 100644 --- a/app/src/test/java/com/podometer/data/repository/StepRepositoryTest.kt +++ b/app/src/test/java/com/podometer/data/repository/StepRepositoryTest.kt @@ -91,6 +91,10 @@ class StepRepositoryTest { override suspend fun getAllDailySummaries(): List = emptyList() override suspend fun getAllHourlyAggregates(): List = emptyList() + + override suspend fun insertAllDailySummaries(summaries: List) { } + + override suspend fun insertAllHourlyAggregates(aggregates: List) { } } private class FakeActivityTransitionDao( @@ -113,6 +117,8 @@ class StepRepositoryTest { override suspend fun getAllTransitions(): List = emptyList() override suspend fun getNextTransitionAfter(afterTimestamp: Long): ActivityTransition? = null + + override suspend fun insertAllTransitions(transitions: List) { } } // ─── getTodaySteps: null → 0 mapping ──────────────────────────────────── diff --git a/app/src/test/java/com/podometer/domain/usecase/ExportDataUseCaseTest.kt b/app/src/test/java/com/podometer/domain/usecase/ExportDataUseCaseTest.kt index 34180fd..1c20efe 100644 --- a/app/src/test/java/com/podometer/domain/usecase/ExportDataUseCaseTest.kt +++ b/app/src/test/java/com/podometer/domain/usecase/ExportDataUseCaseTest.kt @@ -7,6 +7,8 @@ import com.podometer.data.db.CyclingSession import com.podometer.data.db.CyclingSessionDao import com.podometer.data.db.DailySummary import com.podometer.data.db.HourlyStepAggregate +import com.podometer.data.db.SensorWindow +import com.podometer.data.db.SensorWindowDao import com.podometer.data.db.StepDao import com.podometer.data.export.ExportData import com.podometer.data.repository.CyclingRepository @@ -68,6 +70,10 @@ class ExportDataUseCaseTest { override suspend fun getAllDailySummaries(): List = dailySummaries override suspend fun getAllHourlyAggregates(): List = hourlyAggregates + + override suspend fun insertAllDailySummaries(summaries: List) { } + + override suspend fun insertAllHourlyAggregates(aggregates: List) { } } private class FakeActivityTransitionDao( @@ -83,6 +89,8 @@ class ExportDataUseCaseTest { override suspend fun getAllTransitions(): List = transitions override suspend fun getNextTransitionAfter(afterTimestamp: Long): ActivityTransition? = null + + override suspend fun insertAllTransitions(transitions: List) { } } private class FakeCyclingSessionDao( @@ -102,6 +110,16 @@ class ExportDataUseCaseTest { override suspend fun getOngoingSession(): CyclingSession? = null override suspend fun getSessionCoveringTimestamp(timestamp: Long): CyclingSession? = null + + override suspend fun insertAllSessions(sessions: List) { } + } + + private class FakeSensorWindowDao : SensorWindowDao { + override suspend fun insert(window: SensorWindow) = Unit + override fun getWindowsBetween(startMs: Long, endMs: Long): Flow> = + flowOf(emptyList()) + override suspend fun getAllWindows(): List = emptyList() + override suspend fun deleteOlderThan(cutoffMs: Long) = Unit } // ─── Helpers ───────────────────────────────────────────────────────────── @@ -118,7 +136,7 @@ class ExportDataUseCaseTest { FakeActivityTransitionDao(transitions), ) val cyclingRepo = CyclingRepository(FakeCyclingSessionDao(sessions)) - return ExportDataUseCase(stepRepo, cyclingRepo, deviceModel) + return ExportDataUseCase(stepRepo, cyclingRepo, FakeSensorWindowDao(), deviceModel) } // ─── Tests ─────────────────────────────────────────────────────────────── diff --git a/app/src/test/java/com/podometer/domain/usecase/UseCaseTest.kt b/app/src/test/java/com/podometer/domain/usecase/UseCaseTest.kt index 59ac3aa..9942908 100644 --- a/app/src/test/java/com/podometer/domain/usecase/UseCaseTest.kt +++ b/app/src/test/java/com/podometer/domain/usecase/UseCaseTest.kt @@ -76,6 +76,8 @@ class UseCaseTest { override suspend fun addCyclingMinutes(date: String, minutes: Int) = Unit override suspend fun getAllDailySummaries(): List = emptyList() override suspend fun getAllHourlyAggregates(): List = emptyList() + override suspend fun insertAllDailySummaries(summaries: List) { } + override suspend fun insertAllHourlyAggregates(aggregates: List) { } } private class FakeActivityTransitionDao( @@ -97,6 +99,8 @@ class UseCaseTest { override suspend fun getNextTransitionAfter(afterTimestamp: Long): ActivityTransition? = nextTransitionAfter + + override suspend fun insertAllTransitions(transitions: List) { } } private class FakeCyclingSessionDao( @@ -122,6 +126,8 @@ class UseCaseTest { override suspend fun getOngoingSession(): CyclingSession? = null override suspend fun getSessionCoveringTimestamp(timestamp: Long): CyclingSession? = sessionCoveringTimestamp + + override suspend fun insertAllSessions(sessions: List) { } } // ─── Helpers ───────────────────────────────────────────────────────────── From 302e8acedc83cdee5c2f88e380ea3a31d53f314c Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Thu, 5 Mar 2026 21:39:47 +0100 Subject: [PATCH 2/2] feat: density-based walking detection and 30s evaluation interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the consecutive-window walking classifier with a density-based approach that counts walking-quality windows (hz >= 1.5) within a 5-minute rolling window. This tolerates accelerometer throttling gaps that previously broke walking detection entirely. Key changes: - Walking entry: 5 windows with hz >= 1.5 in 5 min (was: 36 consecutive) - Walking exit: density drops below 2, or 2 min still grace period - Cycling entry: 6 consecutive windows (was: 2) + 60s duration gate - Cycling exit: 8 consecutive windows with hz >= 2.0 (filters road vibration false step events at 0.4-1.8 Hz) - Evaluation interval: 30s (was: 5s), aligned with step frequency window - Accelerometer buffer: 150 samples / 30s (was: 75 / 15s) - DB migration v2→v3: consolidates existing 5s sensor windows into 30s by keeping highest-variance window per 30s slot Validated against real sensor data: correctly detects a 17-min walk and a 60-min bike ride with zero false positives from indoor chores. Co-Authored-By: Claude Opus 4.6 --- .../podometer/data/db/PodometerDatabase.kt | 31 +- .../data/sensor/AccelerometerSampleBuffer.kt | 8 +- .../data/sensor/CyclingClassifier.kt | 576 ++---- .../java/com/podometer/di/DatabaseModule.kt | 2 +- .../podometer/service/StepTrackingService.kt | 10 +- .../sensor/AccelerometerSampleBufferTest.kt | 4 +- .../data/sensor/CyclingClassifierTest.kt | 1601 +++++------------ 7 files changed, 637 insertions(+), 1595 deletions(-) diff --git a/app/src/main/java/com/podometer/data/db/PodometerDatabase.kt b/app/src/main/java/com/podometer/data/db/PodometerDatabase.kt index 995baa3..855c3a0 100644 --- a/app/src/main/java/com/podometer/data/db/PodometerDatabase.kt +++ b/app/src/main/java/com/podometer/data/db/PodometerDatabase.kt @@ -14,6 +14,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase * daily summaries, and cycling sessions. * 2 — Added sensor_windows table for raw classifier window storage * (7-day retention, ~6 MB). + * 3 — Consolidate 5-second sensor windows into 30-second windows. + * Keeps the window with the highest magnitudeVariance per 30 s slot. */ @Database( entities = [ @@ -23,7 +25,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase CyclingSession::class, SensorWindow::class, ], - version = 2, + version = 3, exportSchema = false, ) abstract class PodometerDatabase : RoomDatabase() { @@ -55,5 +57,32 @@ abstract class PodometerDatabase : RoomDatabase() { ) } } + + /** + * Migration from version 2 to 3: consolidate 5-second sensor windows + * into 30-second windows. + * + * For each 30-second time slot (timestamp / 30000), keeps only the + * window with the highest magnitudeVariance. This preserves the + * cycling signal (which depends on variance) while reducing storage + * by ~6x. The stepFrequencyHz already encodes a 30-second sliding + * window, so no information is lost from the step frequency perspective. + */ + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + DELETE FROM sensor_windows WHERE id NOT IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY timestamp / 30000 + ORDER BY magnitudeVariance DESC + ) AS rn FROM sensor_windows + ) WHERE rn = 1 + ) + """.trimIndent(), + ) + } + } } } diff --git a/app/src/main/java/com/podometer/data/sensor/AccelerometerSampleBuffer.kt b/app/src/main/java/com/podometer/data/sensor/AccelerometerSampleBuffer.kt index d768b9a..7a0d2a0 100644 --- a/app/src/main/java/com/podometer/data/sensor/AccelerometerSampleBuffer.kt +++ b/app/src/main/java/com/podometer/data/sensor/AccelerometerSampleBuffer.kt @@ -43,7 +43,7 @@ data class WindowFeatures( * so contention overhead is negligible. * * @param capacity Maximum number of samples to retain. Default: [DEFAULT_CAPACITY] - * (75 slots = 15 seconds at ~5 Hz SENSOR_DELAY_NORMAL rate). + * (150 slots = 30 seconds at ~5 Hz SENSOR_DELAY_NORMAL rate). */ class AccelerometerSampleBuffer(val capacity: Int = DEFAULT_CAPACITY) { @@ -184,10 +184,10 @@ class AccelerometerSampleBuffer(val capacity: Int = DEFAULT_CAPACITY) { /** * Default circular buffer capacity. * - * 75 slots covers 15 seconds at SENSOR_DELAY_NORMAL (~5 Hz delivery rate). - * This is sufficient for activity-classification sliding windows. + * 150 slots covers 30 seconds at SENSOR_DELAY_NORMAL (~5 Hz delivery rate), + * aligned with the 30-second classifier evaluation interval. */ - const val DEFAULT_CAPACITY = 75 + const val DEFAULT_CAPACITY = 150 /** * Minimum number of samples required before [computeWindowFeatures] will diff --git a/app/src/main/java/com/podometer/data/sensor/CyclingClassifier.kt b/app/src/main/java/com/podometer/data/sensor/CyclingClassifier.kt index 236e7c7..724af4f 100644 --- a/app/src/main/java/com/podometer/data/sensor/CyclingClassifier.kt +++ b/app/src/main/java/com/podometer/data/sensor/CyclingClassifier.kt @@ -9,100 +9,47 @@ import javax.inject.Singleton * Heuristic classifier that detects cycling vs walking vs still activity by * combining accelerometer variance with step-cadence frequency. * - * ## V1 Heuristic + * ## V2 — Density-based walking detection * - * A window is classified as a "cycling window" when: - * 1. `magnitudeVariance > varianceThreshold` — there is enough motion to rule - * out a stationary state; AND - * 2. `stepFrequency < stepFrequencyThreshold` — the step cadence is too low to - * be walking (cycling does not produce step-counter events). + * Window classification: + * - **Still:** `magnitudeVariance ≤ 0.5` AND `stepFrequency < 0.3 Hz` + * - **Cycling window:** `magnitudeVariance > 2.0` AND `stepFrequency < 0.3 Hz` + * - **Walking window:** `stepFrequency ≥ 1.5 Hz` (regardless of variance) * - * A window is classified as a "still window" when: - * - `magnitudeVariance <= stillVarianceThreshold` AND `stepFrequency ≈ 0` + * Walking detection uses a density-based approach: the classifier maintains a + * rolling time window (default 5 minutes) and counts how many evaluations within + * that window have `stepFrequency ≥ 1.5 Hz`. This tolerates sensor throttling + * gaps that broke the previous consecutive-window requirement. * - * To reduce false positives the classifier requires: - * 1. At least [consecutiveWindowsRequired] consecutive cycling windows (minimum - * stability check); AND - * 2. The elapsed time since the first consecutive cycling window is at least - * [minCyclingDurationMs] (default 60 seconds). This directly satisfies the - * product spec: "step counter reports zero/very few steps for >60 seconds → - * classify as cycling". + * The `stepFrequencyHz` value from [StepFrequencyTracker] already encodes a + * 30-second sliding window of step events, so each reading represents 30 seconds + * of continuous step history — even if the accelerometer was throttled during + * part of that interval. * * ## State machine * * ``` - * STILL ──36+ walking windows (3 min) + CV check─► WALKING - * STILL / WALKING ──2+ cycling windows & >=60 s───────────► CYCLING - * CYCLING ──4+ walking windows (~20s)──────────────► WALKING - * WALKING ──still for >= 2 min / cadence breakdown────────────────────► STILL - * CYCLING ──still for >= 3 min────────────────────► STILL + * STILL ──≥5 walking windows in 5 min──────────────────► WALKING + * STILL ──6+ consecutive cycling windows & ≥60 s────────► CYCLING + * WALKING ──<2 walking windows in 5 min──────────────────► STILL + * WALKING ──still for ≥ 2 min────────────────────────────► STILL + * WALKING ──6+ consecutive cycling windows & ≥60 s────────► CYCLING + * CYCLING ──8+ consecutive windows with hz ≥ 2.0─────────► WALKING + * CYCLING ──still for ≥ 3 min────────────────────────────► STILL * ``` * - * Grace periods prevent noisy fragmentation: a crosswalk pause no longer splits - * a walk into two segments, and kitchen steps require sustained walking (~20 s) - * before entering WALKING. + * Cycling exit requires `hz ≥ 2.0` (not 1.5) because road/bike vibration + * generates false step events at 0.4–1.8 Hz. Requiring 2.0 Hz for 8 consecutive + * windows filters these out while still detecting genuine walking. * * ## Thread safety * - * All public methods are `@Synchronized` on this instance. The lock is - * acquired at most once per classifier evaluation period (~5 seconds), so - * contention overhead is negligible. + * All public methods are `@Synchronized` on this instance. * * ## Pure Kotlin * * This class has no Android framework dependencies and is fully unit-testable * on the JVM. - * - * @param varianceThreshold Minimum accelerometer magnitude variance - * (in (m/s²)²) to consider a window as containing motion indicative of - * cycling. Default: [DEFAULT_VARIANCE_THRESHOLD]. - * @param stepFrequencyThreshold Maximum step frequency (Hz) allowed while - * still classifying a window as cycling. Cycling generates very few or no - * step events. Default: [DEFAULT_STEP_FREQ_THRESHOLD]. - * @param consecutiveWindowsRequired Number of back-to-back cycling windows - * required before a CYCLING transition is emitted. This is the minimum - * stability check; the primary gate is [minCyclingDurationMs]. Default: - * [DEFAULT_CONSECUTIVE_WINDOWS]. - * @param stillVarianceThreshold Maximum variance below which the device is - * considered stationary (gravity-only noise). Default: - * [DEFAULT_STILL_VARIANCE_THRESHOLD]. - * @param minCyclingDurationMs Minimum elapsed time in milliseconds from - * the first consecutive cycling window to when the CYCLING transition may be - * emitted. Satisfies the spec requirement: zero/very few steps for at least - * 60 seconds before classifying as cycling. Default: - * [DEFAULT_MIN_CYCLING_DURATION_MS]. - * @param walkingGracePeriodMs Duration of sustained stillness (ms) required - * before WALKING transitions to STILL. Brief pauses (crosswalk, traffic light) - * are absorbed. Default: [DEFAULT_WALKING_GRACE_PERIOD_MS] (2 minutes). - * @param cyclingGracePeriodMs Duration of sustained stillness (ms) required - * before CYCLING transitions to STILL. Brief stops (red light, intersection) - * are absorbed. Default: [DEFAULT_CYCLING_GRACE_PERIOD_MS] (3 minutes). - * @param consecutiveWalkingWindowsRequired Number of consecutive walking windows - * required before STILL transitions to WALKING. Filters spurious walking from - * kitchen steps or brief hand movements. Default: - * [DEFAULT_CONSECUTIVE_WALKING_WINDOWS] (3 minutes at 5-second evaluation). - * @param cadenceCvThreshold Maximum coefficient of variation (standard deviation - * divided by mean) of step frequency across the walking window streak. A low - * CV indicates steady outdoor walking; a high CV suggests indoor pacing with - * irregular cadence. Default: [DEFAULT_CADENCE_CV_THRESHOLD] (0.35 = 35%). - * @param consecutiveWalkingWindowsForCyclingExit Number of consecutive walking - * windows required while in CYCLING before transitioning to WALKING. Prevents - * brief step-frequency spikes (phone bounce) from breaking cycling state. - * Default: [DEFAULT_CONSECUTIVE_WALKING_WINDOWS_FOR_CYCLING_EXIT] (~20 seconds). - * @param cadenceBreakdownWindowSize Size of the rolling circular buffer used - * to detect cadence breakdown while WALKING. At 5-second evaluation intervals, - * 36 entries = 3 minutes. Default: [DEFAULT_CADENCE_BREAKDOWN_WINDOW_SIZE]. - * @param cadenceBreakdownDensityThreshold Minimum fraction of walking windows - * (stepFreq >= [stepFrequencyThreshold]) in the rolling buffer. Below this - * density the walk is considered ended (e.g., chores with lots of pauses). - * Default: [DEFAULT_CADENCE_BREAKDOWN_DENSITY_THRESHOLD] (0.4 = 40%). - * @param cadenceBreakdownCvThreshold Maximum coefficient of variation of step - * frequency among walking windows in the rolling buffer. Above this threshold - * (combined with low mean) the walk is considered puttering. - * Default: [DEFAULT_CADENCE_BREAKDOWN_CV_THRESHOLD] (0.7 = 70%). - * @param cadenceBreakdownMeanFloor Maximum mean step frequency (Hz) among - * walking windows that, combined with high CV, indicates puttering rather - * than genuine walking. Default: [DEFAULT_CADENCE_BREAKDOWN_MEAN_FLOOR] (1.0 Hz). */ @Singleton class CyclingClassifier( @@ -113,21 +60,16 @@ class CyclingClassifier( private val minCyclingDurationMs: Long = DEFAULT_MIN_CYCLING_DURATION_MS, private val walkingGracePeriodMs: Long = DEFAULT_WALKING_GRACE_PERIOD_MS, private val cyclingGracePeriodMs: Long = DEFAULT_CYCLING_GRACE_PERIOD_MS, - private val consecutiveWalkingWindowsRequired: Int = DEFAULT_CONSECUTIVE_WALKING_WINDOWS, - private val cadenceCvThreshold: Double = DEFAULT_CADENCE_CV_THRESHOLD, - private val consecutiveWalkingWindowsForCyclingExit: Int = DEFAULT_CONSECUTIVE_WALKING_WINDOWS_FOR_CYCLING_EXIT, - private val cadenceBreakdownWindowSize: Int = DEFAULT_CADENCE_BREAKDOWN_WINDOW_SIZE, - private val cadenceBreakdownDensityThreshold: Double = DEFAULT_CADENCE_BREAKDOWN_DENSITY_THRESHOLD, - private val cadenceBreakdownCvThreshold: Double = DEFAULT_CADENCE_BREAKDOWN_CV_THRESHOLD, - private val cadenceBreakdownMeanFloor: Double = DEFAULT_CADENCE_BREAKDOWN_MEAN_FLOOR, + private val walkingHzThreshold: Double = DEFAULT_WALKING_HZ_THRESHOLD, + private val walkingEntryCount: Int = DEFAULT_WALKING_ENTRY_COUNT, + private val walkingExitCount: Int = DEFAULT_WALKING_EXIT_COUNT, + private val densityWindowMs: Long = DEFAULT_DENSITY_WINDOW_MS, + private val cyclingWalkExitHz: Double = DEFAULT_CYCLING_WALK_EXIT_HZ, + private val cyclingWalkExitCount: Int = DEFAULT_CYCLING_WALK_EXIT_COUNT, ) { /** * No-argument constructor used by Hilt for dependency injection. - * - * Uses default threshold constants. The parameterised constructor is - * provided for unit tests that need custom thresholds without requiring - * the DI graph. */ @Inject constructor() : this( @@ -138,55 +80,30 @@ class CyclingClassifier( minCyclingDurationMs = DEFAULT_MIN_CYCLING_DURATION_MS, walkingGracePeriodMs = DEFAULT_WALKING_GRACE_PERIOD_MS, cyclingGracePeriodMs = DEFAULT_CYCLING_GRACE_PERIOD_MS, - consecutiveWalkingWindowsRequired = DEFAULT_CONSECUTIVE_WALKING_WINDOWS, - cadenceCvThreshold = DEFAULT_CADENCE_CV_THRESHOLD, - consecutiveWalkingWindowsForCyclingExit = DEFAULT_CONSECUTIVE_WALKING_WINDOWS_FOR_CYCLING_EXIT, - cadenceBreakdownWindowSize = DEFAULT_CADENCE_BREAKDOWN_WINDOW_SIZE, - cadenceBreakdownDensityThreshold = DEFAULT_CADENCE_BREAKDOWN_DENSITY_THRESHOLD, - cadenceBreakdownCvThreshold = DEFAULT_CADENCE_BREAKDOWN_CV_THRESHOLD, - cadenceBreakdownMeanFloor = DEFAULT_CADENCE_BREAKDOWN_MEAN_FLOOR, + walkingHzThreshold = DEFAULT_WALKING_HZ_THRESHOLD, + walkingEntryCount = DEFAULT_WALKING_ENTRY_COUNT, + walkingExitCount = DEFAULT_WALKING_EXIT_COUNT, + densityWindowMs = DEFAULT_DENSITY_WINDOW_MS, + cyclingWalkExitHz = DEFAULT_CYCLING_WALK_EXIT_HZ, + cyclingWalkExitCount = DEFAULT_CYCLING_WALK_EXIT_COUNT, ) // Internal state — protected by @Synchronized on each public method. private var currentState: ActivityState = ActivityState.STILL private var consecutiveCyclingWindows: Int = 0 - - /** - * Wall-clock timestamp (from [System.currentTimeMillis]) of the first - * cycling window in the current consecutive streak. Reset to `0L` when - * the streak is broken or [reset] is called. - */ - private var cyclingWindowStartTimeMs: Long = 0L - - /** Number of consecutive still windows observed. Used for grace-period tracking. */ + private var cyclingWindowStartTimeMs: Long = -1L private var consecutiveStillWindows: Int = 0 + private var stillWindowStartTimeMs: Long = -1L - /** Timestamp of the first still window in the current consecutive still streak. */ - private var stillWindowStartTimeMs: Long = 0L - - /** Number of consecutive walking windows observed from STILL (for entry threshold). */ - private var consecutiveWalkingWindows: Int = 0 - - /** Timestamp of the first walking window in the current consecutive walking streak from STILL. */ - private var walkingWindowStartTimeMs: Long = 0L + // Density-based walking: rolling buffer of (timestamp, stepFrequency) pairs. + private val densityTimestamps = LongArray(DENSITY_BUFFER_CAPACITY) + private val densityFrequencies = DoubleArray(DENSITY_BUFFER_CAPACITY) + private var densityHead: Int = 0 + private var densityCount: Int = 0 - /** Running sum of step frequencies across consecutive walking windows (for CV calculation). */ - private var walkingFrequencySum: Double = 0.0 - - /** Running sum of squared step frequencies across consecutive walking windows. */ - private var walkingFrequencySquaredSum: Double = 0.0 - - /** Number of consecutive walking windows observed while in CYCLING (for exit threshold). */ - private var consecutiveWalkingWindowsInCycling: Int = 0 - - /** Timestamp of the first walking window in the current consecutive streak while CYCLING. */ - private var walkingInCyclingStartTimeMs: Long = 0L - - // Rolling cadence buffer for cadence-breakdown detection in WALKING state. - private val walkingRollingFreqs = DoubleArray(cadenceBreakdownWindowSize) - private val walkingRollingTimestamps = LongArray(cadenceBreakdownWindowSize) - private var walkingRollingIndex: Int = 0 - private var walkingRollingCount: Int = 0 + // Cycling → Walking exit: consecutive windows with high hz. + private var consecutiveHighWalkInCycling: Int = 0 + private var highWalkInCyclingStartMs: Long = 0L // ─── Public API ─────────────────────────────────────────────────────────── @@ -194,29 +111,22 @@ class CyclingClassifier( * Evaluates the supplied sensor features and returns a [TransitionResult] * if the activity state changed, or `null` if the state is unchanged. * - * Call this periodically (e.g., every 5 seconds) from a background + * Call this periodically (e.g., every 30 seconds) from a background * coroutine, feeding the latest [WindowFeatures] from * [AccelerometerSampleBuffer.computeWindowFeatures] and the current * [stepFrequency] from [StepFrequencyTracker.computeStepFrequency]. - * - * @param features Feature vector computed from the accelerometer - * sliding window. - * @param stepFrequency Step cadence in Hz from [StepFrequencyTracker]. - * @param currentTimeMs Current wall-clock time in milliseconds, typically - * `System.currentTimeMillis()`. Used to enforce [minCyclingDurationMs]. - * @return [TransitionResult] describing the state change, or `null` if - * no transition occurred. */ @Synchronized fun evaluate(features: WindowFeatures, stepFrequency: Double, currentTimeMs: Long): TransitionResult? { val variance = features.magnitudeVariance - // Determine what this window looks like val isStillWindow = variance <= stillVarianceThreshold && stepFrequency < stepFrequencyThreshold val isCyclingWindow = variance > varianceThreshold && stepFrequency < stepFrequencyThreshold - val isWalkingWindow = !isCyclingWindow && !isStillWindow && stepFrequency >= stepFrequencyThreshold - // Update consecutive cycling window count and track when the streak started + // Update density buffer + addToDensityBuffer(currentTimeMs, stepFrequency) + + // Update consecutive cycling window count if (isCyclingWindow) { if (consecutiveCyclingWindows == 0) { cyclingWindowStartTimeMs = currentTimeMs @@ -224,7 +134,7 @@ class CyclingClassifier( consecutiveCyclingWindows++ } else { consecutiveCyclingWindows = 0 - cyclingWindowStartTimeMs = 0L + cyclingWindowStartTimeMs = -1L } // Update consecutive still window count @@ -235,36 +145,21 @@ class CyclingClassifier( consecutiveStillWindows++ } else { consecutiveStillWindows = 0 - stillWindowStartTimeMs = 0L - } - - // Update consecutive walking windows (for STILL → WALKING threshold) - if (isWalkingWindow) { - if (consecutiveWalkingWindows == 0) { - walkingWindowStartTimeMs = currentTimeMs - walkingFrequencySum = 0.0 - walkingFrequencySquaredSum = 0.0 - } - consecutiveWalkingWindows++ - walkingFrequencySum += stepFrequency - walkingFrequencySquaredSum += stepFrequency * stepFrequency - } else { - consecutiveWalkingWindows = 0 - walkingWindowStartTimeMs = 0L - walkingFrequencySum = 0.0 - walkingFrequencySquaredSum = 0.0 + stillWindowStartTimeMs = -1L } val previousState = currentState - // Cycling transition: enough consecutive cycling windows, duration gate - // satisfied, and not already cycling — checked first in all states - val durationSatisfied = (currentTimeMs - cyclingWindowStartTimeMs) >= minCyclingDurationMs + // Cycling transition: checked first in all states + val durationSatisfied = cyclingWindowStartTimeMs >= 0L && + (currentTimeMs - cyclingWindowStartTimeMs) >= minCyclingDurationMs if (consecutiveCyclingWindows >= consecutiveWindowsRequired && durationSatisfied && currentState != ActivityState.CYCLING ) { currentState = ActivityState.CYCLING + consecutiveHighWalkInCycling = 0 + highWalkInCyclingStartMs = 0L return TransitionResult( fromState = previousState, toState = ActivityState.CYCLING, @@ -272,97 +167,80 @@ class CyclingClassifier( ) } + val walkingCount = countWalkingInDensityWindow(currentTimeMs) + return when (currentState) { ActivityState.STILL -> { - // Walking entry requires sustained walking with consistent cadence - if (isWalkingWindow && consecutiveWalkingWindows >= consecutiveWalkingWindowsRequired) { - val count = consecutiveWalkingWindows.toDouble() - val mean = walkingFrequencySum / count - val variance = (walkingFrequencySquaredSum / count) - mean * mean - // Guard against floating-point edge cases - val cv = if (mean > 0.0 && variance > 0.0) { - kotlin.math.sqrt(variance) / mean - } else { - 0.0 - } - if (cv < cadenceCvThreshold) { - currentState = ActivityState.WALKING - resetWalkingRollingBuffer() - TransitionResult( - fromState = previousState, - toState = ActivityState.WALKING, - effectiveTimestamp = walkingWindowStartTimeMs, - ) - } else { - null // cadence too irregular — likely indoor pacing - } + if (walkingCount >= walkingEntryCount) { + val firstWalkTs = firstWalkingTimestampInWindow(currentTimeMs) + currentState = ActivityState.WALKING + TransitionResult( + fromState = previousState, + toState = ActivityState.WALKING, + effectiveTimestamp = firstWalkTs, + ) } else { null } } ActivityState.WALKING -> { - // Push to rolling cadence buffer - walkingRollingFreqs[walkingRollingIndex] = stepFrequency - walkingRollingTimestamps[walkingRollingIndex] = currentTimeMs - walkingRollingIndex = (walkingRollingIndex + 1) % cadenceBreakdownWindowSize - if (walkingRollingCount < cadenceBreakdownWindowSize) walkingRollingCount++ - - // 1. Grace period: sustained stillness exits to STILL - if (isStillWindow && (currentTimeMs - stillWindowStartTimeMs) >= walkingGracePeriodMs) { - resetWalkingRollingBuffer() + // Grace period: sustained stillness exits to STILL + if (isStillWindow && stillWindowStartTimeMs >= 0L + && (currentTimeMs - stillWindowStartTimeMs) >= walkingGracePeriodMs + ) { currentState = ActivityState.STILL TransitionResult( fromState = previousState, toState = ActivityState.STILL, effectiveTimestamp = stillWindowStartTimeMs, ) - } else if (walkingRollingCount >= cadenceBreakdownWindowSize) { - // 2. Cadence breakdown: rolling window detects intermittent activity - checkCadenceBreakdown(previousState) + } else if (walkingCount < walkingExitCount) { + // Density-based exit: not enough walking windows in the rolling window + currentState = ActivityState.STILL + TransitionResult( + fromState = previousState, + toState = ActivityState.STILL, + effectiveTimestamp = currentTimeMs, + ) } else { - null // buffer not full — stay WALKING + null } } ActivityState.CYCLING -> { - // Track consecutive walking windows while in CYCLING - if (isWalkingWindow) { - if (consecutiveWalkingWindowsInCycling == 0) { - walkingInCyclingStartTimeMs = currentTimeMs + // Track consecutive high-hz walking windows for cycling exit + if (stepFrequency >= cyclingWalkExitHz) { + if (consecutiveHighWalkInCycling == 0) { + highWalkInCyclingStartMs = currentTimeMs } - consecutiveWalkingWindowsInCycling++ + consecutiveHighWalkInCycling++ } else { - consecutiveWalkingWindowsInCycling = 0 - walkingInCyclingStartTimeMs = 0L + consecutiveHighWalkInCycling = 0 + highWalkInCyclingStartMs = 0L } - // Walking exit requires sustained walking windows to filter phone bounce - if (isWalkingWindow && consecutiveWalkingWindowsInCycling >= consecutiveWalkingWindowsForCyclingExit) { - val effectiveTs = walkingInCyclingStartTimeMs - consecutiveWalkingWindowsInCycling = 0 - walkingInCyclingStartTimeMs = 0L + if (consecutiveHighWalkInCycling >= cyclingWalkExitCount) { + val effectiveTs = highWalkInCyclingStartMs + consecutiveHighWalkInCycling = 0 + highWalkInCyclingStartMs = 0L currentState = ActivityState.WALKING - resetWalkingRollingBuffer() TransitionResult( fromState = previousState, toState = ActivityState.WALKING, effectiveTimestamp = effectiveTs, ) - } else if (isStillWindow) { - val stillDuration = currentTimeMs - stillWindowStartTimeMs - if (stillDuration >= cyclingGracePeriodMs) { - currentState = ActivityState.STILL - TransitionResult( - fromState = previousState, - toState = ActivityState.STILL, - effectiveTimestamp = stillWindowStartTimeMs, - ) - } else { - null // within grace period — stay CYCLING - } + } else if (isStillWindow && stillWindowStartTimeMs >= 0L + && (currentTimeMs - stillWindowStartTimeMs) >= cyclingGracePeriodMs + ) { + currentState = ActivityState.STILL + TransitionResult( + fromState = previousState, + toState = ActivityState.STILL, + effectiveTimestamp = stillWindowStartTimeMs, + ) } else { - null // cycling window — stay CYCLING + null } } } @@ -371,236 +249,104 @@ class CyclingClassifier( /** * Returns the current activity state as determined by the most recent * [evaluate] call. - * - * Starts as [ActivityState.STILL] and is reset to [ActivityState.STILL] - * by [reset]. */ @Synchronized fun getCurrentState(): ActivityState = currentState /** * Resets the classifier to its initial state. - * - * Call this when the sensor session ends (e.g., [StepTrackingService] - * `onDestroy`) so that stale state from a previous session does not - * contaminate the next one. */ @Synchronized fun reset() { currentState = ActivityState.STILL consecutiveCyclingWindows = 0 - cyclingWindowStartTimeMs = 0L + cyclingWindowStartTimeMs = -1L consecutiveStillWindows = 0 - stillWindowStartTimeMs = 0L - consecutiveWalkingWindows = 0 - walkingWindowStartTimeMs = 0L - walkingFrequencySum = 0.0 - walkingFrequencySquaredSum = 0.0 - consecutiveWalkingWindowsInCycling = 0 - walkingInCyclingStartTimeMs = 0L - resetWalkingRollingBuffer() + stillWindowStartTimeMs = -1L + densityHead = 0 + densityCount = 0 + consecutiveHighWalkInCycling = 0 + highWalkInCyclingStartMs = 0L } // ─── Private helpers ────────────────────────────────────────────────────── - /** Clears the rolling cadence buffer. Called on WALKING entry and exit. */ - private fun resetWalkingRollingBuffer() { - walkingRollingIndex = 0 - walkingRollingCount = 0 + private fun addToDensityBuffer(timestampMs: Long, stepFrequency: Double) { + densityTimestamps[densityHead] = timestampMs + densityFrequencies[densityHead] = stepFrequency + densityHead = (densityHead + 1) % DENSITY_BUFFER_CAPACITY + if (densityCount < DENSITY_BUFFER_CAPACITY) densityCount++ } - /** - * Checks the rolling cadence buffer for breakdown patterns that indicate - * the user has stopped genuinely walking (e.g., chores, puttering). - * - * Called only when the buffer is full ([walkingRollingCount] >= [cadenceBreakdownWindowSize]). - * - * @return [TransitionResult] if breakdown detected, `null` otherwise. - */ - private fun checkCadenceBreakdown(previousState: ActivityState): TransitionResult? { - // In a full circular buffer, walkingRollingIndex points to the oldest entry - val effectiveTs = walkingRollingTimestamps[walkingRollingIndex] - - var walkingWindows = 0 - var freqSum = 0.0 - var freqSqSum = 0.0 - for (i in 0 until cadenceBreakdownWindowSize) { - if (walkingRollingFreqs[i] >= stepFrequencyThreshold) { - walkingWindows++ - freqSum += walkingRollingFreqs[i] - freqSqSum += walkingRollingFreqs[i] * walkingRollingFreqs[i] + private fun countWalkingInDensityWindow(currentTimeMs: Long): Int { + val cutoff = currentTimeMs - densityWindowMs + var count = 0 + val startIdx = (densityHead - densityCount + DENSITY_BUFFER_CAPACITY) % DENSITY_BUFFER_CAPACITY + for (i in 0 until densityCount) { + val idx = (startIdx + i) % DENSITY_BUFFER_CAPACITY + if (densityTimestamps[idx] >= cutoff && densityFrequencies[idx] >= walkingHzThreshold) { + count++ } } + return count + } - val density = walkingWindows.toDouble() / cadenceBreakdownWindowSize - - // Low walking density — chores with lots of pauses - if (density < cadenceBreakdownDensityThreshold) { - resetWalkingRollingBuffer() - currentState = ActivityState.STILL - return TransitionResult( - fromState = previousState, - toState = ActivityState.STILL, - effectiveTimestamp = effectiveTs, - ) - } - - // High CV + low mean among walking windows — puttering - if (walkingWindows >= 3) { - val mean = freqSum / walkingWindows - val variance = (freqSqSum / walkingWindows) - mean * mean - val cv = if (mean > 0.0 && variance > 0.0) { - kotlin.math.sqrt(variance) / mean - } else { - 0.0 - } - if (cv > cadenceBreakdownCvThreshold && mean < cadenceBreakdownMeanFloor) { - resetWalkingRollingBuffer() - currentState = ActivityState.STILL - return TransitionResult( - fromState = previousState, - toState = ActivityState.STILL, - effectiveTimestamp = effectiveTs, - ) + private fun firstWalkingTimestampInWindow(currentTimeMs: Long): Long { + val cutoff = currentTimeMs - densityWindowMs + val startIdx = (densityHead - densityCount + DENSITY_BUFFER_CAPACITY) % DENSITY_BUFFER_CAPACITY + for (i in 0 until densityCount) { + val idx = (startIdx + i) % DENSITY_BUFFER_CAPACITY + if (densityTimestamps[idx] >= cutoff && densityFrequencies[idx] >= walkingHzThreshold) { + return densityTimestamps[idx] } } - - return null + return currentTimeMs } // ─── Constants ──────────────────────────────────────────────────────────── companion object { - /** - * Default accelerometer magnitude variance threshold in (m/s²)². - * - * A value of 2.0 is above gravity-only sensor noise (~0.2–0.5) but - * indicates purposeful motion. Cycling on typical roads produces - * variances well above this level. - */ + /** Accelerometer magnitude variance threshold for cycling detection (m/s²)². */ const val DEFAULT_VARIANCE_THRESHOLD = 2.0 - /** - * Default step frequency threshold in Hz. - * - * Walking cadence is typically 1.5–2.5 Hz. A threshold of 0.3 Hz - * means "effectively no step events" — consistent with cycling, which - * does not generate step-counter events. - */ + /** Maximum step frequency (Hz) for a window to be classified as cycling. */ const val DEFAULT_STEP_FREQ_THRESHOLD = 0.3 - /** - * Default number of consecutive cycling windows required before the - * CYCLING transition is emitted. - * - * Two windows (~10 seconds at a 5-second evaluation period) provides - * minimum stability against brief high-variance, low-step episodes. - * The primary gate is [DEFAULT_MIN_CYCLING_DURATION_MS]. - */ - const val DEFAULT_CONSECUTIVE_WINDOWS = 2 - - /** - * Maximum variance considered "still" (gravity-only noise) in (m/s²)². - * - * Below 0.5 (m/s²)² the device is essentially stationary; gravity - * alone accounts for the signal. - */ + /** Consecutive cycling windows required before entering CYCLING. */ + const val DEFAULT_CONSECUTIVE_WINDOWS = 6 + + /** Maximum variance considered still (gravity-only noise) in (m/s²)². */ const val DEFAULT_STILL_VARIANCE_THRESHOLD = 0.5 - /** - * Default minimum elapsed duration of consecutive cycling windows - * before a CYCLING transition is emitted, in milliseconds. - * - * 60 000 ms = 60 seconds satisfies the product spec: - * "step counter reports zero/very few steps for >60 seconds → - * classify as cycling". - */ + /** Minimum elapsed duration of consecutive cycling windows before CYCLING (ms). */ const val DEFAULT_MIN_CYCLING_DURATION_MS = 60_000L - /** - * Default grace period before WALKING transitions to STILL, in ms. - * - * 120 000 ms = 2 minutes. Brief pauses (crosswalks, traffic lights) - * do not fragment a walking session. - */ + /** Grace period before WALKING transitions to STILL (ms). */ const val DEFAULT_WALKING_GRACE_PERIOD_MS = 120_000L - /** - * Default grace period before CYCLING transitions to STILL, in ms. - * - * 180 000 ms = 3 minutes. Brief stops (red lights, intersections) - * do not fragment a cycling session. - */ + /** Grace period before CYCLING transitions to STILL (ms). */ const val DEFAULT_CYCLING_GRACE_PERIOD_MS = 180_000L - /** - * Default number of consecutive walking windows required before - * STILL transitions to WALKING. - * - * 36 windows (36 x 5 s = 180 s = 3 minutes at a 5-second evaluation - * period) filters out indoor movement such as walking to the kitchen - * or bathroom. The transition timestamp is back-dated to when the - * sustained walking started. - */ - const val DEFAULT_CONSECUTIVE_WALKING_WINDOWS = 36 - - /** - * Default maximum coefficient of variation for step frequency during - * the consecutive walking window streak. - * - * CV = standard deviation / mean. A value of 0.35 means up to 35% - * variation is allowed. Outdoor walking typically has a steady cadence - * (CV < 0.2); indoor pacing with turns and stops tends to have higher - * variability. - */ - const val DEFAULT_CADENCE_CV_THRESHOLD = 0.35 - - /** - * Default number of consecutive walking windows required while in - * CYCLING before transitioning to WALKING. - * - * Four windows (~20 seconds at a 5-second evaluation period) prevents - * brief step-frequency spikes from phone bounce during cycling from - * breaking the cycling state. Genuine walking (sustained ~20s of - * steps) still triggers the exit. - */ - const val DEFAULT_CONSECUTIVE_WALKING_WINDOWS_FOR_CYCLING_EXIT = 4 - - /** - * Default size of the rolling circular buffer for cadence breakdown - * detection while in WALKING state. - * - * 36 entries at a 5-second evaluation period = 180 seconds = 3 minutes. - */ - const val DEFAULT_CADENCE_BREAKDOWN_WINDOW_SIZE = 36 - - /** - * Default minimum walking-window density in the rolling buffer. - * - * 0.4 = 40%. Fewer than ~14 of 36 windows with stepFreq >= 0.3 Hz - * indicates the user is doing chores (short walks with long pauses) - * rather than genuinely walking. - */ - const val DEFAULT_CADENCE_BREAKDOWN_DENSITY_THRESHOLD = 0.4 - - /** - * Default maximum coefficient of variation of step frequency among - * walking windows in the rolling buffer for puttering detection. - * - * 0.7 = 70%. Combined with a low mean (< [DEFAULT_CADENCE_BREAKDOWN_MEAN_FLOOR]), - * a high CV indicates irregular slow stepping (puttering) rather than - * genuine walking. - */ - const val DEFAULT_CADENCE_BREAKDOWN_CV_THRESHOLD = 0.7 - - /** - * Default mean step-frequency ceiling (Hz) for the puttering check. - * - * 1.0 Hz. Walking windows with mean frequency below this value AND - * CV above [DEFAULT_CADENCE_BREAKDOWN_CV_THRESHOLD] are classified as - * puttering. Normal outdoor walking cadence (1.5–2.5 Hz) is well above - * this floor. - */ - const val DEFAULT_CADENCE_BREAKDOWN_MEAN_FLOOR = 1.0 + /** Minimum step frequency (Hz) for a window to count as walking in the density check. */ + const val DEFAULT_WALKING_HZ_THRESHOLD = 1.5 + + /** Number of walking windows in the density window required to enter WALKING. */ + const val DEFAULT_WALKING_ENTRY_COUNT = 5 + + /** Walking window count below which WALKING exits to STILL. */ + const val DEFAULT_WALKING_EXIT_COUNT = 2 + + /** Rolling time window (ms) for walking density calculation. */ + const val DEFAULT_DENSITY_WINDOW_MS = 300_000L + + /** Step frequency (Hz) required for cycling→walking exit (higher than entry to + * filter road vibration false positives). */ + const val DEFAULT_CYCLING_WALK_EXIT_HZ = 2.0 + + /** Consecutive windows with hz ≥ [DEFAULT_CYCLING_WALK_EXIT_HZ] to exit cycling. */ + const val DEFAULT_CYCLING_WALK_EXIT_COUNT = 8 + + /** Pre-allocated density buffer capacity. 600 entries covers 5 hours at 30s intervals. */ + internal const val DENSITY_BUFFER_CAPACITY = 600 } } diff --git a/app/src/main/java/com/podometer/di/DatabaseModule.kt b/app/src/main/java/com/podometer/di/DatabaseModule.kt index 7762c0d..15943f3 100644 --- a/app/src/main/java/com/podometer/di/DatabaseModule.kt +++ b/app/src/main/java/com/podometer/di/DatabaseModule.kt @@ -35,7 +35,7 @@ object DatabaseModule { PodometerDatabase::class.java, "podometer.db", ) - .addMigrations(PodometerDatabase.MIGRATION_1_2) + .addMigrations(PodometerDatabase.MIGRATION_1_2, PodometerDatabase.MIGRATION_2_3) .build() @Provides diff --git a/app/src/main/java/com/podometer/service/StepTrackingService.kt b/app/src/main/java/com/podometer/service/StepTrackingService.kt index 6c54e93..b173da4 100644 --- a/app/src/main/java/com/podometer/service/StepTrackingService.kt +++ b/app/src/main/java/com/podometer/service/StepTrackingService.kt @@ -357,7 +357,7 @@ class StepTrackingService : Service() { * The [ActivityTransition] DB insert runs asynchronously because it has no * downstream effect on classifier state. * - * Runs every [CLASSIFIER_INTERVAL_MS] (~5 seconds) until the service + * Runs every [CLASSIFIER_INTERVAL_MS] (~30 seconds) until the service * scope is cancelled. */ private fun launchClassifier(): Job = serviceScope.launch { @@ -520,13 +520,15 @@ class StepTrackingService : Service() { private const val SENSOR_WINDOW_RETENTION_MS = 7L * 24 * 60 * 60 * 1000 /** - * Classifier evaluation interval in milliseconds (~5 seconds). + * Classifier evaluation interval in milliseconds (~30 seconds). * - * At SENSOR_DELAY_NORMAL (~5 Hz) this gives ~25 new accelerometer + * Aligned with [StepFrequencyTracker.DEFAULT_WINDOW_MS] so each + * evaluation samples an independent 30-second step-frequency reading. + * At SENSOR_DELAY_NORMAL (~5 Hz) this gives ~150 new accelerometer * samples per evaluation — sufficient for a stable variance estimate. * Each evaluation is stored as a [SensorWindow] for retroactive replay. */ - private const val CLASSIFIER_INTERVAL_MS = 5_000L + private const val CLASSIFIER_INTERVAL_MS = 30_000L /** * Distributes [delta] step events evenly between [lastEventMs] and [nowMs], diff --git a/app/src/test/java/com/podometer/data/sensor/AccelerometerSampleBufferTest.kt b/app/src/test/java/com/podometer/data/sensor/AccelerometerSampleBufferTest.kt index 49a041e..b386ae4 100644 --- a/app/src/test/java/com/podometer/data/sensor/AccelerometerSampleBufferTest.kt +++ b/app/src/test/java/com/podometer/data/sensor/AccelerometerSampleBufferTest.kt @@ -364,8 +364,8 @@ class AccelerometerSampleBufferTest { } @Test - fun `DEFAULT_CAPACITY is 75 to cover 15 seconds at 5Hz`() { - assertEquals(75, AccelerometerSampleBuffer.DEFAULT_CAPACITY) + fun `DEFAULT_CAPACITY is 150 to cover 30 seconds at 5Hz`() { + assertEquals(150, AccelerometerSampleBuffer.DEFAULT_CAPACITY) } @Test diff --git a/app/src/test/java/com/podometer/data/sensor/CyclingClassifierTest.kt b/app/src/test/java/com/podometer/data/sensor/CyclingClassifierTest.kt index 7444612..e704c10 100644 --- a/app/src/test/java/com/podometer/data/sensor/CyclingClassifierTest.kt +++ b/app/src/test/java/com/podometer/data/sensor/CyclingClassifierTest.kt @@ -10,7 +10,7 @@ import org.junit.Test import kotlin.concurrent.thread /** - * Unit tests for [CyclingClassifier]. + * Unit tests for [CyclingClassifier] (v2 — density-based walking detection). * * Uses synthetic [WindowFeatures] and step-frequency values to verify * state-machine transitions without any Android framework dependency. @@ -18,14 +18,15 @@ import kotlin.concurrent.thread * Threshold defaults used in tests (unless overridden): * varianceThreshold = 2.0 (m/s²)² * stepFreqThreshold = 0.3 Hz - * consecutiveWindows = 2 + * consecutiveWindows = 6 * stillVarianceThreshold = 0.5 (m/s²)² * minCyclingDurationMs = 60_000L - * - * Timestamp conventions: - * T0 = 0L — first cycling window - * T_61S = 61_000L — 61 seconds later (satisfies >=60 s gate) - * T_59S = 59_000L — 59 seconds later (does NOT satisfy gate) + * walkingHzThreshold = 1.5 Hz + * walkingEntryCount = 5 + * walkingExitCount = 2 + * densityWindowMs = 300_000L (5 min) + * cyclingWalkExitHz = 2.0 Hz + * cyclingWalkExitCount = 8 */ class CyclingClassifierTest { @@ -37,9 +38,10 @@ class CyclingClassifierTest { private const val LOW_VARIANCE = 1.0 // below DEFAULT_VARIANCE_THRESHOLD, above STILL private const val STILL_VARIANCE = 0.2 // below DEFAULT_STILL_VARIANCE_THRESHOLD (0.5) - // Step frequency values relative to threshold (0.3 Hz) - private const val CYCLING_STEP_FREQ = 0.1 // below threshold — very few steps - private const val WALKING_STEP_FREQ = 2.0 // well above threshold — active walking + // Step frequency values relative to thresholds + private const val CYCLING_STEP_FREQ = 0.1 // below 0.3 threshold — very few steps + private const val WALKING_STEP_FREQ = 2.0 // above walkingHzThreshold (1.5) AND cyclingWalkExitHz (2.0) + private const val MODERATE_WALK_FREQ = 1.6 // above walkingHzThreshold but below cyclingWalkExitHz private const val ZERO_STEP_FREQ = 0.0 // no steps at all private const val DELTA = 1e-9 @@ -47,29 +49,26 @@ class CyclingClassifierTest { // Timestamps private const val T0 = 0L private const val T_61S = 61_000L - private const val T_59S = 59_000L private const val T_30S = 30_000L private const val T_65S = 65_000L - /** - * Interval between simulated 5-second windows so that a series of N - * windows spans at least 60 seconds when N * WINDOW_INTERVAL_MS >= 60_000. - */ - private const val WINDOW_INTERVAL_MS = 5_000L + /** Interval between simulated 30-second windows. */ + private const val WINDOW_INTERVAL_MS = 30_000L } @Before fun setUp() { - // Use minCyclingDurationMs = 0 so that existing consecutive-window tests - // are not broken by the duration gate. Grace periods, walking entry - // threshold, and cycling exit threshold are also disabled so existing - // tests behave as before. + // Minimal thresholds for basic tests: cycling entry with 2 windows, walking with 1, + // no grace periods. Override per test for specific behavior. classifier = CyclingClassifier( minCyclingDurationMs = 0L, walkingGracePeriodMs = 0L, cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - consecutiveWalkingWindowsForCyclingExit = 1, + consecutiveWindowsRequired = 2, + walkingEntryCount = 1, + walkingExitCount = 0, + cyclingWalkExitCount = 1, + cyclingWalkExitHz = WALKING_STEP_FREQ, ) } @@ -102,19 +101,15 @@ class CyclingClassifierTest { @Test fun `still state - low variance stays non-cycling - no transition`() { - // LOW_VARIANCE is below varianceThreshold but above stillVarianceThreshold - // With zero steps, should stay in STILL val result = classifier.evaluate(featuresWithVariance(LOW_VARIANCE), ZERO_STEP_FREQ, T0) assertNull("Expected no transition for low-variance window", result) assertEquals(ActivityState.STILL, classifier.getCurrentState()) } - // ─── Walking detection: moderate variance, high step frequency ──────────── + // ─── Walking detection: density-based ─────────────────────────────────── @Test - fun `walking - high step frequency prevents cycling detection even with high variance`() { - // High variance but WALKING_STEP_FREQ is above stepFrequencyThreshold → not a cycling window - // With the STILL → WALKING fix, this should now produce a WALKING transition + fun `walking - high step frequency from STILL triggers WALKING`() { val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) assertNotNull("Walking step freq from STILL should trigger WALKING transition", result) assertEquals(ActivityState.STILL, result!!.fromState) @@ -123,52 +118,115 @@ class CyclingClassifierTest { } @Test - fun `walking - consecutive windows with walking step freq do not trigger cycling`() { - // First window: STILL → WALKING transition - val firstResult = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) - assertNotNull("First walking window should trigger STILL → WALKING transition", firstResult) - assertEquals(ActivityState.WALKING, firstResult!!.toState) - - // Subsequent windows: no cycling transition (walking step freq blocks it), no duplicate WALKING - for (i in 1 until 5) { - val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0 + i * WINDOW_INTERVAL_MS) - assertNull("No cycling transition when walking step freq present", result) - } + fun `walking - no duplicate transition when already WALKING`() { + classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) + val result2 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0 + WINDOW_INTERVAL_MS) + assertNull("No duplicate transition when already WALKING", result2) assertEquals(ActivityState.WALKING, classifier.getCurrentState()) } - // ─── STILL → WALKING transition ────────────────────────────────────────── + @Test + fun `walking entry - density count respected`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = 0L, + cyclingGracePeriodMs = 0L, + walkingEntryCount = 3, + walkingExitCount = 0, + cyclingWalkExitCount = 1, + cyclingWalkExitHz = WALKING_STEP_FREQ, + ) + // First 2 walking windows — not enough + for (i in 0 until 2) { + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, i * WINDOW_INTERVAL_MS) + assertNull("Walking window $i should not trigger (need 3)", result) + assertEquals(ActivityState.STILL, gc.getCurrentState()) + } + // 3rd triggers + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 2 * WINDOW_INTERVAL_MS) + assertNotNull("3rd walking window should trigger STILL → WALKING", result) + assertEquals(ActivityState.WALKING, result!!.toState) + } + + @Test + fun `walking entry - effective timestamp is first walking window in density window`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = 0L, + cyclingGracePeriodMs = 0L, + walkingEntryCount = 3, + walkingExitCount = 0, + cyclingWalkExitCount = 1, + cyclingWalkExitHz = WALKING_STEP_FREQ, + ) + // Still windows then walking windows + gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 0L) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 30_000L) // first walking + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 60_000L) + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 90_000L) + assertNotNull(result) + assertEquals(ActivityState.WALKING, result!!.toState) + assertEquals(30_000L, result.effectiveTimestamp) // back-dated to first walking + } @Test - fun `still to walking - walking window from STILL transitions to WALKING`() { - // High variance + high step frequency = walking (not cycling, not still) - val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) - assertNotNull("Walking window from STILL should emit a WALKING transition", result) - assertEquals(ActivityState.STILL, result!!.fromState) - assertEquals(ActivityState.WALKING, result.toState) - assertEquals(ActivityState.WALKING, classifier.getCurrentState()) + fun `walking entry - windows outside density window are not counted`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = 0L, + cyclingGracePeriodMs = 0L, + walkingEntryCount = 3, + walkingExitCount = 0, + densityWindowMs = 120_000L, // 2 min + cyclingWalkExitCount = 1, + cyclingWalkExitHz = WALKING_STEP_FREQ, + ) + // 2 walking windows early + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 30_000L) + // Gap that pushes the first 2 outside the 2-min window + gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 150_000L) // 2:30 + // Only 1 walking window inside the density window now + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 180_000L) // 3:00 + assertNull("Only 1 walking window in density window (need 3)", result) + assertEquals(ActivityState.STILL, gc.getCurrentState()) } + // ─── Walking exit: density drop ───────────────────────────────────────── + @Test - fun `still to walking - no duplicate transition when already WALKING`() { - // First window triggers STILL → WALKING - val result1 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) - assertNotNull(result1) - assertEquals(ActivityState.WALKING, result1!!.toState) + fun `walking exit - density drops below exit threshold`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = Long.MAX_VALUE, // disable grace + cyclingGracePeriodMs = 0L, + walkingEntryCount = 1, + walkingExitCount = 2, + densityWindowMs = 120_000L, // 2 min + cyclingWalkExitCount = 1, + cyclingWalkExitHz = WALKING_STEP_FREQ, + ) + // Enter WALKING + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) + assertEquals(ActivityState.WALKING, gc.getCurrentState()) - // Subsequent walking windows must not emit another transition - val result2 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0 + WINDOW_INTERVAL_MS) - assertNull("No duplicate transition when already WALKING", result2) - val result3 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0 + 2 * WINDOW_INTERVAL_MS) - assertNull("No duplicate transition when already WALKING", result3) - assertEquals(ActivityState.WALKING, classifier.getCurrentState()) + // Stay walking with enough density + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 30_000L) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 60_000L) + assertEquals(ActivityState.WALKING, gc.getCurrentState()) + + // Walking windows at 0, 30_000, 60_000. Density window = 2 min. + // At t=180_000: cutoff = 60_000. Window at 60_000 is exactly at cutoff (included). + // Walking count = 1 < 2 → exits immediately. + val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 180_000L) // still at 3:00 + assertNotNull("Should exit WALKING when density drops", result) + assertEquals(ActivityState.STILL, result!!.toState) } - // ─── Consecutive windows requirement ────────────────────────────────────── + // ─── Consecutive windows requirement (cycling) ────────────────────────── @Test fun `single cycling window does not trigger transition`() { - // Only 1 cycling window — require 2 (DEFAULT_CONSECUTIVE_WINDOWS) val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) assertNull("Single cycling window must not trigger transition", result) assertEquals(ActivityState.STILL, classifier.getCurrentState()) @@ -176,98 +234,175 @@ class CyclingClassifierTest { @Test fun `two consecutive cycling windows trigger transition to CYCLING`() { - val result1 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - assertNull("First cycling window should not trigger", result1) - - val result2 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) - assertNotNull("Second cycling window should trigger transition", result2) - assertEquals(ActivityState.STILL, result2!!.fromState) - assertEquals(ActivityState.CYCLING, result2.toState) + classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) + val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) + assertNotNull("Second cycling window should trigger transition", result) + assertEquals(ActivityState.STILL, result!!.fromState) + assertEquals(ActivityState.CYCLING, result.toState) assertEquals(ActivityState.CYCLING, classifier.getCurrentState()) } @Test fun `non-cycling window between cycling windows resets consecutive count`() { - // First cycling window — state stays STILL (only 1 of 2 required) classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - - // Non-cycling interruption (walking step freq) — triggers STILL → WALKING + // Interruption — triggers STILL → WALKING (walkingEntryCount=1) classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T_30S) assertEquals(ActivityState.WALKING, classifier.getCurrentState()) - // Second cycling window (first after reset) — count=1, not enough for 2 required + // Only 1 cycling window since reset val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) - assertNull("Consecutive count reset by non-cycling window; only 1 window so far", result) - // State is WALKING (cycling check not satisfied yet, no walking transition since it's a cycling window) + assertNull("Consecutive count reset by non-cycling window", result) assertEquals(ActivityState.WALKING, classifier.getCurrentState()) + } + + @Test + fun `cycling entry - 6 consecutive windows required by default`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = 0L, + cyclingGracePeriodMs = 0L, + walkingEntryCount = 100, // high to prevent walking + ) + // 5 windows — not enough (default is 6) + for (i in 0 until 5) { + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, i * WINDOW_INTERVAL_MS) + assertNull("Window $i should not trigger (need 6)", result) + } + assertEquals(ActivityState.STILL, gc.getCurrentState()) - // Third cycling window — second consecutive cycling window, triggers WALKING → CYCLING - val result2 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_65S) - assertNotNull("Second consecutive cycling window triggers cycling", result2) - assertEquals(ActivityState.CYCLING, result2!!.toState) + // 6th triggers + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 5 * WINDOW_INTERVAL_MS) + assertNotNull("6th consecutive cycling window should trigger", result) + assertEquals(ActivityState.CYCLING, result!!.toState) } - // ─── Cycling to WALKING transition ──────────────────────────────────────── + // ─── Cycling to WALKING transition (sticky exit) ──────────────────────── @Test - fun `cycling to walking - steps resume after cycling`() { - // Transition to CYCLING - classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) - assertEquals(ActivityState.CYCLING, classifier.getCurrentState()) + fun `cycling to walking - requires high hz for exit`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = 0L, + cyclingGracePeriodMs = 0L, + consecutiveWindowsRequired = 2, + walkingEntryCount = 1, + walkingExitCount = 0, + cyclingWalkExitHz = 2.0, + cyclingWalkExitCount = 2, + ) + // Enter CYCLING + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) + assertEquals(ActivityState.CYCLING, gc.getCurrentState()) + + // Moderate walking hz (1.6) — below cyclingWalkExitHz (2.0) — stays CYCLING + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), MODERATE_WALK_FREQ, T_65S) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), MODERATE_WALK_FREQ, T_65S + WINDOW_INTERVAL_MS) + assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - // Now steps resume (WALKING_STEP_FREQ above threshold) → transition back to WALKING - val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T_65S) - assertNotNull("Resuming steps while in CYCLING should trigger WALKING transition", result) + // High walking hz (2.0) — meets threshold + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T_65S + 2 * WINDOW_INTERVAL_MS) + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T_65S + 3 * WINDOW_INTERVAL_MS) + assertNotNull("High hz should exit CYCLING → WALKING", result) assertEquals(ActivityState.CYCLING, result!!.fromState) assertEquals(ActivityState.WALKING, result.toState) - assertEquals(ActivityState.WALKING, classifier.getCurrentState()) + } + + @Test + fun `cycling to walking - interrupted high-hz streak resets counter`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = 0L, + cyclingGracePeriodMs = 0L, + consecutiveWindowsRequired = 2, + walkingEntryCount = 1, + walkingExitCount = 0, + cyclingWalkExitHz = 2.0, + cyclingWalkExitCount = 3, + ) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) + assertEquals(ActivityState.CYCLING, gc.getCurrentState()) + + // 2 high-hz windows + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 70_000L) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 100_000L) + // Cycling window interrupts + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 130_000L) + assertEquals(ActivityState.CYCLING, gc.getCurrentState()) + + // 2 more — only 2 since reset, need 3 + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 160_000L) + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 190_000L) + assertNull("Only 2 consecutive high-hz since reset (need 3)", result) + assertEquals(ActivityState.CYCLING, gc.getCurrentState()) + } + + @Test + fun `cycling to walking - effective timestamp is first high-hz window`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = 0L, + cyclingGracePeriodMs = 0L, + consecutiveWindowsRequired = 2, + walkingEntryCount = 1, + walkingExitCount = 0, + cyclingWalkExitHz = 2.0, + cyclingWalkExitCount = 2, + ) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) + + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 100_000L) + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 130_000L) + assertNotNull(result) + assertEquals(100_000L, result!!.effectiveTimestamp) } @Test fun `cycling to walking - ambiguous window stays CYCLING`() { - // Reach CYCLING classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) - // LOW_VARIANCE + CYCLING_STEP_FREQ is not cycling, not still, and not walking - // (step freq too low to be walking) — stays CYCLING + // LOW_VARIANCE + CYCLING_STEP_FREQ: not cycling, not still, not walking val result = classifier.evaluate(featuresWithVariance(LOW_VARIANCE), CYCLING_STEP_FREQ, T_65S) - assertNull("Ambiguous window (low variance, low step freq) should stay CYCLING", result) + assertNull("Ambiguous window should stay CYCLING", result) assertEquals(ActivityState.CYCLING, classifier.getCurrentState()) } - // ─── STILL detection ───────────────────────────────────────────────────── + // ─── STILL detection ────────────────────────────────────────────────────── @Test - fun `still detection - very low variance and near-zero steps from WALKING`() { - // Manually move to WALKING first by triggering cycling then walking - classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) - classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T_65S) - assertEquals(ActivityState.WALKING, classifier.getCurrentState()) + fun `still detection - from WALKING via density drop`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = 0L, + cyclingGracePeriodMs = 0L, + walkingEntryCount = 1, + walkingExitCount = 1, + densityWindowMs = 60_000L, + cyclingWalkExitCount = 1, + cyclingWalkExitHz = WALKING_STEP_FREQ, + ) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) + assertEquals(ActivityState.WALKING, gc.getCurrentState()) - // Now still-level variance with zero steps → should transition to STILL - val result = classifier.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, T_65S + WINDOW_INTERVAL_MS) - assertNotNull("Still variance + no steps from WALKING should transition to STILL", result) - assertEquals(ActivityState.WALKING, result!!.fromState) - assertEquals(ActivityState.STILL, result.toState) - assertEquals(ActivityState.STILL, classifier.getCurrentState()) + // Still windows push walking outside density window + val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 90_000L) // 1.5 min later + assertNotNull("Should transition to STILL", result) + assertEquals(ActivityState.STILL, result!!.toState) } @Test fun `still detection from CYCLING - very low variance transitions to STILL`() { - // Reach CYCLING classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) assertEquals(ActivityState.CYCLING, classifier.getCurrentState()) - // Still variance with zero steps val result = classifier.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, T_65S) - assertNotNull("Still variance + no steps from CYCLING should trigger transition", result) + assertNotNull("Still from CYCLING should trigger transition", result) assertEquals(ActivityState.CYCLING, result!!.fromState) assertEquals(ActivityState.STILL, result.toState) - assertEquals(ActivityState.STILL, classifier.getCurrentState()) } // ─── No duplicate transitions ───────────────────────────────────────────── @@ -275,15 +410,10 @@ class CyclingClassifierTest { @Test fun `repeated cycling windows after entering CYCLING do not emit further transitions`() { classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - val transition = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) - assertNotNull(transition) + classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) - // Additional cycling windows while already CYCLING → no new transition val extra1 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_65S) assertNull("No duplicate transition when already CYCLING", extra1) - val extra2 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_65S + WINDOW_INTERVAL_MS) - assertNull("No duplicate transition when already CYCLING", extra2) - assertEquals(ActivityState.CYCLING, classifier.getCurrentState()) } @@ -296,11 +426,10 @@ class CyclingClassifierTest { assertEquals(ActivityState.STILL, classifier.getCurrentState()) } - // ─── Reset ─────────────────────────────────────────────────────────────── + // ─── Reset ──────────────────────────────────────────────────────────────── @Test fun `reset clears state back to STILL`() { - // Advance to CYCLING classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) assertEquals(ActivityState.CYCLING, classifier.getCurrentState()) @@ -311,283 +440,233 @@ class CyclingClassifierTest { @Test fun `reset clears consecutive window counter`() { - // One cycling window before reset classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) classifier.reset() - // After reset, single cycling window should not trigger (counter was cleared) val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) assertNull("Counter should be zero after reset", result) assertEquals(ActivityState.STILL, classifier.getCurrentState()) } @Test - fun `reset allows fresh cycling detection`() { - // Full cycle then reset - classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) - classifier.reset() - - // Need 2 new consecutive cycling windows to re-detect - val r1 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_65S) - assertNull(r1) - val r2 = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_65S + WINDOW_INTERVAL_MS) - assertNotNull("Should re-detect cycling after reset + 2 windows (minCyclingDurationMs=0)", r2) - assertEquals(ActivityState.CYCLING, r2!!.toState) - } - - @Test - fun `reset clears cycling window start timestamp`() { - // Use a classifier with the real 60 s duration gate - val durationClassifier = CyclingClassifier( - minCyclingDurationMs = 60_000L, + fun `reset clears density buffer`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, walkingGracePeriodMs = 0L, cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, + walkingEntryCount = 3, + walkingExitCount = 0, + cyclingWalkExitCount = 1, + cyclingWalkExitHz = WALKING_STEP_FREQ, ) + // 2 walking windows + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 30_000L) + gc.reset() - // Accumulate one cycling window at T0 - durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - // Reset wipes the start timestamp - durationClassifier.reset() - - // After reset, even with timestamps spanning >60 s from T0, only 1 consecutive window - // has elapsed — should NOT trigger - val result = durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) - assertNull("After reset the cyclingWindowStartTimeMs must be cleared", result) - assertEquals(ActivityState.STILL, durationClassifier.getCurrentState()) + // After reset, only 1 walking window (the 2 before reset are gone) + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 60_000L) + assertNull("Density buffer should be cleared after reset (need 3, only 1)", result) + assertEquals(ActivityState.STILL, gc.getCurrentState()) } // ─── 60-second duration gate ────────────────────────────────────────────── - /** - * Spec requirement: "step counter reports zero/very few steps for >60 seconds - * → classify as cycling". - * - * Consecutive cycling windows spanning less than 60 seconds must NOT trigger - * the CYCLING transition even when the consecutive-windows count is satisfied. - */ @Test - fun `duration gate - cycling windows spanning less than 60 seconds do NOT trigger transition`() { - val durationClassifier = CyclingClassifier( + fun `duration gate - cycling windows spanning less than 60 seconds do NOT trigger`() { + val gc = CyclingClassifier( consecutiveWindowsRequired = 2, minCyclingDurationMs = 60_000L, walkingGracePeriodMs = 0L, cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, + walkingEntryCount = 100, ) - // Window 1 at T=0 - val r1 = durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - assertNull(r1) - // Window 2 at T=59 s — only 59 s elapsed, below the 60 s gate - val r2 = durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_59S) - assertNull("59-second span must not satisfy the 60-second duration gate", r2) - assertEquals(ActivityState.STILL, durationClassifier.getCurrentState()) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 59_000L) + assertNull("59-second span must not satisfy 60-second gate", result) + assertEquals(ActivityState.STILL, gc.getCurrentState()) } @Test - fun `duration gate - cycling windows spanning exactly 60 seconds DO trigger transition`() { - val durationClassifier = CyclingClassifier( + fun `duration gate - cycling windows spanning 60 seconds DO trigger`() { + val gc = CyclingClassifier( consecutiveWindowsRequired = 2, minCyclingDurationMs = 60_000L, walkingGracePeriodMs = 0L, cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, + walkingEntryCount = 100, ) - // Window 1 at T=0 - durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - // Window 2 at T=60 s — exactly at threshold (>= 60 000 ms elapsed) - val result = durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 60_000L) - assertNotNull("60-second span must satisfy the duration gate", result) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) + val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 60_000L) + assertNotNull("60-second span must satisfy duration gate", result) assertEquals(ActivityState.CYCLING, result!!.toState) } @Test - fun `duration gate - cycling windows spanning more than 60 seconds DO trigger transition`() { - val durationClassifier = CyclingClassifier( + fun `duration gate - many windows accumulate to satisfy 60-second requirement`() { + val gc = CyclingClassifier( consecutiveWindowsRequired = 2, minCyclingDurationMs = 60_000L, walkingGracePeriodMs = 0L, cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, + walkingEntryCount = 100, ) - durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - val result = durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S) - assertNotNull("61-second span must satisfy the duration gate", result) - assertEquals(ActivityState.CYCLING, result!!.toState) + // 2 windows at 30s apart = 30s span < 60s + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) + val r1 = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 30_000L) + assertNull("30s span must not trigger", r1) + + // 3rd at 60s = 60s span + val r2 = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 60_000L) + assertNotNull("60s span must trigger", r2) + assertEquals(ActivityState.CYCLING, r2!!.toState) } + // ─── Walking grace period ───────────────────────────────────────────────── + @Test - fun `duration gate - many short windows accumulate to satisfy 60-second requirement`() { - // 13 consecutive 5-second windows = 60 s of cycling data (windows 0..12) - val durationClassifier = CyclingClassifier( - consecutiveWindowsRequired = 2, - minCyclingDurationMs = 60_000L, - walkingGracePeriodMs = 0L, + fun `grace period - walking plus still window stays WALKING within 2 min`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = 120_000L, cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, + walkingEntryCount = 1, + walkingExitCount = 0, // disable density exit ) - var lastResult: TransitionResult? = null - // Windows at 0, 5, 10, …, 55 s: only 11 windows, spanning 55 s — not enough - for (i in 0 until 12) { - val t = i * WINDOW_INTERVAL_MS - lastResult = durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, t) - } - // 12 windows spans 0–55 s = 55 s elapsed: still below 60 s gate - assertNull("55-second span (12 windows at 5 s each) must not trigger", lastResult) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) + assertEquals(ActivityState.WALKING, gc.getCurrentState()) - // Window 13 at 60 s: exactly 60 s elapsed from the first window - lastResult = durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 60_000L) - assertNotNull("13th window at t=60 s must finally satisfy duration gate", lastResult) - assertEquals(ActivityState.CYCLING, lastResult!!.toState) + val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 5_000L) + assertNull("Should stay WALKING within grace period", result) + assertEquals(ActivityState.WALKING, gc.getCurrentState()) } @Test - fun `duration gate - streak interrupted resets start time`() { - val durationClassifier = CyclingClassifier( - consecutiveWindowsRequired = 2, - minCyclingDurationMs = 60_000L, - walkingGracePeriodMs = 0L, + fun `grace period - walking plus still for 2 min transitions to STILL`() { + val gc = CyclingClassifier( + minCyclingDurationMs = 0L, + walkingGracePeriodMs = 120_000L, cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, + walkingEntryCount = 1, + walkingExitCount = 0, // disable density exit ) - // Build up 55 s of cycling windows (12 windows at t=0,5,...,55 s) - // Duration gate not yet satisfied (55 s < 60 s required), so state stays STILL - for (i in 0 until 12) { - durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, i * WINDOW_INTERVAL_MS) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) + + val stillStart = 10_000L + var t = stillStart + while (t < stillStart + 120_000L) { + val r = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, t) + assertNull("Should stay WALKING during grace period at t=$t", r) + t += WINDOW_INTERVAL_MS } - assertEquals(ActivityState.STILL, durationClassifier.getCurrentState()) - - // Interruption at t=55 s with walking step freq — triggers STILL → WALKING - durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 55_000L) - assertEquals(ActivityState.WALKING, durationClassifier.getCurrentState()) - - // New streak starts at T=60 s; second window 5 s later is only 5 s from new start - durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 60_000L) - val result = durationClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 65_000L) - assertNull("After streak reset, new streak spanning 5 s must not satisfy 60-second gate", result) - // State is WALKING: consecutive cycling windows insufficient duration, no cycling transition - assertEquals(ActivityState.WALKING, durationClassifier.getCurrentState()) - } - @Test - fun `DEFAULT_MIN_CYCLING_DURATION_MS is 60_000`() { - assertEquals(60_000L, CyclingClassifier.DEFAULT_MIN_CYCLING_DURATION_MS) + val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, stillStart + 120_000L) + assertNotNull("Should transition to STILL after 2 min", result) + assertEquals(ActivityState.WALKING, result!!.fromState) + assertEquals(ActivityState.STILL, result.toState) + assertEquals(stillStart, result.effectiveTimestamp) } - // ─── Configurable thresholds ────────────────────────────────────────────── + // ─── Cycling grace period ───────────────────────────────────────────────── @Test - fun `custom variance threshold - lower threshold triggers cycling at lower variance`() { - val lowThresholdClassifier = CyclingClassifier( - varianceThreshold = 0.5, // lower than the 1.0 we'll use - stepFrequencyThreshold = 0.3, - consecutiveWindowsRequired = 2, + fun `grace period - cycling plus still window stays CYCLING within 3 min`() { + val gc = CyclingClassifier( minCyclingDurationMs = 0L, walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, + cyclingGracePeriodMs = 180_000L, + consecutiveWindowsRequired = 2, + walkingEntryCount = 100, ) - // Use variance of 1.0 which is above the 0.5 threshold but below default 2.0 - val customVariance = 1.0 + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 5_000L) + assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - lowThresholdClassifier.evaluate(featuresWithVariance(customVariance), CYCLING_STEP_FREQ, T0) - val result = lowThresholdClassifier.evaluate(featuresWithVariance(customVariance), CYCLING_STEP_FREQ, T_61S) - assertNotNull("Custom lower threshold should detect cycling at variance $customVariance", result) - assertEquals(ActivityState.CYCLING, result!!.toState) + val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 10_000L) + assertNull("Should stay CYCLING within grace period", result) + assertEquals(ActivityState.CYCLING, gc.getCurrentState()) } @Test - fun `custom step frequency threshold - higher threshold triggers cycling with more steps`() { - val highFreqThresholdClassifier = CyclingClassifier( - varianceThreshold = 2.0, - stepFrequencyThreshold = 3.0, // higher threshold: 3.0 Hz - consecutiveWindowsRequired = 2, + fun `grace period - cycling plus still for 3 min transitions to STILL`() { + val gc = CyclingClassifier( minCyclingDurationMs = 0L, walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, + cyclingGracePeriodMs = 180_000L, + consecutiveWindowsRequired = 2, + walkingEntryCount = 100, ) - // At 2.0 Hz steps (WALKING_STEP_FREQ) — above default 0.3 but below custom 3.0 threshold - // So with custom classifier, 2.0 Hz steps still count as "cycling-like" - highFreqThresholdClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) - val result = highFreqThresholdClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T_61S) - assertNotNull("At 2.0 Hz steps, custom 3.0 Hz threshold should still detect cycling", result) - assertEquals(ActivityState.CYCLING, result!!.toState) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 5_000L) + assertEquals(ActivityState.CYCLING, gc.getCurrentState()) + + val stillStart = 10_000L + var t = stillStart + while (t < stillStart + 180_000L) { + val r = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, t) + assertNull("Should stay CYCLING during grace period", r) + t += WINDOW_INTERVAL_MS + } + + val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, stillStart + 180_000L) + assertNotNull("Should transition to STILL after 3 min", result) + assertEquals(ActivityState.CYCLING, result!!.fromState) + assertEquals(ActivityState.STILL, result.toState) + assertEquals(stillStart, result.effectiveTimestamp) } + // ─── Grace period reset ─────────────────────────────────────────────────── + @Test - fun `custom consecutive windows required - 1 window sufficient`() { - val singleWindowClassifier = CyclingClassifier( - varianceThreshold = 2.0, - stepFrequencyThreshold = 0.3, - consecutiveWindowsRequired = 1, + fun `grace period reset - walking plus still plus walking resets counter`() { + val gc = CyclingClassifier( minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, + walkingGracePeriodMs = 120_000L, cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, + walkingEntryCount = 1, + walkingExitCount = 0, ) - val result = singleWindowClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - assertNotNull("Single window required: should detect on first window", result) - assertEquals(ActivityState.CYCLING, result!!.toState) + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) + + gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 10_000L) + gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 40_000L) + assertEquals(ActivityState.WALKING, gc.getCurrentState()) + + // Walking window breaks the still streak + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 50_000L) + assertEquals(ActivityState.WALKING, gc.getCurrentState()) + + // Even after original stillStart + 120s, no transition (counter was reset) + val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 130_000L) + assertNull("Grace period was reset; not enough still time from new start", result) + assertEquals(ActivityState.WALKING, gc.getCurrentState()) } + // ─── Companion object constants ─────────────────────────────────────────── + @Test - fun `custom consecutive windows required - 3 windows required`() { - val threeWindowClassifier = CyclingClassifier( - varianceThreshold = 2.0, - stepFrequencyThreshold = 0.3, - consecutiveWindowsRequired = 3, - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - ) - threeWindowClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - val r2 = threeWindowClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_30S) - assertNull("Two windows not enough when 3 required", r2) - val r3 = threeWindowClassifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_65S) - assertNotNull("Third window triggers cycling with 3 required", r3) - assertEquals(ActivityState.CYCLING, r3!!.toState) + fun `DEFAULT_VARIANCE_THRESHOLD is 2_0`() { + assertEquals(2.0, CyclingClassifier.DEFAULT_VARIANCE_THRESHOLD, DELTA) } - // ─── TransitionResult data class ───────────────────────────────────────── + @Test + fun `DEFAULT_STEP_FREQ_THRESHOLD is 0_3`() { + assertEquals(0.3, CyclingClassifier.DEFAULT_STEP_FREQ_THRESHOLD, DELTA) + } @Test - fun `TransitionResult has correct fromState and toState`() { - classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S)!! - assertEquals(ActivityState.STILL, result.fromState) - assertEquals(ActivityState.CYCLING, result.toState) - } - - @Test - fun `TransitionResult is data class with equality semantics`() { - val r1 = TransitionResult(ActivityState.STILL, ActivityState.CYCLING, 0L) - val r2 = TransitionResult(ActivityState.STILL, ActivityState.CYCLING, 0L) - assertEquals(r1, r2) + fun `DEFAULT_CONSECUTIVE_WINDOWS is 6`() { + assertEquals(6, CyclingClassifier.DEFAULT_CONSECUTIVE_WINDOWS) } - // ─── Companion object constants ─────────────────────────────────────────── - @Test - fun `DEFAULT_VARIANCE_THRESHOLD is 2_0`() { - assertEquals(2.0, CyclingClassifier.DEFAULT_VARIANCE_THRESHOLD, DELTA) - } - - @Test - fun `DEFAULT_STEP_FREQ_THRESHOLD is 0_3`() { - assertEquals(0.3, CyclingClassifier.DEFAULT_STEP_FREQ_THRESHOLD, DELTA) - } - - @Test - fun `DEFAULT_CONSECUTIVE_WINDOWS is 2`() { - assertEquals(2, CyclingClassifier.DEFAULT_CONSECUTIVE_WINDOWS) + fun `DEFAULT_STILL_VARIANCE_THRESHOLD is 0_5`() { + assertEquals(0.5, CyclingClassifier.DEFAULT_STILL_VARIANCE_THRESHOLD, DELTA) } @Test - fun `DEFAULT_STILL_VARIANCE_THRESHOLD is 0_5`() { - assertEquals(0.5, CyclingClassifier.DEFAULT_STILL_VARIANCE_THRESHOLD, DELTA) + fun `DEFAULT_MIN_CYCLING_DURATION_MS is 60_000`() { + assertEquals(60_000L, CyclingClassifier.DEFAULT_MIN_CYCLING_DURATION_MS) } @Test @@ -601,898 +680,84 @@ class CyclingClassifierTest { } @Test - fun `DEFAULT_CONSECUTIVE_WALKING_WINDOWS is 36`() { - assertEquals(36, CyclingClassifier.DEFAULT_CONSECUTIVE_WALKING_WINDOWS) - } - - @Test - fun `DEFAULT_CADENCE_CV_THRESHOLD is 0_35`() { - assertEquals(0.35, CyclingClassifier.DEFAULT_CADENCE_CV_THRESHOLD, DELTA) - } - - // ─── Walking grace period ───────────────────────────────────────────────── - - @Test - fun `grace period - walking plus still window stays WALKING within 2 min`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 120_000L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - ) - // Enter WALKING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - - // Still window at T=5 s — within 2 min grace period - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 5_000L) - assertNull("Should stay WALKING within grace period", result) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - } - - @Test - fun `grace period - walking plus still for 2 min transitions to STILL`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 120_000L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - ) - // Enter WALKING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - - // Still windows spanning 2 minutes - val stillStart = 10_000L - var t = stillStart - while (t < stillStart + 120_000L) { - val r = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, t) - assertNull("Should stay WALKING during grace period at t=$t", r) - t += WINDOW_INTERVAL_MS - } - - // Window at stillStart + 120_000 should trigger transition - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, stillStart + 120_000L) - assertNotNull("Should transition to STILL after 2 min grace period", result) - assertEquals(ActivityState.WALKING, result!!.fromState) - assertEquals(ActivityState.STILL, result.toState) - assertEquals(stillStart, result.effectiveTimestamp) - } - - // ─── Cycling grace period ───────────────────────────────────────────────── - - @Test - fun `grace period - cycling plus still window stays CYCLING within 3 min`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 180_000L, - consecutiveWalkingWindowsRequired = 1, - ) - // Enter CYCLING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 5_000L) - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - - // Still window at T=10 s — within 3 min grace period - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 10_000L) - assertNull("Should stay CYCLING within grace period", result) - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - } - - @Test - fun `grace period - cycling plus still for 3 min transitions to STILL`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 180_000L, - consecutiveWalkingWindowsRequired = 1, - ) - // Enter CYCLING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 5_000L) - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - - // Still windows spanning 3 minutes - val stillStart = 10_000L - var t = stillStart - while (t < stillStart + 180_000L) { - val r = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, t) - assertNull("Should stay CYCLING during grace period at t=$t", r) - t += WINDOW_INTERVAL_MS - } - - // Window at stillStart + 180_000 should trigger transition - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, stillStart + 180_000L) - assertNotNull("Should transition to STILL after 3 min grace period", result) - assertEquals(ActivityState.CYCLING, result!!.fromState) - assertEquals(ActivityState.STILL, result.toState) - assertEquals(stillStart, result.effectiveTimestamp) - } - - // ─── Grace period reset ─────────────────────────────────────────────────── - - @Test - fun `grace period reset - walking plus still plus walking resets counter`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 120_000L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - ) - // Enter WALKING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - - // Several still windows (within grace period) - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 10_000L) - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 15_000L) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - - // Walking window breaks the still streak, resets grace period - val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 20_000L) - assertNull("No transition — still in WALKING", result) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - - // New still windows — grace period starts fresh from 25 s - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 25_000L) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - - // Even after original stillStart + 120 s (10_000 + 120_000 = 130_000), no transition - // because the still counter was reset at 20_000. New grace started at 25_000. - val shouldStayWalking = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 130_000L) - assertNull("Grace period was reset; 130s from new start (25s) is only 105s", shouldStayWalking) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) + fun `DEFAULT_WALKING_HZ_THRESHOLD is 1_5`() { + assertEquals(1.5, CyclingClassifier.DEFAULT_WALKING_HZ_THRESHOLD, DELTA) } - // ─── Walking entry threshold ────────────────────────────────────────────── - @Test - fun `walking entry - 1 to 3 walking windows stays STILL`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 4, - ) - for (i in 0 until 3) { - val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, i * WINDOW_INTERVAL_MS) - assertNull("Walking window $i of 3 should not trigger transition (need 4)", result) - assertEquals(ActivityState.STILL, gc.getCurrentState()) - } + fun `DEFAULT_WALKING_ENTRY_COUNT is 5`() { + assertEquals(5, CyclingClassifier.DEFAULT_WALKING_ENTRY_COUNT) } @Test - fun `walking entry - 4 walking windows transitions to WALKING`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 4, - ) - for (i in 0 until 3) { - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, i * WINDOW_INTERVAL_MS) - } - val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 3 * WINDOW_INTERVAL_MS) - assertNotNull("4th walking window should trigger STILL → WALKING", result) - assertEquals(ActivityState.STILL, result!!.fromState) - assertEquals(ActivityState.WALKING, result.toState) + fun `DEFAULT_WALKING_EXIT_COUNT is 2`() { + assertEquals(2, CyclingClassifier.DEFAULT_WALKING_EXIT_COUNT) } @Test - fun `walking entry - interrupted by still window resets counter`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 4, - ) - // 3 walking windows - for (i in 0 until 3) { - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, i * WINDOW_INTERVAL_MS) - } - // Still window interrupts - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 3 * WINDOW_INTERVAL_MS) - assertEquals(ActivityState.STILL, gc.getCurrentState()) - - // 3 more walking windows — only 3 since reset, not 4 - for (i in 0 until 3) { - val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, (4 + i) * WINDOW_INTERVAL_MS) - assertNull("Walking window after reset ($i of 3) should not trigger", result) - } - assertEquals(ActivityState.STILL, gc.getCurrentState()) - - // 4th window after reset triggers transition - val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 7 * WINDOW_INTERVAL_MS) - assertNotNull("4th consecutive walking window after reset triggers transition", result) - assertEquals(ActivityState.WALKING, result!!.toState) + fun `DEFAULT_DENSITY_WINDOW_MS is 300_000`() { + assertEquals(300_000L, CyclingClassifier.DEFAULT_DENSITY_WINDOW_MS) } - // ─── 3-minute walking entry threshold ──────────────────────────────────── - @Test - fun `walking entry - 35 walking windows stays STILL`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 36, - ) - for (i in 0 until 35) { - val result = gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - i * WINDOW_INTERVAL_MS, - ) - assertNull("Walking window $i of 35 should not trigger transition (need 36)", result) - assertEquals(ActivityState.STILL, gc.getCurrentState()) - } + fun `DEFAULT_CYCLING_WALK_EXIT_HZ is 2_0`() { + assertEquals(2.0, CyclingClassifier.DEFAULT_CYCLING_WALK_EXIT_HZ, DELTA) } @Test - fun `walking entry - 36 walking windows transitions to WALKING`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 36, - ) - for (i in 0 until 35) { - gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - i * WINDOW_INTERVAL_MS, - ) - } - val result = gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - 35 * WINDOW_INTERVAL_MS, - ) - assertNotNull("36th walking window should trigger STILL → WALKING", result) - assertEquals(ActivityState.STILL, result!!.fromState) - assertEquals(ActivityState.WALKING, result.toState) - } - - @Test - fun `walking entry - effective timestamp is back-dated to first walking window`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 4, - ) - // 4 walking windows starting at 10_000 - for (i in 0 until 3) { - gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - 10_000L + i * WINDOW_INTERVAL_MS, - ) - } - val result = gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - 10_000L + 3 * WINDOW_INTERVAL_MS, - ) - assertNotNull(result) - assertEquals(ActivityState.WALKING, result!!.toState) - assertEquals(10_000L, result.effectiveTimestamp) + fun `DEFAULT_CYCLING_WALK_EXIT_COUNT is 8`() { + assertEquals(8, CyclingClassifier.DEFAULT_CYCLING_WALK_EXIT_COUNT) } - // ─── Cadence CV check ───────────────────────────────────────────────────── - - @Test - fun `walking entry - high CV step frequency does not trigger WALKING`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 4, - cadenceCvThreshold = 0.35, - ) - // Alternate between 0.5 Hz and 2.0 Hz — high variability - // mean = (0.5 + 2.0 + 0.5 + 2.0) / 4 = 1.25 - // variance = ((0.5^2 + 2.0^2 + 0.5^2 + 2.0^2)/4) - 1.25^2 - // = (0.25 + 4.0 + 0.25 + 4.0)/4 - 1.5625 = 2.125 - 1.5625 = 0.5625 - // CV = sqrt(0.5625) / 1.25 = 0.75 / 1.25 = 0.6 > 0.35 - val frequencies = listOf(0.5, 2.0, 0.5, 2.0) - for (i in frequencies.indices) { - val result = gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), frequencies[i], - i * WINDOW_INTERVAL_MS, - ) - assertNull("High CV should prevent WALKING transition at window $i", result) - } - assertEquals(ActivityState.STILL, gc.getCurrentState()) - } - - @Test - fun `walking entry - low CV step frequency triggers WALKING`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 4, - cadenceCvThreshold = 0.35, - ) - // Steady cadence: 1.8, 1.9, 1.8, 1.9 Hz - // mean = 1.85, variance = very small, CV << 0.35 - val frequencies = listOf(1.8, 1.9, 1.8, 1.9) - var lastResult: TransitionResult? = null - for (i in frequencies.indices) { - lastResult = gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), frequencies[i], - i * WINDOW_INTERVAL_MS, - ) - } - assertNotNull("Low CV should allow WALKING transition", lastResult) - assertEquals(ActivityState.WALKING, lastResult!!.toState) - } - - @Test - fun `walking entry - identical frequencies have zero CV and pass`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 4, - cadenceCvThreshold = 0.35, - ) - // All windows at exactly 2.0 Hz → CV = 0.0 - for (i in 0 until 3) { - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 2.0, i * WINDOW_INTERVAL_MS) - } - val result = gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), 2.0, - 3 * WINDOW_INTERVAL_MS, - ) - assertNotNull("Identical frequencies (CV=0) should trigger WALKING", result) - assertEquals(ActivityState.WALKING, result!!.toState) - } - - // ─── Effective timestamp ────────────────────────────────────────────────── - - @Test - fun `effective timestamp - grace period STILL transition uses first still window time`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 10_000L, // short grace for test - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - ) - // Enter WALKING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, T0) - - // First still at 5_000 - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 5_000L) - // Grace period expires at 5_000 + 10_000 = 15_000 - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 15_000L) - assertNotNull(result) - assertEquals(ActivityState.STILL, result!!.toState) - assertEquals(5_000L, result.effectiveTimestamp) - } - - @Test - fun `effective timestamp - non-grace transitions use currentTimeMs`() { - // STILL → WALKING: effectiveTimestamp = currentTimeMs - val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 42_000L) - assertNotNull(result) - assertEquals(42_000L, result!!.effectiveTimestamp) - - // WALKING → STILL (grace = 0): effectiveTimestamp = stillWindowStartTimeMs = currentTimeMs - val result2 = classifier.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 50_000L) - assertNotNull(result2) - assertEquals(50_000L, result2!!.effectiveTimestamp) - } - - // ─── Cycling exit walking threshold ──────────────────────────────────────── - - @Test - fun `cycling exit - 1 to 3 walking windows stays CYCLING`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - consecutiveWalkingWindowsForCyclingExit = 4, - ) - // Enter CYCLING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 5_000L) - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - - // 1 to 3 walking windows — not enough to exit - for (i in 0 until 3) { - val result = gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - 10_000L + i * WINDOW_INTERVAL_MS, - ) - assertNull("Walking window $i of 3 should not exit CYCLING (need 4)", result) - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - } - } - - @Test - fun `cycling exit - 4 walking windows transitions to WALKING`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - consecutiveWalkingWindowsForCyclingExit = 4, - ) - // Enter CYCLING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 5_000L) - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - - // 3 walking windows — not enough - for (i in 0 until 3) { - gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - 10_000L + i * WINDOW_INTERVAL_MS, - ) - } - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - - // 4th walking window triggers exit - val result = gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - 10_000L + 3 * WINDOW_INTERVAL_MS, - ) - assertNotNull("4th walking window should exit CYCLING → WALKING", result) - assertEquals(ActivityState.CYCLING, result!!.fromState) - assertEquals(ActivityState.WALKING, result.toState) - } - - @Test - fun `cycling exit - walking streak interrupted resets counter`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 0L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - consecutiveWalkingWindowsForCyclingExit = 4, - ) - // Enter CYCLING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 5_000L) - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - - // 3 walking windows - for (i in 0 until 3) { - gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - 10_000L + i * WINDOW_INTERVAL_MS, - ) - } - // Cycling window interrupts - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 25_000L) - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - - // 3 more walking windows (only 3 since reset, not 4) - for (i in 0 until 3) { - val result = gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - 30_000L + i * WINDOW_INTERVAL_MS, - ) - assertNull("Walking window $i after reset should not exit CYCLING", result) - } - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - - // 4th walking window after reset triggers exit - val result = gc.evaluate( - featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, - 30_000L + 3 * WINDOW_INTERVAL_MS, - ) - assertNotNull("4th consecutive walking window after reset triggers exit", result) - assertEquals(ActivityState.WALKING, result!!.toState) - } + // ─── Thread safety ──────────────────────────────────────────────────────── @Test - fun `cycling exit - effective timestamp is back-dated to first walking window`() { + fun `concurrent evaluate calls do not corrupt state`() { val gc = CyclingClassifier( minCyclingDurationMs = 0L, walkingGracePeriodMs = 0L, cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - consecutiveWalkingWindowsForCyclingExit = 3, - ) - // Enter CYCLING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 5_000L) - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - - // 3 walking windows starting at 10_000 - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 10_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 15_000L) - val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 20_000L) - - assertNotNull(result) - assertEquals(ActivityState.WALKING, result!!.toState) - assertEquals(10_000L, result.effectiveTimestamp) - } - - @Test - fun `DEFAULT_CONSECUTIVE_WALKING_WINDOWS_FOR_CYCLING_EXIT is 4`() { - assertEquals(4, CyclingClassifier.DEFAULT_CONSECUTIVE_WALKING_WINDOWS_FOR_CYCLING_EXIT) - } - - // ─── Cadence breakdown constants ────────────────────────────────────────── - - @Test - fun `DEFAULT_CADENCE_BREAKDOWN_WINDOW_SIZE is 36`() { - assertEquals(36, CyclingClassifier.DEFAULT_CADENCE_BREAKDOWN_WINDOW_SIZE) - } - - @Test - fun `DEFAULT_CADENCE_BREAKDOWN_DENSITY_THRESHOLD is 0_4`() { - assertEquals(0.4, CyclingClassifier.DEFAULT_CADENCE_BREAKDOWN_DENSITY_THRESHOLD, DELTA) - } - - @Test - fun `DEFAULT_CADENCE_BREAKDOWN_CV_THRESHOLD is 0_7`() { - assertEquals(0.7, CyclingClassifier.DEFAULT_CADENCE_BREAKDOWN_CV_THRESHOLD, DELTA) - } - - @Test - fun `DEFAULT_CADENCE_BREAKDOWN_MEAN_FLOOR is 1_0`() { - assertEquals(1.0, CyclingClassifier.DEFAULT_CADENCE_BREAKDOWN_MEAN_FLOOR, DELTA) - } - - // ─── Cadence breakdown: density exit ────────────────────────────────────── - - @Test - fun `cadence breakdown - chores pattern exits WALKING via low density`() { - // Small buffer (6 windows) for test tractability. Density threshold 0.4 → need < 2.4 walking windows - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = Long.MAX_VALUE, // disable grace period - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - cadenceBreakdownWindowSize = 6, - cadenceBreakdownDensityThreshold = 0.4, - ) - // Enter WALKING (this eval runs in STILL branch — no buffer push) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - - // Chores pattern: 2 walking + 4 still in WALKING state = 6 buffer entries - // density = 2/6 = 0.33 < 0.4 - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 5_000L) // walking - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 10_000L) // walking - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 15_000L) // still - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 20_000L) // still - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 25_000L) // still - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 30_000L) // still → buffer full - - assertNotNull("Low density chores pattern should exit WALKING", result) - assertEquals(ActivityState.WALKING, result!!.fromState) - assertEquals(ActivityState.STILL, result.toState) - assertEquals(ActivityState.STILL, gc.getCurrentState()) - } - - @Test - fun `cadence breakdown - effective timestamp is oldest buffer entry`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = Long.MAX_VALUE, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - cadenceBreakdownWindowSize = 4, - cadenceBreakdownDensityThreshold = 0.4, - ) - // Enter WALKING at t=100 (STILL branch — no buffer push) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 100L) - - // 4 evals in WALKING to fill buffer: 1 walking + 3 still - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 200L) // buffer[0], ts=200 - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 300L) - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 400L) - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 500L) - - // density = 1/4 = 0.25 < 0.4 → exits. Oldest buffer entry is t=200 - assertNotNull(result) - assertEquals(ActivityState.STILL, result!!.toState) - assertEquals(200L, result.effectiveTimestamp) - } - - @Test - fun `cadence breakdown - crosswalk stays WALKING (high density)`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = Long.MAX_VALUE, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - cadenceBreakdownWindowSize = 6, - cadenceBreakdownDensityThreshold = 0.4, - ) - // Enter WALKING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) - - // 30s crosswalk: 5 walking + 1 still = density 5/6 = 83% > 40% - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 5_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 10_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 15_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 20_000L) - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 25_000L) - - assertNull("Crosswalk with high density should stay WALKING", result) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - } - - @Test - fun `cadence breakdown - slower steady walking stays WALKING`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = Long.MAX_VALUE, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - cadenceBreakdownWindowSize = 6, - cadenceBreakdownDensityThreshold = 0.4, - cadenceBreakdownCvThreshold = 0.7, - cadenceBreakdownMeanFloor = 1.0, - ) - // Enter WALKING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 1.2, 0L) - - // Steady 1.2 Hz walking: density = 100%, CV ≈ 0, mean = 1.2 > 1.0 - for (i in 1 until 6) { - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 1.2, i * WINDOW_INTERVAL_MS) - } - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - } - - // ─── Cadence breakdown: CV + mean exit (puttering) ──────────────────────── - - @Test - fun `cadence breakdown - puttering exits WALKING via high CV and low mean`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = Long.MAX_VALUE, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - cadenceBreakdownWindowSize = 6, - cadenceBreakdownDensityThreshold = 0.4, - cadenceBreakdownCvThreshold = 0.7, - cadenceBreakdownMeanFloor = 1.0, - ) - // Enter WALKING (STILL branch — no buffer push) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) - - // 6 evals in WALKING to fill buffer. Mix of slow walking + one spike: - // Buffer: [0.3, 0.3, 0.3, 2.0, 0.0, 0.0] - // Walking windows (>= 0.3): 4 → density = 4/6 = 67% > 40% (passes density) - // Among walking windows: mean = (0.3+0.3+0.3+2.0)/4 = 0.725 < 1.0 ✓ - // sum_sq = (0.09+0.09+0.09+4.0)/4 = 4.27/4 = 1.0675 - // var = 1.0675 - 0.725^2 = 0.5419 - // CV = sqrt(0.5419)/0.725 = 0.736/0.725 = 1.015 > 0.7 ✓ - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 0.3, 5_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 0.3, 10_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 0.3, 15_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 2.0, 20_000L) - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 25_000L) - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 30_000L) - - assertNotNull("Puttering (high CV, low mean) should exit WALKING", result) - assertEquals(ActivityState.WALKING, result!!.fromState) - assertEquals(ActivityState.STILL, result.toState) - } - - @Test - fun `cadence breakdown - high CV but high mean stays WALKING`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = Long.MAX_VALUE, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - cadenceBreakdownWindowSize = 6, - cadenceBreakdownDensityThreshold = 0.4, - cadenceBreakdownCvThreshold = 0.7, - cadenceBreakdownMeanFloor = 1.0, - ) - // Enter WALKING at 2.0 Hz - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) - - // Alternating 1.0 and 3.0 Hz — high CV but high mean - // Buffer: [2.0, 1.0, 3.0, 1.0, 3.0, 1.0] - // All >= 0.3 → density 100%. mean = (2+1+3+1+3+1)/6 = 11/6 = 1.833 > 1.0 - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 1.0, 5_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 3.0, 10_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 1.0, 15_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 3.0, 20_000L) - val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), 1.0, 25_000L) - - assertNull("High CV but mean > 1.0 should stay WALKING", result) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - } - - // ─── Cadence breakdown: buffer management ───────────────────────────────── - - @Test - fun `cadence breakdown - buffer not checked before full`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = Long.MAX_VALUE, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - cadenceBreakdownWindowSize = 6, - cadenceBreakdownDensityThreshold = 0.4, - ) - // Enter WALKING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) - - // 4 still windows — only 5 entries in buffer (not full at 6) - // If buffer were checked, density = 1/5 = 20% < 40% would trigger exit - for (i in 1 until 5) { - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, i * WINDOW_INTERVAL_MS) - assertNull("Buffer not full — should not check cadence breakdown at window $i", result) - } - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - } - - @Test - fun `cadence breakdown - buffer resets on WALKING entry from STILL`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = Long.MAX_VALUE, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - cadenceBreakdownWindowSize = 4, - cadenceBreakdownDensityThreshold = 0.4, - ) - // Enter WALKING, fill buffer with 4 windows (mostly still → would trigger) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 5_000L) - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 10_000L) - // 3 entries so far, buffer not yet full - - // Force back to STILL via cycling then still - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 15_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, 20_000L) - assertEquals(ActivityState.CYCLING, gc.getCurrentState()) - - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 25_000L) - assertEquals(ActivityState.STILL, gc.getCurrentState()) - - // Re-enter WALKING (buffer should be reset) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 30_000L) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - - // Feed 3 all-walking windows — buffer has 4 entries (full), all walking - // density = 4/4 = 100% → should NOT trigger breakdown - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 35_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 40_000L) - val result = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 45_000L) - assertNull("Buffer reset on WALKING entry — all walking windows should stay WALKING", result) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - } - - @Test - fun `cadence breakdown - grace period still fires before cadence check`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = 10_000L, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - cadenceBreakdownWindowSize = 4, - cadenceBreakdownDensityThreshold = 0.4, - ) - // Enter WALKING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) - - // 3 still windows (buffer at 4 entries = full), still duration = 15s > 10s grace - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 5_000L) - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 10_000L) - val result = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 15_000L) - - // Grace period fires (stillStart=5000, current=15000, duration=10000 >= 10000) - // Effective timestamp should be stillWindowStartTimeMs (5000), not buffer oldest - assertNotNull("Grace period should fire", result) - assertEquals(ActivityState.STILL, result!!.toState) - assertEquals(5_000L, result.effectiveTimestamp) - } - - @Test - fun `cadence breakdown - rolling window continues sliding after full`() { - val gc = CyclingClassifier( - minCyclingDurationMs = 0L, - walkingGracePeriodMs = Long.MAX_VALUE, - cyclingGracePeriodMs = 0L, - consecutiveWalkingWindowsRequired = 1, - cadenceBreakdownWindowSize = 4, - cadenceBreakdownDensityThreshold = 0.4, - ) - // Enter WALKING - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 0L) - - // Fill buffer with 3 more walking windows (4 total, all walking, density 100%) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 5_000L) - gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 10_000L) - val r1 = gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, 15_000L) - assertNull("All walking → stays WALKING", r1) - - // Now push 3 still windows — they slide in, pushing out walking windows - // After 1 still: [walking, walking, walking, still] → density 3/4 = 75% > 40% - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 20_000L) - // After 2 still: [walking, walking, still, still] → density 2/4 = 50% > 40% - gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 25_000L) - assertEquals(ActivityState.WALKING, gc.getCurrentState()) - // After 3 still: [walking, still, still, still] → density 1/4 = 25% < 40% → exits! - val r2 = gc.evaluate(featuresWithVariance(STILL_VARIANCE), ZERO_STEP_FREQ, 30_000L) - assertNotNull("Density dropped below threshold after sliding", r2) - assertEquals(ActivityState.STILL, r2!!.toState) - } - - // ─── Thread safety ──────────────────────────────────────────────────────── - - @Test - fun `concurrent evaluate calls do not throw`() { - val features = featuresWithVariance(HIGH_VARIANCE) - val exceptions = mutableListOf() - - val writer1 = thread(start = true) { - try { - repeat(1_000) { i -> - classifier.evaluate(features, CYCLING_STEP_FREQ, T0 + i * WINDOW_INTERVAL_MS) - } - } catch (e: Throwable) { - synchronized(exceptions) { exceptions.add(e) } - } - } - - val writer2 = thread(start = true) { - try { - repeat(1_000) { i -> - classifier.evaluate(featuresWithVariance(LOW_VARIANCE), WALKING_STEP_FREQ, T0 + i * WINDOW_INTERVAL_MS) + consecutiveWindowsRequired = 2, + walkingEntryCount = 1, + walkingExitCount = 0, + cyclingWalkExitCount = 1, + cyclingWalkExitHz = WALKING_STEP_FREQ, + ) + + val threads = (0 until 8).map { threadIdx -> + thread(start = false) { + for (i in 0 until 100) { + val t = (threadIdx * 100 + i) * WINDOW_INTERVAL_MS + if (i % 2 == 0) { + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, t) + } else { + gc.evaluate(featuresWithVariance(HIGH_VARIANCE), WALKING_STEP_FREQ, t) + } } - } catch (e: Throwable) { - synchronized(exceptions) { exceptions.add(e) } } } + threads.forEach { it.start() } + threads.forEach { it.join() } - val resetter = thread(start = true) { - try { - repeat(100) { - classifier.reset() - Thread.sleep(1) - } - } catch (e: Throwable) { - synchronized(exceptions) { exceptions.add(e) } - } - } + val state = gc.getCurrentState() + // Verify state is one of the valid enum values (no corruption) + assert(state == ActivityState.STILL || state == ActivityState.WALKING || state == ActivityState.CYCLING) + } - writer1.join(10_000) - writer2.join(10_000) - resetter.join(10_000) + // ─── TransitionResult data class ────────────────────────────────────────── - assert(exceptions.isEmpty()) { - "Expected no exceptions during concurrent access but got: $exceptions" - } + @Test + fun `TransitionResult has correct fromState and toState`() { + classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T0) + val result = classifier.evaluate(featuresWithVariance(HIGH_VARIANCE), CYCLING_STEP_FREQ, T_61S)!! + assertEquals(ActivityState.STILL, result.fromState) + assertEquals(ActivityState.CYCLING, result.toState) } @Test - fun `concurrent getCurrentState and evaluate do not throw`() { - val features = featuresWithVariance(HIGH_VARIANCE) - val exceptions = mutableListOf() - - val writer = thread(start = true) { - try { - repeat(2_000) { i -> classifier.evaluate(features, CYCLING_STEP_FREQ, T0 + i * WINDOW_INTERVAL_MS) } - } catch (e: Throwable) { - synchronized(exceptions) { exceptions.add(e) } - } - } - - val reader = thread(start = true) { - try { - repeat(2_000) { classifier.getCurrentState() } - } catch (e: Throwable) { - synchronized(exceptions) { exceptions.add(e) } - } - } - - writer.join(10_000) - reader.join(10_000) - - assert(exceptions.isEmpty()) { - "Expected no exceptions during concurrent access but got: $exceptions" - } + fun `TransitionResult is data class with equality semantics`() { + val r1 = TransitionResult(ActivityState.STILL, ActivityState.CYCLING, 0L) + val r2 = TransitionResult(ActivityState.STILL, ActivityState.CYCLING, 0L) + assertEquals(r1, r2) } }