From 842d362c8ff876de3eefc1d51e8a2f1c3cf5e6df Mon Sep 17 00:00:00 2001 From: Ashin Walpola Date: Fri, 24 Jul 2026 11:41:29 +0200 Subject: [PATCH] initial commit --- .gitignore | 19 ++ .idea/.gitignore | 3 + .idea/AndroidProjectSystem.xml | 6 + .idea/appInsightsSettings.xml | 6 + .idea/compiler.xml | 6 + .idea/deploymentTargetSelector.xml | 31 ++ .idea/deviceManager.xml | 13 + .idea/gradle.xml | 19 ++ .idea/inspectionProfiles/Project_Default.xml | 50 ++++ .idea/markdown.xml | 8 + .idea/migrations.xml | 10 + .idea/misc.xml | 9 + .idea/runConfigurations.xml | 17 ++ .idea/studiobot.xml | 6 + .idea/vcs.xml | 6 + V2X_MicrOBU_Android_App_Requirements_v10.docx | Bin 0 -> 68914 bytes .../micr0bu/data/cam/CamUseCaseRepository.kt | 192 ++++++++++++ .../data/mqtt/UseCaseAlertPreferences.kt | 35 +++ .../com/hawhamburg/micr0bu/domain/cam/Cam.kt | 64 ++++ .../micr0bu/domain/cam/CamParser.kt | 64 ++++ .../micr0bu/domain/cam/JsonFieldReader.kt | 80 +++++ .../micr0bu/domain/cam/ObuGnssParser.kt | 59 ++++ .../micr0bu/domain/denm/DenmUseCase.kt | 29 ++ .../micr0bu/domain/usecase/AlertLevel.kt | 21 ++ .../micr0bu/domain/usecase/GeoMath.kt | 128 ++++++++ .../micr0bu/domain/usecase/UseCaseAlert.kt | 38 +++ .../domain/usecase/UseCaseDetectionConfig.kt | 111 +++++++ .../domain/usecase/UseCaseDetectionEngine.kt | 278 ++++++++++++++++++ .../micr0bu/domain/usecase/UseCaseType.kt | 34 +++ 29 files changed, 1342 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/appInsightsSettings.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/markdown.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/studiobot.xml create mode 100644 .idea/vcs.xml create mode 100644 V2X_MicrOBU_Android_App_Requirements_v10.docx create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/UseCaseAlertPreferences.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Cam.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/cam/CamParser.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/cam/JsonFieldReader.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/cam/ObuGnssParser.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmUseCase.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/AlertLevel.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/GeoMath.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseAlert.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseDetectionConfig.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseDetectionEngine.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseType.kt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15d0e52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +*.iml +.gradle/ +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build/ +captures/ +.externalNativeBuild +.cxx +local.properties +*.hprof +*.jks +*.keystore +secrets.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..7f73dc8 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..c07d1d1 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,31 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml new file mode 100644 index 0000000..9298202 --- /dev/null +++ b/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/V2X_MicrOBU_Android_App_Requirements_v10.docx b/V2X_MicrOBU_Android_App_Requirements_v10.docx new file mode 100644 index 0000000000000000000000000000000000000000..b2aa5ddaeba4232293e697ed89926a45da8412e3 GIT binary patch literal 68914 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%0D%4tG<9?BL3_|W9EL8KxTZSh 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 literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt new file mode 100644 index 0000000..5a5d8ec --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt @@ -0,0 +1,192 @@ +package com.hawhamburg.micr0bu.data.cam + +import android.content.Context +import com.hawhamburg.micr0bu.data.GnssReading +import com.hawhamburg.micr0bu.data.SensorRepository +import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState +import com.hawhamburg.micr0bu.data.mqtt.MqttRepository +import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences +import com.hawhamburg.micr0bu.domain.cam.Cam +import com.hawhamburg.micr0bu.domain.cam.CamParser +import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser +import com.hawhamburg.micr0bu.domain.cam.StationType +import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert +import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine +import com.hawhamburg.micr0bu.domain.usecase.UseCaseType +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +private const val CAM_TOPIC = "v2x-uca/output/json/cam" +private const val OBU_GNSS_TOPIC = "v2x/rx/obu_gnss" +private const val PRUNE_INTERVAL_MS = 1_000L + +// If no v2x/rx/obu_gnss update has arrived within this window, the ego state is considered +// stale enough that a fresh phone GNSS fix (if available) is preferred over it — see +// [handlePhoneGnss]. obu_gnss updates at ~4 Hz per the requirements doc, so 2.5 s is several +// missed updates, not just normal jitter between samples. +private const val OBU_GNSS_STALE_MS = 2_500L + +/** + * Bridges the raw MQTT CAM stream (plus the ego's own obu_gnss/phone GNSS state) to + * [UseCaseDetectionEngine] and exposes the resulting CAM-based Use Case Alerts to the UI + * (requirements doc Section 10.2 / 10.4). + * + * **Ego state sourcing:** `v2x/rx/obu_gnss` is the primary source for the ego bike's own + * position/speed/heading/yaw rate (~4 Hz, includes yaw rate). If it goes stale (cable + * unplugged, OBU hiccup, etc.), phone GNSS (`FusedLocationProviderClient` via + * [SensorRepository]) is used as a fallback so the engine keeps running — at the cost of yaw + * rate, which the phone alone doesn't provide. The CAM topic's own low-rate "own" entry is + * also fed in as a third fallback. [UseCaseDetectionEngine.onOwnCam] always keeps whichever + * update is freshest and ignores out-of-order ones, so no explicit priority juggling is needed + * beyond "only use phone GNSS when obu_gnss is stale". + * + * A singleton so detection keeps running (and alert state survives) even while no screen is + * collecting it — same rationale as [MqttRepository]'s per-topic message log. + * + * No DENM is generated or consumed anywhere in this class. + */ +@Singleton +class CamUseCaseRepository @Inject constructor( + private val mqttRepository: MqttRepository, + private val prefs: UseCaseAlertPreferences, + @ApplicationContext private val context: Context, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val engine = UseCaseDetectionEngine() + private val sensorRepository = SensorRepository(context) + + private val _ownStationId = MutableStateFlow(null) + /** The ego OBU's own station ID, learned from `v2x/rx/obu_gnss`. Null until known. */ + val ownStationId: StateFlow = _ownStationId.asStateFlow() + + @Volatile private var lastOwnStationType: Int = StationType.CYCLIST + @Volatile private var lastObuGnssTimestamp: Long = 0L + + /** Per-use-case enable/disable toggles (Settings > Use Case Alerts). */ + val enabledMap: StateFlow> = prefs.enabledMapFlow.stateIn( + scope, SharingStarted.Eagerly, UseCaseType.entries.associateWith { true }, + ) + + /** All currently active alerts, regardless of per-use-case enablement. */ + val allAlerts: StateFlow> = engine.currentAlerts + + /** Alerts filtered to only the use cases the user has enabled — what the UI should show. */ + val enabledAlerts: StateFlow> = combine(engine.currentAlerts, enabledMap) { alerts, enabled -> + alerts.filter { enabled[it.useCase] != false } + }.stateIn(scope, SharingStarted.Eagerly, emptyList()) + + init { + scope.launch { + mqttRepository.messages.collect { msg -> + when (msg.topic) { + OBU_GNSS_TOPIC -> handleObuGnss(msg.payload, msg.timestamp) + CAM_TOPIC -> handleCam(msg.payload, msg.timestamp) + } + } + } + + // Phone GNSS fallback — only applied when obu_gnss has gone stale (see class KDoc). + // Retries in a loop: this singleton can be created before the user grants location + // permission (requested at app startup), so a single subscription attempt isn't + // enough — re-subscribe periodically until it succeeds, and again if it ever ends. + scope.launch { + while (true) { + runCatching { + sensorRepository.gnssFlow().collect { reading -> handlePhoneGnss(reading) } + } + delay(5_000L) + } + } + + // Clear all state on disconnect — stale remote CAMs from a previous session + // shouldn't linger as alerts after the OBU link drops. + scope.launch { + mqttRepository.connectionState.collect { state -> + if (state == MqttConnectionState.DISCONNECTED) { + engine.reset() + _ownStationId.value = null + lastObuGnssTimestamp = 0L + } + } + } + + // Periodic staleness sweep so alerts clear once a remote road user goes out of + // range / stops transmitting, even without a new CAM arriving to trigger re-evaluation. + scope.launch { + while (true) { + delay(PRUNE_INTERVAL_MS) + engine.pruneStale(System.currentTimeMillis()) + } + } + } + + fun setUseCaseEnabled(type: UseCaseType, enabled: Boolean) { + scope.launch { prefs.setEnabled(type, enabled) } + } + + /** True if [stationId] matches the ego OBU's own station ID (for OWN/REMOTE UI badges). */ + fun isOwnStationId(stationId: Long): Boolean = stationId != 0L && stationId == _ownStationId.value + + /** + * Primary ego state source: `v2x/rx/obu_gnss`, ~4 Hz, carries position/speed/heading/yaw + * rate/stationType directly (Section 6 / audit context) — a richer and higher-rate source + * than the CAM topic's own low-rate entry. + */ + private fun handleObuGnss(payload: String, timestamp: Long) { + val ego = ObuGnssParser.parseEgo(payload, fallbackStationId = _ownStationId.value ?: 0L, timestamp = timestamp) + ?: return + lastObuGnssTimestamp = timestamp + if (ego.stationId != 0L) _ownStationId.value = ego.stationId + lastOwnStationType = ego.stationType + engine.onOwnCam(ego) + } + + /** + * Fallback ego state source: phone GNSS via [SensorRepository], used only once obu_gnss + * has gone stale for longer than [OBU_GNSS_STALE_MS] — "more accurate/reliable" in this + * context means "still updating" when the OBU feed isn't. Yaw rate is unavailable from + * phone GNSS alone, so alerts relying on it fall back to heading-trend from history + * (see [UseCaseDetectionEngine]) while this source is active. + */ + private fun handlePhoneGnss(reading: GnssReading) { + val now = System.currentTimeMillis() + if (now - lastObuGnssTimestamp <= OBU_GNSS_STALE_MS) return // obu_gnss is fresh enough — prefer it + + val ego = Cam( + stationId = _ownStationId.value ?: 0L, + stationType = lastOwnStationType, + latitude = reading.latitude, + longitude = reading.longitude, + speedMps = reading.speedMs.toDouble(), + headingDeg = reading.bearingDeg.toDouble(), + yawRateDps = null, // phone alone has no yaw rate; engine falls back to heading-trend + timestamp = reading.timestamp, + isOwn = true, + ) + engine.onOwnCam(ego) + } + + private fun handleCam(payload: String, timestamp: Long) { + val cam = CamParser.parse(payload, _ownStationId.value, timestamp) ?: return + if (cam.isOwn) { + // Third fallback — the CAM topic's own low-rate entry. onOwnCam() keeps whichever + // update is freshest, so this only actually wins when both obu_gnss and phone GNSS + // are unavailable/stale. + engine.onOwnCam(cam) + } else { + engine.onRemoteCam(cam) + } + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/UseCaseAlertPreferences.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/UseCaseAlertPreferences.kt new file mode 100644 index 0000000..1327364 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/UseCaseAlertPreferences.kt @@ -0,0 +1,35 @@ +package com.hawhamburg.micr0bu.data.mqtt + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.hawhamburg.micr0bu.domain.usecase.UseCaseType +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.useCaseAlertDataStore by preferencesDataStore(name = "use_case_alert_prefs") + +/** + * Per-use-case enable/disable toggles for the CAM-based Use Case Alert panel + * (Settings > Use Case Alerts, requirements doc Section 2.2 / 10.4). All in-scope use cases + * default to enabled. + */ +@Singleton +class UseCaseAlertPreferences @Inject constructor( + @ApplicationContext private val context: Context, +) { + private fun keyFor(type: UseCaseType) = booleanPreferencesKey("uc_enabled_${type.name}") + + /** Map of use case -> enabled, defaulting to true for any use case not yet persisted. */ + val enabledMapFlow: Flow> = context.useCaseAlertDataStore.data.map { prefs -> + UseCaseType.entries.associateWith { type -> prefs[keyFor(type)] ?: true } + } + + suspend fun setEnabled(type: UseCaseType, enabled: Boolean) { + context.useCaseAlertDataStore.edit { prefs -> prefs[keyFor(type)] = enabled } + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Cam.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Cam.kt new file mode 100644 index 0000000..404fd62 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Cam.kt @@ -0,0 +1,64 @@ +package com.hawhamburg.micr0bu.domain.cam + +/** + * ETSI EN 302 637-2 stationType values relevant to this project (Table 1). + * Only the values the app actively checks for are enumerated; the raw int is always + * preserved on [Cam.stationType] regardless. + */ +object StationType { + const val CYCLIST = 2 + const val PASSENGER_CAR = 5 +} + +/** + * A single Cooperative Awareness Message, parsed from the consider it Use Case API's + * processed JSON on `v2x-uca/output/json/cam` (Section 10.2 of the requirements doc). + * + * This is a pure domain model — no Android or Room imports — so [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] + * stays fully unit-testable, matching the pattern already used by + * [com.hawhamburg.micr0bu.domain.detection.EventDetector] / [com.hawhamburg.micr0bu.domain.detection.DetectedEvent]. + * + * CAM is broadcast autonomously and periodically by every V2X station (own OBU and any + * nearby remote road users) — no application trigger is required, unlike DENM. + */ +data class Cam( + /** Originating V2X station ID. Used to tell own CAMs from remote ones. */ + val stationId: Long, + + /** Raw ETSI EN 302 637-2 stationType (see [StationType] for values this project cares about). */ + val stationType: Int, + + /** Reference position latitude/longitude (WGS84 degrees). */ + val latitude: Double, + val longitude: Double, + + /** Ground speed, m/s. */ + val speedMps: Double, + + /** Heading, degrees clockwise from true north, [0, 360). */ + val headingDeg: Double, + + /** + * Yaw rate, degrees per second, where available (positive = turning clockwise). + * Carried by both `v2x/rx/obu_gnss` (ego, ~4 Hz) and `v2x-uca/output/json/cam` (remote). + * A much more direct "is this road user turning" signal than heading-delta heuristics — + * see [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine]. + */ + val yawRateDps: Double? = null, + + /** Optional drive direction flag (forward/backward), where available. */ + val driveDirection: Int? = null, + + /** Optional vehicle dimensions, metres, where available (CAMv2 / extended fields). */ + val vehicleLengthM: Double? = null, + val vehicleWidthM: Double? = null, + + /** Optional longitudinal acceleration control field, m/s², where available. */ + val accelerationMps2: Double? = null, + + /** Wall-clock ms this CAM was received/processed. */ + val timestamp: Long, + + /** True if this CAM originated from the ego micrOBU itself, false if a remote road user. */ + val isOwn: Boolean, +) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/CamParser.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/CamParser.kt new file mode 100644 index 0000000..477baf2 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/CamParser.kt @@ -0,0 +1,64 @@ +package com.hawhamburg.micr0bu.domain.cam + +import org.json.JSONObject + +/** + * Parses the processed CAM JSON published by the consider it Use Case API on + * `v2x-uca/output/json/cam` (CI-CiT-MQTT_API_Documentation-v6) into a [Cam]. + * + * **Field-name tolerance:** confirmed field names are tried first (`stationId`, `heading_deg`, + * `speed_mps`, `yawRate_dps`, GeoJSON `position`), with several other plausible spellings/ + * nestings as fallbacks — see [JsonFieldReader]. Once the exact schema is fully confirmed + * against real OBU payloads (Phase 02 bench test, Section 10.3), the fallbacks that never hit + * can be deleted. + */ +object CamParser { + + /** + * @param json raw MQTT payload string from `v2x-uca/output/json/cam`. + * @param ownStationId the ego OBU's own station ID, if known yet (from `v2x/rx/obu_gnss` — + * see [ObuGnssParser] / `CamUseCaseRepository`). May be null before the first + * obu_gnss message arrives, in which case [Cam.isOwn] falls back to any explicit + * own/self flag in the payload, or false. + * @param timestamp wall-clock ms to stamp the parsed [Cam] with. + * @return the parsed [Cam], or null if the payload isn't a recognisable CAM message. + */ + fun parse(json: String, ownStationId: Long?, timestamp: Long = System.currentTimeMillis()): Cam? { + val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null + + val stationId = JsonFieldReader.firstLong(obj, "stationId", "stationID", "station_id") ?: return null + val stationType = JsonFieldReader.firstInt(obj, "stationType", "station_type") ?: return null + + val (lat, lon) = JsonFieldReader.firstLatLon(obj) ?: return null + + val speed = JsonFieldReader.firstDouble(obj, "speed_mps", "speed", "speedMps") ?: 0.0 + val heading = JsonFieldReader.firstDouble(obj, "heading_deg", "heading", "headingDeg") ?: 0.0 + val yawRate = JsonFieldReader.firstDouble(obj, "yawRate_dps", "yawRateDps", "yaw_rate_dps", "yawRate") + + val driveDirection = JsonFieldReader.firstInt(obj, "driveDirection", "drive_direction") + val vehicleLength = JsonFieldReader.firstDouble(obj, "vehicleLength", "vehicle_length") + val vehicleWidth = JsonFieldReader.firstDouble(obj, "vehicleWidth", "vehicle_width") + val accel = JsonFieldReader.firstDouble(obj, "accelerationControl", "longitudinalAcceleration", "acceleration_mps2") + + // Own/remote: prefer an explicit flag if the API provides one; otherwise compare + // against the ego station ID learned from v2x/rx/obu_gnss. + val explicitOwn = obj.optBoolean("own", obj.optBoolean("isOwn", false)) + val isOwn = explicitOwn || (ownStationId != null && ownStationId == stationId) + + return Cam( + stationId = stationId, + stationType = stationType, + latitude = lat, + longitude = lon, + speedMps = speed, + headingDeg = JsonFieldReader.normaliseHeadingDeg(heading), + yawRateDps = yawRate, + driveDirection = driveDirection, + vehicleLengthM = vehicleLength, + vehicleWidthM = vehicleWidth, + accelerationMps2 = accel, + timestamp = timestamp, + isOwn = isOwn, + ) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/JsonFieldReader.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/JsonFieldReader.kt new file mode 100644 index 0000000..d2e9a8c --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/JsonFieldReader.kt @@ -0,0 +1,80 @@ +package com.hawhamburg.micr0bu.domain.cam + +import org.json.JSONObject + +/** + * Shared defensive JSON field readers used by [CamParser] and [ObuGnssParser]. + * + * Both `v2x-uca/output/json/cam` and `v2x/rx/obu_gnss` are processed JSON from the consider it + * Use Case API, and the exact key spelling wasn't available when this was written — so every + * field is looked up by trying several plausible spellings (confirmed ones first, e.g. + * `heading_deg` / `speed_mps` / `yawRate_dps`), and unwraps either a bare numeric value or an + * ETSI-style `{"value": ...}` wrapper. + */ +internal object JsonFieldReader { + + /** Reads the first present key as a Long, unwrapping a `{"value": n}` object if needed. */ + fun firstLong(obj: JSONObject, vararg keys: String): Long? { + for (key in keys) { + if (!obj.has(key)) continue + unwrapValue(obj.opt(key))?.let { return it.toLong() } + } + return null + } + + fun firstInt(obj: JSONObject, vararg keys: String): Int? = + firstLong(obj, *keys)?.toInt() + + fun firstDouble(obj: JSONObject, vararg keys: String): Double? { + for (key in keys) { + if (!obj.has(key)) continue + unwrapValue(obj.opt(key))?.let { return it } + } + return null + } + + /** + * Reads a WGS84 lat/lon pair, trying (in order): a `referencePosition`/`reference_position` + * sub-object with `lat`/`lon` keys, a GeoJSON-style `position` sub-object with + * `{"type":"Point","coordinates":[lon,lat,alt]}` (the shape the codebase's own prior CAM-TX + * scaffold used), or bare `lat`/`lon` keys on [obj] itself. + */ + fun firstLatLon(obj: JSONObject): Pair? { + val posObj = obj.optJSONObject("referencePosition") ?: obj.optJSONObject("reference_position") + if (posObj != null) { + val lat = firstDouble(posObj, "lat", "latitude") + val lon = firstDouble(posObj, "lon", "lng", "longitude") + if (lat != null && lon != null) return lat to lon + } + + val geoJson = obj.optJSONObject("position") + val coords = geoJson?.optJSONArray("coordinates") + if (coords != null && coords.length() >= 2) { + // GeoJSON coordinate order is [longitude, latitude, altitude?] + val lon = coords.optDouble(0, Double.NaN) + val lat = coords.optDouble(1, Double.NaN) + if (!lat.isNaN() && !lon.isNaN()) return lat to lon + } + + val lat = firstDouble(obj, "lat", "latitude") + val lon = firstDouble(obj, "lon", "lng", "longitude") + if (lat != null && lon != null) return lat to lon + + return null + } + + /** Normalises a heading/bearing in degrees to [0, 360). */ + fun normaliseHeadingDeg(deg: Double): Double { + var h = deg % 360.0 + if (h < 0) h += 360.0 + return h + } + + /** Unwraps a raw Number, a numeric String, or a `{"value": n}` object into a Double. */ + private fun unwrapValue(v: Any?): Double? = when (v) { + is Number -> v.toDouble() + is String -> v.toDoubleOrNull() + is JSONObject -> if (v.has("value")) unwrapValue(v.opt("value")) else null + else -> null + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/ObuGnssParser.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/ObuGnssParser.kt new file mode 100644 index 0000000..a1e7e09 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/ObuGnssParser.kt @@ -0,0 +1,59 @@ +package com.hawhamburg.micr0bu.domain.cam + +import org.json.JSONObject + +/** + * Parses the ego OBU's own kinematic state from `v2x/rx/obu_gnss`. + * + * This topic updates at ~4 Hz and carries the ego bike's own position, speed, heading, yaw + * rate, and stationType — a higher-rate, more complete source for the ego side of + * [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine]'s evaluation than the lower + * rate (1-10 Hz) "own" entry that also appears on `v2x-uca/output/json/cam`. See + * `CamUseCaseRepository`, which prefers this source and falls back to the CAM-topic "own" + * entry (or phone GNSS) only when this one goes stale. + * + * Same field-name-tolerance caveat as [CamParser] — see [JsonFieldReader]. + */ +object ObuGnssParser { + + /** + * @param json raw MQTT payload string from `v2x/rx/obu_gnss`. + * @param fallbackStationId used if the payload doesn't carry its own station ID (rare — + * own_info normally does, but keeps this robust to a partial payload). + * @return the ego [Cam] (always [Cam.isOwn] == true), or null if position is missing. + */ + fun parseEgo(json: String, fallbackStationId: Long = 0L, timestamp: Long = System.currentTimeMillis()): Cam? { + val root = runCatching { JSONObject(json) }.getOrNull() ?: return null + val obj = root.optJSONObject("own_info") ?: root + + val stationId = JsonFieldReader.firstLong(obj, "stationID", "stationId", "station_id") ?: fallbackStationId + val stationType = JsonFieldReader.firstInt(obj, "stationType", "station_type") ?: StationType.CYCLIST + + val (lat, lon) = JsonFieldReader.firstLatLon(obj) ?: return null + + val speed = JsonFieldReader.firstDouble(obj, "speed_mps", "speed", "speedMps") ?: 0.0 + val heading = JsonFieldReader.firstDouble(obj, "heading_deg", "heading", "headingDeg") ?: 0.0 + val yawRate = JsonFieldReader.firstDouble(obj, "yawRate_dps", "yawRateDps", "yaw_rate_dps", "yawRate") + + return Cam( + stationId = stationId, + stationType = stationType, + latitude = lat, + longitude = lon, + speedMps = speed, + headingDeg = JsonFieldReader.normaliseHeadingDeg(heading), + yawRateDps = yawRate, + timestamp = timestamp, + isOwn = true, + ) + } + + /** Just the stationID/stationType, for when only identity (not a full fix) is needed. */ + fun parseOwnIdentity(json: String): Pair? { + val root = runCatching { JSONObject(json) }.getOrNull() ?: return null + val obj = root.optJSONObject("own_info") ?: return null + val stationId = JsonFieldReader.firstLong(obj, "stationID", "stationId", "station_id") ?: return null + val stationType = JsonFieldReader.firstInt(obj, "stationType", "station_type") ?: return null + return stationId to stationType + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmUseCase.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmUseCase.kt new file mode 100644 index 0000000..b948885 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmUseCase.kt @@ -0,0 +1,29 @@ +package com.hawhamburg.micr0bu.domain.denm + +/** + * MQTT topic for the consider it Use Case API control messages. + * + * **Manual/antenna-test path only.** This project's CAM-based use case architecture + * (`com.hawhamburg.micr0bu.domain.usecase`) never generates or consumes DENM — see + * Section 0.2 / 10.5 of the requirements doc. The DENM trigger below is kept solely as a + * manual test tool: it lets a tester fire an OBU→RSU DENM broadcast on demand to verify the + * antennas/ITS-G5 link are actually working end-to-end, independent of any detected event. + */ +const val DENM_CTRL_TOPIC = "v2x-uca/input/denmtrg" + +/** + * DENM use cases triggerable via [DENM_CTRL_TOPIC]. + * + * Only one use case may be active on the OBU at a time (enforced in MqttRepository). + */ +enum class DenmUseCase(val id: String) { + /** Electronic Emergency Brake Light (CC99/1). */ + EEBL("c2c-eebl"), + + /** Aftermarket Stationary Recovery Vehicle (CC94/0). Default manual test case. */ + STATIONARY("hln-sv"), +} + +/** Builds a uca-denmctrl JSON control payload. */ +fun buildDenmPayload(useCase: String, active: Boolean): String = + """{"type":"uca-denmctrl","active":$active,"usecase":"$useCase","params":{}}""" diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/AlertLevel.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/AlertLevel.kt new file mode 100644 index 0000000..8e9015f --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/AlertLevel.kt @@ -0,0 +1,21 @@ +package com.hawhamburg.micr0bu.domain.usecase + +/** + * The C2C-CC three-tier alert level model (requirements doc Section 5.5). + * + * Governs both HMI presentation (colour, audio/haptic intensity) and the detection engine's + * time-to-conflict thresholds. All in-scope use cases are designed to reach at least + * [AWARENESS] under current BSP1-compliant CAM accuracy; [WARNING] additionally requires + * lane-level positioning accuracy where achievable. The engine supports both tiers from the + * start so the HMI upgrades automatically as positioning accuracy improves. + */ +enum class AlertLevel { + /** Danger present but not imminent. Example threshold: > 10-30 s to conflict. */ + INFO, + + /** Achievable without lane-level accuracy. Example threshold: > 3-15 s to conflict. */ + AWARENESS, + + /** Imminent danger. Example threshold: <= 5 s to conflict. */ + WARNING, +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/GeoMath.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/GeoMath.kt new file mode 100644 index 0000000..32c3e17 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/GeoMath.kt @@ -0,0 +1,128 @@ +package com.hawhamburg.micr0bu.domain.usecase + +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Small geodesy + 2D kinematics helpers used by [UseCaseDetectionEngine]. + * + * Pure Kotlin, no Android dependencies, fully unit-testable — same pattern as + * [com.hawhamburg.micr0bu.domain.detection.RunningStats]. + */ +internal object GeoMath { + + private const val EARTH_RADIUS_M = 6_371_000.0 + + /** A local-tangent-plane 2D vector: x = east (m), y = north (m), or an east/north velocity (m/s). */ + data class Vector2(val x: Double, val y: Double) { + operator fun minus(other: Vector2) = Vector2(x - other.x, y - other.y) + operator fun plus(other: Vector2) = Vector2(x + other.x, y + other.y) + operator fun times(scalar: Double) = Vector2(x * scalar, y * scalar) + fun dot(other: Vector2) = x * other.x + y * other.y + fun length() = sqrt(x * x + y * y) + } + + /** Great-circle distance between two WGS84 points, metres (haversine). */ + fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val phi1 = Math.toRadians(lat1) + val phi2 = Math.toRadians(lat2) + val dPhi = Math.toRadians(lat2 - lat1) + val dLambda = Math.toRadians(lon2 - lon1) + val a = sin(dPhi / 2).let { it * it } + + cos(phi1) * cos(phi2) * sin(dLambda / 2).let { it * it } + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return EARTH_RADIUS_M * c + } + + /** Initial bearing from point 1 to point 2, degrees clockwise from true north, [0, 360). */ + fun initialBearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val phi1 = Math.toRadians(lat1) + val phi2 = Math.toRadians(lat2) + val dLambda = Math.toRadians(lon2 - lon1) + val y = sin(dLambda) * cos(phi2) + val x = cos(phi1) * sin(phi2) - sin(phi1) * cos(phi2) * cos(dLambda) + return normalizeAngle(Math.toDegrees(atan2(y, x))) + } + + /** Normalises an angle in degrees to [0, 360). */ + fun normalizeAngle(deg: Double): Double { + var a = deg % 360.0 + if (a < 0) a += 360.0 + return a + } + + /** Smallest absolute angular difference between two bearings/headings, in [0, 180]. */ + fun angleDiffDeg(a: Double, b: Double): Double { + val diff = abs(normalizeAngle(a) - normalizeAngle(b)) % 360.0 + return if (diff > 180.0) 360.0 - diff else diff + } + + /** + * Signed angular difference `to - from`, in (-180, 180]. Positive means [to] is clockwise + * of [from] (e.g. if [from] is a heading and [to] is a bearing, positive = target is to the + * right). Used to test whether a yaw-rate direction is rotating a heading *toward* a bearing. + */ + fun angleDiffSigned(from: Double, to: Double): Double { + var diff = (normalizeAngle(to) - normalizeAngle(from)) % 360.0 + if (diff > 180.0) diff -= 360.0 + if (diff <= -180.0) diff += 360.0 + return diff + } + + /** + * Projects a point [lat]/[lon] onto a local east/north tangent plane centred at + * [lat0]/[lon0], in metres. Equirectangular approximation — accurate for the short + * (sub-kilometre) ranges relevant to V2X. + */ + fun toLocalMeters(lat0: Double, lon0: Double, lat: Double, lon: Double): Vector2 { + val phi0 = Math.toRadians(lat0) + val dLat = Math.toRadians(lat - lat0) + val dLon = Math.toRadians(lon - lon0) + val north = dLat * EARTH_RADIUS_M + val east = dLon * EARTH_RADIUS_M * cos(phi0) + return Vector2(east, north) + } + + /** East/north velocity vector (m/s) from speed (m/s) and heading (deg clockwise from north). */ + fun velocityVector(speedMps: Double, headingDeg: Double): Vector2 { + val rad = Math.toRadians(headingDeg) + return Vector2(x = speedMps * sin(rad), y = speedMps * cos(rad)) + } + + /** + * Closest point of approach between two converging tracks, given the relative position + * ([relPos] = other - self, metres) and relative velocity ([relVel] = other's velocity + * minus self's velocity, m/s). + * + * @return time to CPA (s, clamped to [0, maxHorizonSec]) and the separation distance (m) + * at that time. If the tracks are not closing (or [relVel] is ~stationary), time + * is reported as 0 and distance as the current separation. + */ + fun closestPointOfApproach( + relPos: Vector2, + relVel: Vector2, + maxHorizonSec: Double, + ): Pair { + val vSq = relVel.dot(relVel) + if (vSq < 1e-6) return 0.0 to relPos.length() + + val tRaw = -relPos.dot(relVel) / vSq + val t = tRaw.coerceIn(0.0, maxHorizonSec) + val posAtT = relPos + relVel * t + return t to posAtT.length() + } + + /** + * Rate of closure (m/s, positive = closing) of [relVel] (other's velocity minus self's) + * along the current line of sight [relPos] (other - self, metres). + */ + fun closingSpeed(relPos: Vector2, relVel: Vector2): Double { + val dist = relPos.length() + if (dist < 1e-6) return 0.0 + val unit = Vector2(relPos.x / dist, relPos.y / dist) + return -unit.dot(relVel) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseAlert.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseAlert.kt new file mode 100644 index 0000000..316d702 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseAlert.kt @@ -0,0 +1,38 @@ +package com.hawhamburg.micr0bu.domain.usecase + +/** + * A currently-active use case alert produced by [UseCaseDetectionEngine], evaluating one + * remote CAM against the ego OBU's own most recent CAM. + * + * Pure domain model — no Android imports. + */ +data class UseCaseAlert( + val useCase: UseCaseType, + val alertLevel: AlertLevel, + + /** stationID of the remote road user contributing to this alert. */ + val remoteStationId: Long, + + /** Raw ETSI stationType of the remote road user (see [com.hawhamburg.micr0bu.domain.cam.StationType]). */ + val remoteStationType: Int, + + /** Great-circle distance between ego and remote, metres. */ + val distanceMeters: Double, + + /** Rate of closure along the line of sight, m/s. Positive = closing. */ + val closingSpeedMps: Double, + + /** Estimated time to closest point of approach / conflict point, seconds. */ + val timeToConflictSec: Double, + + /** + * Which signal(s) contributed to this alert, for debugging/verification — e.g. + * "yaw-rate", "heading-trend", "static-heuristic". Not shown as the primary UI text, but + * useful to confirm the engine is actually using per-station history/yaw rate rather than + * a single-sample heuristic. + */ + val signalNote: String, + + /** Wall-clock ms this alert was last (re)computed. */ + val lastUpdated: Long, +) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseDetectionConfig.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseDetectionConfig.kt new file mode 100644 index 0000000..0cc6383 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseDetectionConfig.kt @@ -0,0 +1,111 @@ +package com.hawhamburg.micr0bu.domain.usecase + +/** + * All CAM-based use case detection thresholds in one place. + * + * As with [com.hawhamburg.micr0bu.domain.detection.DetectionConfig], these are **initial + * engineering estimates** — the C2C-CC White Paper's exact geometric/kinematic definitions + * were not available when this was written, only the plain-language use case descriptions in + * Section 1.2 of the requirements doc. Validate and tune against real CAM traffic from the + * Phase 02 bench test (Section 10.3) and, ultimately, real test-intersection data. + * + * Notably, baseline CAM carries no turn-signal/indicator field, so RTW-B/LTW-B are + * approximated here via parallel-heading + lateral-sector + closing-distance heuristics + * rather than true turn-intent detection. Precision should improve once CAMv2 + * exterior-lights/indicator fields are consumed. + */ +data class UseCaseDetectionConfig( + + // ── Distance gates ──────────────────────────────────────────────────────── + /** Max distance (m) considered for intersection-scale use cases (IMA-B, IMA-S, RTW-B, LTW-B). */ + val intersectionRadiusM: Double = 40.0, + + /** Max distance (m) considered for same-direction/rural use cases (SMVA/BCW-B). */ + val roadwayRadiusM: Double = 120.0, + + /** Beyond this distance (m), skip evaluation entirely (cheap early-out). */ + val maxConsiderationRadiusM: Double = 150.0, + + /** Closest-point-of-approach distance (m) below which paths are considered "in conflict". */ + val conflictRadiusM: Double = 5.0, + + /** Slightly wider CPA tolerance (m) for the turn-warning use cases, which model an + * as-yet-unexecuted turn rather than the vehicle's current heading. */ + val turnConflictRadiusM: Double = 7.5, + + // ── Heading / bearing gates ────────────────────────────────────────────── + /** Heading delta (deg) range considered "crossing paths" (near-perpendicular). */ + val crossingHeadingMinDeg: Double = 50.0, + val crossingHeadingMaxDeg: Double = 130.0, + + /** Heading delta (deg) at or below which two road users are considered travelling parallel. */ + val parallelHeadingMaxDeg: Double = 30.0, + + /** Lateral-sector half-width (deg either side of dead-ahead/dead-astern) used for the + * same-direction closing check in SMVA/BCW-B. */ + val sameDirectionSectorDeg: Double = 20.0, + + // ── Speed gates ─────────────────────────────────────────────────────────── + /** Below this speed (m/s), a road user is considered stationary/standstill. */ + val standstillSpeedThresholdMps: Double = 0.5, + + /** Minimum speed (m/s) for a road user to count as "moving" for crossing use cases. */ + val minMovingSpeedMps: Double = 1.0, + + /** Minimum positive closing speed (m/s) along the line of sight to count as "approaching". */ + val closingSpeedMinMps: Double = 0.3, + + /** Minimum speed differential (m/s) between ego and remote for SMVA/BCW-B to trigger. */ + val speedDifferentialMinMps: Double = 3.0, + + // ── Time-to-conflict → alert level thresholds (Section 5.5) ────────────── + /** TTC (s) at or below which the alert escalates to WARNING. */ + val ttcWarningSec: Double = 5.0, + + /** TTC (s) at or below which the alert is at least AWARENESS. */ + val ttcAwarenessSec: Double = 15.0, + + /** TTC (s) at or below which the alert is at least INFO. Beyond this, no alert is raised. */ + val ttcInfoSec: Double = 30.0, + + /** Cap (s) on the closest-point-of-approach time projection — road users converging + * further out than this are not yet considered for an alert. */ + val maxProjectionHorizonSec: Double = 30.0, + + // ── Staleness ───────────────────────────────────────────────────────────── + /** Drop a remote CAM (and any alerts derived from it) if no update arrives within this + * many ms — CAMs typically arrive at 1-10 Hz, so a multi-second gap means the remote + * road user is out of range or the link dropped. */ + val staleRemoteMs: Long = 3_000L, + + // ── Per-station history / trend (not reacting to a single message in isolation) ─── + /** Max number of past CAMs retained per remote station for trend computation. */ + val historyMaxSamples: Int = 8, + + /** Max age (ms) of a history sample before it's dropped from the trend window. */ + val historyMaxAgeMs: Long = 5_000L, + + /** Minimum samples in a station's history before trend (heading-rate/distance-rate) is + * trusted; below this, classification falls back to the instantaneous-geometry heuristics + * only. */ + val minHistorySamplesForTrend: Int = 2, + + // ── Turn-toward-ego detection (yaw rate / heading-trend based) ──────────── + /** Minimum |yaw rate| (deg/s, from the CAM field if present, else derived from heading + * history) to consider a remote road user "actively turning". */ + val turnYawRateThresholdDegPerSec: Double = 8.0, + + /** Max |signed angle from the remote's heading to the ego's bearing| (deg) for the ego to + * count as roughly "in front of" the turning remote — beyond this the remote is turning + * away from/behind the ego, not toward it. */ + val turnTowardEgoMaxAngleDeg: Double = 90.0, +) + +/** Buckets a time-to-conflict estimate into an [AlertLevel], or null if beyond all thresholds. */ +fun UseCaseDetectionConfig.alertLevelForTtc(ttcSec: Double): AlertLevel? = when { + ttcSec < 0 -> null + ttcSec <= ttcWarningSec -> AlertLevel.WARNING + ttcSec <= ttcAwarenessSec -> AlertLevel.AWARENESS + ttcSec <= ttcInfoSec -> AlertLevel.INFO + else -> null +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseDetectionEngine.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseDetectionEngine.kt new file mode 100644 index 0000000..c3d47e8 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseDetectionEngine.kt @@ -0,0 +1,278 @@ +package com.hawhamburg.micr0bu.domain.usecase + +import com.hawhamburg.micr0bu.domain.cam.Cam +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlin.math.abs +import kotlin.math.sign + +/** + * CAM-based Use Case Detection Engine (requirements doc Section 4.2 / 10.2). + * + * Continuously correlates the ego bike's own latest state (from `v2x/rx/obu_gnss`, ~4 Hz — + * see `CamUseCaseRepository`) with a short **history** of CAMs received per remote road user + * to evaluate the geometric/kinematic conditions for each in-scope C2C-CC bike safety use case + * ([UseCaseType]), and raises alerts per the three-tier model ([AlertLevel], Section 5.5). + * + * Reacting to a single CAM in isolation is noisy (GNSS jitter, one-off heading glitches), so + * each remote station's recent samples are kept in [remoteHistory] and used to derive a + * short-term trend (heading-rate, distance-rate) that corroborates or substitutes for + * instantaneous fields — notably yaw rate, which not all CAM sources carry. + * + * **No DENM is generated or consumed here.** The OBU's own autonomous CAM broadcast, received + * by the other road user's OBU, already carries the ego vehicle's presence — this engine only + * needs to *consume* CAM (and the ego's own obu_gnss state) to decide when to surface a warning + * locally. + * + * Pure Kotlin, no Android imports — unit-testable with synthetic CAM input exactly like + * [com.hawhamburg.micr0bu.domain.detection.EventDetector]. + * + * Thread-safety: this class is not synchronized. Callers (see `CamUseCaseRepository`) should + * confine calls to a single coroutine/thread, e.g. by collecting from a single Flow. + */ +class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCaseDetectionConfig()) { + + private var ownCam: Cam? = null + + // Short history per remote station ID — oldest first, bounded by size and age + // (config.historyMaxSamples / historyMaxAgeMs). This is what lets the engine compute + // trends (heading-rate, distance-rate) instead of reacting to one message in isolation. + private val remoteHistory = mutableMapOf>() + + // Currently active alerts, keyed by (remote station ID, use case) so one remote road user + // can concurrently contribute to more than one use case (e.g. IMA-B and SMVA/BCW-B). + private val activeAlerts = mutableMapOf, UseCaseAlert>() + + private val _currentAlerts = MutableStateFlow>(emptyList()) + + /** Currently active alerts across all remote road users, most severe first. */ + val currentAlerts: StateFlow> = _currentAlerts.asStateFlow() + + /** + * Feed the ego bike's own most recent state (from `v2x/rx/obu_gnss`, or a fallback source + * — see `CamUseCaseRepository`). Out-of-order/late updates are ignored. Re-evaluates all + * tracked remote stations against the new state. + */ + fun onOwnCam(cam: Cam) { + val current = ownCam + if (current != null && cam.timestamp < current.timestamp) return // stale/out-of-order + ownCam = cam + remoteHistory.keys.toList().forEach { id -> remoteHistory[id]?.lastOrNull()?.let { evaluate(it) } } + publish() + } + + /** + * Feed a CAM from a remote road user. Pushed into that station's history, then evaluated + * immediately against the last ego state, if any. + */ + fun onRemoteCam(cam: Cam) { + val history = remoteHistory.getOrPut(cam.stationId) { ArrayDeque() } + history.addLast(cam) + trimHistory(history, cam.timestamp) + evaluate(cam) + publish() + } + + private fun trimHistory(history: ArrayDeque, nowMs: Long) { + while (history.size > config.historyMaxSamples) history.removeFirst() + while (history.isNotEmpty() && nowMs - history.first().timestamp > config.historyMaxAgeMs) { + history.removeFirst() + } + } + + /** + * Drops remote CAMs (and any alerts derived from them) that haven't been updated within + * [UseCaseDetectionConfig.staleRemoteMs]. Call periodically (e.g. once per second) from a + * ticker — this is what makes an alert "clear automatically once the geometry resolves" or + * the remote road user goes out of range (Section 10.3 test procedure). + */ + fun pruneStale(nowMs: Long) { + val staleIds = remoteHistory.filterValues { history -> + val last = history.lastOrNull() ?: return@filterValues true + nowMs - last.timestamp > config.staleRemoteMs + }.keys + if (staleIds.isEmpty()) return + staleIds.forEach { id -> + remoteHistory.remove(id) + activeAlerts.keys.filter { it.first == id }.forEach { activeAlerts.remove(it) } + } + publish() + } + + /** Resets all state (e.g. on disconnect). */ + fun reset() { + ownCam = null + remoteHistory.clear() + activeAlerts.clear() + publish() + } + + // ── Trend (per-station history → deltas) ───────────────────────────────── + + /** Trend derived from a remote station's history: how its heading/distance are changing. */ + private data class Trend( + val headingRateDegPerSec: Double?, + val distanceTrendMps: Double?, + val sampleCount: Int, + ) + + private fun computeTrend(history: ArrayDeque, own: Cam): Trend { + if (history.size < config.minHistorySamplesForTrend) return Trend(null, null, history.size) + + val oldest = history.first() + val newest = history.last() + val dtSec = (newest.timestamp - oldest.timestamp) / 1000.0 + if (dtSec < 0.1) return Trend(null, null, history.size) // too little time elapsed to trust a rate + + val headingRate = GeoMath.angleDiffSigned(oldest.headingDeg, newest.headingDeg) / dtSec + + val distOld = GeoMath.haversineMeters(own.latitude, own.longitude, oldest.latitude, oldest.longitude) + val distNew = GeoMath.haversineMeters(own.latitude, own.longitude, newest.latitude, newest.longitude) + val distanceTrend = (distNew - distOld) / dtSec + + return Trend(headingRate, distanceTrend, history.size) + } + + // ── Evaluation ──────────────────────────────────────────────────────────── + + private fun evaluate(remote: Cam) { + val own = ownCam + if (own == null) { + // No ego state yet — nothing to correlate against. + UseCaseType.entries.forEach { activeAlerts.remove(remote.stationId to it) } + return + } + + val distance = GeoMath.haversineMeters(own.latitude, own.longitude, remote.latitude, remote.longitude) + if (distance > config.maxConsiderationRadiusM) { + UseCaseType.entries.forEach { activeAlerts.remove(remote.stationId to it) } + return + } + + val history = remoteHistory[remote.stationId] ?: ArrayDeque().also { it.addLast(remote) } + val trend = computeTrend(history, own) + + val relPos = GeoMath.toLocalMeters(own.latitude, own.longitude, remote.latitude, remote.longitude) + val ownVel = GeoMath.velocityVector(own.speedMps, own.headingDeg) + val remoteVel = GeoMath.velocityVector(remote.speedMps, remote.headingDeg) + val relVel = remoteVel - ownVel + + val (tCpa, dCpa) = GeoMath.closestPointOfApproach(relPos, relVel, config.maxProjectionHorizonSec) + val closingSpeed = GeoMath.closingSpeed(relPos, relVel) + val headingDelta = GeoMath.angleDiffDeg(own.headingDeg, remote.headingDeg) + + // Bearing to the remote, relative to the ego's own heading: 0 = dead ahead, + // 90 = right side, 180 = dead astern, 270 = left side. + val bearingToRemote = GeoMath.initialBearingDeg(own.latitude, own.longitude, remote.latitude, remote.longitude) + val relBearing = GeoMath.normalizeAngle(bearingToRemote - own.headingDeg) + + // Corroborate instantaneous closing speed with the distance trend when we have enough + // history to trust it; otherwise fall back to the single-sample closing speed alone. + val isActuallyClosing = when { + trend.distanceTrendMps != null -> trend.distanceTrendMps < 0.0 + else -> closingSpeed >= config.closingSpeedMinMps + } + + // ── Turn-toward-ego detection: prefer the CAM's own yaw rate field; fall back to the + // heading-rate derived from this station's history when the field isn't present. ── + val effectiveYawRateDps = remote.yawRateDps ?: trend.headingRateDegPerSec + val yawSignalSource = when { + remote.yawRateDps != null -> "yaw-rate" + trend.headingRateDegPerSec != null -> "heading-trend" + else -> null + } + val bearingFromRemoteToEgo = GeoMath.initialBearingDeg(remote.latitude, remote.longitude, own.latitude, own.longitude) + val angleRemoteHeadingToEgo = GeoMath.angleDiffSigned(remote.headingDeg, bearingFromRemoteToEgo) + val turningTowardEgo = effectiveYawRateDps != null && + abs(effectiveYawRateDps) >= config.turnYawRateThresholdDegPerSec && + abs(angleRemoteHeadingToEgo) <= config.turnTowardEgoMaxAngleDeg && + sign(effectiveYawRateDps) == sign(angleRemoteHeadingToEgo) + + val results = mutableMapOf>() // use case -> (ttc, signalNote) + + // ── IMA-B: crossing paths near an intersection (primary use case, Section 1.1) ── + if (headingDelta in config.crossingHeadingMinDeg..config.crossingHeadingMaxDeg && + distance <= config.intersectionRadiusM && + own.speedMps >= config.minMovingSpeedMps && + remote.speedMps >= config.minMovingSpeedMps && + dCpa <= config.conflictRadiusM && + tCpa > 0.0 + ) { + results[UseCaseType.IMA_B] = tCpa to "crossing-geometry" + } + + // ── IMA-S: standstill remote vehicle, ego approaching an intersection ─────────── + if (remote.speedMps < config.standstillSpeedThresholdMps && + own.speedMps >= config.minMovingSpeedMps && + distance <= config.intersectionRadiusM && + isActuallyClosing + ) { + val ttc = if (tCpa > 0.0) tCpa else distance / closingSpeed.coerceAtLeast(config.closingSpeedMinMps) + val note = if (trend.distanceTrendMps != null) "distance-trend" else "static-heuristic" + results[UseCaseType.IMA_S] = ttc to note + } + + // ── RTW-B / LTW-B: turning toward the ego (yaw-rate/heading-trend) with a parallel- + // heading + lateral-sector + closing-distance fallback for when no yaw signal exists. ── + val turnSideGatesPass = distance <= config.intersectionRadiusM && isActuallyClosing + if (turnSideGatesPass) { + val staticHeuristicPasses = headingDelta <= config.parallelHeadingMaxDeg && dCpa <= config.turnConflictRadiusM + val ttcFallback = { tCpa.takeIf { it > 0.0 } ?: (distance / closingSpeed.coerceAtLeast(config.closingSpeedMinMps)) } + + if (relBearing in 0.0..90.0 && (turningTowardEgo || staticHeuristicPasses)) { + val note = if (turningTowardEgo) (yawSignalSource ?: "static-heuristic") else "static-heuristic" + results[UseCaseType.RTW_B] = ttcFallback() to note + } + if (relBearing in 270.0..360.0 && (turningTowardEgo || staticHeuristicPasses)) { + val note = if (turningTowardEgo) (yawSignalSource ?: "static-heuristic") else "static-heuristic" + results[UseCaseType.LTW_B] = ttcFallback() to note + } + } + + // ── SMVA / BCW-B: same-direction closing speed (rural / lateral accident pattern) ── + val sameDirectionSector = relBearing <= config.sameDirectionSectorDeg || + relBearing >= 360.0 - config.sameDirectionSectorDeg || + (relBearing - 180.0) in -config.sameDirectionSectorDeg..config.sameDirectionSectorDeg + if (headingDelta <= config.parallelHeadingMaxDeg && + distance <= config.roadwayRadiusM && + sameDirectionSector && + abs(own.speedMps - remote.speedMps) >= config.speedDifferentialMinMps && + isActuallyClosing + ) { + val ttc = if (tCpa > 0.0) tCpa else distance / closingSpeed.coerceAtLeast(config.closingSpeedMinMps) + val note = if (trend.distanceTrendMps != null) "distance-trend" else "static-heuristic" + results[UseCaseType.SMVA_BCW_B] = ttc to note + } + + // ── Publish / clear per use case ───────────────────────────────────────────── + UseCaseType.entries.forEach { type -> + val key = remote.stationId to type + val result = results[type] + val level = result?.let { config.alertLevelForTtc(it.first) } + if (result != null && level != null) { + activeAlerts[key] = UseCaseAlert( + useCase = type, + alertLevel = level, + remoteStationId = remote.stationId, + remoteStationType = remote.stationType, + distanceMeters = distance, + closingSpeedMps = closingSpeed, + timeToConflictSec = result.first, + signalNote = result.second, + lastUpdated = remote.timestamp, + ) + } else { + activeAlerts.remove(key) + } + } + } + + private fun publish() { + _currentAlerts.value = activeAlerts.values + .sortedWith( + compareBy { it.alertLevel.ordinal * -1 } + .thenBy { it.timeToConflictSec } + ) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseType.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseType.kt new file mode 100644 index 0000000..8b65d8c --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/usecase/UseCaseType.kt @@ -0,0 +1,34 @@ +package com.hawhamburg.micr0bu.domain.usecase + +/** + * The C2C-CC Bicycle Safety Use Cases (White Paper C2CCC_WP_2324, v1.0, 2026-05-07) that are + * in scope for this project's CAM-only architecture (Section 1.2 of the requirements doc). + * + * All five are achievable from CAM/CAMv2 exchange alone (position, speed, heading, station + * type) — no DENM generation or consumption is involved. + * + * Deliberately **not** included: Bike Accident Warning (BAW, UC_BIKE_00010) — it depends on + * DENM generation by a fallen cyclist's OBU, which this project does not implement. See + * `com.hawhamburg.micr0bu.domain.denm` for the (unrelated, manual/test-only) DENM trigger. + */ +enum class UseCaseType( + /** Short label as used in the C2C-CC white paper and this project's requirements doc. */ + val label: String, + /** C2C-CC use case ID(s). */ + val c2cId: String, +) { + /** Intersection Movement Assist for bikes — primary Use Case 1 (Section 1.1). */ + IMA_B("IMA-B", "UC_BIKE_00001/2"), + + /** Intersection Movement Assist with Standstill Vehicle. */ + IMA_S("IMA-S", "UC_BIKE_00003"), + + /** Right-turn Warning for bike. */ + RTW_B("RTW-B", "UC_BIKE_00004/5"), + + /** Left-turn Warning for bike. */ + LTW_B("LTW-B", "UC_BIKE_00006/7"), + + /** Slow Moving Vehicle Ahead / Backward Collision Warning for bike. */ + SMVA_BCW_B("SMVA/BCW-B", "UC_BIKE_00008/9"), +}