Construye una brújula inteligente en Android con Kotlin y Jetpack Compose
Inicia sesión para descargarAprende a construir una brújula inteligente en Android usando Kotlin y Jetpack Compose. Combinaremos acelerómetro y magnetómetro, calcularemos el rumbo magnético, estabilizaremos las lecturas, detectaremos posibles interferencias y mostraremos el estado de …
Contenido del tutorial ⌄
- El verdadero reto de una brújula
- Qué vamos a construir
- Preparar el entorno de desarrollo
- Activar la depuración del dispositivo
- Requisitos
- Activar la depuración USB
- Comprobar que Android reconoce el teléfono
- Comprobar los sensores del teléfono
- Verificar Gradle y compilar el proyecto
- Por qué necesitamos dos sensores
- El acelerómetro
- El magnetómetro
- Por qué deben trabajar juntos
- Cómo se calcula el rumbo
- Las lecturas como vectores
- 1. Construir la matriz de rotación
- 2. Corregir la rotación de la pantalla
- 3. Obtener azimut, pitch y roll
- 4. Convertir los radianes a grados
- 5. Normalizar el rumbo
- 6. Estabilizar el rumbo
- 7. Entregar la lectura a la interfaz
- Norte magnético frente a norte verdadero
- La declinación magnética
- Alcance y limitaciones del proyecto
- Lo que sí hará SmartCompass
- Lo que no hará SmartCompass
- Cuatro conceptos que no deben confundirse
- Crear el proyecto Android
- Crear una aplicación vacía con Compose
- Comprobar el paquete de MainActivity
- Compilar el proyecto base
- Configurar Gradle
- Dependencias utilizadas por SmartCompass
- Utilizar el catálogo de versiones
- Revisar el bloque dependencies
- Por qué existen varios tipos de dependencia
- Mantener alineadas las bibliotecas de Compose
- Verificar el ejecutor de pruebas instrumentadas
- Sincronizar el proyecto
- Comprobar la configuración
- Revisar AndroidManifest.xml
- Por qué no necesitamos modificarlo
- Crear las operaciones angulares con AngleMath
- Normalizar el rumbo
- Convertir radianes a grados
- Encontrar la diferencia angular mínima
- Implementar AngleMath
- Por qué esta clase pertenece a domain
- Convertir el rumbo en una dirección cardinal
- Dividir el círculo en ocho sectores
- Calcular el sector
- Comprobar los límites de cada sector
- Utilizar la conversión
- Reducir el ruido con LowPassVectorFilter
- Combinar la lectura anterior con la nueva
- Tres decisiones importantes
- Implementar el filtro
- Probar el comportamiento con dos muestras
- Reiniciar el estado
- Estabilizar el rumbo con CircularAngleFilter
- Por qué una media normal produce un error
- Representar cada ángulo como un vector
- Cómo funcionará el filtro
- Controlar la velocidad de respuesta
- Implementar CircularAngleFilter
- Mantener una ventana sin crear objetos
- Resolver direcciones opuestas
- Comprobar el cruce por el norte
- Reiniciar el filtro
- Construir una lectura con CompassReading y CompassEngine
- Calcular la intensidad del campo magnético
- Modelar una lectura de la brújula
- Transformar los resultados con CompassEngine
- Flujo de transformación
- Comprobar una lectura sencilla
- Modelar disponibilidad y precisión de los sensores
- Representar la precisión del magnetómetro
- Identificar sensores faltantes
- Desacoplar las lecturas con CompassSensorSource
- Incluir la precisión en CompassReading
- Mantener el rumbo al rotar la pantalla
- Representar los ejes sin depender de Android
- Revisar las cuatro combinaciones
- Crear un proveedor de rotación
- Consultar la rotación en Android
- Utilizar el proveedor más adelante
- Modelar el análisis del campo magnético
- Separar la lectura actual de su análisis
- Medir la distancia respecto a la referencia
- Implementar MagneticFieldAssessment
- Representar el estado inicial
- Representar un análisis completo
- Lugar del modelo en el flujo
- Detectar posibles interferencias magnéticas
- Cómo funciona el detector
- Parámetros utilizados por SmartCompass
- Implementar MagneticInterferenceDetector
- Evitar que la referencia siga una anomalía
- Exigir persistencia en ambos sentidos
- Comprobar la activación y la recuperación
- Reiniciar el detector
- Evaluar el estado de calibración
- Definir estados comprensibles
- Combinar precisión y análisis magnético
- Traducir SensorAccuracy con CalibrationEvaluator
- Interpretar el resultado final
- Cerrar el contrato de CompassSensorSource
- Qué expone el contrato
Combinaremos acelerómetro y magnetómetro para calcular el rumbo magnético, estabilizar las lecturas y mostrar cuándo la orientación necesita calibración.
Leer una brújula desde Android parece sencillo: obtener un ángulo y dibujar una aguja. En la práctica, los sensores producen ruido, el teléfono puede cambiar de orientación y el magnetómetro puede verse afectado por objetos metálicos o campos cercanos.
Además, los ángulos no se comportan como números lineales. El promedio entre 359° y 1° debería quedar cerca de 0°, no en 180°. Por eso necesitaremos matemáticas circulares y filtros diseñados para trabajar con direcciones.
En este tutorial construiremos una aplicación con Kotlin y Jetpack Compose que combinará acelerómetro y magnetómetro, calculará el rumbo magnético, corregirá la rotación de la pantalla y evaluará la estabilidad de las lecturas.
El verdadero reto de una brújula
- Reducir el movimiento provocado por el ruido de los sensores.
- Evitar saltos al cruzar entre
359°y0°. - Corregir el rumbo cuando cambia la rotación de la pantalla.
- Detectar posibles alteraciones del entorno magnético.
- Explicar al usuario cuándo la lectura puede considerarse estable.
El objetivo no será leer un único valor del magnetómetro, sino construir un flujo completo: recibir datos físicos, procesarlos, convertirlos en un rumbo comprensible y mostrarlos mediante una interfaz desacoplada y comprobable.
Qué vamos a construir
Construiremos una aplicación Android que utiliza el acelerómetro y el magnetómetro para calcular el rumbo magnético del teléfono y mostrarlo mediante una brújula animada creada con Jetpack Compose.
La aplicación no se limitará a dibujar una aguja. También filtrará el ruido de los sensores, corregirá la rotación de la pantalla, analizará el campo magnético y explicará cuándo la lectura necesita calibración.
- Rumbo magnético: 023°
- Dirección: NE · Noreste
- Precisión del sensor: Media
- Campo magnético: 43.2 μT
- Referencia local: 41.8 μT
- Estado general: Lista
Además de mostrar el rumbo, la aplicación incluirá una rosa de los vientos animada, una aguja orientada hacia el norte magnético, valores de pitch y roll, información sobre la precisión del magnetómetro y advertencias ante posibles alteraciones del entorno magnético.
- Estabilizará las lecturas mediante filtros.
- Evitará saltos al pasar de 359° a 0°.
- Se adaptará a la rotación de la pantalla.
- Manejará teléfonos que no tengan los sensores necesarios.
- Iniciará y detendrá los sensores con el ciclo de vida de la aplicación.
- No solicitará permisos de ubicación.
Antes de comenzar con el código, ya sabemos qué información mostrará la aplicación y qué problemas técnicos resolverá.
Preparar el entorno de desarrollo
Para construir y probar la brújula necesitaremos Android Studio y un teléfono Android físico. El emulador puede servir para revisar la interfaz, pero normalmente no reproduce de forma realista las lecturas del magnetómetro.
- Android Studio con soporte para Kotlin y Jetpack Compose.
- Teléfono Android físico para probar los sensores.
- Depuración USB o depuración inalámbrica activada.
- Acelerómetro para conocer la dirección de la gravedad.
- Magnetómetro para medir el campo magnético.
- Cable USB compatible con transferencia de datos, cuando no se utilice conexión inalámbrica.
Activar la depuración del dispositivo
En el teléfono, abre las opciones para desarrolladores y activa la depuración USB. Después, conecta el dispositivo al computador y acepta la autorización que aparecerá en la pantalla.
Requisitos
- Android Studio con soporte para Kotlin y Jetpack Compose.
- Teléfono Android físico para probar las lecturas reales.
- Depuración USB o depuración inalámbrica activada.
- Acelerómetro para identificar la dirección de la gravedad.
- Magnetómetro para medir el campo magnético.
- Cable USB de datos cuando no se utilice depuración inalámbrica.
- Conocimientos básicos de Kotlin y Jetpack Compose.
Activar la depuración USB
En el teléfono, abre las opciones para desarrolladores y activa la depuración USB. Después conecta el dispositivo al computador y acepta la autorización que aparecerá en la pantalla.
En muchos dispositivos, las opciones para desarrolladores se habilitan tocando varias veces el número de compilación dentro de la información del teléfono.
Comprobar que Android reconoce el teléfono
Abre la terminal de Android Studio o PowerShell y ejecuta:
adb devices
El teléfono debe aparecer con el estado device.
List of devices attached
XXXXXXXXXXXX device
unauthorized, desbloquea el teléfono y acepta la autorización de depuración. Si no aparece ningún dispositivo, revisa el cable USB, el modo de conexión y los controladores instalados.
Comprobar los sensores del teléfono
Podemos consultar las características declaradas por el dispositivo y buscar específicamente el acelerómetro y el magnetómetro.
adb shell pm list features | findstr /I "android.hardware.sensor.accelerometer android.hardware.sensor.compass"
feature:android.hardware.sensor.accelerometer
feature:android.hardware.sensor.compass
Si aparecen ambas características, el teléfono declara los sensores necesarios para ejecutar la brújula. La aplicación también comprobará su disponibilidad durante la ejecución.
Verificar Gradle y compilar el proyecto
Desde la carpeta raíz del proyecto, comprueba primero que el Gradle Wrapper funciona.
.\gradlew.bat --version
Después genera la compilación de depuración:
.\gradlew.bat assembleDebug
BUILD SUCCESSFUL
Confirma que el teléfono aparece como device, que declara acelerómetro y magnetómetro y que assembleDebug termina con BUILD SUCCESSFUL.
Por qué necesitamos dos sensores
El teléfono no obtiene el rumbo magnético leyendo un único valor. Para calcular correctamente su orientación, Android combina la información del acelerómetro y del magnetómetro.
El acelerómetro
El acelerómetro mide fuerzas en los ejes X, Y y Z. Cuando el teléfono está relativamente quieto, estas mediciones permiten identificar la dirección de la gravedad y conocer cómo está inclinado el dispositivo.
El magnetómetro
El magnetómetro mide la intensidad del campo magnético alrededor del teléfono en los mismos tres ejes. Sus lecturas permiten obtener una referencia hacia el norte magnético.
Por qué deben trabajar juntos
El magnetómetro indica cómo se comporta el campo magnético, mientras que el acelerómetro informa la inclinación del teléfono. Al combinar ambos vectores, Android puede compensar la posición del dispositivo y calcular su orientación.
Acelerómetro
└── Dirección de la gravedad e inclinación
Magnetómetro
└── Campo magnético en X, Y y Z
Acelerómetro + magnetómetro
└── Orientación del dispositivo
- Acelerómetro: ayuda a identificar la dirección de la gravedad y la inclinación.
- Magnetómetro: mide el campo magnético en tres ejes.
- Ambos sensores: permiten calcular una orientación compensada según la posición del teléfono.
El acelerómetro aporta la referencia de gravedad y el magnetómetro aporta la referencia magnética. La orientación se obtiene al combinar ambos.
Cómo se calcula el rumbo
Las lecturas del acelerómetro y del magnetómetro todavía no representan un rumbo listo para mostrar. Cada sensor entrega valores en los ejes X, Y y Z, por lo que Android debe combinarlos y transformarlos en una orientación comprensible.
El procesamiento sigue una cadena definida: primero se construye una matriz de rotación, después se corrigen los ejes según la orientación de la pantalla y, finalmente, se obtienen el azimut, el pitch y el roll.
Acelerómetro + magnetómetro
↓
Matriz de rotación
↓
Corrección por rotación de pantalla
↓
Azimut, pitch y roll
↓
Conversión de radianes a grados
↓
Normalización del rumbo
↓
Filtro circular
↓
Interfaz con Jetpack Compose
Las lecturas como vectores
El acelerómetro entrega un vector que representa la aceleración medida en tres ejes, mientras que el magnetómetro entrega otro vector con las componentes del campo magnético.
En esta expresión, $\vec{a}$ representa la lectura del acelerómetro y $\vec{m}$ representa la lectura del magnetómetro. Android combina ambos vectores para calcular cómo está orientado el dispositivo.
1. Construir la matriz de rotación
Android utiliza el vector de gravedad y el vector del campo magnético para construir una matriz de rotación. Esta matriz representa la orientación del teléfono con respecto al entorno.
2. Corregir la rotación de la pantalla
Los ejes físicos del teléfono no siempre coinciden con la parte superior visible de la pantalla. El dispositivo puede estar en orientación vertical, horizontal o completamente rotado.
Antes de calcular el rumbo, la aplicación reorganiza los ejes para las rotaciones de 0°, 90°, 180° y 270°. Así, la parte superior de la pantalla continúa siendo la dirección hacia la que apunta el teléfono.
3. Obtener azimut, pitch y roll
Después de corregir los ejes, Android extrae tres ángulos de orientación:
- Azimut: giro alrededor del eje vertical. Será la base del rumbo de la brújula.
- Pitch: inclinación del teléfono hacia delante o hacia atrás.
- Roll: inclinación lateral del dispositivo.
4. Convertir los radianes a grados
SensorManager.getOrientation() entrega los ángulos en radianes. Para mostrarlos como una brújula convencional, la aplicación convierte el azimut a grados.
Por ejemplo, un ángulo de $\frac{\pi}{2}$ radianes equivale a $90^\circ$.
5. Normalizar el rumbo
Después de convertir el azimut, el resultado puede ser negativo o superar una vuelta completa. La aplicación lo normaliza para mantenerlo en el intervalo $0^\circ \leq \theta < 360^\circ$.
El segundo módulo hace que valores negativos y positivos terminen dentro de una sola vuelta.
-1° → 359°
360° → 0°
721° → 1°
Una vez normalizado, el rumbo puede relacionarse con los puntos cardinales:
- 0°: norte.
- 90°: este.
- 180°: sur.
- 270°: oeste.
6. Estabilizar el rumbo
El ángulo calculado puede cambiar ligeramente incluso cuando el teléfono parece inmóvil. Por eso la lectura pasa por un filtro circular que reduce el ruido y conserva la continuidad alrededor de la vuelta completa.
En esta etapa solo necesitamos entender que los ángulos forman un círculo. La construcción matemática completa del promedio circular se explicará más adelante, cuando implementemos CircularAngleFilter.
359° y 1° están alrededor del norte, aunque una media aritmética ordinaria produciría un resultado incorrecto.
7. Entregar la lectura a la interfaz
Después de convertir, normalizar y filtrar el rumbo, la aplicación construye una lectura con el heading, el pitch, el roll, la precisión del magnetómetro y la intensidad del campo magnético.
Esa lectura llegará al ViewModel, se transformará en un estado de interfaz y finalmente será mostrada mediante Jetpack Compose. La pantalla no realizará cálculos con sensores: recibirá datos ya preparados.
El rumbo no procede directamente de una sola lectura del magnetómetro. Es el resultado de combinar dos vectores, construir una matriz de rotación, corregir los ejes, extraer el azimut, convertirlo a grados, normalizarlo y estabilizarlo antes de mostrarlo.
Norte magnético frente a norte verdadero
El rumbo calculado por nuestra aplicación procede del magnetómetro del teléfono. Por esta razón, la brújula apunta hacia el norte magnético y no directamente hacia el norte geográfico.
Aunque ambos conceptos suelen representarse con la letra N, no describen exactamente la misma dirección.
Magnetómetro
↓
Campo magnético
↓
Norte magnético
↓
Rumbo mostrado por SmartCompass
Norte magnético
+
Ubicación, fecha y modelo geomagnético
↓
Corrección por declinación
↓
Norte verdadero
La declinación magnética
La diferencia angular entre el norte magnético y el norte verdadero se conoce como declinación magnética. Su valor no es igual en todos los lugares y puede variar con el tiempo.
Para realizar esa corrección, una aplicación necesitaría conocer la ubicación del dispositivo, la fecha y consultar un modelo geomagnético. Ese procesamiento queda fuera del alcance de este tutorial.
En consecuencia, cuando la interfaz muestre un rumbo de 000° o la dirección N · Norte, significará que la parte superior del teléfono apunta aproximadamente hacia el norte magnético detectado por el sensor.
No debemos describir ese resultado como norte geográfico ni utilizarlo como referencia exacta para navegación profesional.
SmartCompass muestra norte magnético. Obtener norte verdadero requeriría ubicación, fecha y una corrección mediante declinación magnética.
Alcance y limitaciones del proyecto
SmartCompass está diseñada como una aplicación educativa para aprender a procesar sensores reales en Android. Su objetivo es mostrar cómo combinar mediciones físicas, estabilizar el rumbo y comunicar al usuario el estado de la lectura.
Antes de comenzar con la arquitectura y el código, conviene establecer qué funciones tendrá la aplicación y cuáles permanecerán fuera del alcance del tutorial.
Lo que sí hará SmartCompass
- Utilizar el acelerómetro y el magnetómetro del teléfono.
- Calcular una matriz de rotación a partir de ambos sensores.
- Obtener azimut, pitch y roll.
- Mostrar un rumbo magnético entre
0°y359°. - Convertir el rumbo en una dirección cardinal.
- Corregir los ejes cuando cambia la rotación de la pantalla.
- Filtrar el ruido de los vectores del acelerómetro y del magnetómetro.
- Estabilizar el rumbo mediante matemáticas circulares.
- Calcular la intensidad total del campo magnético en microteslas.
- Aprender una referencia magnética local durante los primeros segundos.
- Advertir sobre posibles cambios magnéticos persistentes.
- Informar la precisión y el estado de calibración.
- Manejar dispositivos que no tengan los sensores requeridos.
- Iniciar y detener los sensores según el ciclo de vida de la aplicación.
Lo que no hará SmartCompass
- No calculará el norte verdadero o geográfico.
- No aplicará correcciones por declinación magnética.
- No utilizará GPS ni solicitará permisos de ubicación.
- No realizará navegación terrestre o marítima.
- No reemplazará una brújula profesional.
- No medirá científicamente el campo magnético terrestre.
- No funcionará como detector profesional de metales.
- No certificará que exista una interferencia electromagnética.
- No combinará las lecturas con un giroscopio.
- No garantizará el mismo nivel de precisión en todos los teléfonos.
Cuatro conceptos que no deben confundirse
Durante el tutorial aparecerán varios indicadores relacionados con la confiabilidad de la lectura. Aunque están conectados, cada uno representa algo diferente.
El resultado final debe interpretarse como una estimación basada en sensores de consumo. La calidad puede variar según el hardware, la calibración del teléfono, la inclinación del dispositivo y los objetos presentes en el entorno.
Por eso la interfaz no mostrará únicamente un número: también explicará si los sensores están disponibles, si la precisión es suficiente, si la referencia local ya está lista y si existe una posible alteración magnética.
SmartCompass es una herramienta educativa que muestra norte magnético, procesa sensores reales y comunica posibles problemas de estabilidad. No debe presentarse como una brújula científica, un detector de metales ni un instrumento profesional de navegación.
Crear el proyecto Android
Con los fundamentos y las limitaciones definidos, podemos comenzar la implementación. Crearemos primero una aplicación vacía con Jetpack Compose y verificaremos que el proyecto compile correctamente antes de agregar sensores, filtros o estados de interfaz.
Esta comprobación inicial es importante: si el proyecto base compila, cualquier error posterior podrá relacionarse con los cambios que vayamos incorporando.
Crear una aplicación vacía con Compose
Abre Android Studio y selecciona la opción para crear un proyecto nuevo. En la lista de plantillas, elige una actividad vacía compatible con Jetpack Compose.
com.tucodigocotidiano.smartcompass. Cuando Android Studio permita elegir el lenguaje de los archivos de compilación, conserva Kotlin DSL.
-
Nombre:
SmartCompass -
Nombre del paquete:
com.tucodigocotidiano.smartcompass - Lenguaje: Kotlin.
- Interfaz: Jetpack Compose.
- Configuración de compilación: Kotlin DSL cuando Android Studio muestre esta opción.
com.tucodigocotidiano.smartcompass. Las clases del tutorial utilizarán esta ruta en sus declaraciones package e importaciones.
Después de crear el proyecto, espera a que Android Studio termine la sincronización de Gradle. No agregues todavía código de sensores ni dependencias adicionales.
En este momento solo necesitamos confirmar que existe una aplicación Compose mínima y que la estructura inicial fue generada correctamente.
SmartCompass/
├── app/
│ └── src/
│ └── main/
│ ├── AndroidManifest.xml
│ ├── java/
│ │ └── com/
│ │ └── tucodigocotidiano/
│ │ └── smartcompass/
│ │ ├── MainActivity.kt
│ │ └── ui/
│ │ └── theme/
│ └── res/
├── build.gradle.kts
├── settings.gradle.kts
├── gradlew
└── gradlew.bat
Comprobar el paquete de MainActivity
Abre el archivo MainActivity.kt y comprueba que la primera línea utiliza el paquete del proyecto.
package com.tucodigocotidiano.smartcompass
La plantilla puede incluir una pantalla de ejemplo, un saludo o una vista previa de Compose. No es necesario conservar ese contenido a largo plazo, pero por ahora sirve para confirmar que la configuración inicial funciona.
Compilar el proyecto base
Abre PowerShell o la terminal integrada de Android Studio en la carpeta raíz del proyecto. La terminal debe estar ubicada en el mismo directorio donde se encuentra gradlew.bat.
Ejecuta la compilación de depuración:
.\gradlew.bat assembleDebug
BUILD SUCCESSFUL
Gradle generará el APK de depuración dentro de la carpeta de resultados del módulo app. Todavía no necesitamos instalarlo manualmente: lo importante es confirmar que el proyecto base puede compilarse sin errores.
gradlew.bat no existe, revisa que la terminal esté abierta en la raíz del proyecto. Si la compilación falla durante la descarga de dependencias, comprueba la conexión a internet y vuelve a sincronizar Gradle desde Android Studio.
Confirma que el proyecto se llama SmartCompass, que utiliza el paquete com.tucodigocotidiano.smartcompass y que el comando .\gradlew.bat assembleDebug termina con BUILD SUCCESSFUL.
Configurar Gradle
El proyecto Compose ya incluye una configuración inicial, pero SmartCompass necesitará algunas bibliotecas adicionales para manejar el ciclo de vida, conservar el estado en un ViewModel, trabajar con Flow y ejecutar pruebas.
No reemplazaremos todo el archivo de compilación. Revisaremos el módulo app, conservaremos la configuración generada por Android Studio y agregaremos únicamente las dependencias que falten.
Dependencias utilizadas por SmartCompass
-
Activity Compose:
permite mostrar la interfaz Compose desde
MainActivity. -
Material 3:
proporciona componentes como
Scaffold,Card,Text,SurfaceyCircularProgressIndicator. -
Lifecycle Runtime Compose:
permite recoger el
StateFlowrespetando el ciclo de vida mediantecollectAsStateWithLifecycle(). -
Lifecycle Runtime KTX:
proporciona
lifecycleScopeyrepeatOnLifecycle()para recolectar las lecturas de los sensores. -
Lifecycle ViewModel:
permite conservar y exponer el estado de la brújula mediante
CompassViewModel. -
Coroutines:
proporciona
Flow,StateFlow,SharedFlowy las corrutinas utilizadas por la aplicación. - JUnit: permite ejecutar pruebas unitarias locales de las clases matemáticas y de dominio.
- Compose UI Test: permite comprobar el rumbo, los estados de pantalla y los elementos accesibles de la interfaz.
SensorManager, el acelerómetro y el magnetómetro forman parte del framework de Android. Tampoco agregaremos dependencias de GPS, mapas o ubicación.
Utilizar el catálogo de versiones
Los proyectos recientes de Android Studio suelen centralizar las versiones y los nombres de las bibliotecas en gradle/libs.versions.toml. El archivo app/build.gradle.kts utiliza después esos alias mediante el objeto libs.
Por esta razón, no escribiremos números de versión directamente en el tutorial. Así evitamos que la guía quede vinculada a una versión concreta de Compose, Lifecycle o Coroutines.
libs.versions.toml y adapta únicamente los nombres que sean diferentes.
Revisar el bloque dependencies
Dentro de app/build.gradle.kts, localiza el bloque dependencies. La configuración final debe conservar las dependencias base de Compose e incluir las bibliotecas de ciclo de vida, corrutinas y pruebas.
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
Las dependencias compose.ui y compose.ui.graphics son necesarias porque la rosa de los vientos y la aguja utilizarán Canvas, primitivas gráficas, modificadores y elementos de dibujo.
compose.ui.tooling.preview y compose.ui.tooling permiten utilizar las vistas previas de Compose durante el desarrollo. La aplicación final no depende de las herramientas de inspección en su versión de producción.
Por qué existen varios tipos de dependencia
-
implementationincluye bibliotecas necesarias para compilar y ejecutar la aplicación. -
testImplementationincluye herramientas utilizadas únicamente por las pruebas locales de la JVM. -
androidTestImplementationincluye herramientas para pruebas que se ejecutan en un dispositivo o emulador Android. -
debugImplementationagrega herramientas disponibles únicamente en compilaciones de desarrollo.
Mantener alineadas las bibliotecas de Compose
La plataforma androidx.compose.bom mantiene compatibles entre sí las bibliotecas de Compose. Gracias a ella, las dependencias de interfaz no necesitan declarar una versión individual dentro de build.gradle.kts.
La misma plataforma se declara para la aplicación y para las pruebas instrumentadas, evitando diferencias entre las versiones utilizadas por ambos entornos.
Verificar el ejecutor de pruebas instrumentadas
Dentro de android.defaultConfig, conserva el ejecutor generado por Android Studio. Lo utilizaremos más adelante para las pruebas de Compose.
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
android si el archivo ya contiene uno. Copia únicamente la propiedad testInstrumentationRunner dentro del bloque defaultConfig existente.
Sincronizar el proyecto
Guarda el archivo y ejecuta la sincronización de Gradle desde Android Studio. Durante este proceso, el entorno comprobará que todos los alias existen en libs.versions.toml y descargará las bibliotecas que todavía no estén disponibles.
Si Android Studio muestra un alias en rojo, revisa primero su nombre en el catálogo de versiones. No agregues números de versión directamente al módulo solo para ocultar el error.
Comprobar la configuración
Después de completar la sincronización, abre PowerShell en la raíz del proyecto y vuelve a compilar la aplicación.
.\gradlew.bat assembleDebug
BUILD SUCCESSFUL
Unresolved reference: lifecycle, Unresolved reference: coroutines o Unresolved reference: test, revisa que la dependencia correspondiente exista en el catálogo y que su alias coincida con el utilizado en build.gradle.kts.
Confirma que el módulo incluye Activity Compose, Material 3, Lifecycle Runtime, Lifecycle Runtime Compose, Lifecycle ViewModel, Coroutines, JUnit y Compose UI Test. La sincronización no debe mostrar errores y .\gradlew.bat assembleDebug debe terminar con BUILD SUCCESSFUL.
Revisar AndroidManifest.xml
Antes de comenzar con las clases de dominio, revisaremos el manifiesto generado por Android Studio.
SmartCompass puede acceder al acelerómetro y al magnetómetro mediante SensorManager sin solicitar permisos al usuario. Por tanto, conservaremos el archivo actual sin modificaciones.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Smartcompass">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Smartcompass"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Por qué no necesitamos modificarlo
El manifiesto ya contiene la actividad principal, el tema, los iconos y la configuración necesaria para iniciar la aplicación.
La existencia real del acelerómetro y del magnetómetro no se resolverá mediante el manifiesto. Más adelante, la capa de sensores consultará ambos componentes con SensorManager.getDefaultSensor() y podrá detectar cuando alguno no esté disponible.
uses-permission para acceder a estos sensores y tampoco utiliza ubicación, cámara o servicios de red.
Confirma que AndroidManifest.xml conserva su contenido original, que MainActivity continúa declarada como actividad principal y que no existen permisos que SmartCompass no utilice.
Crear las operaciones angulares con AngleMath
Antes de procesar las lecturas de orientación, centralizaremos las operaciones matemáticas que se repetirán en distintas partes de SmartCompass.
AngleMath será un objeto de dominio independiente de Android. Su responsabilidad será normalizar ángulos, convertir radianes a grados y calcular el recorrido más corto entre dos direcciones.
Normalizar el rumbo
Los cálculos de orientación pueden producir valores negativos o mayores que una vuelta completa. Para representar un rumbo, necesitamos llevar cualquier resultado al intervalo comprendido entre 0° y menos de 360°.
El módulo se aplica dos veces porque una sola operación puede conservar un resultado negativo. Con esta normalización obtenemos:
-1°se convierte en359°.360°se convierte en0°.721°se convierte en1°.
Convertir radianes a grados
Las funciones de orientación de Android entregan el azimut, el pitch y el roll en radianes. La interfaz y el resto de nuestra lógica trabajan con grados, por lo que necesitaremos realizar esta conversión.
Esta operación solo cambia la unidad. No normaliza el resultado, porque valores como -20° pueden ser necesarios para representar una inclinación o una diferencia angular.
Encontrar la diferencia angular mínima
Al mover una brújula no siempre conviene restar directamente el rumbo inicial al final. Entre 350° y 10°, por ejemplo, existe una diferencia numérica de −340°, pero el trayecto real más corto es avanzar solamente 20°.
La diferencia mínima se mantendrá en el intervalo desde −180° hasta menos de 180°.
- Desde
350°hasta10°, el resultado es+20°. - Desde
10°hasta350°, el resultado es-20°.
En el sistema de rumbo de la brújula, un resultado positivo representa un desplazamiento horario y uno negativo representa un desplazamiento antihorario.
Implementar AngleMath
Crea el paquete domain dentro del paquete principal del proyecto y agrega el archivo AngleMath.kt con el siguiente contenido:
package com.tucodigocotidiano.smartcompass.domain
/**
* Funciones matemáticas puras utilizadas por la brújula.
*
* Esta clase no depende de Android, SensorManager, Compose
* ni de ninguna API específica del sistema operativo.
*/
object AngleMath {
private const val FULL_CIRCLE_DEGREES = 360f
private const val HALF_CIRCLE_DEGREES = 180f
/**
* Normaliza cualquier ángulo para que quede dentro del intervalo:
*
* 0° <= resultado < 360°
*
* Ejemplos:
* - normalizeDegrees(-1f) = 359f
* - normalizeDegrees(360f) = 0f
* - normalizeDegrees(721f) = 1f
*/
fun normalizeDegrees(angle: Float): Float {
require(angle.isFinite()) {
"El ángulo debe ser un número finito."
}
val normalized =
((angle % FULL_CIRCLE_DEGREES) + FULL_CIRCLE_DEGREES) %
FULL_CIRCLE_DEGREES
// Evita devolver -0.0f.
return if (normalized == 0f) {
0f
} else {
normalized
}
}
/**
* Convierte radianes a grados.
*
* Esta función no normaliza el resultado porque una conversión
* puede necesitar conservar valores negativos o superiores a 360°.
*/
fun radiansToDegrees(radians: Float): Float {
require(radians.isFinite()) {
"El valor en radianes debe ser un número finito."
}
return Math.toDegrees(radians.toDouble()).toFloat()
}
/**
* Calcula la diferencia angular mínima para pasar desde [from]
* hasta [to].
*
* El resultado estará dentro del intervalo:
*
* -180° <= resultado < 180°
*
* Un resultado positivo significa giro horario.
* Un resultado negativo significa giro antihorario.
*
* Ejemplos:
* - de 350° a 10° = 20°
* - de 10° a 350° = -20°
*/
fun shortestAngleDifference(
from: Float,
to: Float
): Float {
require(from.isFinite() && to.isFinite()) {
"Los ángulos deben ser números finitos."
}
val normalizedFrom = normalizeDegrees(from)
val normalizedTo = normalizeDegrees(to)
return normalizeDegrees(
normalizedTo - normalizedFrom + HALF_CIRCLE_DEGREES
) - HALF_CIRCLE_DEGREES
}
}
Por qué esta clase pertenece a domain
AngleMath no importa clases de Android, sensores ni componentes de Compose. Solo recibe números y devuelve resultados matemáticos.
Esta independencia permitirá reutilizarla en el motor de la brújula, el filtro circular y la animación angular, además de probarla mediante pruebas unitarias locales.
NaN e infinito mediante require(). Un valor no finito no debe propagarse hacia los filtros, el estado de la interfaz o la animación de la brújula.
Comprueba que AngleMath produce conceptualmente los siguientes resultados:
-1° → 359°,
360° → 0°,
350° → 10° = +20° y
10° → 350° = -20°.
Estos casos se convertirán en pruebas automáticas más adelante.
Convertir el rumbo en una dirección cardinal
Un rumbo numérico es preciso, pero no siempre resulta cómodo para el usuario. Por eso convertiremos cada ángulo en una de las ocho direcciones principales de la brújula.
CardinalDirection almacenará la abreviatura y el nombre completo de cada dirección, además de una función capaz de transformar cualquier ángulo en su sector correspondiente.
Dividir el círculo en ocho sectores
Una vuelta completa tiene 360° y utilizaremos ocho direcciones. Por tanto, cada sector ocupa 45°.
- N: Norte.
- NE: Noreste.
- E: Este.
- SE: Sureste.
- S: Sur.
- SO: Suroeste.
- O: Oeste.
- NO: Noroeste.
Calcular el sector
El ángulo se normaliza primero con AngleMath.normalizeDegrees(). Después se suman 22.5°, que representan la mitad de un sector, y el resultado se divide entre 45°.
Este desplazamiento hace que el Norte quede centrado alrededor de 0° en lugar de comenzar exactamente allí.
val sectorIndex = (
(normalizedAngle + HALF_SECTOR_DEGREES) /
SECTOR_SIZE_DEGREES
).toInt() % orderedDirections.size
El módulo final permite volver al primer elemento cuando el cálculo alcanza el sector número ocho. Así, los ángulos cercanos a 360° regresan correctamente a Norte.
package com.tucodigocotidiano.smartcompass.domain
/**
* Representa las ocho direcciones cardinales principales.
*
* Cada dirección incluye:
* - abreviatura para la interfaz;
* - nombre completo en español.
*/
enum class CardinalDirection(
val abbreviation: String,
val displayName: String
) {
NORTH(
abbreviation = "N",
displayName = "Norte"
),
NORTHEAST(
abbreviation = "NE",
displayName = "Noreste"
),
EAST(
abbreviation = "E",
displayName = "Este"
),
SOUTHEAST(
abbreviation = "SE",
displayName = "Sureste"
),
SOUTH(
abbreviation = "S",
displayName = "Sur"
),
SOUTHWEST(
abbreviation = "SO",
displayName = "Suroeste"
),
WEST(
abbreviation = "O",
displayName = "Oeste"
),
NORTHWEST(
abbreviation = "NO",
displayName = "Noroeste"
);
companion object {
private const val SECTOR_SIZE_DEGREES = 45f
private const val HALF_SECTOR_DEGREES = 22.5f
private val orderedDirections = values()
/**
* Convierte un ángulo en una dirección cardinal.
*
* Sectores:
* - Norte: 337.5° hasta menos de 22.5°
* - Noreste: 22.5° hasta menos de 67.5°
* - Este: 67.5° hasta menos de 112.5°
* - Sureste: 112.5° hasta menos de 157.5°
* - Sur: 157.5° hasta menos de 202.5°
* - Suroeste: 202.5° hasta menos de 247.5°
* - Oeste: 247.5° hasta menos de 292.5°
* - Noroeste: 292.5° hasta menos de 337.5°
*/
fun fromDegrees(angle: Float): CardinalDirection {
val normalizedAngle = AngleMath.normalizeDegrees(angle)
val sectorIndex = (
(normalizedAngle + HALF_SECTOR_DEGREES) /
SECTOR_SIZE_DEGREES
).toInt() % orderedDirections.size
return orderedDirections[sectorIndex]
}
}
}
Comprobar los límites de cada sector
Los valores situados exactamente en un límite deben avanzar al siguiente sector. Estos casos son importantes porque pequeños cambios en el rumbo pueden modificar la dirección mostrada.
22.499°corresponde a N · Norte.22.5°corresponde a NE · Noreste.67.499°corresponde a NE · Noreste.67.5°corresponde a E · Este.337.499°corresponde a NO · Noroeste.337.5°vuelve a N · Norte.
Utilizar la conversión
Cualquier capa que reciba un rumbo normalizado podrá obtener su dirección mediante fromDegrees().
val direction =
CardinalDirection.fromDegrees(
23f
)
println(direction.abbreviation)
println(direction.displayName)
NE
Noreste
Esta enumeración permanece dentro de domain porque no depende de Android ni de Compose. Más adelante, el ViewModel utilizará esta función para transformar el rumbo de cada lectura en información lista para la interfaz.
Confirma que CardinalDirection contiene las ocho direcciones en orden horario, que utiliza AngleMath para normalizar el rumbo y que los límites 22.5°, 67.5° y 337.5° cambian al sector correcto.
Estos límites se cubrirán con pruebas unitarias en la sección dedicada a testing.
Reducir el ruido con LowPassVectorFilter
Las lecturas de los sensores cambian ligeramente incluso cuando el teléfono permanece casi inmóvil. Antes de calcular la orientación, suavizaremos por separado los componentes X, Y y Z del acelerómetro y del magnetómetro.
Para ello crearemos un filtro pasa-bajos con estado. Cada nueva muestra se combinará con el resultado anterior, reduciendo variaciones rápidas sin detener completamente la respuesta del sensor.
Combinar la lectura anterior con la nueva
El filtro aplica la misma operación a cada componente del vector. El resultado anterior recibe un peso definido por alpha, mientras que la muestra nueva recibe el peso restante.
En la fórmula, x_t es la nueva lectura, y_{t-1} es el último valor filtrado y y_t es el nuevo resultado.
- Un
alphacercano a 1 produce mayor suavizado, pero responde más lentamente. - Un
alphacercano a 0 sigue con mayor rapidez la lectura nueva. - Con
alpha = 0, el resultado coincide con la muestra nueva. - Con
alpha = 1, el resultado permanece en la primera muestra recibida.
Tres decisiones importantes
- La primera muestra se copia directamente. Así evitamos que el filtro comience artificialmente desde un vector lleno de ceros.
- Los arreglos se reutilizan. El estado interno y el arreglo de salida pueden actualizarse durante muchos eventos sin crear objetos nuevos en cada lectura.
-
Los valores no finitos se descartan.
Una muestra que contenga
NaNo infinito devuelvefalsey no modifica el estado del filtro.
Implementar el filtro
Crea el archivo LowPassVectorFilter.kt dentro del paquete domain y agrega el siguiente contenido:
package com.tucodigocotidiano.smartcompass.domain
/**
* Filtro pasa-bajos para vectores de sensores.
*
* Aplica por separado la fórmula:
*
* valorFiltrado =
* alpha * valorAnterior +
* (1 - alpha) * valorNuevo
*
* Un valor alpha cercano a 1 produce mayor suavizado,
* pero también aumenta ligeramente el retraso.
*
* La clase mantiene arreglos internos reutilizables para no crear
* objetos nuevos durante cada evento del sensor.
*
* @param alpha peso asignado al valor filtrado anterior.
* Debe estar entre 0 y 1.
*
* @param dimensions cantidad de componentes del vector.
* Para acelerómetro y magnetómetro se utilizan tres: X, Y y Z.
*/
class LowPassVectorFilter(
private val alpha: Float,
val dimensions: Int = DEFAULT_DIMENSIONS
) {
init {
require(alpha.isFinite()) {
"El valor alpha debe ser finito."
}
require(alpha in MINIMUM_ALPHA..MAXIMUM_ALPHA) {
"El valor alpha debe estar entre 0 y 1."
}
require(dimensions > 0) {
"La cantidad de dimensiones debe ser mayor que cero."
}
}
private val state = FloatArray(dimensions)
private var initialized = false
/**
* Indica si el filtro ya recibió una primera muestra válida.
*/
val isInitialized: Boolean
get() = initialized
/**
* Filtra [input] y escribe el resultado en [output].
*
* Los dos arreglos deben tener al menos [dimensions] posiciones.
*
* La primera muestra válida se copia directamente. Esto evita que
* el filtro comience artificialmente desde cero.
*
* @return true cuando la muestra fue procesada.
* Devuelve false si contiene NaN o infinito.
*/
fun filter(
input: FloatArray,
output: FloatArray
): Boolean {
require(input.size >= dimensions) {
"El arreglo de entrada debe tener al menos $dimensions componentes."
}
require(output.size >= dimensions) {
"El arreglo de salida debe tener al menos $dimensions componentes."
}
for (index in 0 until dimensions) {
if (!input[index].isFinite()) {
return false
}
}
if (!initialized) {
for (index in 0 until dimensions) {
state[index] = input[index]
output[index] = input[index]
}
initialized = true
return true
}
val newValueWeight = 1f - alpha
for (index in 0 until dimensions) {
val filteredValue =
alpha * state[index] +
newValueWeight * input[index]
state[index] = filteredValue
output[index] = filteredValue
}
return true
}
/**
* Reinicia el filtro.
*
* La siguiente muestra volverá a considerarse como primera lectura.
*/
fun reset() {
state.fill(0f)
initialized = false
}
companion object {
const val DEFAULT_DIMENSIONS = 3
private const val MINIMUM_ALPHA = 0f
private const val MAXIMUM_ALPHA = 1f
}
}
Probar el comportamiento con dos muestras
En este ejemplo utilizamos alpha = 0.8. La primera muestra se copia directamente y la segunda se combina con el estado anterior.
val filter =
LowPassVectorFilter(
alpha = 0.8f
)
val output =
FloatArray(3)
filter.filter(
input = floatArrayOf(
9.8f,
0.2f,
-0.1f
),
output = output
)
filter.filter(
input = floatArrayOf(
10.2f,
0.6f,
0.3f
),
output = output
)
println(output.contentToString())
[9.88, 0.28, -0.02]
La segunda lectura no reemplaza inmediatamente a la primera. El filtro avanza parcialmente hacia los valores nuevos y reduce los cambios bruscos en cada eje.
Más adelante crearemos dos instancias independientes: una para el acelerómetro y otra para el magnetómetro. Cada una conservará su propio estado.
Reiniciar el estado
El método reset() borra el vector interno y marca el filtro como no inicializado. La siguiente muestra volverá a copiarse directamente.
Este reinicio será necesario cuando los sensores se detengan o cuando comience una nueva sesión de lectura.
Confirma que la primera muestra se copia sin combinarse con ceros, que las siguientes lecturas aplican el factor alpha, que los arreglos tienen tres componentes y que una muestra con NaN o infinito devuelve false.
El comportamiento de filter() y reset() se cubrirá posteriormente con pruebas unitarias.
Estabilizar el rumbo con CircularAngleFilter
El filtro pasa-bajos anterior funciona con los componentes X, Y y Z, pero no debe aplicarse directamente al rumbo. Los ángulos forman un círculo y el punto 359° está situado junto a 0°.
Para estabilizar el rumbo construiremos una media móvil circular. Cada ángulo se convertirá en un vector mediante seno y coseno, calcularemos la dirección promedio con atan2() y avanzaremos hacia ella utilizando la diferencia angular mínima.
Por qué una media normal produce un error
Si tratamos los ángulos como números lineales, el promedio entre 359° y 1° produce un resultado completamente opuesto:
Sin embargo, ambas direcciones están alrededor del norte. El promedio correcto debe quedar cerca de 0°, no del sur.
Representar cada ángulo como un vector
Cada muestra se transforma en un punto de una circunferencia unitaria. El coseno representa su componente horizontal y el seno su componente vertical.
En lugar de sumar directamente los grados, sumamos los componentes de todos los vectores almacenados. Después, atan2() recupera la dirección promedio.
Cómo funcionará el filtro
- Normaliza cada rumbo entre 0° y menos de 360°.
- Convierte el ángulo a radianes.
- Calcula y almacena sus componentes seno y coseno.
- Mantiene una ventana circular de las últimas muestras.
- Obtiene la dirección promedio mediante
atan2(). - Calcula el trayecto mínimo desde el resultado anterior.
- Aplica un factor de respuesta para controlar la suavidad.
Controlar la velocidad de respuesta
El parámetro responseFactor determina qué proporción de la diferencia angular se aplica en cada actualización.
1.0: alcanza inmediatamente la media circular.0.5: recorre la mitad de la diferencia.0.25: produce un movimiento más suave, pero aumenta el retraso.
El desplazamiento siempre utiliza AngleMath.shortestAngleDifference(). Así, pasar de 359° a 1° representa un avance de 2° y no un recorrido de −358°.
Implementar CircularAngleFilter
Crea el archivo CircularAngleFilter.kt dentro del paquete domain y agrega el siguiente contenido:
package com.tucodigocotidiano.smartcompass.domain
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
/**
* Filtro circular para rumbos expresados en grados.
*
* Los ángulos no deben promediarse como números lineales.
*
* Ejemplo:
*
* 359° y 1°
*
* El promedio correcto está cerca de 0°, no de 180°.
*
* El filtro realiza dos operaciones:
*
* 1. Calcula una media móvil circular utilizando seno y coseno.
* 2. Se desplaza hacia el promedio utilizando la diferencia angular
* mínima, evitando recorridos largos al cruzar entre 359° y 0°.
*
* Los datos se almacenan en arreglos primitivos circulares para evitar
* crear objetos nuevos en cada lectura.
*
* @param windowSize cantidad de muestras utilizadas por la media circular.
*
* @param responseFactor proporción del desplazamiento angular aplicada
* en cada actualización:
*
* - 1.0: alcanza inmediatamente el promedio circular.
* - 0.5: recorre la mitad de la diferencia.
* - 0.25: movimiento más suave, pero con mayor retraso.
*/
class CircularAngleFilter(
private val windowSize: Int = DEFAULT_WINDOW_SIZE,
private val responseFactor: Float = DEFAULT_RESPONSE_FACTOR
) {
init {
require(windowSize > 0) {
"El tamaño de la ventana debe ser mayor que cero."
}
require(responseFactor.isFinite()) {
"El factor de respuesta debe ser finito."
}
require(
responseFactor > MINIMUM_RESPONSE_FACTOR &&
responseFactor <= MAXIMUM_RESPONSE_FACTOR
) {
"El factor de respuesta debe ser mayor que 0 y menor o igual que 1."
}
}
/*
* Arreglos circulares reutilizables.
*
* Cada posición almacena los componentes seno y coseno de una muestra.
*/
private val sineSamples = DoubleArray(windowSize)
private val cosineSamples = DoubleArray(windowSize)
private var nextSampleIndex = 0
private var storedSampleCount = 0
private var sineSum = 0.0
private var cosineSum = 0.0
private var filteredHeading: Float? = null
/**
* Cantidad actual de muestras almacenadas.
*/
val sampleCount: Int
get() = storedSampleCount
/**
* Último rumbo filtrado.
*
* Será null antes de recibir la primera muestra.
*/
val currentValue: Float?
get() = filteredHeading
/**
* Agrega una nueva muestra y devuelve el rumbo estabilizado.
*/
fun add(angleDegrees: Float): Float {
val normalizedAngle =
AngleMath.normalizeDegrees(angleDegrees)
val radians =
Math.toRadians(normalizedAngle.toDouble())
val newSine = sin(radians)
val newCosine = cos(radians)
/*
* Cuando la ventana está llena se elimina primero la muestra
* que será reemplazada.
*/
if (storedSampleCount == windowSize) {
sineSum -= sineSamples[nextSampleIndex]
cosineSum -= cosineSamples[nextSampleIndex]
} else {
storedSampleCount++
}
sineSamples[nextSampleIndex] = newSine
cosineSamples[nextSampleIndex] = newCosine
sineSum += newSine
cosineSum += newCosine
nextSampleIndex =
(nextSampleIndex + 1) % windowSize
val vectorMagnitude = hypot(
sineSum,
cosineSum
)
/*
* Vectores opuestos pueden cancelarse.
*
* Ejemplo:
* 0° y 180°
*
* En ese caso no existe una dirección promedio bien definida.
* Conservamos el último resultado válido.
*/
if (vectorMagnitude < MIN_VECTOR_MAGNITUDE) {
val fallback =
filteredHeading ?: normalizedAngle
filteredHeading = fallback
return fallback
}
val averagedRadians = atan2(
sineSum,
cosineSum
)
val circularAverage =
AngleMath.normalizeDegrees(
Math.toDegrees(
averagedRadians
).toFloat()
)
val previousHeading = filteredHeading
if (previousHeading == null) {
filteredHeading = circularAverage
return circularAverage
}
/*
* La diferencia angular mínima siempre está entre -180° y 180°.
*
* Por ejemplo:
*
* desde 359° hasta 1° = +2°
*
* y no:
*
* desde 359° hasta 1° = -358°
*/
val shortestDifference =
AngleMath.shortestAngleDifference(
from = previousHeading,
to = circularAverage
)
val smoothedHeading =
AngleMath.normalizeDegrees(
previousHeading +
shortestDifference * responseFactor
)
filteredHeading = smoothedHeading
return smoothedHeading
}
/**
* Elimina todas las muestras y reinicia el estado.
*/
fun reset() {
sineSamples.fill(0.0)
cosineSamples.fill(0.0)
nextSampleIndex = 0
storedSampleCount = 0
sineSum = 0.0
cosineSum = 0.0
filteredHeading = null
}
companion object {
const val DEFAULT_WINDOW_SIZE = 5
const val DEFAULT_RESPONSE_FACTOR = 1f
private const val MINIMUM_RESPONSE_FACTOR = 0f
private const val MAXIMUM_RESPONSE_FACTOR = 1f
private const val MIN_VECTOR_MAGNITUDE = 1e-6
}
}
Mantener una ventana sin crear objetos
Los arreglos sineSamples y cosineSamples funcionan como buffers circulares. Cuando la ventana se llena, el filtro resta primero la muestra más antigua y escribe la nueva en la misma posición.
También conserva las sumas acumuladas de seno y coseno. De esta manera no necesita recorrer toda la ventana en cada evento del sensor.
Resolver direcciones opuestas
Dos direcciones opuestas, como 0° y 180°, pueden cancelar sus vectores. En ese caso la magnitud de la suma queda prácticamente en cero y no existe una dirección promedio única.
Cuando esto sucede, el filtro conserva el último rumbo válido. Si todavía no existe uno, utiliza la muestra normalizada actual.
Comprobar el cruce por el norte
Podemos realizar una comprobación sencilla con una ventana de dos muestras y respuesta inmediata:
val filter =
CircularAngleFilter(
windowSize = 2,
responseFactor = 1f
)
val firstResult =
filter.add(
359f
)
val secondResult =
filter.add(
1f
)
println(firstResult)
println(secondResult)
359.0
0.0
Después de recibir 359° y 1°, la media circular queda alrededor de 0°. La lectura no salta hacia 180° y tampoco recorre casi toda la circunferencia.
En la fuente real de sensores utilizaremos una ventana de cinco muestras y un factor de respuesta menor que uno para obtener un movimiento más gradual.
Reiniciar el filtro
El método reset() borra los arreglos, las sumas, los contadores y el último rumbo filtrado. La siguiente muestra iniciará una nueva ventana.
Este reinicio se utilizará cuando los sensores se detengan y cuando cambie la rotación de la pantalla, evitando combinar lecturas pertenecientes a sistemas de coordenadas diferentes.
Confirma que el filtro normaliza cada muestra, almacena seno y coseno, calcula la dirección mediante atan2(), utiliza la diferencia angular mínima y produce un resultado cercano a 0° al recibir 359° y 1°.
También verifica que reset() deja sampleCount en cero y currentValue en null. Estos comportamientos se convertirán más adelante en pruebas unitarias.
.\gradlew.bat assembleDebug
BUILD SUCCESSFUL
Construir una lectura con CompassReading y CompassEngine
Ya podemos normalizar ángulos y estabilizar el rumbo. El siguiente paso será convertir los resultados matemáticos de orientación en una lectura coherente que pueda circular por el resto de la aplicación.
Separaremos esta responsabilidad en dos elementos: CompassEngine realizará las conversiones y CompassReading almacenará el resultado validado.
SensorAccuracy y el sistema de calibración.
Calcular la intensidad del campo magnético
El magnetómetro entrega tres componentes expresadas en microteslas: Bx, By y Bz. Para obtener una sola intensidad calcularemos la magnitud del vector.
El resultado continúa expresado en microteslas. Por ejemplo, un vector con componentes 3, 4 y 12 tiene una magnitud de 13 μT.
Utilizaremos hypot() dos veces en lugar de sumar directamente los cuadrados con valores Float. Esto ofrece una operación numéricamente más estable.
Modelar una lectura de la brújula
CompassReading contendrá datos ya convertidos a grados y listos para utilizarse fuera del motor matemático.
magneticHeading: rumbo normalizado entre 0° y menos de 360°.pitch: inclinación frontal en grados.roll: inclinación lateral en grados.magneticFieldStrength: intensidad total en microteslas.
package com.tucodigocotidiano.smartcompass.domain
import com.tucodigocotidiano.smartcompass.sensor.SensorAccuracy
/**
* Lectura completa producida por la fuente de sensores.
*
* @property magneticHeading rumbo magnético normalizado entre
* 0° y menos de 360°.
*
* @property pitch inclinación hacia delante o hacia atrás, en grados.
*
* @property roll inclinación lateral, en grados.
*
* @property accuracy precisión reportada por el magnetómetro.
*
* @property magneticFieldStrength intensidad total del campo magnético,
* expresada en microteslas.
*
* @property calibration evaluación de precisión, calibración
* e interferencia magnética.
*/
data class CompassReading(
val magneticHeading: Float,
val pitch: Float,
val roll: Float,
val accuracy: SensorAccuracy,
val magneticFieldStrength: Float?,
val calibration: CalibrationAssessment =
CalibrationAssessment.INITIALIZING
) {
init {
require(magneticHeading.isFinite()) {
"El rumbo magnético debe ser un número finito."
}
require(
magneticHeading >= MINIMUM_HEADING &&
magneticHeading < MAXIMUM_HEADING
) {
"El rumbo magnético debe estar entre 0° y menos de 360°."
}
require(pitch.isFinite()) {
"La inclinación pitch debe ser un número finito."
}
require(roll.isFinite()) {
"La inclinación roll debe ser un número finito."
}
magneticFieldStrength?.let { strength ->
require(strength.isFinite()) {
"La intensidad magnética debe ser un número finito."
}
require(strength >= 0f) {
"La intensidad magnética no puede ser negativa."
}
}
}
/**
* Accesos simplificados para la futura capa de presentación.
*/
val calibrationRequired: Boolean
get() =
calibration.calibrationRequired
val magneticInterferenceDetected: Boolean
get() =
calibration.magneticInterferenceDetected
companion object {
private const val MINIMUM_HEADING = 0f
private const val MAXIMUM_HEADING = 360f
}
}
Transformar los resultados con CompassEngine
CompassEngine recibirá azimut, pitch y roll en radianes, porque esa será la unidad entregada por el cálculo de orientación.
El motor convertirá los tres valores a grados, normalizará únicamente el azimut y construirá una instancia válida de CompassReading.
package com.tucodigocotidiano.smartcompass.domain
import com.tucodigocotidiano.smartcompass.sensor.SensorAccuracy
import kotlin.math.hypot
/**
* Convierte los resultados matemáticos obtenidos de los sensores
* en una lectura de brújula válida.
*
* Esta clase no depende de Android, SensorManager ni Compose.
* Por ello puede probarse mediante pruebas unitarias locales.
*/
class CompassEngine {
/**
* Crea una lectura completa a partir de los ángulos de orientación
* y de los tres componentes del campo magnético.
*
* Los valores de azimut, pitch y roll se reciben en radianes porque
* SensorManager.getOrientation() utiliza esa unidad.
*/
fun createReading(
azimuthRadians: Float,
pitchRadians: Float,
rollRadians: Float,
magneticFieldX: Float,
magneticFieldY: Float,
magneticFieldZ: Float,
accuracy: SensorAccuracy
): CompassReading {
requireOrientationValuesAreFinite(
azimuthRadians = azimuthRadians,
pitchRadians = pitchRadians,
rollRadians = rollRadians
)
requireMagneticValuesAreFinite(
magneticFieldX = magneticFieldX,
magneticFieldY = magneticFieldY,
magneticFieldZ = magneticFieldZ
)
val magneticHeading = AngleMath.normalizeDegrees(
AngleMath.radiansToDegrees(azimuthRadians)
)
val pitchDegrees = AngleMath.radiansToDegrees(
pitchRadians
)
val rollDegrees = AngleMath.radiansToDegrees(
rollRadians
)
val magneticFieldStrength = calculateMagneticFieldStrength(
magneticFieldX = magneticFieldX,
magneticFieldY = magneticFieldY,
magneticFieldZ = magneticFieldZ
)
return CompassReading(
magneticHeading = magneticHeading,
pitch = pitchDegrees,
roll = rollDegrees,
accuracy = accuracy,
magneticFieldStrength = magneticFieldStrength
)
}
/**
* Calcula la magnitud del vector magnético:
*
* |B| = √(Bx² + By² + Bz²)
*
* El resultado se expresa en microteslas porque los valores del
* magnetómetro de Android utilizan esa unidad.
*/
fun calculateMagneticFieldStrength(
magneticFieldX: Float,
magneticFieldY: Float,
magneticFieldZ: Float
): Float {
requireMagneticValuesAreFinite(
magneticFieldX = magneticFieldX,
magneticFieldY = magneticFieldY,
magneticFieldZ = magneticFieldZ
)
/*
* hypot ofrece mayor estabilidad numérica que calcular
* directamente x*x + y*y + z*z usando Float.
*/
val xyMagnitude = hypot(
magneticFieldX.toDouble(),
magneticFieldY.toDouble()
)
val totalMagnitude = hypot(
xyMagnitude,
magneticFieldZ.toDouble()
).toFloat()
require(totalMagnitude.isFinite()) {
"La intensidad magnética calculada debe ser finita."
}
return totalMagnitude
}
private fun requireOrientationValuesAreFinite(
azimuthRadians: Float,
pitchRadians: Float,
rollRadians: Float
) {
require(azimuthRadians.isFinite()) {
"El azimut debe ser un número finito."
}
require(pitchRadians.isFinite()) {
"El pitch debe ser un número finito."
}
require(rollRadians.isFinite()) {
"El roll debe ser un número finito."
}
}
private fun requireMagneticValuesAreFinite(
magneticFieldX: Float,
magneticFieldY: Float,
magneticFieldZ: Float
) {
require(magneticFieldX.isFinite()) {
"El componente X del campo magnético debe ser finito."
}
require(magneticFieldY.isFinite()) {
"El componente Y del campo magnético debe ser finito."
}
require(magneticFieldZ.isFinite()) {
"El componente Z del campo magnético debe ser finito."
}
}
}
Flujo de transformación
La información atraviesa el motor en un orden concreto:
Azimut en radianes
↓
Conversión a grados
↓
Normalización entre 0° y menos de 360°
↓
Rumbo magnético
Pitch y roll en radianes
↓
Conversión a grados
↓
Inclinaciones conservando el signo
Bx, By y Bz
↓
Magnitud del vector
↓
Intensidad en μT
Todos los resultados
↓
CompassReading
Comprobar una lectura sencilla
Podemos utilizar ángulos iguales a cero y un vector magnético conocido para revisar el resultado:
val engine =
CompassEngine()
val reading =
engine.createReading(
azimuthRadians = 0f,
pitchRadians = 0f,
rollRadians = 0f,
magneticFieldX = 3f,
magneticFieldY = 4f,
magneticFieldZ = 12f
)
println(reading)
CompassReading(magneticHeading=0.0, pitch=0.0, roll=0.0, magneticFieldStrength=13.0)
Tanto CompassReading como CompassEngine pertenecen al paquete domain. Ninguno conoce eventos de sensores, clases de Android o elementos de Compose.
Esta separación permitirá probar las conversiones y la magnitud magnética mediante pruebas unitarias locales.
Confirma que el azimut se convierte y normaliza, que pitch y roll conservan su signo, que el vector (3, 4, 12) produce 13 μT y que los valores NaN o infinitos son rechazados.
En la siguiente sección agregaremos la precisión del magnetómetro a la lectura sin repetir la implementación completa de estas clases.
.\gradlew.bat assembleDebug
BUILD SUCCESSFUL
Modelar disponibilidad y precisión de los sensores
Antes de acceder a las API de Android, crearemos modelos propios para representar la precisión del magnetómetro, los sensores requeridos y su disponibilidad en el teléfono.
También definiremos una interfaz para obtener lecturas. De esta manera, las capas superiores podrán trabajar con un contrato estable sin depender directamente de SensorManager.
Representar la precisión del magnetómetro
Android informa distintos niveles de precisión para el magnetómetro. En lugar de propagar sus constantes por toda la aplicación, las traduciremos a una enumeración propia.
- UNKNOWN: todavía no existe información suficiente.
- UNRELIABLE: la lectura no debe considerarse confiable.
- LOW: la precisión es baja.
- MEDIUM: la lectura tiene una precisión aceptable.
- HIGH: Android informa su nivel más alto de precisión.
package com.tucodigocotidiano.smartcompass.sensor
/**
* Representa la calidad de los datos entregados por el magnetómetro.
*
* Esta enumeración no depende de las constantes de Android. La traducción
* desde SensorManager.SENSOR_STATUS_* se realizará más adelante dentro
* de AndroidCompassSensorSource.
*/
enum class SensorAccuracy {
/**
* Todavía no se ha recibido información de precisión.
*/
UNKNOWN,
/**
* Los datos no deberían considerarse confiables.
*/
UNRELIABLE,
/**
* La precisión es baja y probablemente requiere calibración.
*/
LOW,
/**
* La precisión es suficiente para mostrar el rumbo.
*/
MEDIUM,
/**
* El sensor informa su nivel más alto de precisión.
*/
HIGH;
/**
* Indica si la precisión es suficientemente buena para utilizar
* las lecturas normalmente.
*/
val isReliable: Boolean
get() = this == MEDIUM || this == HIGH
/**
* Indica si la interfaz debería recomendar una calibración.
*/
val requiresCalibration: Boolean
get() = when (this) {
UNKNOWN,
UNRELIABLE,
LOW -> true
MEDIUM,
HIGH -> false
}
}
SensorAccuracy representa únicamente la información técnica reportada por Android. Más adelante combinaremos este dato con la referencia magnética local para construir un estado de calibración comprensible.
Identificar sensores faltantes
La brújula solo puede calcular una orientación magnética cuando están disponibles simultáneamente el acelerómetro y el magnetómetro.
RequiredSensor identificará cada componente y SensorAvailability resumirá el soporte real del dispositivo.
package com.tucodigocotidiano.smartcompass.sensor
/**
* Sensores físicos requeridos para calcular una orientación magnética.
*/
enum class RequiredSensor {
ACCELEROMETER,
MAGNETOMETER
}
/**
* Describe qué sensores requeridos están disponibles en el teléfono.
*/
data class SensorAvailability(
val accelerometerAvailable: Boolean,
val magnetometerAvailable: Boolean
) {
/**
* La brújula necesita simultáneamente acelerómetro y magnetómetro.
*/
val isCompassSupported: Boolean
get() = accelerometerAvailable && magnetometerAvailable
/**
* Conjunto de sensores que no están disponibles.
*/
val missingSensors: Set<RequiredSensor>
get() {
val missing = mutableSetOf<RequiredSensor>()
if (!accelerometerAvailable) {
missing += RequiredSensor.ACCELEROMETER
}
if (!magnetometerAvailable) {
missing += RequiredSensor.MAGNETOMETER
}
return missing
}
companion object {
/**
* Estado utilizado cuando ambos sensores existen.
*/
val ALL_AVAILABLE = SensorAvailability(
accelerometerAvailable = true,
magnetometerAvailable = true
)
/**
* Estado utilizado cuando ninguno de los sensores existe.
*/
val NONE_AVAILABLE = SensorAvailability(
accelerometerAvailable = false,
magnetometerAvailable = false
)
}
}
La propiedad isCompassSupported solo devuelve true cuando ambos sensores existen. En caso contrario, missingSensors permite informar con precisión qué componente falta.
Esta comprobación se realizará durante la ejecución mediante SensorManager.getDefaultSensor(). El modelo creado aquí no consulta Android: únicamente representa el resultado.
Desacoplar las lecturas con CompassSensorSource
La aplicación necesita recibir lecturas, consultar la disponibilidad y controlar el inicio y la detención de los sensores. Estas operaciones se reunirán en una interfaz.
La implementación Android utilizará SensorManager, pero el resto de la aplicación conocerá únicamente CompassSensorSource.
package com.tucodigocotidiano.smartcompass.sensor
import com.tucodigocotidiano.smartcompass.domain.CompassReading
import kotlinx.coroutines.flow.Flow
/**
* Contrato utilizado para obtener lecturas de orientación.
*
* Las capas superiores dependerán de esta interfaz y no directamente
* de SensorManager. Esto permite reemplazar la implementación real
* por una fuente falsa durante las pruebas.
*/
interface CompassSensorSource {
/**
* Flujo de lecturas calculadas por la fuente.
*/
val readings: Flow<CompassReading>
/**
* Sensores físicos disponibles en el dispositivo.
*/
val availability: SensorAvailability
/**
* Indica si la fuente se encuentra activa.
*/
val isRunning: Boolean
/**
* Inicia la producción de lecturas.
*
* La implementación debe permitir que llamar varias veces a start()
* sea seguro.
*/
fun start()
/**
* Detiene la producción de lecturas y libera los recursos asociados.
*
* La implementación debe permitir que llamar varias veces a stop()
* sea seguro.
*/
fun stop()
}
SensorManager y SensorEvent
↓
Implementación Android
↓
CompassSensorSource
↓
Flow<CompassReading>
↓
ViewModel y presentación
Gracias a esta separación, una prueba podrá reemplazar la implementación real por una fuente falsa que emita lecturas conocidas.
El ViewModel y la interfaz no necesitarán importar SensorManager, Sensor ni SensorEvent.
Incluir la precisión en CompassReading
La lectura creada en la sección anterior debe conservar también la precisión recibida del magnetómetro. En CompassReading, agrega el import y la propiedad accuracy.
import com.tucodigocotidiano.smartcompass.sensor.SensorAccuracy
data class CompassReading(
val magneticHeading: Float,
val pitch: Float,
val roll: Float,
val accuracy: SensorAccuracy,
val magneticFieldStrength: Float?
)
CompassEngine.createReading() recibirá esa precisión y la copiará en la lectura. No realizará todavía ninguna regla de calibración.
fun createReading(
azimuthRadians: Float,
pitchRadians: Float,
rollRadians: Float,
magneticFieldX: Float,
magneticFieldY: Float,
magneticFieldZ: Float,
accuracy: SensorAccuracy
): CompassReading {
// Se mantienen las conversiones y validaciones
// implementadas en la sección anterior.
return CompassReading(
magneticHeading = magneticHeading,
pitch = pitchDegrees,
roll = rollDegrees,
accuracy = accuracy,
magneticFieldStrength =
magneticFieldStrength
)
}
accuracy y pásalo al constructor de CompassReading. Conserva las conversiones, validaciones y el cálculo magnético de la sección anterior.
val availability =
SensorAvailability(
accelerometerAvailable = true,
magnetometerAvailable = false
)
println(
availability.isCompassSupported
)
println(
availability.missingSensors
)
println(
SensorAccuracy.MEDIUM.isReliable
)
false
[MAGNETOMETER]
true
Confirma que SensorAccuracy contiene los cinco niveles, que SensorAvailability exige ambos sensores y que missingSensors identifica correctamente el componente ausente.
Verifica también que CompassSensorSource expone las lecturas, la disponibilidad, el estado de ejecución y los métodos start() y stop(), sin importar clases de Android.
.\gradlew.bat assembleDebug
BUILD SUCCESSFUL
Mantener el rumbo al rotar la pantalla
La orientación calculada por los sensores utiliza los ejes físicos del dispositivo. Cuando la pantalla gira, debemos seleccionar una nueva pareja de ejes para que la parte superior visible continúe siendo la referencia del rumbo.
Separaremos esta lógica en modelos comprobables y en un proveedor Android encargado únicamente de consultar la rotación actual.
Representar los ejes sin depender de Android
CoordinateAxis representa los ejes X, Y y sus direcciones negativas. Así podremos definir el remapeo sin utilizar todavía constantes como SensorManager.AXIS_X.
CoordinateAxisMapping contiene los dos ejes que se entregarán posteriormente a remapCoordinateSystem(). El tercer eje no necesita declararse porque Android lo obtiene a partir de los dos primeros.
package com.tucodigocotidiano.smartcompass.sensor
/**
* Ejes lógicos que pueden utilizarse para expresar nuevamente
* la matriz de rotación del dispositivo.
*
* Este enum no depende de las constantes de Android, por lo que
* la selección de ejes puede probarse mediante pruebas JVM.
*/
enum class CoordinateAxis {
X,
Y,
MINUS_X,
MINUS_Y
}
/**
* Pareja de ejes requerida por SensorManager.remapCoordinateSystem().
*
* Solo se especifican X e Y porque el tercer eje se obtiene
* automáticamente mediante un producto vectorial.
*/
data class CoordinateAxisMapping(
val xAxis: CoordinateAxis,
val yAxis: CoordinateAxis
)
/**
* Representa la rotación de la pantalla con respecto a su
* orientación natural.
*/
enum class DisplayRotation(
val degrees: Int
) {
ROTATION_0(
degrees = 0
),
ROTATION_90(
degrees = 90
),
ROTATION_180(
degrees = 180
),
ROTATION_270(
degrees = 270
);
/**
* Ejes que permiten expresar la matriz de orientación con respecto
* a la parte superior actual de la pantalla.
*/
val axisMapping: CoordinateAxisMapping
get() = when (this) {
ROTATION_0 -> {
CoordinateAxisMapping(
xAxis = CoordinateAxis.X,
yAxis = CoordinateAxis.Y
)
}
ROTATION_90 -> {
CoordinateAxisMapping(
xAxis = CoordinateAxis.Y,
yAxis = CoordinateAxis.MINUS_X
)
}
ROTATION_180 -> {
CoordinateAxisMapping(
xAxis = CoordinateAxis.MINUS_X,
yAxis = CoordinateAxis.MINUS_Y
)
}
ROTATION_270 -> {
CoordinateAxisMapping(
xAxis = CoordinateAxis.MINUS_Y,
yAxis = CoordinateAxis.X
)
}
}
}
Revisar las cuatro combinaciones
Cada rotación utiliza una pareja diferente de ejes. El objetivo no es sumar grados al rumbo, sino volver a expresar la matriz de orientación en relación con la parte superior actual de la pantalla.
Rotación 0°
X → X
Y → Y
Rotación 90°
X → Y
Y → −X
Rotación 180°
X → −X
Y → −Y
Rotación 270°
X → −Y
Y → X
Crear un proveedor de rotación
La fuente de sensores no necesita saber si la rotación procede de Context.display, de WindowManager o de una implementación falsa utilizada en pruebas.
Para mantener esa separación crearemos una interfaz funcional que devuelva únicamente un valor de DisplayRotation.
package com.tucodigocotidiano.smartcompass.sensor
/**
* Abstracción para consultar la rotación actual de la pantalla.
*
* AndroidCompassSensorSource depende de esta interfaz en lugar
* de consultar directamente WindowManager o Display.
*
* Esto permitirá reemplazarla por una implementación falsa
* en pruebas futuras.
*/
fun interface DisplayRotationProvider {
/**
* Devuelve la rotación actual de la pantalla.
*/
fun currentRotation(): DisplayRotation
}
Al tratarse de una fun interface, durante las pruebas podremos crear un proveedor falso mediante una expresión lambda.
val fakeRotationProvider =
DisplayRotationProvider {
DisplayRotation.ROTATION_90
}
println(
fakeRotationProvider
.currentRotation()
.degrees
)
90
Consultar la rotación en Android
La implementación real consultará la rotación disponible en el sistema y traducirá las constantes de Surface a nuestro modelo.
En Android 11 y versiones posteriores utilizaremos Context.display. Para versiones anteriores conservaremos una ruta compatible mediante WindowManager.defaultDisplay.
package com.tucodigocotidiano.smartcompass.sensor
import android.content.Context
import android.os.Build
import android.view.Surface
import android.view.WindowManager
/**
* Obtiene la rotación de la pantalla mediante las APIs de Android.
*
* En Android 11 y versiones posteriores se utiliza Context.display.
*
* Para Android 10 y anteriores se conserva una ruta de compatibilidad
* mediante WindowManager.defaultDisplay, ya que el proyecto admite
* dispositivos desde API 24.
*/
class AndroidDisplayRotationProvider(
private val context: Context
) : DisplayRotationProvider {
private val windowManager: WindowManager =
context.getSystemService(
Context.WINDOW_SERVICE
) as WindowManager
override fun currentRotation(): DisplayRotation {
return when (currentSurfaceRotation()) {
Surface.ROTATION_0 ->
DisplayRotation.ROTATION_0
Surface.ROTATION_90 ->
DisplayRotation.ROTATION_90
Surface.ROTATION_180 ->
DisplayRotation.ROTATION_180
Surface.ROTATION_270 ->
DisplayRotation.ROTATION_270
else ->
DisplayRotation.ROTATION_0
}
}
private fun currentSurfaceRotation(): Int {
return if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
) {
context.display?.rotation
?: Surface.ROTATION_0
} else {
/*
* Context.display fue incorporado para sustituir
* WindowManager.defaultDisplay en API 30.
*
* Esta rama solo se utiliza entre API 24 y API 29.
*/
@Suppress("DEPRECATION")
windowManager.defaultDisplay.rotation
}
}
}
Utilizar el proveedor más adelante
La fuente real de sensores recibirá un DisplayRotationProvider. Antes de obtener los ángulos consultará la rotación actual y seleccionará su correspondiente axisMapping.
val displayRotation =
displayRotationProvider
.currentRotation()
val axisMapping =
displayRotation.axisMapping
En la implementación de AndroidCompassSensorSource, los valores CoordinateAxis se convertirán en las constantes SensorManager.AXIS_* y se entregarán a remapCoordinateSystem().
Cuando cambie la rotación también reiniciaremos el filtro circular. Esto evitará mezclar rumbos obtenidos con dos sistemas de coordenadas distintos.
Confirma que DisplayRotation representa las rotaciones de 0°, 90°, 180° y 270°, y que cada una devuelve la pareja correcta de ejes.
Verifica además que DisplayRotationProvider no depende de Android y que AndroidDisplayRotationProvider traduce correctamente las cuatro constantes de Surface.
Las combinaciones de ejes se convertirán posteriormente en pruebas unitarias dentro de DisplayRotationTest.
.\gradlew.bat assembleDebug
BUILD SUCCESSFUL
Modelar el análisis del campo magnético
SmartCompass ya puede calcular la intensidad total del campo magnético. Ahora necesitamos un modelo que reúna el resultado del análisis realizado sobre esas mediciones.
MagneticFieldAssessment indicará si existe una referencia local, cuánto se aleja la lectura de ella, cuántas muestras se han procesado y si se ha detectado una posible alteración persistente.
Separar la lectura actual de su análisis
La intensidad actual ya está disponible en CompassReading.magneticFieldStrength. No la duplicaremos dentro del nuevo modelo.
-
Intensidad actual:
permanece en
CompassReadingy se expresa en microteslas. - Referencia local: valor aproximado aprendido durante las primeras muestras de la sesión.
- Desviación absoluta: diferencia en microteslas entre la lectura actual y la referencia.
- Desviación relativa: proporción de esa diferencia respecto a la referencia.
- Número de muestras: cantidad de mediciones procesadas hasta el momento.
- Posible interferencia: indica si el detector encontró una alteración persistente.
Medir la distancia respecto a la referencia
La desviación absoluta conserva la unidad original del magnetómetro. Si la lectura actual es de 48 μT y la referencia local es de 40 μT, la diferencia absoluta es de 8 μT.
La desviación relativa permite evaluar el mismo cambio según el tamaño de la referencia. Se almacena como una proporción: un valor de 0.20 representa una diferencia del 20 %.
La referencia y las desviaciones son anulables porque al iniciar la aplicación todavía no existen datos suficientes. El modelo podrá representar tanto el estado inicial como un análisis completamente construido.
Implementar MagneticFieldAssessment
Crea el archivo dentro del paquete domain y agrega el siguiente contenido:
package com.tucodigocotidiano.smartcompass.domain
/**
* Resultado del análisis de la intensidad del campo magnético.
*
* La línea base no representa una verdad universal. Es una referencia
* local aprendida durante los primeros segundos de funcionamiento.
*
* @property interferenceDetected indica si se detectó una alteración
* magnética persistente.
*
* @property isBaselineReady indica si ya existen suficientes muestras
* para utilizar la referencia local.
*
* @property baselineStrength intensidad magnética local de referencia,
* expresada en microteslas.
*
* @property absoluteDeviation diferencia absoluta respecto a la línea base.
*
* @property relativeDeviation diferencia relativa respecto a la línea base.
*
* @property sampleCount cantidad de muestras magnéticas procesadas.
*/
data class MagneticFieldAssessment(
val interferenceDetected: Boolean,
val isBaselineReady: Boolean,
val baselineStrength: Float?,
val absoluteDeviation: Float?,
val relativeDeviation: Float?,
val sampleCount: Int
) {
init {
require(sampleCount >= 0) {
"La cantidad de muestras no puede ser negativa."
}
baselineStrength?.let { baseline ->
require(baseline.isFinite()) {
"La línea base magnética debe ser finita."
}
require(baseline >= 0f) {
"La línea base magnética no puede ser negativa."
}
}
absoluteDeviation?.let { deviation ->
require(deviation.isFinite()) {
"La desviación absoluta debe ser finita."
}
require(deviation >= 0f) {
"La desviación absoluta no puede ser negativa."
}
}
relativeDeviation?.let { deviation ->
require(deviation.isFinite()) {
"La desviación relativa debe ser finita."
}
require(deviation >= 0f) {
"La desviación relativa no puede ser negativa."
}
}
}
companion object {
/**
* Estado inicial antes de recibir datos del magnetómetro.
*/
val INITIAL = MagneticFieldAssessment(
interferenceDetected = false,
isBaselineReady = false,
baselineStrength = null,
absoluteDeviation = null,
relativeDeviation = null,
sampleCount = 0
)
}
}
Representar el estado inicial
MagneticFieldAssessment.INITIAL se utilizará antes de recibir la primera muestra y cada vez que la fuente de sensores reinicie su estado.
- No existe todavía una referencia local.
- Las desviaciones no pueden calcularse.
- El contador de muestras permanece en cero.
- No se informa una posible interferencia.
val initialAssessment =
MagneticFieldAssessment.INITIAL
println(
initialAssessment
.isBaselineReady
)
println(
initialAssessment
.baselineStrength
)
println(
initialAssessment
.sampleCount
)
false
null
0
Representar un análisis completo
El siguiente ejemplo muestra cómo se relaciona una intensidad actual de 48 μT con una referencia local de 40 μT:
val currentFieldStrength =
48f
val assessment =
MagneticFieldAssessment(
interferenceDetected = false,
isBaselineReady = true,
baselineStrength = 40f,
absoluteDeviation = 8f,
relativeDeviation = 0.20f,
sampleCount = 30
)
println(
"Intensidad actual: " +
"$currentFieldStrength μT"
)
println(
"Referencia local: " +
"${assessment.baselineStrength} μT"
)
println(
"Desviación absoluta: " +
"${assessment.absoluteDeviation} μT"
)
println(
"Desviación relativa: " +
"${assessment.relativeDeviation!! * 100f} %"
)
println(
"Muestras: " +
assessment.sampleCount
)
println(
"Posible interferencia: " +
assessment.interferenceDetected
)
Intensidad actual: 48.0 μT
Referencia local: 40.0 μT
Desviación absoluta: 8.0 μT
Desviación relativa: 20.0 %
Muestras: 30
Posible interferencia: false
Lugar del modelo en el flujo
MagneticFieldAssessment no calcula por sí mismo la referencia ni las desviaciones. Su responsabilidad consiste únicamente en conservar un resultado válido e inmutable.
Bx, By y Bz
↓
Magnitud actual en μT
↓
MagneticInterferenceDetector
↓
Referencia y desviaciones
↓
MagneticFieldAssessment
↓
Calibración y presentación
MagneticInterferenceDetector reunirá muestras, aprenderá la referencia local y decidirá cuándo una variación debe marcarse como posible interferencia.
Confirma que MagneticFieldAssessment.INITIAL contiene cero muestras, no tiene referencia ni desviaciones y no informa interferencia.
Verifica también que el modelo rechaza contadores negativos, valores no finitos y magnitudes negativas. El detector de la siguiente sección será responsable de producir combinaciones coherentes de estos campos.
Detectar posibles interferencias magnéticas
Una lectura aislada puede cambiar por ruido, movimiento o una variación momentánea del sensor. Por eso SmartCompass no mostrará una advertencia ante cualquier valor diferente.
MagneticInterferenceDetector aprenderá primero una referencia local y solo marcará una posible interferencia cuando varias lecturas consecutivas se alejen suficientemente de ella.
Cómo funciona el detector
- Reúne varias muestras iniciales sin emitir advertencias.
- Calcula una referencia local mediante una media acumulada.
- Compara cada lectura posterior con esa referencia.
- Exige simultáneamente una desviación absoluta y una desviación relativa.
- Activa la advertencia únicamente después de varias anomalías consecutivas.
- Exige varias lecturas normales antes de considerar recuperado el entorno.
Parámetros utilizados por SmartCompass
-
24muestras iniciales para construir la referencia. -
Una diferencia mínima de
15 μT. -
Una desviación relativa mínima del
45 %. -
4anomalías consecutivas para activar la advertencia. -
8muestras normales para desactivarla. -
Una actualización lenta de la referencia con
alpha = 0.04.
También se utilizan límites auxiliares entre 8 μT y 120 μT para reconocer valores extremos. Estos límites no sustituyen la comparación con la referencia local.
Implementar MagneticInterferenceDetector
Crea el archivo dentro del paquete domain. Esta clase no importa componentes de Android ni de Compose, por lo que podrá probarse directamente en la JVM.
package com.tucodigocotidiano.smartcompass.domain
import kotlin.math.abs
/**
* Detector adaptativo de interferencia magnética.
*
* No considera una cifra única como verdad absoluta. Primero aprende
* una línea base local y después compara las lecturas nuevas contra
* esa referencia.
*
* Para reducir falsas alarmas:
*
* - espera varias muestras antes de analizar;
* - exige una desviación absoluta y relativa;
* - exige varias anomalías consecutivas;
* - exige varias muestras normales para recuperar el estado.
*
* También utiliza límites muy amplios únicamente como protección ante
* lecturas extremas. Estos límites no representan una calibración
* geográfica ni una medición científica del campo terrestre.
*/
class MagneticInterferenceDetector(
private val warmupSamples: Int =
DEFAULT_WARMUP_SAMPLES,
private val baselineAlpha: Float =
DEFAULT_BASELINE_ALPHA,
private val relativeDeviationThreshold: Float =
DEFAULT_RELATIVE_DEVIATION_THRESHOLD,
private val minimumAbsoluteDeviation: Float =
DEFAULT_MINIMUM_ABSOLUTE_DEVIATION,
private val consecutiveAnomaliesRequired: Int =
DEFAULT_CONSECUTIVE_ANOMALIES_REQUIRED,
private val recoverySamplesRequired: Int =
DEFAULT_RECOVERY_SAMPLES_REQUIRED,
private val minimumGuardrail: Float =
DEFAULT_MINIMUM_GUARDRAIL,
private val maximumGuardrail: Float =
DEFAULT_MAXIMUM_GUARDRAIL
) {
init {
require(warmupSamples > 0) {
"La cantidad de muestras iniciales debe ser mayor que cero."
}
require(
baselineAlpha.isFinite() &&
baselineAlpha > 0f &&
baselineAlpha <= 1f
) {
"El factor de actualización debe estar entre 0 y 1."
}
require(
relativeDeviationThreshold.isFinite() &&
relativeDeviationThreshold > 0f
) {
"El umbral relativo debe ser mayor que cero."
}
require(
minimumAbsoluteDeviation.isFinite() &&
minimumAbsoluteDeviation >= 0f
) {
"La desviación absoluta mínima no puede ser negativa."
}
require(consecutiveAnomaliesRequired > 0) {
"La cantidad de anomalías consecutivas debe ser mayor que cero."
}
require(recoverySamplesRequired > 0) {
"La cantidad de muestras de recuperación debe ser mayor que cero."
}
require(
minimumGuardrail.isFinite() &&
maximumGuardrail.isFinite() &&
minimumGuardrail >= 0f &&
maximumGuardrail > minimumGuardrail
) {
"Los límites magnéticos deben formar un intervalo válido."
}
}
private var baselineStrength: Float? = null
private var samplesSeen = 0
private var anomalyStreak = 0
private var recoveryStreak = 0
private var interferenceDetected = false
/**
* Procesa una nueva magnitud del campo magnético.
*/
fun add(
fieldStrength: Float
): MagneticFieldAssessment {
require(
fieldStrength.isFinite() &&
fieldStrength >= 0f
) {
"La intensidad magnética debe ser finita y no negativa."
}
samplesSeen++
val currentBaseline = baselineStrength
if (currentBaseline == null) {
baselineStrength = fieldStrength
return createAssessment(
fieldStrength = fieldStrength
)
}
/*
* Durante el calentamiento se utiliza una media acumulada.
*
* No se emiten alertas antes de reunir suficientes muestras,
* porque los primeros valores del sensor pueden estar
* transitorios.
*/
if (samplesSeen <= warmupSamples) {
val sampleWeight =
1f / samplesSeen.toFloat()
baselineStrength =
currentBaseline +
(
fieldStrength -
currentBaseline
) * sampleWeight
anomalyStreak = 0
recoveryStreak = 0
interferenceDetected = false
return createAssessment(
fieldStrength = fieldStrength
)
}
val absoluteDeviation =
abs(
fieldStrength -
currentBaseline
)
val relativeDeviation =
if (
currentBaseline >
MINIMUM_BASELINE_FOR_DIVISION
) {
absoluteDeviation /
currentBaseline
} else {
null
}
/*
* Una anomalía adaptativa necesita superar simultáneamente:
*
* - una diferencia mínima en microteslas;
* - una diferencia proporcional respecto a la referencia.
*/
val adaptiveAnomaly =
relativeDeviation != null &&
absoluteDeviation >=
minimumAbsoluteDeviation &&
relativeDeviation >=
relativeDeviationThreshold
/*
* Los límites amplios actúan únicamente ante valores extremos.
*/
val outsideGuardrails =
fieldStrength < minimumGuardrail ||
fieldStrength > maximumGuardrail
val anomalousSample =
adaptiveAnomaly ||
outsideGuardrails
if (anomalousSample) {
anomalyStreak++
recoveryStreak = 0
if (
anomalyStreak >=
consecutiveAnomaliesRequired
) {
interferenceDetected = true
}
} else {
anomalyStreak = 0
if (interferenceDetected) {
recoveryStreak++
if (
recoveryStreak >=
recoverySamplesRequired
) {
interferenceDetected = false
recoveryStreak = 0
/*
* Después de recuperarse se vuelve a centrar
* suavemente la referencia.
*/
baselineStrength =
currentBaseline +
baselineAlpha *
(
fieldStrength -
currentBaseline
)
}
} else {
recoveryStreak = 0
/*
* La referencia sigue lentamente los cambios normales
* del entorno sin perseguir alteraciones repentinas.
*/
baselineStrength =
currentBaseline +
baselineAlpha *
(
fieldStrength -
currentBaseline
)
}
}
return createAssessment(
fieldStrength = fieldStrength
)
}
/**
* Elimina la referencia y todos los contadores.
*/
fun reset() {
baselineStrength = null
samplesSeen = 0
anomalyStreak = 0
recoveryStreak = 0
interferenceDetected = false
}
private fun createAssessment(
fieldStrength: Float
): MagneticFieldAssessment {
val baseline =
baselineStrength
val absoluteDeviation =
baseline?.let { value ->
abs(
fieldStrength -
value
)
}
val relativeDeviation =
if (
baseline != null &&
baseline >
MINIMUM_BASELINE_FOR_DIVISION &&
absoluteDeviation != null
) {
absoluteDeviation /
baseline
} else {
null
}
return MagneticFieldAssessment(
interferenceDetected =
interferenceDetected,
isBaselineReady =
samplesSeen >= warmupSamples,
baselineStrength =
baseline,
absoluteDeviation =
absoluteDeviation,
relativeDeviation =
relativeDeviation,
sampleCount =
samplesSeen
)
}
companion object {
/**
* Con SENSOR_DELAY_UI estas muestras suelen reunirse
* rápidamente, pero evitan analizar los primeros transitorios.
*/
const val DEFAULT_WARMUP_SAMPLES = 24
/**
* La referencia se actualiza lentamente.
*/
const val DEFAULT_BASELINE_ALPHA = 0.04f
/**
* La lectura debe alejarse al menos un 45 % de la referencia.
*/
const val DEFAULT_RELATIVE_DEVIATION_THRESHOLD =
0.45f
/**
* Además debe existir una diferencia absoluta significativa.
*/
const val DEFAULT_MINIMUM_ABSOLUTE_DEVIATION =
15f
/**
* Una sola lectura extrema no activa la alarma.
*/
const val DEFAULT_CONSECUTIVE_ANOMALIES_REQUIRED =
4
/**
* La advertencia desaparece después de varias lecturas normales.
*/
const val DEFAULT_RECOVERY_SAMPLES_REQUIRED =
8
/**
* Límites amplios usados únicamente como señal auxiliar.
*/
const val DEFAULT_MINIMUM_GUARDRAIL =
8f
const val DEFAULT_MAXIMUM_GUARDRAIL =
120f
private const val
MINIMUM_BASELINE_FOR_DIVISION = 0.001f
}
}
Evitar que la referencia siga una anomalía
Después del calentamiento, la referencia solo se actualiza lentamente cuando las lecturas son normales. El nuevo valor se obtiene avanzando una pequeña proporción desde la referencia anterior hacia la lectura actual.
Cuando una muestra es anómala, la referencia permanece inmóvil. Esto evita que un objeto metálico cercano desplace rápidamente la referencia y termine siendo interpretado como parte normal del entorno.
Exigir persistencia en ambos sentidos
El contador anomalyStreak evita que una única lectura extrema active la advertencia. Una muestra normal rompe la secuencia y devuelve ese contador a cero.
Cuando la advertencia ya está activa, recoveryStreak exige varias lecturas normales consecutivas antes de desactivarla. Esto evita que el estado cambie continuamente entre alerta y normalidad.
Comprobar la activación y la recuperación
Para revisar el comportamiento sin enviar decenas de muestras, podemos crear temporalmente un detector con cantidades reducidas. Estos valores son solo para la comprobación; la aplicación utilizará los parámetros predeterminados.
val detector =
MagneticInterferenceDetector(
warmupSamples = 3,
baselineAlpha = 0.10f,
relativeDeviationThreshold = 0.25f,
minimumAbsoluteDeviation = 10f,
consecutiveAnomaliesRequired = 2,
recoverySamplesRequired = 3,
minimumGuardrail = 5f,
maximumGuardrail = 200f
)
detector.add(40f)
detector.add(41f)
val baselineReady =
detector.add(39f)
val firstAnomaly =
detector.add(60f)
val secondAnomaly =
detector.add(60f)
detector.add(40f)
detector.add(40f)
val recovered =
detector.add(40f)
println(
"Referencia lista: " +
baselineReady.isBaselineReady
)
println(
"Después de una anomalía: " +
firstAnomaly.interferenceDetected
)
println(
"Después de dos anomalías: " +
secondAnomaly.interferenceDetected
)
println(
"Después de tres lecturas normales: " +
recovered.interferenceDetected
)
Referencia lista: true
Después de una anomalía: false
Después de dos anomalías: true
Después de tres lecturas normales: false
Reiniciar el detector
El método reset() elimina la referencia, devuelve los contadores a cero y desactiva cualquier advertencia existente.
La fuente Android lo utilizará cuando los sensores se detengan. Al comenzar una nueva sesión, el detector tendrá que construir otra referencia local en lugar de reutilizar mediciones anteriores.
Confirma que durante el calentamiento no se detectan anomalías, que la referencia queda disponible después del número requerido de muestras y que una sola lectura extrema no activa la advertencia.
Verifica también que la interferencia necesita varias anomalías consecutivas, que desaparece únicamente después de varias lecturas normales y que reset() obliga a construir una referencia nueva.
.\gradlew.bat assembleDebug
BUILD SUCCESSFUL
Evaluar el estado de calibración
Hasta ahora tenemos dos fuentes de información diferentes: Android reporta la precisión técnica del magnetómetro y SmartCompass analiza el comportamiento del campo respecto a una referencia local.
En esta etapa combinaremos ambos resultados para construir un estado de calibración fácil de interpretar. La capa de presentación no tendrá que decidir qué significa una precisión baja, una referencia incompleta o una posible interferencia.
Definir estados comprensibles
SensorAccuracy conserva la información técnica recibida del sistema. CalibrationStatus, en cambio, expresa esa información con palabras que después podremos mostrar en la interfaz.
- Inicializando: todavía no se conoce la precisión.
- Buena: Android informa precisión alta.
- Aceptable: Android informa precisión media.
- Necesita calibración: Android informa precisión baja.
- No confiable: Android considera que las lecturas no son confiables.
package com.tucodigocotidiano.smartcompass.domain
/**
* Estado comprensible de calibración de la brújula.
*
* Este modelo no depende de Android ni de Compose.
*/
enum class CalibrationStatus(
val requiresCalibration: Boolean
) {
/**
* Todavía no existe información suficiente.
*/
INITIALIZING(
requiresCalibration = false
),
/**
* Android informa precisión alta.
*/
GOOD(
requiresCalibration = false
),
/**
* Android informa precisión media.
*/
ACCEPTABLE(
requiresCalibration = false
),
/**
* Android informa precisión baja.
*/
NEEDS_CALIBRATION(
requiresCalibration = true
),
/**
* Android considera que los datos no son confiables.
*/
UNRELIABLE(
requiresCalibration = true
)
}
INITIALIZING utiliza requiresCalibration = false.
Combinar precisión y análisis magnético
El estado técnico por sí solo no describe toda la situación. Una precisión alta puede coexistir con un cambio magnético persistente, y una precisión media todavía necesita que la referencia local termine de construirse.
CalibrationAssessment reunirá el estado de precisión y el último MagneticFieldAssessment. También expondrá tres propiedades preparadas para las capas superiores:
-
calibrationRequired: será verdadera cuando la precisión requiera calibración o exista una posible interferencia. -
magneticInterferenceDetected: acceso directo al resultado del detector. -
isReady: indicará que la precisión es suficiente, la referencia está lista y no existe interferencia.
package com.tucodigocotidiano.smartcompass.domain
/**
* Evaluación completa de calibración.
*
* Combina:
*
* - precisión reportada por Android;
* - comportamiento de la intensidad magnética;
* - posible interferencia local.
*/
data class CalibrationAssessment(
val status: CalibrationStatus,
val magneticField: MagneticFieldAssessment
) {
/**
* Se recomienda calibrar cuando:
*
* - Android informa precisión baja o no confiable;
* - se detecta una alteración magnética persistente.
*/
val calibrationRequired: Boolean
get() =
status.requiresCalibration ||
magneticField.interferenceDetected
/**
* Acceso simplificado para las capas superiores.
*/
val magneticInterferenceDetected: Boolean
get() = magneticField.interferenceDetected
/**
* La lectura está lista cuando la precisión es suficiente,
* existe una línea base y no hay interferencia.
*/
val isReady: Boolean
get() =
(
status == CalibrationStatus.GOOD ||
status == CalibrationStatus.ACCEPTABLE
) &&
magneticField.isBaselineReady &&
!magneticField.interferenceDetected
companion object {
val INITIALIZING = CalibrationAssessment(
status = CalibrationStatus.INITIALIZING,
magneticField = MagneticFieldAssessment.INITIAL
)
}
}
MagneticFieldAssessment.INITIAL representa únicamente el análisis magnético antes de recibir muestras. CalibrationAssessment.INITIALIZING representa la evaluación completa y contiene internamente ese estado magnético inicial.
Traducir SensorAccuracy con CalibrationEvaluator
La última clase realizará una transformación directa entre la precisión informada por Android y el estado de calibración.
UNKNOWNse convierte enINITIALIZING.UNRELIABLEse convierte enUNRELIABLE.LOWse convierte enNEEDS_CALIBRATION.MEDIUMse convierte enACCEPTABLE.HIGHse convierte enGOOD.
El evaluador no modifica el análisis magnético. Lo incorpora completo al resultado para que la posible interferencia y la referencia local sigan disponibles.
package com.tucodigocotidiano.smartcompass.domain
import com.tucodigocotidiano.smartcompass.sensor.SensorAccuracy
/**
* Traduce la precisión técnica del magnetómetro a un estado
* comprensible para la aplicación.
*
* No depende de Android ni de Compose.
*/
class CalibrationEvaluator {
fun evaluate(
accuracy: SensorAccuracy,
magneticFieldAssessment: MagneticFieldAssessment
): CalibrationAssessment {
val status = when (accuracy) {
SensorAccuracy.UNKNOWN ->
CalibrationStatus.INITIALIZING
SensorAccuracy.UNRELIABLE ->
CalibrationStatus.UNRELIABLE
SensorAccuracy.LOW ->
CalibrationStatus.NEEDS_CALIBRATION
SensorAccuracy.MEDIUM ->
CalibrationStatus.ACCEPTABLE
SensorAccuracy.HIGH ->
CalibrationStatus.GOOD
}
return CalibrationAssessment(
status = status,
magneticField = magneticFieldAssessment
)
}
}
SensorAccuracy
↓
CalibrationEvaluator
↓
CalibrationStatus
│
├───────────────┐
↓ ↓
Precisión MagneticFieldAssessment
↓
Referencia e interferencia
↓
CalibrationAssessment
↓
calibrationRequired / isReady
Interpretar el resultado final
Una precisión media o alta no basta para considerar lista la lectura. La referencia local también debe haber terminado su calentamiento y el detector no debe informar una posible interferencia.
De la misma manera, una precisión alta no oculta una alteración magnética. Cuando interferenceDetected es verdadera, calibrationRequired también será verdadera y isReady será falsa.
val evaluator =
CalibrationEvaluator()
val stableMagneticField =
MagneticFieldAssessment(
interferenceDetected = false,
isBaselineReady = true,
baselineStrength = 42f,
absoluteDeviation = 1f,
relativeDeviation = 0.024f,
sampleCount = 30
)
val interferenceMagneticField =
stableMagneticField.copy(
interferenceDetected = true,
absoluteDeviation = 22f,
relativeDeviation = 0.52f
)
val acceptable =
evaluator.evaluate(
accuracy = SensorAccuracy.MEDIUM,
magneticFieldAssessment =
stableMagneticField
)
val lowAccuracy =
evaluator.evaluate(
accuracy = SensorAccuracy.LOW,
magneticFieldAssessment =
stableMagneticField
)
val interference =
evaluator.evaluate(
accuracy = SensorAccuracy.HIGH,
magneticFieldAssessment =
interferenceMagneticField
)
println(
"Precisión media: " +
"${acceptable.status}, " +
"lista=${acceptable.isReady}"
)
println(
"Precisión baja: " +
"${lowAccuracy.status}, " +
"calibrar=${lowAccuracy.calibrationRequired}"
)
println(
"Precisión alta con interferencia: " +
"${interference.status}, " +
"lista=${interference.isReady}, " +
"calibrar=${interference.calibrationRequired}"
)
Precisión media: ACCEPTABLE, lista=true
Precisión baja: NEEDS_CALIBRATION, calibrar=true
Precisión alta con interferencia: GOOD, lista=false, calibrar=true
Con estas clases, la propiedad calibration que ya existe en CompassReading queda completamente definida. Una lectura creada antes del análisis utilizará CalibrationAssessment.INITIALIZING, y la fuente Android podrá reemplazarla posteriormente mediante copy().
Confirma que cada nivel de SensorAccuracy se convierte en el estado correcto y que solamente NEEDS_CALIBRATION y UNRELIABLE requieren calibración por precisión.
Verifica también que isReady exige precisión media o alta, una referencia magnética lista y ausencia de interferencia. Una posible interferencia debe activar calibrationRequired incluso cuando el estado sea GOOD.
Cerrar el contrato de CompassSensorSource
En la sección donde modelamos la disponibilidad y la precisión de los sensores ya creamos CompassSensorSource. Ahora que CompassReading también contiene la evaluación de calibración, podemos confirmar que ese contrato sigue siendo suficiente para conectar los sensores con el resto de la aplicación.
No modificaremos ni volveremos a copiar la interfaz. Revisaremos brevemente qué garantiza antes de construir su implementación para Android.
Qué expone el contrato
-
readings: Flow<CompassReading>entrega lecturas completas con rumbo, inclinación, precisión, campo magnético y calibración. -
availability: SensorAvailabilityinforma si existen el acelerómetro y el magnetómetro. -
isRunning: Booleanindica si la implementación está produciendo lecturas. -
start()solicita el inicio de la fuente. -
stop()detiene la producción de lecturas y permite liberar los recursos asociados.
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!