Arquitectura Técnica de BorealisClima: APIs y Algoritmos

Análisis de la arquitectura técnica de BorealisClima: una app Android nativa que integra Open-Meteo, guarda el pronóstico en el dispositivo con Room y puntúa 27 deportes con un motor de reglas en Kotlin.

Construir BorealisClima requirió una arquitectura Android nativa que obtuviera el pronóstico de Open-Meteo, lo mantuviera disponible sin conexión y puntuara los deportes con reglas deterministas, todo en el dispositivo. En este artículo desgloso cada componente técnico y las decisiones arquitectónicas clave.

🎯 Desafío arquitectónico: convertir el pronóstico de Open-Meteo en recomendaciones deportivas útiles en menos de 2 segundos, funcionando también sin conexión y sin enviar datos a ningún servidor propio.

Visión General de la Arquitectura

BorealisClima es una app Android nativa (Kotlin + Jetpack Compose) construida sobre una arquitectura limpia por capas: MVVM con Hilt para la inyección de dependencias, Room para la persistencia local, Retrofit para el acceso a la API meteorológica y un motor de reglas en Kotlin puro para el scoring deportivo. Todo el procesamiento ocurre en el dispositivo, con énfasis en la velocidad de respuesta y la disponibilidad offline.

Capas de la Arquitectura

📱 Capa de Presentación (Jetpack Compose)
Composables
ViewModel + StateFlow
Navigation Compose
Animaciones
🔄 Capa de Dominio
WeatherRepository
SportsScoringEngine (reglas)
GetRecommendationsUseCase
WeatherCache
💾 Capa de Datos
Room Database
Retrofit (Open-Meteo)
Caché offline en Room
DataStore (preferencias)
🌐 Servicio Externo
Open-Meteo (forecast)
Sin API key
Sin backend propio
Sin analytics remoto

Integración con Open-Meteo

BorealisClima obtiene el pronóstico de Open-Meteo, una API abierta y gratuita que no necesita clave. Con una sola petición se traen todas las variables que necesita el motor de reglas, para el momento actual y para las próximas 72 horas.

🌍
Open-Meteo
API meteorológica abierta que combina modelos nacionales, sin API key ni límites de uso restrictivos.
  • Sin registro ni clave
  • Cobertura global
  • Pronóstico horario a 7 días
  • Bien documentada y estable
  • Respuesta JSON compacta
📊
Variables por hora
Una sola llamada devuelve todo lo que puntúa el motor de reglas.
  • Temperatura y sensación térmica
  • Viento y ráfagas
  • Índice UV
  • Humedad relativa
  • Precipitación y probabilidad
🔒
Sin coste de privacidad
La app solo envía unas coordenadas aproximadas. Nada más sale del dispositivo.
  • Sin cuentas de usuario
  • Sin analytics remoto
  • Sin claves que filtrar
  • Ubicación a nivel de ciudad
  • Todo el histórico vive en Room

Cliente de Open-Meteo con Retrofit

// Servicio Retrofit: una sola llamada trae todo lo necesario
interface OpenMeteoService {
    @GET("v1/forecast")
    suspend fun getForecast(
        @Query("latitude") lat: Double,
        @Query("longitude") lon: Double,
        @Query("current") current: String =
            "temperature_2m,relative_humidity_2m,apparent_temperature," +
            "precipitation,wind_speed_10m,wind_gusts_10m,uv_index",
        @Query("hourly") hourly: String =
            "temperature_2m,precipitation_probability,wind_speed_10m,uv_index",
        @Query("forecast_days") days: Int = 3,
        @Query("timezone") timezone: String = "auto"
    ): ForecastResponse
}

// DTO -> modelo de dominio
data class WeatherConditions(
    val temperature: Double,
    val apparentTemperature: Double,
    val humidity: Int,
    val windSpeed: Double,
    val windGusts: Double,
    val uvIndex: Double,
    val precipitation: Double,
    val fetchedAt: Instant
)

class WeatherRepository(
    private val api: OpenMeteoService,
    private val cache: WeatherCache
) {
    // Devuelve datos frescos si hay red; si no, el último pronóstico guardado
    suspend fun getConditions(lat: Double, lon: Double): Result<WeatherConditions> =
        runCatching {
            val response = api.getForecast(lat, lon)
            response.toDomain().also { cache.save(lat, lon, it) }
        }.recoverCatching { error ->
            cache.lastKnown(lat, lon) ?: throw error
        }
}

Algoritmos de Scoring Deportivo

