lección 2
Día 1 -- Pipeline de ETAs en real-time
Lees eventos GPS de Kafka, calculas distancia restante con la fórmula Haversine e implementas estimación de tiempo de llegada para el dashboard de Ana.
⏱ 45 min
### Lunes, 9:00 AM -- Tu primer standup como owner
El standup de FlashRoute es rápido: 10 minutos de pie junto a la pizarra. Marcos modera. Cada uno dice en una frase qué va a hacer hoy y si tiene bloqueos. No hay "ayer hice X" -- Marcos dice que eso se ve en los commits. Hoy es tu primera semana operativa así que escuchas: Laura habla de una discrepancia en facturación con un cliente de electrónica, Diego de un A/B test en el algoritmo de rutas que mejoró el tiempo medio un 4%, Marta de la integración con un nuevo cliente B2B que empieza el mes que viene, Iván de un problema de stock en el hub de Bilbao.
Cuando termina, Marcos te mira: "Tú hoy: pipeline de ETAs. Ana necesita el dato en DynamoDB para que el frontend lo lea. El servicio de Carlos ya publica GPS en Kafka. Tú consumes, calculas y escribes. Preguntas?" Tienes varias. La más importante: cómo calcular el ETA. Marcos: "Empieza simple. Distancia Haversine dividida por velocidad actual. Factor de corrección urbano para compensar que la ruta no es línea recta. Luego mejora si los números no cuadran."
Es un buen consejo que reconoces de NeoBank: empezar con algo funcional y mejorar iterativamente, no buscar la solución perfecta el día 1. La diferencia es que en NeoBank Alberto te habría dado un diseño detallado. Aquí, Marcos te da el objetivo y tú decides el cómo.
### La fórmula Haversine: distancia entre dos puntos GPS
La fórmula Haversine calcula la distancia en línea recta entre dos puntos sobre la superficie de la Tierra usando sus coordenadas GPS (latitud y longitud). Es la fórmula estándar para navegación y geolocalización cuando las distancias son inferiores a ~500km. Para última milla (distancias de 1-20km) tiene una precisión de +/- 0.5%, más que suficiente.
La fórmula toma como input las coordenadas de dos puntos (en grados) y retorna la distancia en kilómetros. Usa el radio de la Tierra (6371 km) y funciones trigonométricas para tener en cuenta la curvatura terrestre. Para distancias cortas se podría usar pitágoras, pero Haversine es más precisa y el coste computacional es trivial.
1import math23def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:4 """5 Calcula la distancia en km entre dos puntos GPS usando la formula Haversine.6 Precision: +/- 0.5% para distancias < 20km (perfecto para última milla).78 Parametros:9 lat1, lon1: coordenadas del punto A (grados decimales)10 lat2, lon2: coordenadas del punto B (grados decimales)11 Retorna:12 Distancia en kilometros (linea recta sobre la superficie terrestre)13 """14 R = 6371.0 # Radio de la Tierra en km1516 # Convertir grados a radianes17 phi1 = math.radians(lat1)18 phi2 = math.radians(lat2)19 delta_phi = math.radians(lat2 - lat1)20 delta_lambda = math.radians(lon2 - lon1)2122 # Formula Haversine23 a = (math.sin(delta_phi / 2) ** 2 +24 math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2)25 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))2627 return R * c282930# --- Ejemplos con datos reales de FlashRoute ---3132# Repartidor DRV-0847: posición actual -> destino del cliente33dist = haversine_km(40.4168, -3.7038, 40.4015, -3.7105)34print(f"DRV-0847 a destino: {dist:.3f} km") # ~1.78 km3536# Hub Madrid Centro -> Aeropuerto (referencia larga)37dist_larga = haversine_km(40.4168, -3.7038, 40.4722, -3.5611)38print(f"Centro -> Aeropuerto: {dist_larga:.3f} km") # ~13.2 km3940# Factor de correccion urbano: la ruta real es ~1.3-1.5x la linea recta41# (calles, semaforos, sentidos únicos, rotondas)42URBAN_FACTOR = 1.443dist_real = dist * URBAN_FACTOR44print(f"Distancia estimada real (x{URBAN_FACTOR}): {dist_real:.3f} km") # ~2.49 km
Fórmula Haversine -- el cálculo base para distancia entre coordenadas GPS
El factor de corrección urbano de 1.4x es una aproximación razonable para ciudades españolas. En ciudades con grid regular (como Manhattan) sería ~1.2x. En ciudades con calles irregulares (como el casco antiguo de Sevilla) puede llegar a 1.6x. FlashRoute podría calibrar este factor por zona usando datos históricos reales de rutas completadas, pero eso es una optimización futura.
### El pipeline completo: Kafka -> Spark -> DynamoDB
Decides implementar el pipeline con Spark Structured Streaming. Ya tienes experiencia de NeoBank con Spark Streaming y el stack de FlashRoute usa EMR. El flujo es: leer GPS de Kafka, hacer join con destinos de entregas activas, calcular distancia + ETA, escribir a DynamoDB. El trigger es cada 30 segundos para cumplir el SLA de latencia del dashboard de Ana.
1from pyspark.sql import SparkSession2from pyspark.sql.functions import (3 col, from_json, udf, current_timestamp, lit, greatest4)5from pyspark.sql.types import (6 StructType, StructField, StringType, DoubleType, IntegerType7)8import math910# --- Schema del evento GPS ---11gps_schema = StructType([12 StructField("driver_id", StringType(), False),13 StructField("lat", DoubleType(), False),14 StructField("lon", DoubleType(), False),15 StructField("timestamp", StringType(), False),16 StructField("speed_kmh", DoubleType(), False),17 StructField("heading", IntegerType(), True),18 StructField("battery_pct", IntegerType(), True),19 StructField("accuracy_m", DoubleType(), True),20 StructField("delivery_id", StringType(), True),21])2223# --- SparkSession con config para streaming ---24spark = SparkSession.builder \25 .appName("flashroute-eta-pipeline") \26 .config("spark.sql.streaming.checkpointLocation",27 "s3://flashroute-checkpoints/eta-pipeline/") \28 .config("spark.executor.instances", "4") \29 .config("spark.executor.cores", "2") \30 .config("spark.executor.memory", "4g") \31 .getOrCreate()3233# --- UDF de Haversine para usar en DataFrames ---34@udf(returnType=DoubleType())35def calc_haversine(lat1, lon1, lat2, lon2):36 """Calcula distancia en km entre dos puntos GPS."""37 if any(v is None for v in [lat1, lon1, lat2, lon2]):38 return None39 R = 6371.040 phi1, phi2 = math.radians(lat1), math.radians(lat2)41 dp = math.radians(lat2 - lat1)42 dl = math.radians(lon2 - lon1)43 a = math.sin(dp/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dl/2)**244 return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))4546# --- Leer stream de GPS desde Kafka ---47gps_stream = spark.readStream \48 .format("kafka") \49 .option("kafka.bootstrap.servers", "kafka-1.flashroute.internal:9092") \50 .option("subscribe", "driver.location") \51 .option("startingOffsets", "latest") \52 .option("maxOffsetsPerTrigger", 10000) \53 .load()5455# --- Parsear JSON y filtrar eventos con entrega activa ---56parsed_gps = gps_stream \57 .selectExpr("CAST(value AS STRING) as json_str") \58 .select(from_json(col("json_str"), gps_schema).alias("gps")) \59 .select("gps.*") \60 .filter(col("delivery_id").isNotNull())6162print(f"Schema parseado: {parsed_gps.schema.simpleString()}")
Primera parte del pipeline: SparkSession, schema, ingesta de Kafka
La segunda parte del pipeline necesita el destino de cada entrega para calcular la distancia restante. En producción, haces un stream-static join con un DataFrame de entregas activas que se refresca periódicamente desde PostgreSQL. Este join es eficiente porque Spark puede broadcastear la tabla de entregas activas (son ~5000 entregas en tránsito en cualquier momento, cabe sobradamente en memoria).
1# --- Lookup de destinos: entregas activas ---2# Se refresca cada 5 minutos con un job aparte que lee de PostgreSQL3# En cada micro-batch, el join usa la versión más reciente en cache4deliveries_df = spark.read \5 .format("jdbc") \6 .option("url", "jdbc:postgresql://db.flashroute.internal/ops") \7 .option("dbtable", "active_deliveries") \8 .option("user", "etl_reader") \9 .load() \10 .select(11 col("delivery_id"),12 col("dest_lat"),13 col("dest_lon"),14 col("promised_eta"),15 col("zone"),16 col("client_b2b")17 )1819# --- Constantes del cálculo de ETA ---20URBAN_FACTOR = 1.4 # Correccion linea recta -> ruta urbana21MIN_SPEED_KMH = 5.0 # Velocidad mínima (evita division por 0 cuando para)2223# --- Join GPS con destino y calcular ETA ---24eta_calculated = parsed_gps \25 .join(deliveries_df, "delivery_id") \26 .withColumn(27 "distance_km",28 calc_haversine(col("lat"), col("lon"), col("dest_lat"), col("dest_lon"))29 * lit(URBAN_FACTOR)30 ) \31 .withColumn(32 "eta_minutes",33 (col("distance_km") /34 greatest(col("speed_kmh"), lit(MIN_SPEED_KMH))) * lit(60)35 ) \36 .select(37 col("delivery_id"),38 col("driver_id"),39 col("distance_km"),40 col("eta_minutes"),41 col("speed_kmh"),42 col("zone"),43 col("client_b2b"),44 current_timestamp().alias("calculated_at")45 )
Segunda parte: join con destinos y cálculo de ETA
### El imprevisto: Ana aparece a las 11:30
Estás concentrado escribiendo el sink a DynamoDB cuando Ana Vega aparece detrás de tu pantalla. No es una persona que escriba por Slack cuando puede decírtelo a la cara. "Eres el nuevo de datos, no? Marcos me dijo que estás montando los ETAs. Necesito que entiendas algo: mis coordinadores toman decisiones cada 30 segundos mirando esa pantalla. Si un repartidor va a llegar 15 minutos tarde, necesito saberlo AHORA, no cuando ya haya llegado tarde. Porque si lo sé ahora, puedo reasignar la entrega a otro repartidor que esté más cerca."
Le explicas que el MVP calcula el ETA basado en distancia Haversine y velocidad actual. Ana asiente: "Bien. Pero añade un campo que diga si va a llegar tarde. Comparado con la hora que prometimos al cliente. Eso es lo que mis coordinadores necesitan ver en rojo en la pantalla." Tomas nota mental -- es un requisito claro: campo is_at_risk como booleano que el frontend interpreta como color.
Ana no es técnica pero es extremadamente precisa en lo que necesita. Cuando dice "30 segundos" es literal. Cuando dice "en rojo", quiere un campo booleano que el frontend interprete como color. Aprende su lenguaje: ella piensa en "repartidores retrasados", no en "watermarks" ni "micro-batches". Tu trabajo es traducir sus necesidades de negocio a soluciones técnicas. No la eduques sobre Spark -- dale datos.
### Escribir a DynamoDB -- el sink del pipeline
El último paso es escribir los ETAs calculados a DynamoDB para que el frontend de Ana los lea. DynamoDB es la elección correcta aquí por tres razones: latencia de lectura inferior a 10ms (crítico para un dashboard real-time), escalado automático sin gestión de infra, y el frontend de Carlos ya está integrado con DynamoDB. Usas foreachBatch para escribir en micro-lotes eficientes.
1import boto32from datetime import datetime, timedelta34def write_etas_to_dynamodb(batch_df, batch_id):5 """6 Escribe los ETAs calculados a DynamoDB.7 Tabla: delivery_etas (PK: delivery_id)89 porque permite usar batch_writer de DynamoDB que agrupa10 hasta 25 items por request HTTP -- mucho más eficiente.11 """12 if batch_df.isEmpty():13 return1415 rows = batch_df.collect()16 dynamodb = boto3.resource('dynamodb', region_name='eu-west-1')17 table = dynamodb.Table('delivery_etas')1819 with table.batch_writer() as writer:20 for row in rows:21 writer.put_item(Item={22 'delivery_id': row['delivery_id'],23 'driver_id': row['driver_id'],24 'eta_minutes': int(row['eta_minutes']),25 'distance_km': str(round(float(row['distance_km']), 2)),26 'speed_kmh': str(round(float(row['speed_kmh']), 1)),27 'zone': row['zone'],28 'is_at_risk': row['is_at_risk'],29 'calculated_at': row['calculated_at'].isoformat(),30 'ttl': int((datetime.now() + timedelta(hours=2)).timestamp())31 })3233# --- Iniciar el pipeline ---34query = eta_calculated.writeStream \35 .foreachBatch(write_etas_to_dynamodb) \36 .trigger(processingTime="30 seconds") \37 .outputMode("update") \38 .start()3940print("Pipeline ETA iniciado -- trigger cada 30 segundos")41query.awaitTermination()
Sink a DynamoDB con foreachBatch -- el frontend de Ana lee de aquí
foreachBatch es la forma correcta de escribir a sistemas externos desde Spark Streaming. No uses foreach (procesa fila a fila, extremadamente lento). Con foreachBatch agrupas escrituras y puedes usar batch_writer que envía hasta 25 items por request HTTP. El batch_id te da idempotencia si necesitas reintentos: puedes verificar si un batch ya se procesó antes de escribirlo.
### El campo is_at_risk -- la alerta que pide Ana
Para implementar el requisito de Ana, necesitas comparar la hora estimada de llegada (ahora + eta_minutes) con la hora prometida al cliente (promised_eta). Si la estimación supera la promesa, la entrega está "at risk" y el coordinador debe actuar.
1from pyspark.sql.functions import (2 unix_timestamp, to_timestamp, when, col, current_timestamp, lit3)45# Anadir is_at_risk y minutes_over_sla al DataFrame6eta_with_risk = eta_calculated \7 .withColumn(8 "estimated_arrival_ts",9 unix_timestamp(current_timestamp()) + col("eta_minutes") * lit(60)10 ) \11 .withColumn(12 "promised_ts",13 unix_timestamp(to_timestamp(col("promised_eta")))14 ) \15 .withColumn(16 "is_at_risk",17 col("estimated_arrival_ts") > col("promised_ts")18 ) \19 .withColumn(20 "minutes_over_sla",21 when(col("is_at_risk"),22 (col("estimated_arrival_ts") - col("promised_ts")) / lit(60)23 ).otherwise(lit(0))24 ) \25 .withColumn(26 "risk_level",27 when(col("minutes_over_sla") > 15, lit("critical"))28 .when(col("minutes_over_sla") > 5, lit("high"))29 .when(col("is_at_risk"), lit("medium"))30 .otherwise(lit("ok"))31 )3233# El frontend interpreta risk_level como:34# "ok" -> verde, "medium" -> amarillo, "high" -> naranja, "critical" -> rojo
Cálculo de is_at_risk y clasificación por niveles de riesgo
Con esto, tu primer día tiene un pipeline funcional end-to-end: GPS -> Kafka -> Spark (Haversine + ETA + risk) -> DynamoDB -> Dashboard de Ana. A las 18:00 lo deploys en el cluster de EMR, verificas que los ETAs aparecen en DynamoDB y escribes a Ana por Slack: "ETAs live en el dashboard. Refresh cada 30 segundos. Mañana agrego el dashboard de SLAs." Respuesta de Ana: "Ok." Una palabra. Suficiente.
## ejercicios
Implementar cálculo de distancia Haversine
Implementa la función haversine_km que calcula la distancia entre dos puntos GPS. Luego crea estimate_eta que use la distancia, velocidad y un factor urbano para estimar minutos restantes. Valida con los datos de ejemplo de FlashRoute.
Detectar entregas en riesgo de incumplir SLA
Implementa la logica que determina si una entrega va a llegar tarde comparando el ETA calculado con el promised_eta. Clasifica en verde (on_time), amarillo (at_risk) y rojo (late). Luego procesa una flota completa y calcula el porcentaje de cumplimiento.
Pipeline Spark Streaming: GPS a ETA
Escribe el job completo de Spark Structured Streaming que lee eventos GPS de Kafka, calcula el ETA usando Haversine y prepara el DataFrame final con is_at_risk para escribir a DynamoDB. El pipeline debe procesar cada 30 segundos.
Regístrate para guardar tu progreso.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...