La ingeniería que hay detrás

Vrain decide sobre datos de salud y prescribe esfuerzo físico. Eso obliga a que la decisión sea reproducible, auditable y explicable sin fe: mismos datos y misma fecha, misma sesión, con el porqué generado de la propia decisión. Esta página cuenta cómo se consigue, con los números y los nombres del código.

18
Tablas, con el esquema agnóstico del proveedor
362
Tests de dominio, 281 de ellos sobre el motor puro
0
Dependencias del paquete de dominio
0
Cifras que escribe un modelo de lenguaje

La arquitectura

Un dominio puro en el centro y adaptadores en los bordes

Todo lo que decide vive en un paquete sin red, sin base de datos y sin framework. Lo que habla con el mundo son adaptadores, y se pueden cambiar de uno en uno sin tocar una sola regla.

FUENTESADAPTADORESESQUEMA CANÓNICODOMINIO PUROSALIDAStravaactividades · series/sGoogle Healthsueño · HRV · SpO₂.FITrespaldo manualpackages/connectorsmapeo a columnasrangos de corduratokens y refrescoventana móvil de 7 dPostgres · 18 tablasactivities · streamsrecovery_dailyathlete_ftp_historyfitness_dailyplan_days · decisionspackages/coremétricas y formamotor de decisiónplanificadorcumplimientosin red · sin BDpackages/fit.zwo · .fitintervals.icu→ Garmin · Zwiftapps/web · apps/workerorquestan el recorrido —sincronizar, recalcular, servir— y no toman ninguna decisión propia

El esquema no sabe de Strava

activities y recovery_daily llevan columna source y ningún campo propio de un proveedor. Esa es la regla que hace barato cambiar de origen: en cuanto un campo específico se cuela en el modelo, deja de serlo.

El núcleo no importa nada

packages/core no conoce Next ni la base. Se puede ejecutar en un test, en un script o en otro producto, y es lo que permite tener cobertura de verdad sobre las reglas en vez de sobre la fontanería.

Ninguna fuente es única

Si un conector cae o cierra su API, los datos siguen pudiendo entrar por fichero. El respaldo .FIT no es una función de más: es lo que impide que un tercero sea un punto único de fallo.

El recorrido

De un fichero en el servidor de Strava al intervalo en el Edge

Cinco pasos, cada uno con una regla que se ganó mirando qué salía mal. La más cara de aprender fue que el orden entre ellos importa.

  1. 01

    Entra

    Strava empuja por webhook y una pasada incremental recoge lo que el webhook perdiera. Google Health se pide en ventana móvil de siete días, porque una noche se afina durante horas y un cursor congelaría la primera versión, que es la peor.

    activitiesactivity_streamsrecovery_daily
  2. 02

    Se valora

    Cada actividad se puntúa con el FTP vigente en su fecha y conserva cuál fue. Mejorar en primavera no puede reescribir tu forma de octubre, y por eso el histórico de FTP es una tabla y no un campo.

    athlete_ftp_historyactivities.ftp_at_time_watts
  3. 03

    Se estima lo que no se midió

    Donde hubo potenciómetro, la carga es medida. Donde no, se estima desde el pulso con un modelo calibrado con tu propio histórico, y se guarda en otra columna. Las dos entran en la curva y en todo momento se sabe cuál es cuál.

    activities.estimated_tssfitness_daily.estimated_share
  4. 04

    Se decide y se reparte

    El motor juzga la sesión del día contra tus señales; el planificador reparte los siete siguientes desde tu ritmo observado y la fase de temporada. Los dos son deterministas y los dos guardan su versión.

    plan_dayscoach_decisions
  5. 05

    Sale y se juzga

    La semana se escribe en el calendario de intervals.icu y baja sola al dispositivo, y sale también como calendario .ics al que se suscribe tu agenda. Cuando la actividad vuelve, se compara con lo que se prescribió en tres dimensiones separadas —volumen, carga y series—, nunca en una nota media.

    plan_days.complianceintervals.icu.ics

El orden no es una manía

Primero se valora lo medido, después se estima lo que no se midió, y solo entonces se juzga el cumplimiento. Estimar antes usaría una recta ajustada con cargas viejas; juzgar antes de estimar dejaría el día valorado contra un hueco. Es una línea de código en una ruta y tres semanas de curva torcida si se pone al revés.