El corazón de BorealisClima es un motor de reglas en Kotlin puro que convierte los datos meteorológicos brutos en recomendaciones deportivas específicas. No hay machine learning ni TensorFlow Lite: son funciones de puntuación deterministas que se ejecutan íntegramente en el dispositivo. Cada deporte define su propio conjunto de rangos ideales y pesos.

27
Deportes soportados
5
Factores por deporte
0-100
Escala de puntuación
72h
Horizonte de pronóstico

Motor de Reglas Multifactorial (Kotlin puro)

// Reglas por deporte: rangos ideales y pesos. Datos, no modelo entrenado.
data class SportRule(
    val temperatureOptimal: ClosedRange<Double>,
    val temperatureWeight: Double,
    val windSpeedMax: Double,
    val windWeight: Double,
    val humidityOptimal: IntRange,
    val humidityWeight: Double,
    val uvIndexMax: Double,
    val uvWeight: Double,
    val precipitationTolerance: Double,
    val precipitationWeight: Double
)

val SPORT_RULES: Map<Sport, SportRule> = mapOf(
    Sport.RUNNING to SportRule(
        temperatureOptimal = 15.0..22.0, temperatureWeight = 0.35,
        windSpeedMax = 15.0, windWeight = 0.20,
        humidityOptimal = 30..60, humidityWeight = 0.15,
        uvIndexMax = 6.0, uvWeight = 0.20,
        precipitationTolerance = 0.1, precipitationWeight = 0.10
    ),
    Sport.CYCLING to SportRule(
        temperatureOptimal = 18.0..28.0, temperatureWeight = 0.30,
        windSpeedMax = 25.0, windWeight = 0.35,  // más sensible al viento
        humidityOptimal = 40..70, humidityWeight = 0.10,
        uvIndexMax = 8.0, uvWeight = 0.15,
        precipitationTolerance = 0.0, precipitationWeight = 0.10  // cero lluvia
    ),
    Sport.GOLF to SportRule(
        temperatureOptimal = 16.0..30.0, temperatureWeight = 0.25,
        windSpeedMax = 20.0, windWeight = 0.30,
        humidityOptimal = 40..80, humidityWeight = 0.05,
        uvIndexMax = 9.0, uvWeight = 0.25,
        precipitationTolerance = 0.2, precipitationWeight = 0.15
    )
    // ... hasta 27 deportes
)

object SportsScoringEngine {

    // Función pura: mismas entradas, misma salida. Sin estado, sin red.
    fun score(sport: Sport, weather: WeatherConditions): Double {
        val rule = SPORT_RULES.getValue(sport)
        var total = 100.0

        total *= factor(temperatureScore(weather.temperature, rule.temperatureOptimal), rule.temperatureWeight)
        total *= factor(windScore(weather.windSpeed, rule.windSpeedMax), rule.windWeight)
        total *= factor(humidityScore(weather.humidity, rule.humidityOptimal), rule.humidityWeight)
        total *= factor(uvScore(weather.uvIndex, rule.uvIndexMax), rule.uvWeight)

        if (weather.precipitation > rule.precipitationTolerance) {
            val penalty = min(weather.precipitation / rule.precipitationTolerance.coerceAtLeast(0.01), 1.0)
            total *= (1 - penalty * rule.precipitationWeight)
        }

        return total.coerceIn(0.0, 100.0)
    }

    private fun factor(subScore: Double, weight: Double) = subScore * weight + (1 - weight)

    private fun temperatureScore(temp: Double, optimal: ClosedRange<Double>): Double {
        if (temp in optimal) return 1.0
        val deviation = if (temp < optimal.start) optimal.start - temp else temp - optimal.endInclusive
        return (1.0 - deviation * 0.08).coerceAtLeast(0.0)  // -8% por grado fuera de rango
    }

    private fun windScore(windSpeed: Double, maxWind: Double): Double {
        if (windSpeed <= maxWind) return 1.0
        val excess = windSpeed - maxWind
        return (1.0 - (excess / 10.0).pow(1.5)).coerceAtLeast(0.0)  // penalización exponencial
    }
}

Caché Offline con Room

Para que la app responda rápido y funcione sin conexión, el pronóstico se guarda en Room en cuanto llega. Una capa en memoria evita releer la base de datos en consultas seguidas.

Flujo de Datos

