Skip to content

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:

leaking ──▶ restarting ──▶ con_restart ──▶ waiting

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

  1. Siempre resetear el engine antes de cada nuevo caso de prueba (garantiza buffers vacíos).
  2. Esperar 40 segundos después del restart antes de arrancar el OPC-UA con el nuevo CSV (warm-up del buffer de señales).
  3. Ejecutar el mismo caso 2–3 veces si se requiere validar la estabilidad: la variación esperada es < 3% relativo.
  4. 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__.py líneas 110–119, 163–180, 183–197
  • Código fuente core: idetectfugas/app/core.py líneas 704–722
  • Simulación offline de estado: mlops/scripts/run_test_offline_pipeline.py función _simulate_state_buffers()
  • Configuración de modelos: mlops/configs/pipelines_config.yml → sección supe_2transmitters_diesel
  • Documentación del pipeline offline: pipelines/test_offline.md