Lo medido y lo estimado

Un bloque entero de gimnasio no puede ser un mes sin entrenar

Solo hay carga medida donde hubo potenciómetro. Sin más, la curva de forma decaía en los meses en que sí se entrenaba, y para el motor aquel bloque sencillamente no había ocurrido.

De la frecuencia cardiaca media al factor de intensidadcarga = horas × IF² × 100
00,51techo · IF 1,15suelo · 50% de la FC máximaumbral · 90% · IF 1,00100120140160180IF estimadoFC media de la sesión (lpm)

Una recta con dos anclas —el pulso donde el IF vale 0 y el pulso donde vale 1—. La linealidad no es la última palabra en fisiología, pero sobre la media de una sesión entera se porta bien y cualquier cosa más fina pediría parámetros que aquí no se pueden medir. El ejemplo dibuja las anclas de manual sobre una FC máxima de 185 lpm.

Las anclas son tuyas

Con 12 salidas que lleven potencia y pulso a la vez, y al menos 20 lpm de recorrido entre ellas, la recta se ajusta con tu propio histórico: ahí se conocen las dos caras. No hace falta ni umbral de pulso ni FC en reposo, y ese era el problema, porque no los guarda nadie.

La pendiente sale por Theil–Sen —la mediana de las pendientes de todos los pares—, que aguanta hasta un 29% de datos corruptos. Y los extremos aquí son la norma: el pulsómetro suelto marcando 40, el día de calor, la carrera.

El gimnasio va por duración

Levantar pesado deja el pulso bajo entre series mientras la sesión es cara de verdad: estimarla con el modelo aeróbico la contaría como un paseo, que es justo el error que se venía a corregir. Se usa una tarifa declarada de 35 puntos por hora, y se dice que es declarada y no medida.

Techo de IF en 1,15

El pulso no sabe de esfuerzos cortos y sí sabe de la fiebre y del calor. Sin tope, un dato raro produce una sesión de 400 puntos que arrastra la forma seis semanas.

Sin estimación posible, hueco

Nunca un cero. Un cero en la curva significa «descansó», que es una afirmación distinta de «no sabemos medirlo», y las dos se propagan.

La potencia siempre manda

En cuanto una actividad tiene medida, su estimación deja de escribirse. Lo estimado se marca en la interfaz con una tilde y en tono apagado: entra en la curva, pero no se disfraza de medida.

El FTP

No es un resultado: es la entrada de la que cuelga todo lo demás

Con el FTP bajo, el TSS sale inflado, la forma sube sola y el plan presupuesta de más; con el FTP alto, pasa lo contrario y se entrena por debajo. Un valor viejo no da un error: da un sistema entero corrido.

Todas las maneras de llegar al número, no solo la que ganóventana de 90 días
dispersión 5,2%8′ × 0,90309 W5′ × 0,80302 W20′ × 0,95293 WCP − 11,5 W273 Wel modelo se enseña, pero no manda: mandan los protocolos, que son medidas directas270280290300310320vatios

Calculado al renderizar esta página con la misma función que usa la app, sobre una curva de muestra. Un FTP suelto no permite saber si las demás rutas decían lo mismo, y esa concordancia es la mitad de lo que hay que juzgar.

Se toma el más alto, y no es optimismo

Aquí no se administra un test: se rebusca en lo que ya se hizo. Cada factor está calibrado para un esfuerzo máximo, así que sobre esfuerzos encontrados cada protocolo responde «tu FTP es al menos esto». Con tres cotas inferiores de la misma magnitud, quedarse con la más baja es descartar una medida por otra que ya se sabe peor.

Lo que eso no arregla es el sesgo de fondo: si no hubo máximo en ninguna de las tres duraciones, la más alta también se queda corta. Por eso el número es un suelo y no una medida, y la interfaz no empuja a bajar el FTP guardado por estar por encima de él.

La CP entra descontada

La potencia crítica es la asíntota de un modelo y el FTP una convención: en ciclistas entrenados la primera queda por encima. Hay dos medidas publicadas de esa diferencia y no coinciden — 7 W en Karsten (2021), 16 W en McGrath (2021)—, así que se guardan las dos y se descuenta la media. Elegir una fingiría una precisión que la literatura no tiene.

