Construir BorealisClima requirió diseñar una arquitectura que manejara múltiples fuentes de datos meteorológicos, procesamiento en tiempo real y algoritmos complejos de recomendación deportiva. En este artículo, desgloso cada componente técnico y las decisiones arquitectónicas clave.
🎯 Desafío arquitectónico: Crear un sistema que procese datos de múltiples APIs meteorológicas, aplique algoritmos de scoring deportivo y entregue recomendaciones personalizadas en menos de 2 segundos.
Visión General de la Arquitectura
BorealisClima sigue una arquitectura híbrida client-server optimizada para aplicaciones móviles, con énfasis en la velocidad de respuesta y la disponibilidad offline.
Capas de la Arquitectura
Integración de APIs Meteorológicas
La precisión de BorealisClima depende de combinar datos de múltiples fuentes meteorológicas. Cada API tiene fortalezas específicas que aprovecho estratégicamente.
- Cobertura global excelente
- Datos históricos disponibles
- API estable y bien documentada
- Forecast hasta 5 días
- Índice UV detallado
- Datos históricos extensos
- Análisis de tendencias
- Precisión en precipitación
- Datos de calidad del aire
- Alertas meteorológicas
- Respuesta ultra-rápida
- Datos locales precisos
- Radar meteorológico
- Condiciones en tiempo real
- Economía de requests
Estrategia de Agregación de Datos
class WeatherAggregator {
static const List<WeatherProvider> PROVIDERS = [
WeatherProvider.openWeatherMap,
WeatherProvider.visualCrossing,
WeatherProvider.weatherAPI,
];
Future<AggregatedWeatherData> getWeatherData(
double lat, double lon) async {
final futures = PROVIDERS.map((provider) =>
_fetchFromProvider(provider, lat, lon).timeout(
Duration(seconds: 3),
onTimeout: () => null,
)
);
final results = await Future.wait(futures);
final validResults = results.where((r) => r != null).toList();
if (validResults.isEmpty) {
throw WeatherDataException('No weather data available');
}
return _aggregateResults(validResults);
}
AggregatedWeatherData _aggregateResults(
List<WeatherData> results) {
// Weighted average basado en confiabilidad de cada API
final weights = {
WeatherProvider.openWeatherMap: 0.4,
WeatherProvider.visualCrossing: 0.35,
WeatherProvider.weatherAPI: 0.25,
};
double weightedTemp = 0.0;
double weightedHumidity = 0.0;
double weightedWindSpeed = 0.0;
double totalWeight = 0.0;
for (final result in results) {
final weight = weights[result.provider] ?? 0.0;
weightedTemp += result.temperature * weight;
weightedHumidity += result.humidity * weight;
weightedWindSpeed += result.windSpeed * weight;
totalWeight += weight;
}
return AggregatedWeatherData(
temperature: weightedTemp / totalWeight,
humidity: weightedHumidity / totalWeight,
windSpeed: weightedWindSpeed / totalWeight,
confidence: _calculateConfidence(results),
sources: results.map((r) => r.provider).toList(),
);
}
double _calculateConfidence(List<WeatherData> results) {
if (results.length == 1) return 0.7;
if (results.length == 2) return 0.85;
return 0.95; // Alta confianza con 3+ fuentes
}
}
Algoritmos de Scoring Deportivo
El corazón de BorealisClima son los algoritmos que convierten datos meteorológicos brutos en recomendaciones deportivas específicas. Cada deporte tiene su propio modelo de scoring.
Modelo de Scoring Multifactorial
class SportsScoringEngine {
static const Map<Sport, SportModel> SPORT_MODELS = {
Sport.running: SportModel(
temperatureOptimal: TemperatureRange(15, 22),
temperatureWeight: 0.35,
windSpeedMax: 15.0,
windWeight: 0.20,
humidityOptimal: HumidityRange(30, 60),
humidityWeight: 0.15,
uvIndexMax: 6.0,
uvWeight: 0.20,
precipitationTolerance: 0.1,
precipitationWeight: 0.10,
),
Sport.cycling: SportModel(
temperatureOptimal: TemperatureRange(18, 28),
temperatureWeight: 0.30,
windSpeedMax: 25.0,
windWeight: 0.35, // Más sensible al viento
humidityOptimal: HumidityRange(40, 70),
humidityWeight: 0.10,
uvIndexMax: 8.0,
uvWeight: 0.15,
precipitationTolerance: 0.0, // Cero tolerancia a lluvia
precipitationWeight: 0.10,
),
Sport.golf: SportModel(
temperatureOptimal: TemperatureRange(16, 30),
temperatureWeight: 0.25,
windSpeedMax: 20.0,
windWeight: 0.30,
humidityOptimal: HumidityRange(40, 80),
humidityWeight: 0.05,
uvIndexMax: 9.0,
uvWeight: 0.25,
precipitationTolerance: 0.2,
precipitationWeight: 0.15,
),
};
double calculateSportScore(Sport sport, WeatherConditions weather) {
final model = SPORT_MODELS[sport]!;
double totalScore = 100.0;
// Factor temperatura
final tempScore = _calculateTemperatureScore(
weather.temperature,
model.temperatureOptimal
);
totalScore = totalScore * (tempScore * model.temperatureWeight +
(1 - model.temperatureWeight));
// Factor viento
final windScore = _calculateWindScore(
weather.windSpeed,
model.windSpeedMax
);
totalScore = totalScore * (windScore * model.windWeight +
(1 - model.windWeight));
// Factor humedad
final humidityScore = _calculateHumidityScore(
weather.humidity,
model.humidityOptimal
);
totalScore = totalScore * (humidityScore * model.humidityWeight +
(1 - model.humidityWeight));
// Factor UV
final uvScore = _calculateUVScore(weather.uvIndex, model.uvIndexMax);
totalScore = totalScore * (uvScore * model.uvWeight +
(1 - model.uvWeight));
// Factor precipitación (penalización severa)
if (weather.precipitation > model.precipitationTolerance) {
final precipPenalty = math.min(
weather.precipitation / model.precipitationTolerance,
1.0
);
totalScore *= (1 - precipPenalty * model.precipitationWeight);
}
return math.max(0.0, math.min(100.0, totalScore));
}
double _calculateTemperatureScore(double temp, TemperatureRange optimal) {
if (temp >= optimal.min && temp <= optimal.max) {
return 1.0; // Temperatura perfecta
}
// Penalización gradual fuera del rango óptimo
final deviation = temp < optimal.min
? optimal.min - temp
: temp - optimal.max;
// Cada grado fuera del rango reduce score en 8%
return math.max(0.0, 1.0 - (deviation * 0.08));
}
double _calculateWindScore(double windSpeed, double maxWind) {
if (windSpeed <= maxWind) {
return 1.0;
}
// Penalización exponencial por viento excesivo
final excess = windSpeed - maxWind;
return math.max(0.0, 1.0 - math.pow(excess / 10.0, 1.5));
}
}
Arquitectura de Cache Inteligente
Para minimizar llamadas a APIs y garantizar respuestas rápidas, implementé un sistema de cache multicapa que balancea frescura de datos con performance.
Flujo de Datos con Cache
App solicita datos meteorológicos para ubicación
Verificar cache en memoria (válido por 10 minutos)
Verificar base de datos local (válido por 30 minutos)
Llamada a múltiples APIs meteorológicas
Agregar datos, calcular scores, actualizar todos los caches
Entregar datos procesados con recomendaciones
Implementación del Sistema de Cache
class WeatherCacheManager {
// Cache L1: Memoria (más rápido, menor capacidad)
static final Map<String, CachedWeatherData> _memoryCache = {};
static const int MEMORY_CACHE_DURATION_MINUTES = 10;
static const int MAX_MEMORY_ENTRIES = 50;
// Cache L2: Base de datos local (mayor capacidad, persistente)
static const int DB_CACHE_DURATION_MINUTES = 30;
static const int MAX_DB_ENTRIES = 500;
Future<WeatherData?> getCachedWeather(
double lat, double lon) async {
final cacheKey = _generateCacheKey(lat, lon);
// 1. Verificar cache en memoria
final memoryData = _memoryCache[cacheKey];
if (memoryData != null && !memoryData.isExpired(MEMORY_CACHE_DURATION_MINUTES)) {
_analytics.trackCacheHit('memory');
return memoryData.weatherData;
}
// 2. Verificar cache en base de datos
final dbData = await _weatherDatabase.getCachedWeather(cacheKey);
if (dbData != null && !dbData.isExpired(DB_CACHE_DURATION_MINUTES)) {
// Promocionar a cache de memoria
_memoryCache[cacheKey] = CachedWeatherData(
weatherData: dbData.weatherData,
timestamp: DateTime.now(),
);
_analytics.trackCacheHit('database');
return dbData.weatherData;
}
_analytics.trackCacheMiss();
return null; // Cache miss, necesita fetch de APIs
}
Future<void> cacheWeatherData(
double lat, double lon,
WeatherData weatherData) async {
final cacheKey = _generateCacheKey(lat, lon);
final cachedData = CachedWeatherData(
weatherData: weatherData,
timestamp: DateTime.now(),
);
// Guardar en memoria
_memoryCache[cacheKey] = cachedData;
_enforceMemoryCacheLimit();
// Guardar en base de datos
await _weatherDatabase.insertWeatherData(cacheKey, cachedData);
await _enforceDbCacheLimit();
}
void _enforceMemoryCacheLimit() {
if (_memoryCache.length > MAX_MEMORY_ENTRIES) {
// LRU eviction: remover entradas más antiguas
final sortedEntries = _memoryCache.entries.toList()
..sort((a, b) => a.value.timestamp.compareTo(b.value.timestamp));
final toRemove = sortedEntries.take(
_memoryCache.length - MAX_MEMORY_ENTRIES
);
for (final entry in toRemove) {
_memoryCache.remove(entry.key);
}
}
}
String _generateCacheKey(double lat, double lon) {
// Redondear coordenadas para agrupar ubicaciones cercanas
final roundedLat = (lat * 100).round() / 100;
final roundedLon = (lon * 100).round() / 100;
return '${roundedLat}_${roundedLon}';
}
}
Manejo de Estados Offline
BorealisClima debe funcionar sin conexión usando datos previamente cached y proporcionar una experiencia degradada pero útil.
⚠️ Desafío de conectividad: Los usuarios frecuentemente consultan el clima en ubicaciones remotas (montañas, playas) donde la conectividad es limitada. La app debe manejar estos escenarios graciosamente.
class OfflineWeatherService {
Future<WeatherResponse> getWeatherWithFallback(
double lat, double lon) async {
try {
// Intentar obtener datos frescos
final onlineData = await _weatherAggregator.getWeatherData(lat, lon);
await _cacheManager.cacheWeatherData(lat, lon, onlineData);
return WeatherResponse(
data: onlineData,
source: DataSource.online,
confidence: onlineData.confidence,
);
} on SocketException {
// Sin conexión a internet
return await _handleOfflineScenario(lat, lon);
} on TimeoutException {
// APIs lentas, intentar con cache
final cachedData = await _cacheManager.getCachedWeather(lat, lon);
if (cachedData != null) {
return WeatherResponse(
data: cachedData,
source: DataSource.cached,
confidence: _calculateCacheConfidence(cachedData),
);
}
throw WeatherServiceException('No data available');
} catch (e) {
// Error genérico, fallback a cache
return await _handleOfflineScenario(lat, lon);
}
}
Future<WeatherResponse> _handleOfflineScenario(
double lat, double lon) async {
// 1. Buscar datos exactos en cache
var cachedData = await _cacheManager.getCachedWeather(lat, lon);
if (cachedData != null) {
return WeatherResponse(
data: cachedData,
source: DataSource.cached,
confidence: _calculateCacheConfidence(cachedData),
);
}
// 2. Buscar datos en ubicaciones cercanas (radio de 50km)
final nearbyData = await _findNearbyWeatherData(lat, lon, 50.0);
if (nearbyData != null) {
// Interpolar datos basado en distancia
final interpolatedData = _interpolateWeatherData(
nearbyData, lat, lon
);
return WeatherResponse(
data: interpolatedData,
source: DataSource.interpolated,
confidence: 0.6, // Menor confianza por interpolación
);
}
// 3. Usar datos históricos promedio para la fecha/ubicación
final historicalData = await _getHistoricalAverage(lat, lon);
if (historicalData != null) {
return WeatherResponse(
data: historicalData,
source: DataSource.historical,
confidence: 0.4, // Muy baja confianza
);
}
throw WeatherServiceException('No weather data available offline');
}
double _calculateCacheConfidence(WeatherData cachedData) {
final ageInMinutes = DateTime.now()
.difference(cachedData.timestamp)
.inMinutes;
if (ageInMinutes <= 10) return 0.95;
if (ageInMinutes <= 30) return 0.85;
if (ageInMinutes <= 60) return 0.70;
return 0.50; // Datos muy antiguos
}
}
Optimización de Performance
Para mantener la app responsiva, implementé varias técnicas de optimización enfocadas en las operaciones más costosas: llamadas a APIs y cálculos de scoring.
Técnicas de Optimización Implementadas
- Precarga inteligente: Fetch proactivo de datos para ubicaciones frecuentes
- Batching de requests: Agrupar múltiples solicitudes en una sola llamada
- Compresión de respuestas: Gzip para reducir transferencia de datos
- Connection pooling: Reutilizar conexiones HTTP para reducir latencia
- Scoring paralelo: Calcular puntuaciones de múltiples deportes simultáneamente
- Algoritmos aproximados: Usar aproximaciones para cálculos no críticos
class PerformanceOptimizedWeatherService {
// Connection pool para reutilizar conexiones HTTP
static final http.Client _httpClient = http.Client();
// Executor para paralelizar cálculos de scoring
static final _scoringExecutor = ComputeExecutor(maxConcurrent: 3);
Future<List<SportRecommendation>> getOptimizedRecommendations(
double lat, double lon, List<Sport> sports) async {
// 1. Fetch paralelo de datos meteorológicos
final weatherFuture = _getWeatherDataOptimized(lat, lon);
// 2. Pre-cargar modelos de deportes
final sportsModels = sports.map((sport) =>
SPORT_MODELS[sport]!
).toList();
// 3. Esperar datos meteorológicos
final weatherData = await weatherFuture;
// 4. Calcular scores en paralelo usando isolates
final scoringFutures = sports.map((sport) =>
_scoringExecutor.execute(
_calculateSportScoreIsolate,
[sport, weatherData, sportsModels[sports.indexOf(sport)]]
)
);
final scores = await Future.wait(scoringFutures);
// 5. Crear recomendaciones finales
return sports.asMap().entries.map((entry) =>
SportRecommendation(
sport: entry.value,
score: scores[entry.key],
weatherData: weatherData,
recommendations: _generateRecommendations(
entry.value, scores[entry.key], weatherData
),
)
).toList();
}
Future<WeatherData> _getWeatherDataOptimized(
double lat, double lon) async {
// 1. Verificar cache primero
final cached = await _cacheManager.getCachedWeather(lat, lon);
if (cached != null) return cached;
// 2. Batch multiple requests en una sola conexión
final request = BatchWeatherRequest(
locations: [LatLng(lat, lon)],
providers: [
WeatherProvider.openWeatherMap,
WeatherProvider.visualCrossing,
],
dataTypes: [
'temperature', 'humidity', 'windSpeed',
'precipitation', 'uvIndex'
],
);
// 3. Request con timeout agresivo
final response = await _httpClient
.post(
Uri.parse('$API_BASE_URL/batch'),
headers: {
'Content-Type': 'application/json',
'Accept-Encoding': 'gzip',
},
body: jsonEncode(request.toJson()),
)
.timeout(Duration(seconds: 3));
final weatherData = WeatherData.fromBatchResponse(response.body);
// 4. Cache para requests futuros
await _cacheManager.cacheWeatherData(lat, lon, weatherData);
return weatherData;
}
}
// Función pura para ejecutar en isolate
double _calculateSportScoreIsolate(List args) {
final sport = args[0] as Sport;
final weather = args[1] as WeatherData;
final model = args[2] as SportModel;
return SportsScoringEngine.calculateSportScore(sport, weather, model);
}
Monitoreo y Analytics
Para mantener y mejorar la calidad del servicio, implementé un sistema de monitoreo que rastrea métricas clave de performance y precisión.
📊 Métricas clave monitoreadas: Latencia de APIs, precisión de recomendaciones, satisfacción del usuario, uso de cache, errores de red, y patrones de uso por deporte.
class WeatherAnalytics {
static final _analytics = FirebaseAnalytics.instance;
// Trackear performance de APIs
Future<void> trackAPIPerformance(
WeatherProvider provider,
Duration responseTime,
bool success) async {
await _analytics.logEvent(
name: 'api_performance',
parameters: {
'provider': provider.name,
'response_time_ms': responseTime.inMilliseconds,
'success': success,
'timestamp': DateTime.now().millisecondsSinceEpoch,
},
);
}
// Trackear precisión de recomendaciones
Future<void> trackRecommendationAccuracy(
Sport sport,
double predictedScore,
bool userActuallyDid) async {
await _analytics.logEvent(
name: 'recommendation_accuracy',
parameters: {
'sport': sport.name,
'predicted_score': predictedScore,
'user_activity': userActuallyDid,
'score_bucket': _getScoreBucket(predictedScore),
},
);
}
// Trackear uso de cache
void trackCacheHit(String cacheLevel) {
_analytics.logEvent(
name: 'cache_performance',
parameters: {
'cache_level': cacheLevel, // 'memory' | 'database' | 'miss'
'timestamp': DateTime.now().millisecondsSinceEpoch,
},
);
}
String _getScoreBucket(double score) {
if (score >= 80) return 'excellent';
if (score >= 60) return 'good';
if (score >= 40) return 'fair';
return 'poor';
}
}
Lecciones Aprendidas y Mejores Prácticas
Después de 18 meses desarrollando y refinando la arquitectura de BorealisClima, estas son las lecciones más valiosas que he aprendido:
🎯 Decisiones Arquitectónicas Clave
- ✅ Múltiples APIs son esenciales: Una sola fuente meteorológica no es suficientemente confiable
- ✅ Cache multinivel salva la UX: Sin cache agresivo, la app sería inutilizable
- ✅ Offline-first approach: Los usuarios necesitan datos incluso sin conectividad
- ✅ Scoring específico por deporte: Un modelo genérico no funciona para diferentes actividades
- ✅ Monitoreo desde el día 1: Sin métricas, no puedes optimizar efectivamente
⚠️ Errores a Evitar
- ❌ Confiar en una sola API: Las APIs meteorológicas fallan más de lo esperado
- ❌ Cache demasiado agresivo: Datos antiguos pueden ser peores que no tener datos
- ❌ Algoritmos demasiado complejos: La simplicidad predecible gana sobre la sofisticación impredecible
- ❌ Ignorar casos edge: Ubicaciones remotas, zonas horarias, condiciones extremas
¿Construyendo una App con APIs Externas?
Si estás desarrollando una aplicación que integra múltiples APIs o necesitas arquitectura robusta para datos en tiempo real, puedo ayudarte a diseñar una solución escalable.
🏗️ Consultoría de Arquitectura