Desarrollo móvil Intermedio–avanzado

Construye una brújula inteligente en Android con Kotlin y Jetpack Compose

Aprende 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 …

Publicado: 12/07/2026 Por: Juan Felipe Orozco Cortés Duración: 180 a 240 minutos Nivel: Intermedio–avanzado
Contenido del tutorial
🧭 Kotlin · Android · Jetpack Compose
Construye una brújula inteligente en Android

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.

Sensores Rumbo magnético Filtros Calibración Compose

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 debería quedar cerca de , 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° y .
  • 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.
Una brújula útil no solo calcula un ángulo: también debe estabilizarlo y explicar cuándo confiar en él.

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.

Teléfono Android mostrando una brújula inteligente con rumbo de 202 grados hacia el sur
La aplicación combina el acelerómetro y el magnetómetro para calcular el rumbo magnético, estabilizar las lecturas y mostrar el estado de funcionamiento de la brújula.

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.

Vista previa de la aplicación
Lectura de brújula
Estos son algunos de los datos que mostrará la interfaz cuando la brújula tenga una lectura estable.
  • 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.
Resultado esperado

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.

Las opciones pueden cambiar ligeramente según el fabricante. En muchos dispositivos primero debes tocar varias veces el número de compilación para habilitar las opciones para desarrolladores.

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.
Necesitarás un teléfono físico. Aunque el emulador permite ejecutar la interfaz, no representa de manera confiable el ruido, la precisión ni las alteraciones reales del magnetómetro.

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
Si aparece 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
Antes de continuar

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.

Función principal
Identificar la inclinación
El acelerómetro ayuda a saber qué parte del teléfono apunta hacia arriba o hacia abajo, pero por sí solo no puede encontrar el norte magnético.

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.

Función principal
Detectar el campo magnético
El magnetómetro aporta la referencia magnética, pero sus valores cambian cuando el teléfono se inclina y pueden alterarse cerca de objetos metálicos, imanes o equipos eléctricos.

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.
Un magnetómetro no funciona como una brújula completa por sí solo. Sin la referencia del acelerómetro, el rumbo puede cambiar o resultar incorrecto cuando el teléfono está inclinado.
Idea clave

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.

$$ \vec{a} = (a_x, a_y, a_z) \qquad \vec{m} = (m_x, m_y, m_z) $$

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.

API utilizada
SensorManager.getRotationMatrix()
Recibe las lecturas filtradas del acelerómetro y del magnetómetro y produce una matriz de rotación cuando los datos permiten calcular una orientación válida.

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 , 90°, 180° y 270°. Así, la parte superior de la pantalla continúa siendo la dirección hacia la que apunta el teléfono.

API utilizada
SensorManager.remapCoordinateSystem()
Adapta la matriz de rotación al sistema de coordenadas correspondiente a la orientación actual de la pantalla.

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.
API utilizada
SensorManager.getOrientation()
Convierte la matriz de rotación corregida en azimut, pitch y roll.

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$.

$$ \frac{\pi}{2} \cdot \frac{180}{\pi} = 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$.

$$ \theta_{\text{normalizado}} = \left( \left( \theta_{\text{grados}} \bmod 360 \right) + 360 \right) \bmod 360 $$

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.

No debemos promediar ángulos como números lineales. Direcciones cercanas a 359° y 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.

Idea clave

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.

Referencia detectada por el teléfono
Norte magnético
Es la dirección obtenida a partir del campo magnético medido por el magnetómetro. Esta es la referencia que utilizará SmartCompass para calcular y mostrar el rumbo.
Referencia geográfica
Norte verdadero
Es la dirección geográfica hacia el polo norte. Para calcularla correctamente es necesario corregir la lectura magnética mediante la declinación correspondiente al lugar y al momento de la medició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.

Alcance de SmartCompass: la aplicación no utiliza GPS, no solicita permisos de ubicación y no aplica un modelo de declinación magnética. Por tanto, todas las direcciones mostradas corresponden al norte magnético.

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.