El aviso está en el 5%

Y es una convención de presentación declarada, no fisiología: dice cuándo avisar de que las rutas no concuerdan, no cuándo el FTP está mal. La dispersión se mide solo entre protocolos, porque son la misma clase de dato con distinto descuento.

La ventana es de 90 días

Suficiente para que haya caído algún esfuerzo largo de verdad, corta para que describa la forma de ahora. Con un año, el FTP estimado de alguien en pretemporada seguiría siendo el de julio.

No lo escribe, y es deliberado

Strava es la fuente de verdad; tener el dato en dos sitios garantiza que uno se quede viejo. La app estima, enseña la evidencia —qué esfuerzo, de qué día— y decide el atleta.

El motor

Ninguna señal aislada manda parar

La variabilidad día a día de la HRV y del pulso en reposo es alta, y un mal dato tras una sesión dura es lo normal, no una alarma. Lo que manda descansar es la convergencia, y está escrito en números que se pueden discutir.

Los umbrales

leídos de THRESHOLDS, el mismo objeto que usa el motor

SeñalAtenciónAvisoCrítico
HRV bajo su base de 30 días-8%-15%
FC en reposo sobre su base3lpm6lpm
Déficit de sueño de anoche60min120min
Déficit de sueño acumulado a 3 días240min
TSB · frescura-20-30-40
Puntos de forma ganados en 7 días7CTL10CTL
Días duros seguidos3días4días
Monotonía de Foster sobre 7 días1,52
Preparación del dispositivo (0-100)655035

Ni la HRV ni el pulso en reposo llegan a «crítico» por sí solos, a propósito. Un día duro sube uno de los dos casi siempre; los dos a la vez, con el sueño corto encima, ya no.

Lo que sí manda descansar: la convergencia

HRV suprimida, pulso en reposo elevado y temperatura de piel por encima de su base, las tres a la vez. Es el patrón clásico de una infección incubándose, y ninguna de las tres por separado dice nada.

HRV -10%FC reposo +4 lpmpiel +0,5 °C

La base de cada señal es su media móvil de 30 días, la del propio atleta. No hay tablas de población: lo que cuenta es cuánto te separas de ti.

De las banderas a la decisión

El mismo cuadro de señales se trata distinto según lo que tocaba hacer

SeñalesSesión exigenteSesión suave
Una críticaDescansoDescanso
Dos avisos o másRecuperaciónRecuperación
Un avisoRecuperaciónRecortar
Dos atencionesRecortarAdelante
Tres atenciones o másRecortarRecortar
NadaAdelanteAdelante

La serie diaria es densa

Los días sin entrenar son ceros y no huecos. Sin el cero, la cuenta de días duros seguidos no para en el descanso, y la monotonía de una semana corriente pasa de 1,39 a 1,82 y cruza el umbral de aviso ella sola.

Un día es duro con IF 0,85 o 90 puntos

Es el único umbral que comparten el motor y el planificador, y a propósito: con dos definiciones, el motor podría avisar de días duros seguidos justo los que el generador acababa de encadenar.

La serie acaba ayer

El motor prescribe la sesión de hoy y hoy todavía no ha terminado. Cerrar la serie en hoy metería un cero al final y la racha de días duros saldría siempre a cero.

El modelo de lenguaje

Traduce a la entrada; no decide, no redacta cifras

Hay un modelo en el circuito y entra por donde no rompe nada. Lee la nota que escribe el atleta —«las series me supieron a poco pero llego con las piernas cargadas, y el martes estoy de viaje»— y la convierte en un puñado de ajustes de vocabulario cerrado. Quien reparte la semana sigue siendo el generador determinista.

La notaTexto libre del atleta
El modeloTraduce a escalones. Aquí se acaba su trabajo
La aduananormalizeDirectives acota, redondea y tira lo que no reconoce
El generadorDeterminista. Mismas directivas, mismo plan
La semanaSiete días con sus intervalos

El vocabulario entero, y no tiene sitio para una cifra

Cuánto vale cada escalón es código con tests. Si el modelo alucina, alucina un entero entre −2 y +2 que la aduana recorta igualmente