1. Solicitud del usuario
La pantalla pide el pronóstico para una ubicación
2. Caché en memoria
Se comprueba la copia en memoria (válida 10 minutos)
3. Room
Se comprueba el último pronóstico guardado (válido 30 minutos)
4. Open-Meteo
Una petición a la API si no hay copia fresca
5. Guardar y puntuar
Se escribe en Room y en memoria, y se calculan los scores
6. Respuesta a la UI
Se entrega el pronóstico con las recomendaciones por deporte

Implementación de la caché

@Entity(tableName = "weather_cache")
data class CachedForecast(
    @PrimaryKey val cacheKey: String,   // lat/lon redondeadas
    val payload: WeatherConditions,     // vía TypeConverter a JSON
    val savedAt: Long
)

@Dao
interface WeatherDao {
    @Query("SELECT * FROM weather_cache WHERE cacheKey = :key LIMIT 1")
    suspend fun find(key: String): CachedForecast?

    @Upsert
    suspend fun upsert(entry: CachedForecast)

    @Query("DELETE FROM weather_cache WHERE savedAt < :threshold")
    suspend fun purgeOlderThan(threshold: Long)
}

class WeatherCache(private val dao: WeatherDao) {

    private val memory = LruCache<String, CachedForecast>(50)
    private val memoryTtl = 10.minutes
    private val roomTtl = 30.minutes

    suspend fun get(lat: Double, lon: Double): WeatherConditions? {
        val key = keyOf(lat, lon)
        val now = System.currentTimeMillis()

        memory.get(key)?.let { if (now - it.savedAt < memoryTtl.inWholeMilliseconds) return it.payload }

        val stored = dao.find(key) ?: return null
        if (now - stored.savedAt < roomTtl.inWholeMilliseconds) {
            memory.put(key, stored)          // se sube a memoria
            return stored.payload
        }
        return null                          // hay algo, pero está caducado
    }

    suspend fun save(lat: Double, lon: Double, conditions: WeatherConditions) {
        val entry = CachedForecast(keyOf(lat, lon), conditions, System.currentTimeMillis())
        memory.put(entry.cacheKey, entry)
        dao.upsert(entry)
    }

    // Última copia sin importar la antigüedad: se usa cuando no hay red
    suspend fun lastKnown(lat: Double, lon: Double): WeatherConditions? =
        dao.find(keyOf(lat, lon))?.payload

    private fun keyOf(lat: Double, lon: Double) =
        "%.2f_%.2f".format(lat, lon)         // agrupa ubicaciones cercanas
}

Manejo de Estados Offline

BorealisClima tiene que seguir siendo útil sin conexión, mostrando el último pronóstico guardado y dejando claro cuándo se descargó.

⚠️ Desafío de conectividad: mucha gente consulta el tiempo justo antes de salir al monte o a la playa, donde la cobertura es mala. La app no puede quedarse en blanco en ese momento.

enum class Source { NETWORK, CACHE_FRESH, CACHE_STALE }

data class ForecastResult(
    val conditions: WeatherConditions,
    val source: Source,
    val ageMinutes: Long
)

class GetForecastUseCase(
    private val repository: WeatherRepository,
    private val cache: WeatherCache
) {
    suspend operator fun invoke(lat: Double, lon: Double): ForecastResult {
        // 1. Copia fresca en memoria o en Room
        cache.get(lat, lon)?.let {
            return ForecastResult(it, Source.CACHE_FRESH, ageIn(it))
        }

        // 2. Pedir a Open-Meteo; el repositorio ya cae en la última copia si no hay red
        return repository.getConditions(lat, lon).fold(
            onSuccess = { fresh ->
                val fromNetwork = ageIn(fresh) < 1
                ForecastResult(
                    fresh,
                    if (fromNetwork) Source.NETWORK else Source.CACHE_STALE,
                    ageIn(fresh)
                )
            },
            onFailure = { error ->
                // 3. Sin red y sin copia fresca: se muestra la caducada, avisando de su antigüedad
                val stale = cache.lastKnown(lat, lon) ?: throw error
                ForecastResult(stale, Source.CACHE_STALE, ageIn(stale))
            }
        )
    }

    private fun ageIn(c: WeatherConditions): Long =
        Duration.between(c.fetchedAt, Instant.now()).toMinutes()
}

Optimización de Performance

Para mantener la app fluida, la optimización se centra en lo que más cuesta: la petición de red y el cálculo de los 27 scores.

<2s
Tiempo de respuesta con red
85%
Consultas servidas desde caché
1
Petición a Open-Meteo por consulta
~50MB
Uso de memoria promedio