Idea clave

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 y 359°.
  • 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.
Advertencia importante: este proyecto tiene fines educativos y experimentales. SmartCompass no reemplaza instrumentos de navegación profesionales y no debe utilizarse en situaciones donde una lectura incorrecta pueda representar un riesgo.

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.

Dato proporcionado por Android
Precisión del sensor
Es el nivel de calidad que Android reporta para las lecturas del magnetómetro: desconocida, no confiable, baja, media o alta.
Estado comprensible para el usuario
Calibración
Traduce la precisión del sensor y el comportamiento magnético en estados como inicializando, buena, aceptable, necesita calibración o no confiable.
Referencia aprendida por la aplicación
Referencia magnética local
Es un valor aproximado construido con las primeras muestras. Describe el entorno cercano durante la sesión, pero no representa una medición universal del campo terrestre.
Advertencia adaptativa
Posible interferencia magnética
Indica que varias lecturas se alejaron de forma persistente de la referencia local. Es una señal preventiva, no una certificación científica.

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.

Antes de continuar

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.

Configuración del proyecto
SmartCompass
Utiliza Kotlin, Jetpack Compose y el paquete 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.
Escribe el paquete exactamente como 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.

Si PowerShell indica que 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.
Antes de continuar

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.

Archivo que vamos a editar
app/build.gradle.kts
Este archivo configura el módulo principal de la aplicación. Aquí se habilita Compose y se declaran las bibliotecas utilizadas por el código de SmartCompass.

Dependencias utilizadas por SmartCompass

  • Activity Compose: permite mostrar la interfaz Compose desde MainActivity.
  • Material 3: proporciona componentes como Scaffold, Card, Text, Surface y CircularProgressIndicator.
  • Lifecycle Runtime Compose: permite recoger el StateFlow respetando el ciclo de vida mediante collectAsStateWithLifecycle().
  • Lifecycle Runtime KTX: proporciona lifecycleScope y repeatOnLifecycle() 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, SharedFlow y 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.
No necesitamos instalar una biblioteca externa para los sensores. 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.

Los nombres exactos de los alias pueden variar según la versión de Android Studio con la que se creó el proyecto. Conserva los alias que ya existan en tu archivo 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

  • implementation incluye bibliotecas necesarias para compilar y ejecutar la aplicación.
  • testImplementation incluye herramientas utilizadas únicamente por las pruebas locales de la JVM.
  • androidTestImplementation incluye herramientas para pruebas que se ejecutan en un dispositivo o emulador Android.
  • debugImplementation agrega 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"
    }
}
No agregues un segundo bloque 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
Si aparece un error como 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.
Antes de continuar

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.

Archivo revisado
app/src/main/AndroidManifest.xml
Mantendremos la configuración existente de la aplicación y de MainActivity.
<?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.

No agregaremos permisos innecesarios. El proyecto no necesita elementos uses-permission para acceder a estos sensores y tampoco utiliza ubicación, cámara o servicios de red.
Antes de continuar

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.

Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/AngleMath.kt
Contendrá funciones matemáticas puras que podrán utilizarse desde la lectura de sensores, los filtros y las animaciones.

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°.

$$ \operatorname{normalize}(\theta) = ((\theta \bmod 360) + 360) \bmod 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 en 359°.
  • 360° se convierte en .
  • 721° se convierte en .

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.

$$ \text{grados} = \text{radianes} \times \frac{180}{\pi} $$

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°.

$$ \Delta = \operatorname{normalize} (\theta_{\text{destino}}-\theta_{\text{origen}}+180) -180 $$
  • Desde 350° hasta 10°, el resultado es +20°.
  • Desde 10° hasta 350°, 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.

Valores no válidos. Las tres funciones rechazan 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.
Antes de continuar

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.

Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/CardinalDirection.kt
Representará Norte, Noreste, Este, Sureste, Sur, Suroeste, Oeste y Noroeste.

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.