CampoValoresQué mueve
intensityBias−2 … +2Cada escalón mueve un 15% el presupuesto de la sesión de series. No mueve el de la semana: por este eje ninguna frase puede producir una semana insegura.
loadBias−2 … +2Cada escalón vale un 7,5% del presupuesto semanal, y con el tope son un 15%: exactamente lo que el planificador ya se permite moverse entre semanas por su cuenta.
strengthskip · light · heavyQuitar el gimnasio siempre se puede; ponerlo, no. Que alguien diga «esta semana le pego al gimnasio» no convierte un afinado en una semana de sentadillas.
unavailableDatesfechas de la ventanaLos días que se cierran. Se guardan donde ya vivían los que se marcan a mano, así que sobreviven a regenerar y la casilla del selector se marca sola.

Los sesgos van por dentro

La nota multiplica el objetivo antes de que lo recorten los tres frenos del presupuesto —la progresión de forma, el salto máximo entre semanas y las horas que caben—. Aplicarla después sería saltárselos: una frase mueve la intención de la semana, nunca la fisiología.

Lo que no se entiende no se inventa

Directivas vacías producen exactamente el plan de siempre, y esa propiedad tiene su test. Es lo que hace que la traducción pueda fallar entera —sin clave, sin red, con una respuesta ilegible— sin llevarse nada por delante. Sin modelo configurado, el cuadro no aparece y la semana se genera igual.

El eco es de plantilla, no del modelo

Antes de regenerar se enseña la lectura estructurada —«Martes 8 · no disponible · Intensidad +1 · Volumen −7,5%»— y entonces decide el atleta. Ese texto sale de las directivas, no de una redacción: un modelo escribiendo el eco podría decir «bajo el volumen» sobre unas directivas que lo suben, y entonces el eco dejaría de servir para lo único que sirve.

Auditabilidad

Cuatro versiones, porque son cuatro artefactos distintos

El motor juzga si hoy se entrena; el planificador propone qué; el cumplimiento mira hacia atrás; las directivas dicen qué se entendió. Cambiar uno no invalida lo que decidió otro, así que cada uno lleva la suya y se persiste con lo que produjo.

Motorv1.1.0

Con qué reglas se decidió si hoy se entrena

Planificadorv3.1.0

Con qué reglas se repartió la semana

Cumplimientov1.1.0

Con qué umbrales se juzgó lo que se hizo

Directivasv1.0.0

Qué se entendió de la nota del atleta

Se guarda la entrada entera

Cada decisión persiste el contexto con el que se tomó, no un resumen. Con eso y la versión se puede reejecutar el motor meses después y obtener exactamente lo mismo, aunque las reglas ya hayan cambiado.

En pantalla se lee de la fila

Nunca de la constante. La constante dice con qué generaría el código de ahora; la fila, con qué se generó lo que se está mirando. Son el mismo número casi siempre, y dejan de serlo justo el día en que la etiqueta empieza a importar.

La variedad no es aleatoria

Que dos semanas no traigan las mismas series sale de rotar según la fecha, no de tirar un dado. El mismo lunes da la misma sesión: si no, «mismos datos, mismo plan» sería una frase bonita y nada más.

La operación

Dos anfitriones y los mismos tres trabajos

Lo que se ejecuta es lo mismo corra donde corra; lo que cambia es quién lo llama y cada cuánto. Cuando el bucle de atletas vivía dentro del worker, desplegar obligaba a copiarlo, y dos copias de un bucle son dos sitios donde arreglar el mismo fallo.

Desplegado

Las actividades entran empujadas por webhook. Un cron diario sincroniza y después recalcula —en ese orden, o la curva se queda un día por detrás de lo que acaba de entrar—, y ahí la sincronización no es la vía principal sino la red: un webhook se pierde si la app estaba caída o si la suscripción caducó.

  • 03:10 UTCSincronizar y recalcular la forma
  • 09:00 UTCRecuperación, en su propia ruta y a otra hora

La recuperación va aparte por el reloj, no por manía de orden: a las 03:10 UTC son las cinco de la madrugada en España y la noche todavía no ha terminado. Metida en la diaria, la pantalla habría enseñado siempre el sueño de anteayer.