Técnicas de optimización aplicadas

  • Una sola petición: Open-Meteo devuelve las variables actuales y las 72 h en una llamada
  • Caché primero: memoria y Room antes de tocar la red
  • Scoring en paralelo: los 27 deportes se puntúan con coroutines en Dispatchers.Default
  • Precarga: al abrir la app se lanza la consulta de la última ubicación
  • Recomposición mínima: estado inmutable y StateFlow para no redibujar de más en Compose
class GetRecommendationsUseCase(
    private val getForecast: GetForecastUseCase
) {
    suspend operator fun invoke(
        lat: Double,
        lon: Double,
        sports: List<Sport>
    ): List<SportRecommendation> = coroutineScope {

        // 1. Pronóstico (red o caché) una sola vez
        val forecast = getForecast(lat, lon)

        // 2. Puntuar los deportes en paralelo; score() es una función pura
        sports.map { sport ->
            async(Dispatchers.Default) {
                val score = SportsScoringEngine.score(sport, forecast.conditions)
                SportRecommendation(
                    sport = sport,
                    score = score,
                    source = forecast.source,
                    tips = buildTips(sport, score, forecast.conditions)
                )
            }
        }.awaitAll().sortedByDescending { it.score }
    }
}

Métricas Locales de Depuración

BorealisClima no envía analítica a ningún servidor. Durante el desarrollo registro unas métricas en memoria, solo en compilaciones de depuración, para detectar regresiones de rendimiento antes de publicar.

📊 Qué se mide en local: latencia de la petición a Open-Meteo, aciertos de caché frente a llamadas de red, errores de red y tiempo de cálculo de los 27 scores. Nada de esto sale del dispositivo ni se guarda de forma persistente.

// Solo debug: contadores en memoria, sin red, sin persistencia
object DebugMetrics {

    private data class Stat(var count: Long = 0, var totalMs: Long = 0) {
        val avgMs get() = if (count == 0L) 0 else totalMs / count
    }

    private val timings = ConcurrentHashMap<String, Stat>()
    private val counters = ConcurrentHashMap<String, Long>()

    inline fun <T> time(label: String, block: () -> T): T {
        if (!BuildConfig.DEBUG) return block()
        val start = SystemClock.elapsedRealtime()
        return block().also { record(label, SystemClock.elapsedRealtime() - start) }
    }

    fun record(label: String, elapsedMs: Long) {
        if (!BuildConfig.DEBUG) return
        timings.getOrPut(label) { Stat() }.apply { count++; totalMs += elapsedMs }
    }

    fun increment(label: String) {
        if (!BuildConfig.DEBUG) return
        counters.merge(label, 1L, Long::plus)
    }

    // Volcado a Logcat bajo demanda; nunca se sube a ningún sitio
    fun dump() = buildString {
        timings.forEach { (k, v) -> appendLine("$k: ${v.avgMs} ms (n=${v.count})") }
        counters.forEach { (k, v) -> appendLine("$k: $v") }
    }
}

// Uso
val forecast = DebugMetrics.time("open_meteo_request") { repository.getConditions(lat, lon) }
DebugMetrics.increment(if (servedFromCache) "cache_hit" else "network_call")

Lecciones Aprendidas y Mejores Prácticas

Tras varios meses desarrollando y refinando la arquitectura de BorealisClima, estas son las lecciones que más me han servido:

🎯 Decisiones Arquitectónicas Clave

  • Una API abierta y sin clave simplifica todo: con Open-Meteo no hay secretos que gestionar ni cuotas que vigilar
  • La caché en Room salva la UX: sin ella, la app sería inservible sin cobertura
  • Enfoque offline-first: el pronóstico útil tiene que estar antes de tocar la red
  • Reglas específicas por deporte: un único conjunto de umbrales no sirve para actividades tan distintas
  • Métricas locales desde el día uno: sin analítica remota, pero con datos suficientes para optimizar

⚠️ Errores a Evitar

  • No cachear el último pronóstico: si la petición falla, el usuario se queda sin nada que mirar
  • Caché demasiado agresiva: un dato muy viejo puede ser peor que avisar de que no hay dato
  • Reglas demasiado complejas: la simplicidad predecible gana a la sofisticación impredecible
  • Ignorar los casos límite: ubicaciones remotas, zonas horarias y condiciones extremas

¿Construyendo una app móvil con datos en tiempo real?

Si estás desarrollando una app Android que consume una API y necesita funcionar bien sin conexión, puedo ayudarte a diseñar una arquitectura limpia y sólida.

🏗️ Consultoría de Arquitectura