Stop retaining detected manoeuvres; the CAM rate bump is their only consumer

The detector runs to raise the CAM transmit rate through a manoeuvre. Nothing
else read its output once the UI was removed, so keeping the rows was storing
data with no reader on the chance it would one day be analysed.

Drops the detected_events table in schema v5, deletes DetectedEventEntity and
the DAO and repository methods behind it, removes the insertEvent call from the
recording service, and removes the per-event rows and their five columns
(event_type, confidence, peak_accel, peak_gyro, duration_ms) from the trip CSV
along with the events parameter threaded through buildTripCsv and shareTripCsv.
A detected manoeuvre now lives for the length of one onDetectedEvent call.

MIGRATION_1_2 still creates the table: a v1 install upgrades 1-2-3-4-5 and so
creates it before v5 drops it. Removing it from the earlier migration would
break that path for anyone who has not upgraded yet.

trips.eventCount is kept. Dropping a SQLite column means recreating the table
and copying every recorded ride across, which is real risk for one unused
integer; the service still writes an accurate count and the CSV header still
reports it. It is the only thing left about detected manoeuvres.

This closes off the route to the false-positive measurement that 11.3 flags as
missing, so 11.3 now says that outright rather than pointing at an export that
no longer carries the data. Docs 11.3/11.4, the user guide, the README and the
traceability matrix updated to match. 55 tests, 0 failures.
This commit is contained in:
Ashin Walpola
2026-09-08 16:29:02 +02:00
parent 1ad123a6f8
commit 5ec3619cbe
11 changed files with 42 additions and 130 deletions
@@ -3,7 +3,6 @@ package com.hawhamburg.micr0bu.data
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
import kotlinx.coroutines.Dispatchers
@@ -23,12 +22,15 @@ fun tripFileName(trip: RecordedTripEntity): String =
/**
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
* events the detector fired, the GPS track, and every V2X message seen during the ride — all in
* one file, ordered by time.
* GPS track, and every V2X message seen during the ride — all in one file, ordered by time.
*
* Detected manoeuvres are deliberately absent. The detector exists to raise the CAM transmit
* rate (see EventDetector's KDoc); its output is not retained, so there is nothing to export
* beyond the per-trip count in the header.
*
* **Why one file rather than a zip of tables.** The point of the export is correlation: what was
* the bike doing when that CAM arrived, what did the detector make of it. Splitting those into
* separate files pushes the join onto whoever opens it. A leading `type` column keeps the rows
* the bike doing when that CAM arrived. Splitting those into separate files pushes the join
* onto whoever opens it. A leading `type` column keeps the rows
* distinguishable, which is the same shape the existing session CSV already uses, so the two
* remain readable by the same tooling.
*
@@ -44,7 +46,6 @@ fun tripFileName(trip: RecordedTripEntity): String =
suspend fun buildTripCsv(
context: Context,
trip: RecordedTripEntity,
events: List<DetectedEventEntity>,
v2xMessages: List<V2xMessageEntity>,
): String = withContext(Dispatchers.IO) {
buildString {
@@ -59,7 +60,6 @@ suspend fun buildTripCsv(
appendLine()
appendLine(
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
"event_type,confidence,peak_accel,peak_gyro,duration_ms," +
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
)
@@ -68,16 +68,6 @@ suspend fun buildTripCsv(
appendLine(
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
"${point.lat},${point.lon},,," +
",,,,," +
",,,"
)
}
for (e in events) {
appendLine(
"event,${e.timestamp},${isoUtc.format(Date(e.timestamp))}," +
"${e.latitude},${e.longitude},${e.speedMps},," +
"${e.type},${e.confidence},${e.peakAccelMagnitude},${e.peakGyroMagnitude},${e.durationMs}," +
",,,,"
)
}
@@ -86,14 +76,13 @@ suspend fun buildTripCsv(
appendLine(
"v2x,${m.timestamp},${isoUtc.format(Date(m.timestamp))}," +
"${m.latitude},${m.longitude},${m.speedMps},${m.headingDeg}," +
",,,,," +
"${m.stationId},${m.stationType},${m.isOwn},${m.yawRateDps ?: ""},${m.rssiDbm ?: ""}"
)
}
// Raw sensor samples, copied verbatim from the session CSV. Appended last rather than
// merge-sorted in: a long ride is hundreds of thousands of rows, and sorting them against
// the (comparatively tiny) event/V2X sets in memory would defeat the streaming that
// the (comparatively tiny) V2X set in memory would defeat the streaming that
// CsvExporter deliberately does. Each row carries its own timestamp, so sort on load.
val sessionCsv = trip.sessionId?.let { File(File(context.filesDir, "sessions"), "$it.csv") }
if (sessionCsv != null && sessionCsv.exists()) {
@@ -110,12 +99,11 @@ suspend fun buildTripCsv(
suspend fun shareTripCsv(
context: Context,
trip: RecordedTripEntity,
events: List<DetectedEventEntity>,
v2xMessages: List<V2xMessageEntity>,
) {
val fileName = tripFileName(trip)
val cacheFile = File(context.cacheDir, fileName)
val csv = buildTripCsv(context, trip, events, v2xMessages)
val csv = buildTripCsv(context, trip, v2xMessages)
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
@@ -3,11 +3,9 @@ package com.hawhamburg.micr0bu.data
import android.content.Context
import android.util.Log
import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import java.io.File
@@ -15,7 +13,7 @@ import java.io.File
private const val TAG = "TripRepository"
/**
* Repository that abstracts Room access for trips and detected events.
* Repository that abstracts Room access for trips and V2X messages.
*
* All suspend functions are safe to call from a coroutine running on any
* dispatcher; Room executes the actual SQL on its own I/O thread pool.
@@ -81,14 +79,11 @@ class TripRepository(db: AppDatabase, private val context: Context) {
* One-shot snapshots for export. The Flow-returning variants above stay observable for the UI;
* an export wants a value it can write out, not a stream it has to unsubscribe from.
*/
suspend fun getEventsForTripOnce(tripId: Long): List<DetectedEventEntity> =
dao.getEventsForTrip(tripId).first()
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
dao.getV2xMessagesForTrip(tripId).first()
/**
* Deletes a trip and everything belonging to it: detected events and V2X messages go via the
* Deletes a trip and everything belonging to it: V2X messages go via the
* schema's CASCADE foreign keys, and the CSV recorded alongside it is removed here.
*
* The CSV is a plain file outside the database, so nothing deletes it implicitly - before
@@ -109,32 +104,6 @@ class TripRepository(db: AppDatabase, private val context: Context) {
}
}
// ── Events ────────────────────────────────────────────────────────────────
/**
* Persists a domain [DetectedEvent] for the given [tripId].
* Converts the domain model to the Room entity.
*/
suspend fun insertEvent(tripId: Long, event: DetectedEvent) =
dao.insertEvent(
DetectedEventEntity(
tripId = tripId,
timestamp = event.timestamp,
type = event.type.name,
confidence = event.confidence.name,
latitude = event.latitude,
longitude = event.longitude,
speedMps = event.speedMps.toFloat(),
peakAccelMagnitude = event.peakAccelMagnitude.toFloat(),
peakGyroMagnitude = event.peakGyroMagnitude.toFloat(),
durationMs = event.durationMs,
)
)
/** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> =
dao.getEventsForTrip(tripId)
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
// Retention policy: only ever called while a trip is actively recording — see
// V2xMessageEntity's KDoc and CamUseCaseRepository.processedCam's collector in
@@ -11,10 +11,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
entities = [
SessionEntity::class,
RecordedTripEntity::class,
DetectedEventEntity::class,
V2xMessageEntity::class,
],
version = 4,
version = 5,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
@@ -34,13 +33,29 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"micr0bu.db",
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.build()
.also { INSTANCE = it }
}
// ── Migrations ────────────────────────────────────────────────────────
/**
* Drops `detected_events`. The cyclist event detector still runs, but its output is now
* consumed only by the CAM transmit-rate policy (see EventDetector's KDoc) and is no
* longer persisted, displayed, or exported, so the table had no reader left.
*
* `trips.eventCount` is deliberately kept. Dropping a column means recreating `trips`
* and copying every recorded ride across, which is real risk for one unused integer;
* the service still writes an accurate count into it and the CSV header still reports it.
*/
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("DROP INDEX IF EXISTS `index_detected_events_tripId`")
database.execSQL("DROP TABLE IF EXISTS `detected_events`")
}
}
/**
* Two additions:
* - `trips.sessionId` links a trip to the CSV recording session captured alongside it, so
@@ -1,50 +0,0 @@
package com.hawhamburg.micr0bu.data.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
/**
* One detected cyclist event (braking / turning / stopping) linked to a
* [RecordedTripEntity] via the [tripId] foreign key.
*
* [type] and [confidence] are stored as the enum name strings so that the
* database remains human-readable.
*/
@Entity(
tableName = "detected_events",
foreignKeys = [
ForeignKey(
entity = RecordedTripEntity::class,
parentColumns = ["id"],
childColumns = ["tripId"],
onDelete = ForeignKey.CASCADE,
)
],
)
data class DetectedEventEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(index = true)
val tripId: Long,
/** Wall-clock epoch ms of the first qualifying sensor frame. */
val timestamp: Long,
/** EventType.name — one of BRAKING, TURNING, STOPPING. */
val type: String,
/** Confidence.name — one of HIGH, MEDIUM, LOW. */
val confidence: String,
val latitude: Double,
val longitude: Double,
val speedMps: Float,
val peakAccelMagnitude: Float,
val peakGyroMagnitude: Float,
/** Duration from first qualifying frame to emission (ms). */
val durationMs: Long,
)
@@ -27,17 +27,6 @@ interface TripDao {
@Query("DELETE FROM trips WHERE id = :id")
suspend fun deleteTripById(id: Long)
// ── Events ────────────────────────────────────────────────────────────────
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEvent(event: DetectedEventEntity)
@Query("SELECT * FROM detected_events WHERE tripId = :tripId ORDER BY timestamp ASC")
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>>
@Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId")
suspend fun getEventCountForTrip(tripId: Long): Int
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -142,9 +142,10 @@ class TripRecordingService : Service() {
private val gpsTrackBuilder = StringBuilder("[")
private var gpsPointCount = 0
// Number of detected events in this trip. Kept only to populate the trips.eventCount
// column; the per-type counts are no longer surfaced anywhere (see EventDetector's KDoc
// for why the detector still runs).
// Number of manoeuvres the detector fired during this trip. The only thing kept about
// them: it fills the trips.eventCount column, which predates this change and cannot be
// dropped without rebuilding the trips table. See EventDetector's KDoc for why the
// detector still runs at all.
private var detectedEventCount = 0
// ── SensorEventListener ───────────────────────────────────────────────────
@@ -265,13 +266,11 @@ class TripRecordingService : Service() {
}
}
// Collect detector events. The detector's primary consumer is the CAM transmit rate
// policy below; persistence is retained so a recorded ride can still be analysed
// offline, but nothing in the UI reads it.
// Collect detector events. The CAM transmit-rate policy is their only consumer:
// detected manoeuvres are not persisted, exported, or displayed.
serviceScope.launch {
detector.events.collect { event ->
detector.events.collect { _ ->
if (currentTripId < 0) return@collect
repository.insertEvent(currentTripId, event)
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
camTransmitLoop.onDetectedEvent()
@@ -117,7 +117,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
shareTripCsv(
context = context,
trip = trip,
events = repository.getEventsForTripOnce(tripId),
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
)
}