Autoalojado

Detrás de un router doméstico el webhook no puede ser —abrir puertos, o un túnel cuya URL cambia en cada arranque y rompe la suscripción—, así que manda el sondeo. Las colas van en el propio Postgres: un contenedor menos que operar.

  • cada 30 minSondeo de actividades
  • cada 3 hSueño y señales de la noche
  • 03:10 diarioExtender la curva de forma hasta hoy

El tercero es el menos evidente y hace falta: la forma y la fatiga bajan cuando no se entrena, pero solo se recalculan al importar. Sin él, una semana sin salir dejaría la curva congelada enseñando una forma que ya no es real.

Cinco detalles que solo aparecen en producción

DecisiónPor qué
Una sola conexión por instanciaEn un servidor hay un proceso y un pool, y diez conexiones son diez. En serverless hay una instancia por petición concurrente, cada una con su pool: diez pasan a ser diez por lambda viva, y un Postgres gestionado se queda sin cupo con muy poco tráfico.
El trabajo del webhook se espera de verdadLa instancia se congela al devolver la respuesta: una promesa que nadie espera se queda a medias y la actividad no entra. Hay que mantener viva la invocación hasta que termine.
Las migraciones usan el driver de la appLa herramienta de migración trae el suyo, y contra un Postgres gestionado que exige TLS con CA propia se cuelga y termina sin imprimir nada. Con el mismo driver, si la app conecta, esto migra.
Las rutas programadas llevan llaveNo hay sesión detrás de un cron, así que son públicas por definición. Sin la variable del secreto responden 503: una puerta que se abre al faltar su llave no es una puerta.
Nada lee el entorno al importar el móduloEl framework importa todas las rutas al compilar para recoger su configuración, así que una conexión creada al cargar el módulo convierte la URL de la base en un requisito del build. Y compilar no consulta ninguna base.

Los límites

Lo que no hace, dicho aquí y no en la letra pequeña

Un hueco que se enseña se puede juzgar; uno que se disimula se descubre con el sistema ya en marcha. Estos son los seis que hoy tiene el producto, con la razón por la que siguen ahí.

Sin potenciómetro no hay carga medida

La potencia que deduce Strava de la pendiente y el peso daría cifras inventadas que contaminarían la forma y las decisiones. Se estima desde el pulso, se guarda en otra columna y se enseña marcada.

La preparación del dispositivo se queda vacía

La puntuación de preparación no tiene todavía tipo de dato publicado en la API de salud, y darle al motor un número que nadie midió es exactamente lo que este proyecto no hace. La regla existe y espera al dato.

Solo hay veredicto donde hubo plan

Una actividad anterior a que existiera un plan se queda sin etiqueta de cumplimiento, y así debe ser: juzgarla contra una plantilla que nunca se le propuso no mediría el cumplimiento de nada.

La proyección de temporada es una simulación

Supone que el plan se cumple entero y que no hay gripes, viajes ni lluvia. Su valor no está en el número exacto de dentro de cuatro meses, sino en responder si el pico cae donde está la carrera y con qué frescura se llega.

El catálogo de sesiones es de ciclismo

Una prueba a pie se guarda con su disciplina, pero la especificidad se calcula sobre la bici. Periodizar una carrera a pie de verdad pide un catálogo de sesiones de carrera estructurada, y ese todavía no existe.

La nota del atleta no tiene memoria

Se aplica a la generación que la acompaña y no se arrastra, que es lo correcto para «el martes estoy de viaje». Pero deja fuera lo que sí es tendencia: quien lleva tres semanas diciendo que las series le saben a poco está diciendo que su FTP está viejo.

En toda la aplicación, un hueco significa «no se sabe» y nunca «no lo hubo». Es la misma distinción que separa un cero de un vacío en la curva de forma, y la única que permite que un número se pueda discutir.

Un plan que no se puede auditar no es un plan: es una opinión con gráficos.

Tu histórico ya sabe cómo entrenas —qué días sales, cuál es tu día largo, cuánta carga aguantas— y de las últimas 12 semanas sale la semana que viene. La forma y la fatiga se promedian a 42 y 7 días, los umbrales están escritos arriba y cada decisión guarda con qué se tomó.