Antes de continuar

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.

Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/LowPassVectorFilter.kt
Filtrará vectores de tres componentes y conservará internamente el último resultado procesado.

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.

$$ y_t = \alpha y_{t-1} + (1-\alpha)x_t $$

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 alpha cercano a 1 produce mayor suavizado, pero responde más lentamente.
  • Un alpha cercano 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 NaN o infinito devuelve false y 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.

Este filtro trabaja con vectores, no directamente con el rumbo. No debemos utilizar una media lineal para suavizar ángulos como 359° y 1°. El rumbo necesita un filtro circular, que construiremos en la siguiente sección.

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.

Antes de continuar

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.

Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/CircularAngleFilter.kt
Mantendrá una ventana de rumbos recientes y devolverá una dirección estabilizada sin producir saltos al cruzar entre 359° y 0°.

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:

$$ \frac{359^\circ + 1^\circ}{2} = 180^\circ $$

Sin embargo, ambas direcciones están alrededor del norte. El promedio correcto debe quedar cerca de 0°, no del sur.

Los grados no forman una línea infinita. Después de 359° aparece nuevamente 0°. Por eso el rumbo necesita matemáticas circulares y no una media aritmética convencional.

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.

$$ x_i = \cos(\theta_i) \qquad y_i = \sin(\theta_i) $$

En lugar de sumar directamente los grados, sumamos los componentes de todos los vectores almacenados. Después, atan2() recupera la dirección promedio.

$$ \bar{\theta} = \operatorname{atan2} \left( \sum_{i=1}^{n}\sin(\theta_i), \sum_{i=1}^{n}\cos(\theta_i) \right) $$

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.

Antes de continuar

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.

Nuevos archivos
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/CompassReading.kt
Representará una lectura inmutable con rumbo magnético, pitch, roll e intensidad del campo.
Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/CompassEngine.kt
Convertirá los valores recibidos en radianes, normalizará el azimut y calculará la magnitud del vector magnético.
Versión correspondiente a esta etapa. Todavía no agregaremos la precisión del magnetómetro ni la evaluación de calibración. Esos campos se incorporarán cuando creemos 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.

$$ |B| = \sqrt{ B_x^2 + B_y^2 + B_z^2 } $$

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
Solo el rumbo se normaliza. El pitch y el roll pueden ser negativos porque su signo indica el sentido de la inclinación. Convertirlos al intervalo de 0° a 360° eliminaría esa información.

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.

Antes de continuar

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.

Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/sensor/SensorAccuracy.kt
Representará la calidad reportada por el magnetómetro sin exponer las constantes de Android.
Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/sensor/SensorAvailability.kt
Indicará si existen el acelerómetro y el magnetómetro, además de identificar cuáles faltan.
Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/sensor/CompassSensorSource.kt
Definirá el contrato utilizado para iniciar, detener y recibir lecturas de la brújula.

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
        }
}
Precisión no significa calibración completa. 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
    )
}
No reemplaces todo CompassEngine con este fragmento. Agrega únicamente el parámetro 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
Antes de continuar

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.

Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/sensor/DisplayRotation.kt
Contendrá los ejes lógicos, sus combinaciones y las cuatro rotaciones admitidas.
Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/sensor/DisplayRotationProvider.kt
Definirá el contrato utilizado para consultar la rotación de la pantalla.
Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/sensor/AndroidDisplayRotationProvider.kt
Traducirá las constantes de Surface a los modelos propios de SmartCompass.

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
No corregiremos el rumbo sumando 90°, 180° o 270°. La corrección debe aplicarse a la matriz de rotación antes de obtener el azimut, el pitch y el roll. Así todos los ángulos quedan expresados en el sistema de coordenadas visible.

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.

Antes de continuar

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.

Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/MagneticFieldAssessment.kt
Representará el estado del análisis magnético sin depender de Android, SensorManager ni Jetpack Compose.

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 CompassReading y 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.

