Determinismo del Test Offline vs. No-Determinismo de Producción¶
Resumen Ejecutivo¶
El pipeline offline (run_test_offline_pipeline.py) es completamente determinista: dada
la misma configuración y el mismo CSV, producirá siempre exactamente los mismos valores
de localización, tamaño y flujo. La producción, en cambio, puede disparar la alarma en
ventanas ligeramente distintas entre corridas (e.g., ventana 141 vs. 143), lo que deriva en
pequeñas diferencias en los diagnósticos finales. Este documento explica por qué ocurre
esto, cuál es la causa raíz, cuánta variación es esperable, y por qué ese comportamiento es
normal e inevitable en un sistema de producción asíncrono.
1. Arquitectura del Sistema de Producción¶
┌──────────────────────────────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────────┐ 1 fila/segundo ┌──────────────────────┐ │
│ │ Servidor OPC-UA │ ────────────────▶ │ iDetectFugas Engine │ │
│ │ (simulador CSV) │ (tag updates) │ (PFM + Observer) │ │
│ └──────────────────┘ └──────────────────────┘ │
│ Timer A: 1 Hz Timer B: 1 Hz │
│ (asyncio loop) (asyncio loop) │
│ │
│ ╔══════════════════════════════════════════════════════════════╗ │
│ ║ Ambos timers corren de forma INDEPENDIENTE sin sincronizar ║ │
│ ╚══════════════════════════════════════════════════════════════╝ │
└──────────────────────────────────────────────────────────────────────┘
Componentes clave¶
| Componente | Tecnología | Intervalo nominal |
|---|---|---|
| Servidor OPC-UA | PyHades state machine + asyncio |
1 Hz (1 fila/segundo) |
| Máquina PFM / Observer | PyAutomation state machine + asyncio |
1 Hz |
| Buffer de señales | Buffer(size=40, roll='backward') |
Se actualiza en cada OPC-UA event |
| Buffer de diagnóstico | Buffer(size=5, roll='forward') |
Se actualiza cuando se detecta fuga |
2. La Causa Raíz: Dos Timers Independientes¶
2.1 Cómo funciona la sincronización ideal¶
En un sistema completamente determinista, la secuencia esperada sería:
Tiempo (s) │ OPC-UA envía fila N │ Machine procesa ventana [N-39 … N]
───────────┼───────────────────────┼────────────────────────────────────
t+0 │ fila 40 │ ventana [1 … 40] ← primera ventana completa
t+1 │ fila 41 │ ventana [2 … 41]
t+2 │ fila 42 │ ventana [3 … 42]
... │ ... │ ...
t+100 │ fila 140 │ ventana [101 … 140] ← detección primera vez
t+101 │ fila 141 │ ventana [102 … 141] ← pre-alarma inicia
...
t+105 │ fila 145 │ ventana [106 … 145] ← 5/5 confirmaciones → ALARMA
2.2 Qué ocurre realmente en producción (asyncio)¶
El event loop de asyncio ejecuta ambos timers en el mismo hilo. No existe garantía de
que el tick de la máquina ocurra después de que el OPC-UA haya actualizado el tag con la
fila siguiente. Dependiendo de la carga del sistema (CPU, I/O, otras corutinas activas), el
orden puede variar:
Escenario A (corrida 1 – "más temprana"):
t=100.001s OPC-UA envía fila 140
t=100.003s Machine tick → lee buffer → detecta → ventana termina en fila 140
→ ALARMA dispara en ventana que termina en fila 144 (pre-alarma 140–144)
Escenario B (corrida 2 – "más tardía"):
t=100.001s OPC-UA envía fila 140
t=100.999s Machine tick → OPC-UA ya envió fila 141 antes del tick
→ El buffer tiene fila 141 como más reciente
→ El tick procesa ventana que termina en fila 141
→ ALARMA dispara en ventana que termina en fila 145 (pre-alarma 141–145)
El desplazamiento entre escenarios es de 0 a 2 filas (0 a 2 segundos), acumulable en cada tick durante los 5 pasos de pre-alarma.
2.3 Diagrama de jitter¶
Posibles momentos de tick de la máquina
(entre dos OPC-UA events consecutivos)
OPC-UA envía │←────── 1 segundo ──────→│ OPC-UA envía
fila N │ │ fila N+1
│ ✦ │
│ ✦ │
│ ✦ │
│ ✦ │
│ ✦ │
│ ✦ │
┼─────────────────────────┼
Jitter: el tick puede caer en cualquier punto
de la ventana de 1 segundo → buffer puede
contener fila N o fila N+1
3. Impacto en los Buffers de Diagnóstico¶
El diagnóstico final (localización, tamaño, flujo) es la media de los últimos 5 valores
en el buffer de diagnóstico (Buffer(size=5, roll='forward')). Cada valor se calcula sobre
los features de la ventana activa en ese tick.
Si la alarma dispara en ventanas distintas entre corridas:
Corrida 1 (alarma en ventana que termina en fila 144):
Buffer diagnóstico = media([pred_140, pred_141, pred_142, pred_143, pred_144])
PFM LOC = 502.1 m
Corrida 2 (alarma en ventana que termina en fila 147):
Buffer diagnóstico = media([pred_143, pred_144, pred_145, pred_146, pred_147])
PFM LOC = 502.8 m ← diferencia de ~0.7 m
La variación entre corridas depende de cuán estable es la predicción del modelo en esa zona de la señal. Para señales estacionarias (sin cambios bruscos en el patrón de fuga), la variación es pequeña.
4. Variaciones Observadas en los 4 Casos de Prueba¶
Datos empíricos de 4 casos con comparación offline vs. 2 corridas de producción:
| Caso | LOC PFM offline | LOC PFM prod #1 | LOC PFM prod #2 | Max Δ |
|---|---|---|---|---|
| SS_V2_1766 | 271.33 m | 272.4 m | 271.9 m | ~1 m (0.4%) |
| TS1_V2_0361 | 59.5 m | 59.45 m | 59.6 m | ~0.2 m (0.3%) |
| TS1_V2_1841 | 1093.8 m | 1074.1 m | 1089.3 m | ~20 m (1.8%) |
| TS3_V2_0066 | 502.1 m | 502.1 m | 502.3 m | ~0.2 m (0.04%) |
Nota sobre TS1_V2_1841: La fuga está a ~1100 m de la transmisora de referencia. A mayores distancias, el error absoluto crece proporcionalmente, aunque el error relativo se mantiene en el mismo orden de magnitud (~2%).
Banda de tolerancia empírica¶
Error relativo esperado offline vs. producción: < 3 %
Error absoluto a distancias cortas (< 200 m): < 5 m
Error absoluto a distancias medias (200–600 m): < 15 m
Error absoluto a distancias largas (> 600 m): < 25 m
5. Por Qué el Test Offline Es Determinista¶
El script run_test_offline_pipeline.py no usa timers, asyncio ni OPC-UA. Procesa las
filas del CSV en un bucle secuencial y síncrono:
# Pseudo-código del loop interno del pipeline offline
for fila in csv.iterrows():
buffer.append(fila) # FIFO determinista
if len(buffer) == window_size: # Exactamente cuando el buffer se llena
features = vectorizer(buffer) # Siempre la misma ventana
pred = model.predict(features)
detection_flags.append(pred > threshold)
# Simulación de estado (running → pre-alarma → leaking)
_simulate_state_buffers(detection_flags, diagnostic_values, ...)
Garantías del offline:
| Propiedad | Offline | Producción |
|---|---|---|
| Orden de procesamiento | Estrictamente secuencial | Sujeto a scheduling asyncio |
| Timing entre fila y tick | Determinista (mismo instante) | No determinista (jitter ±0–2 filas) |
| Buffer al inicio de cada caso | Siempre vacío (40 zeros) | Puede tener datos del caso anterior |
| Resultado para el mismo CSV | Idéntico en cada ejecución | Variación de ±1–3 ventanas |
| Depende de carga del sistema | No | Sí (CPU, I/O, otros procesos) |
6. Flujo de Estado y Reset de Buffers¶
6.1 Cuándo se resetean los buffers (producción)¶
Al hacer restart de los engines, la máquina transita:
Al entrar en waiting, on_enter_waiting() recrea todos los buffers desde cero:
# app/modules/pfm/__init__.py (líneas 163–180)
def on_enter_waiting(self):
window_size = self.models[name]["window_size"] # = 40
self.buffer = {
"inlet_pressure": Buffer(size=window_size, roll="backward"), # Vacío
"inlet_mass_flow": Buffer(size=window_size, roll="backward"), # Vacío
"outlet_pressure": Buffer(size=window_size, roll="backward"), # Vacío
"outlet_mass_flow": Buffer(size=window_size, roll="backward"), # Vacío
}
self.__leak_location = Buffer(size=5) # Vacío
self.__leak_size = Buffer(size=5) # Vacío
self.__leak_flow = Buffer(size=5) # Vacío
super().on_enter_waiting() # Reset de _prealarm_buffer(1000) también
El mismo patrón aplica al Observer (app/modules/observer/__init__.py, líneas 164–180).
6.2 Tabla de buffers y su estado después de restart¶
| Buffer | Contenido antes restart | Contenido después restart | Verificado |
|---|---|---|---|
inlet_pressure (40 filas) |
Últimas 40 filas del CSV anterior | Vacío | ✅ |
outlet_pressure (40 filas) |
Últimas 40 filas del CSV anterior | Vacío | ✅ |
inlet_mass_flow (40 filas) |
Últimas 40 filas del CSV anterior | Vacío | ✅ |
outlet_mass_flow (40 filas) |
Últimas 40 filas del CSV anterior | Vacío | ✅ |
__leak_location (5 vals) |
Diagnósticos de la alarma anterior | Vacío | ✅ |
__leak_size (5 vals) |
Diagnósticos de la alarma anterior | Vacío | ✅ |
__leak_flow (5 vals) |
Diagnósticos de la alarma anterior | Vacío | ✅ |
_prealarm_buffer (1000 vals) |
Historial de detecciones | Vacío | ✅ |
6.3 Warm-up después de restart¶
Con buffers vacíos, la máquina permanece en estado waiting hasta que acumula exactamente
window_size = 40 filas nuevas del OPC-UA. A 1 fila/segundo, esto toma 40 segundos
antes de que comience la detección activa.
7. Por Qué No Importa la Variación de ±1–3 Ventanas¶
Las predicciones del modelo sobre ventanas consecutivas son altamente correlacionadas (la fuga es un fenómeno físico continuo). La diferencia entre los features de la ventana 141 y la ventana 143 es mínima para una señal estacionaria:
Ventana [101…140]: pred_loc = 500.2 m
Ventana [102…141]: pred_loc = 501.8 m ← diferencia: 1.6 m
Ventana [103…142]: pred_loc = 502.1 m ← diferencia: 0.3 m
Ventana [104…143]: pred_loc = 501.9 m ← diferencia: 0.2 m
Ventana [105…144]: pred_loc = 502.3 m ← diferencia: 0.4 m
El promedio de cualquier subconjunto de 5 ventanas consecutivas en esta zona converge al mismo valor. La variación práctica entre corridas es del orden de < 2% relativo, que está dentro de la incertidumbre natural de cualquier sistema de medición industrial.
8. Comparación con el Comportamiento del Offline¶
PRODUCCIÓN (no determinista):
OPC-UA tick : ─┬──┬──┬──┬──┬──┬──┬──┬──┬── (1 Hz, clock real)
Machine tick : ─┤ ├──┼──┤ ├──┼──┤ ├──┼── (1 Hz, asyncio, jitter ±ms)
│ │ │ │ │ │ │ │ │
Corrida 1 : · · · D D D D A · D=detected, A=alarm
Corrida 2 : · · · · D D D D A (1 fila más tarde)
Corrida 3 : · · · D D D D D A (misma ventana que corrida 1)
OFFLINE (determinista):
Iteración : ─1──2──3──4──5──6──7──8──9── (bucle for, sin timer)
Resultado : · · · D D D D A · Siempre igual
9. Recomendaciones Operacionales¶
Para evaluación de modelos (MLOps)¶
Usar siempre el offline como métrica de referencia. Es el simulador determinista correcto del algoritmo de producción. Los resultados son reproducibles al 100% y no dependen de carga del servidor.
python3 scripts/run_test_offline_pipeline.py \
--config configs/pipelines_config.yml \
--parquet-root data/csv/supe/diesel \
--file-glob "*.csv" \
--output-report reports/offline/supe/v5/2transmitters/diesel
Para pruebas en producción¶
- Siempre resetear el engine antes de cada nuevo caso de prueba (garantiza buffers vacíos).
- Esperar 40 segundos después del restart antes de arrancar el OPC-UA con el nuevo CSV (warm-up del buffer de señales).
- Ejecutar el mismo caso 2–3 veces si se requiere validar la estabilidad: la variación esperada es < 3% relativo.
- No comparar contra el offline para el primer run después de un cambio de archivo sin restart: el buffer puede contener datos del CSV anterior.
Tolerancias de aceptación¶
| Distancia de fuga | Tolerancia offline ↔ producción |
|---|---|
| < 200 m | ± 5 m |
| 200 – 600 m | ± 15 m |
| 600 – 1200 m | ± 25 m |
| > 1200 m | ± 3% relativo |
10. Correcciones de Paridad Offline ↔ Producción (v6)¶
Además del jitter de timing (inevitable en producción asíncrona), se identificaron y corrigieron 5 fuentes de divergencia determinista entre el pipeline de features/inferencia offline y el de producción. Estas diferencias causaban que, incluso eliminando el jitter, los resultados de diagnóstico no coincidieran exactamente.
10.1 Diferencias corregidas¶
| # | Fuente de divergencia | Archivo(s) corregido(s) |
|---|---|---|
| 1 | include_raw_vectorization default True en offline vs False en producción |
run_test_offline_pipeline.py (FeatureConfig + from_dict) |
| 2 | temporal_sub_window_size/step no propagados al vectorizer offline |
run_test_offline_pipeline.py (FeatureConfig, worker, from_dict) |
| 3 | num_iteration en producción no intentaba best_iteration primero |
lgbm_wrapper.py (_resolve_num_iteration) |
| 4 | _infer_base_columns_from_features retornaba orden no-determinista (set) |
deploy_bundle.py (dict.fromkeys para orden estable) |
| 5 | Offline usaba transform_batch (batch scipy) vs producción transform_as_dict (individual) |
run_test_offline_pipeline.py (forzar transform_as_dict) |
10.2 Propagación de parámetros en deploy bundle¶
El deploy_bundle.py ahora incluye temporal_sub_window_size y temporal_sub_window_step
en el lgbm_inference_config.json generado, y el features_pipeline persiste
include_raw_vectorization en features_metadata.json.
10.3 Garantía tras las correcciones¶
Con estas correcciones, dado el mismo CSV de entrada y el mismo modelo, el pipeline offline y la producción generan exactamente el mismo vector de features y la misma predicción LightGBM para cada ventana. Las únicas diferencias restantes entre offline y producción son las inherentes al timing asíncrono (secciones 2–4 de este documento).
11. Referencias¶
- Código fuente de buffers:
idetectfugas/app/modules/pfm/__init__.pylíneas 110–119, 163–180, 183–197 - Código fuente core:
idetectfugas/app/core.pylíneas 704–722 - Simulación offline de estado:
mlops/scripts/run_test_offline_pipeline.pyfunción_simulate_state_buffers() - Configuración de modelos:
mlops/configs/pipelines_config.yml→ secciónsupe_2transmitters_diesel - Documentación del pipeline offline:
pipelines/test_offline.md