$$ \Delta_{\text{abs}} = \left| B_{\text{actual}} - B_{\text{referencia}} \right| $$

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 %.

$$ \Delta_{\text{rel}} = \frac{ \Delta_{\text{abs}} }{ B_{\text{referencia}} } $$

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.

La referencia local no es un valor universal. Describe aproximadamente el entorno magnético observado durante la sesión. No representa una medición científica del campo terrestre ni depende de la ubicación geográfica.

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
Modelo y detector tienen responsabilidades diferentes. Esta clase almacena el resultado. En la siguiente sección, MagneticInterferenceDetector reunirá muestras, aprenderá la referencia local y decidirá cuándo una variación debe marcarse como posible interferencia.
Antes de continuar

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.

Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/MagneticInterferenceDetector.kt
Aprenderá una referencia magnética local, analizará las desviaciones y devolverá un MagneticFieldAssessment después de cada muestra.

Cómo funciona el detector

  1. Reúne varias muestras iniciales sin emitir advertencias.
  2. Calcula una referencia local mediante una media acumulada.
  3. Compara cada lectura posterior con esa referencia.
  4. Exige simultáneamente una desviación absoluta y una desviación relativa.
  5. Activa la advertencia únicamente después de varias anomalías consecutivas.
  6. Exige varias lecturas normales antes de considerar recuperado el entorno.

Parámetros utilizados por SmartCompass

  • 24 muestras iniciales para construir la referencia.
  • Una diferencia mínima de 15 μT.
  • Una desviación relativa mínima del 45 %.
  • 4 anomalías consecutivas para activar la advertencia.
  • 8 muestras 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.

Una diferencia grande no basta por sí sola. La detección adaptativa exige que la lectura supere al mismo tiempo la diferencia mínima en microteslas y la diferencia proporcional respecto a la referencia. Después, esa condición debe repetirse durante varias muestras.

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.

Esta detección es adaptativa, no científica. SmartCompass identifica cambios persistentes respecto a una referencia local. No puede determinar la causa del cambio, certificar una interferencia electromagnética ni funcionar como detector profesional de metales.

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.

Antes de continuar

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.

Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/CalibrationStatus.kt
Representará los cinco estados comprensibles de calibración.
Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/CalibrationAssessment.kt
Reunirá el estado de calibración y el análisis magnético de la sesión.
Nuevo archivo
app/src/main/java/com/tucodigocotidiano/smartcompass/domain/CalibrationEvaluator.kt
Traducirá cada nivel de SensorAccuracy a un CalibrationStatus.

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
    )
}
Inicializando no significa mala calibración. Cuando la precisión es desconocida todavía no contamos con información suficiente para recomendar una calibración. Por eso 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
        )
    }
}
Existen dos constantes iniciales diferentes. 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.

  • UNKNOWN se convierte en INITIALIZING.
  • UNRELIABLE se convierte en UNRELIABLE.
  • LOW se convierte en NEEDS_CALIBRATION.
  • MEDIUM se convierte en ACCEPTABLE.
  • HIGH se convierte en GOOD.

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().

Antes de continuar

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.

Archivo ya creado
app/src/main/java/com/tucodigocotidiano/smartcompass/sensor/CompassSensorSource.kt
El archivo permanece sin cambios. Expone las lecturas, la disponibilidad de los sensores, el estado de ejecución y las operaciones para iniciar y detener la fuente.

Qué expone el contrato

  • readings: Flow<CompassReading> entrega lecturas completas con rumbo, inclinación, precisión, campo magnético y calibración.
  • availability: SensorAvailability informa si existen el acelerómetro y el magnetómetro.
  • isRunning: Boolean indica 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.

Estás viendo solo el 60% del contenido. Hazte Premium para acceder al tutorial completo.

Comunidad

Comentarios y valoraciones

No hay comentarios aún. ¡Sé el primero en opinar!