feat(simulator): add scalable Modbus load simulator #13

Merged
henryor merged 1 commits from feat/simulator-scalable-modbus-load into main 2026-06-26 00:11:53 +00:00
4 changed files with 606 additions and 682 deletions
Showing only changes of commit 91181431aa - Show all commits

View File

@@ -1,132 +1,115 @@
# 📊 Simulador Multiprotocolo Unificado (Modbus TCP & Siemens S7) # Simulador de Carga Modbus TCP
Este documento detalla el funcionamiento, configuración y uso del **Simulador Multiprotocolo Unificado** (`tests/simulators/unified_simulator.py`) desarrollado para facilitar las pruebas locales y el desarrollo dentro del ecosistema **OmniOil**. `tests/simulators/unified_simulator.py` es el simulador soportado para pruebas locales de carga Modbus TCP en OmniOil. Genera variables petroleras sintéticas para varios pozos, expone un servidor Modbus TCP, publica un dashboard/API HTTP y crea un CSV compatible con la importación del backend.
El simulador unifica los flujos de simulación industrial en un único script concurrente, evitando tener que levantar múltiples procesos por separado. Además, incorpora un **servidor HTTP con dashboard web interactivo** para visualizar las señales de telemetría en tiempo real. > Este script es **Modbus TCP-only**. No levanta Siemens S7 ni un simulador multiprotocolo. La restauración de S7 queda fuera del alcance de esta herramienta.
--- ## Servicios expuestos
## 🔌 Puertos de Exposición y Servicios | Servicio | Protocolo | Endpoint | Descripción |
|---|---|---|---|
| Modbus TCP | Modbus TCP | `127.0.0.1:5020` | Un `unit_id` por pozo, con Holding/Input Registers base 0. |
| Dashboard | HTTP | `http://127.0.0.1:8085` | Panel web para observar estado y muestras en vivo. |
| API | HTTP JSON | `http://127.0.0.1:8085/api/data` | Snapshot de estado para smoke tests y desarrollo. |
El simulador expone tres servicios en puertos distintos sobre la interfaz de red local (`localhost` / `0.0.0.0`): ## Instalación
| Servicio | Protocolo | Puerto | Descripción | Usá Python 3.8+ y fijá la versión de `pymodbus`:
| :--- | :--- | :--- | :--- |
| **Modbus TCP Server** | Modbus TCP | **`5020`** | Servidor de registros Modbus (Holding/Input Registers) para adquisición de datos. |
| **Siemens S7 Server** | S7 (RFC1006) | **`1102`** | Servidor PLC simulado con área de datos DB1 activa para lectura de bloques. |
| **Dashboard & API** | HTTP (Web) | **`8085`** | Panel visual interactivo y API JSON para desarrollo rápido. |
---
## 🛠️ Requisitos e Instalación
El simulador requiere **Python 3.8+** y un conjunto de dependencias de comunicación industrial.
### 1. Entorno Virtual de Python (Recomendado)
Desde la raíz del proyecto, activa tu entorno virtual:
```bash ```bash
python -m venv venv python -m venv venv
source venv/bin/activate # En Linux/WSL/macOS source venv/bin/activate # Linux/WSL/macOS
# o: venv\Scripts\activate en Windows # o: venv\Scripts\activate # Windows
pip install "pymodbus==3.6.9"
``` ```
### 2. Dependencias de Python La versión `3.6.9` es intencional: el simulador usa `ModbusSlaveContext.setValues` para actualizar registros en vivo. Versiones más nuevas de `pymodbus` cambiaron esa API y pueden romper la actualización dinámica.
Instala las dependencias necesarias:
```bash
pip install pymodbus python-snap7
```
### 3. Requisito del Sistema para Siemens S7 (`snap7`) ## Ejecución
La librería `python-snap7` es un wrapper de la librería nativa en C **Snap7**. Por lo tanto, el sistema operativo necesita el binario de Snap7 para poder instanciar el servidor S7:
#### En Linux (Ubuntu / WSL / Debian): Desde la raíz del repositorio:
```bash
sudo add-apt-repository ppa:gijzelaar/snap7
sudo apt-get update
sudo apt-get install libsnap7-dev libsnap7-1
```
*Nota: Si estás ejecutando en Linux, el puerto S7 estándar es el 102. Como ese puerto es menor que 1024, requiere privilegios de superusuario (`sudo`). Para facilitar el desarrollo y evitar ejecutar como root, este simulador expone S7 en el puerto alternativo **`1102`**.*
#### En Windows o macOS:
Descarga el binario compilado de `snap7.dll` o `libsnap7.dylib` desde el [repositorio oficial de SourceForge de Snap7](https://sourceforge.net/projects/snap7/) y colócalo en el directorio del sistema (por ejemplo, `C:\Windows\System32`) o agrégalo a tus variables de entorno (`PATH` o `LD_LIBRARY_PATH`).
> [!TIP]
> **Resiliencia:** Si no tienes configurado `snap7` en tu sistema, el script unificado capturará el error de forma segura, informará de la falta del binario por consola, y **levantará exitosamente** los servidores Modbus TCP (5020) y HTTP Dashboard (8085) sin colapsar.
---
## 🚀 Ejecución del Simulador
Para iniciar todos los simuladores concurrentemente, ejecuta el script:
```bash ```bash
python tests/simulators/unified_simulator.py python tests/simulators/unified_simulator.py
``` ```
### Comportamiento al iniciar: Al iniciar, el script:
1. Imprime el estado de carga de cada protocolo por consola.
2. Inicia el servidor HTTP en el puerto `8085`.
3. Cada **5 segundos** genera nuevos valores aleatorios realistas y actualiza la memoria interna de los servidores Modbus y S7.
4. Imprime los valores simulados en la terminal con un formato limpio.
--- 1. Construye 5 pozos con 200 variables por pozo.
2. Genera `tests/simulators/variables_import.csv`.
3. Levanta el dashboard/API HTTP en el puerto `8085`.
4. Si `pymodbus==3.6.9` está instalado, levanta Modbus TCP en el puerto `5020`.
5. Actualiza variables de alta frecuencia cada 1 segundo y baja frecuencia cada 30 segundos.
## 📊 Mapeo y Estructura de Datos Simulados Si `pymodbus` no está instalado o no coincide con `3.6.9`, el script muestra una advertencia clara. El CSV y el dashboard pueden seguir funcionando, pero el servidor Modbus TCP queda desactivado.
Los valores cambian dinámicamente cada 5 segundos simulando condiciones industriales en tiempo real: ## Contrato Modbus
### 1. Modbus TCP (Puerto `5020`, ID de Esclavo por defecto: `0x00`) - Host recomendado para clientes locales: `127.0.0.1`.
- Puerto: `5020`.
- Unit IDs: `1..N`, uno por pozo. Con la configuración por defecto: `1..5`.
- Direcciones: base 0, usando el formato `HR:<address>` o `IR:<address>`.
- Ejemplos válidos: `HR:0`, `HR:1`, `IR:0`, `IR:1`.
- Tipos de dato generados para importación: `uint16`.
* **Holding Registers (FC 3):** Este contrato coincide con el flujo de lectura del edge-agent: el `UnitId` identifica el pozo/PLC dentro del mismo endpoint TCP y `Address` indica el banco (`HR` o `IR`) más la dirección raw base 0.
* `HR1` [Registro `40001`]: Temperatura simulada. Rango: `500` a `9500`.
* `HR2` [Registro `40002`]: Presión simulada. Rango: `500` a `9500`.
* `HR3` [Registro `40003`]: Flujo simulado. Rango: `500` a `9500`.
* `HR4` [Registro `40004`]: Nivel simulado. Rango: `500` a `9500`.
* `HR5` [Registro `40005`]: Estado simulado. Rango: `500` a `9500`.
* **Input Registers (FC 4):**
* `IR1` [Registro `30001`]: Voltaje simulado. Rango: `1000` a `15000`.
* `IR2` [Registro `30002`]: Corriente simulada. Rango: `1000` a `15000`.
* `IR3` [Registro `30003`]: Frecuencia simulada. Rango: `1000` a `15000`.
### 2. Siemens S7 (Puerto `1102`, Rack 0, Slot 1) ## API HTTP
* **Data Block 1 (DB1):** `GET http://127.0.0.1:8085/api/data` devuelve un snapshot con esta forma:
* `DB1.DBW0` (Offset 0): Integer de 16 bits (Big Endian). Rango: `100` a `30000`.
* `DB1.DBW2` (Offset 2): Integer de 16 bits (Big Endian). Rango: `50` a `20000`.
### 3. API JSON HTTP (Puerto `8085`)
Puedes realizar una petición HTTP tipo `GET` a `http://localhost:8085/api/data` para recuperar el estado instantáneo de la simulación en formato JSON:
```json ```json
{ {
"timestamp": "2026-06-23 09:30:15", "timestamp": "2026-06-25 15:00:00",
"modbus": { "summary": {
"running": true, "total": 1000,
"holding_registers": [4832, 1289, 7891, 5421, 3312], "hf": 400,
"input_registers": [12015, 8431, 14201] "lf": 600,
"wells": 5,
"changes_last_cycle": 400,
"modbus_running": true
}, },
"s7": { "wells": [
"running": true, { "code": "POZO-01", "unit_id": 1, "tags": 200, "hf": 80, "lf": 120 }
"db1_dbw0": 18451, ],
"db1_dbw2": 11342 "sample": [
}, { "well": "POZO-01", "alias": "Presion_Cabezal_01", "address": "HR:0", "value": 1042, "unit": "psi" }
"http": { ]
"running": true
}
} }
``` ```
--- ## CSV de importación
## 🎨 Visualización en Navegador (Dashboard Web) El simulador genera `tests/simulators/variables_import.csv` al arrancar. Ese archivo es salida generada y está ignorado por Git; no debe commitearse.
Abre tu navegador web favorito y dirígete a: Headers esperados:
👉 **`http://localhost:8085`** o **`http://localhost:8085/dashboard`** ```text
WellCode,DeviceName,Host,Port,UnitId,VariableAlias,Address,DataType,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl
```
### Características del Dashboard: Con la carga por defecto se generan 1000 filas: 5 pozos × 200 variables. Cada fila apunta a `127.0.0.1:5020`, usa `UnitId` `1..5` y direcciones `HR:`/`IR:` base 0.
- **Diseño Premium:** Estética oscura moderna con efectos de traslucidez (glassmorphism) y paleta de colores curada para el sector energético.
- **Gráficas en Vivo:** Tendencias dinámicas e interactivas representadas mediante *Chart.js* con actualizaciones continuas cada 5 segundos. ## Verificación segura
- **Micro-animaciones:** Los valores parpadean con un sutil destello de color cian/púrpura en el momento exacto en el que cambia la telemetría, y los indicadores de estado pulsan en verde cuando los servidores están activos.
- **Selectores de Señales:** Permite alternar la gráfica en tiempo real entre los Holding Registers, Input Registers o las variables S7 con un solo clic. Comandos útiles para validar el contrato sin depender del backend completo:
```bash
python -m py_compile tests/simulators/unified_simulator.py
python -m unittest tests.simulators.test_unified_simulator_contract
```
Smoke manual de runtime:
```bash
pip install "pymodbus==3.6.9"
python tests/simulators/unified_simulator.py
curl http://127.0.0.1:8085/api/data
```
Después de una ejecución real, revisá que el CSV generado siga fuera del control de versiones:
```bash
git status --short --ignored tests/simulators/variables_import.csv
```

View File

@@ -8,27 +8,27 @@ Este directorio contiene las herramientas de prueba y simulación para el ecosis
- `compliance/`: Validaciones de cumplimiento ANH. - `compliance/`: Validaciones de cumplimiento ANH.
- `modbus/`: Pruebas de integración Modbus TCP. - `modbus/`: Pruebas de integración Modbus TCP.
- **`simulators/`**: Simuladores de protocolos industriales (Python). - **`simulators/`**: Simuladores de protocolos industriales (Python).
- `unified_simulator.py`: Simulador Multiprotocolo Unificado (Modbus TCP + S7 + HTTP Dashboard). - `unified_simulator.py`: Simulador de carga Modbus TCP con dashboard/API HTTP.
- `modbus_tcp_sim.py`: Servidor Modbus TCP heredado (Puerto 5020). - `modbus_tcp_sim.py`: Servidor Modbus TCP heredado (Puerto 5020).
- `s7_sim.py`: Servidor Siemens S7 heredado (Puerto 1102). - `s7_sim.py`: Servidor Siemens S7 heredado (Puerto 1102).
- **`utils/`**: Scripts de apoyo, mocks de backend y utilidades MQTT. - **`utils/`**: Scripts de apoyo, mocks de backend y utilidades MQTT.
## Requisitos de Python ## Requisitos de Python
Se recomienda usar un entorno virtual y consultar la documentación completa de requisitos en [docs/SIMULADORES.md](file:///home/yesid_farfan/Proyectos/Personales/OmniOilPersonal/docs/SIMULADORES.md): Se recomienda usar un entorno virtual y consultar la documentación completa de requisitos en [`docs/SIMULADORES.md`](../docs/SIMULADORES.md):
```bash ```bash
python -m venv venv python -m venv venv
source venv/bin/activate # o venv\Scripts\activate en Windows source venv/bin/activate # o venv\Scripts\activate en Windows
pip install pymodbus python-snap7 pycomm3 paho-mqtt requests pip install "pymodbus==3.6.9"
``` ```
## Ejecución de Simuladores ## Ejecución de Simuladores
### Simulador Multiprotocolo Unificado (Recomendado) ### Simulador de Carga Modbus TCP (Recomendado)
Inicia todos los simuladores (Modbus, S7) y el Panel Web (puerto 8085) en un solo comando: Inicia el simulador Modbus TCP en `127.0.0.1:5020`, el dashboard/API en `http://127.0.0.1:8085` y genera `tests/simulators/variables_import.csv`:
```bash ```bash
python tests/simulators/unified_simulator.py python tests/simulators/unified_simulator.py
``` ```
*Para consultar los puertos y mapeos detallados de los simuladores individuales heredados, por favor revisa [docs/SIMULADORES.md](file:///home/yesid_farfan/Proyectos/Personales/OmniOilPersonal/docs/SIMULADORES.md).* El CSV generado está ignorado por Git y no debe commitearse. Para el contrato de Unit IDs `1..N`, direcciones base 0 `HR:0`/`IR:0`, forma de `/api/data` y smoke checks, revisá [`docs/SIMULADORES.md`](../docs/SIMULADORES.md).

View File

@@ -0,0 +1,75 @@
import csv
import importlib.util
import tempfile
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).with_name("unified_simulator.py")
def load_simulator_module():
spec = importlib.util.spec_from_file_location("unified_simulator_contract", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class UnifiedSimulatorContractTests(unittest.TestCase):
def setUp(self):
self.simulator = load_simulator_module()
def test_modbus_contract_constants_are_explicit(self):
self.assertEqual(self.simulator.MODBUS_PORT, 5020)
self.assertEqual(self.simulator.HTTP_PORT, 8085)
self.assertEqual(self.simulator.REQUIRED_PYMODBUS_VERSION, "3.6.9")
self.assertEqual(sorted(self.simulator.BANKS), [1, 2, 3, 4, 5])
def test_pymodbus_version_guard_accepts_only_supported_version(self):
compatible = self.simulator.validate_pymodbus_version("3.6.9")
incompatible = self.simulator.validate_pymodbus_version("3.11.1")
self.assertTrue(compatible.is_compatible)
self.assertFalse(incompatible.is_compatible)
self.assertIn("pymodbus==3.6.9", incompatible.install_hint)
self.assertIn("live register updates", incompatible.reason)
def test_generated_csv_matches_backend_import_contract(self):
expected_headers = [
"WellCode", "DeviceName", "Host", "Port", "UnitId",
"VariableAlias", "Address", "DataType",
"LastCalibrationDate", "NextCalibrationDate", "CalibrationCertificateUrl",
]
with tempfile.TemporaryDirectory() as temp_dir:
original_path = self.simulator.CSV_PATH
self.simulator.CSV_PATH = str(Path(temp_dir) / "variables_import.csv")
try:
csv_path = self.simulator.write_import_csv()
finally:
self.simulator.CSV_PATH = original_path
with open(csv_path, newline="", encoding="utf-8") as csv_file:
reader = csv.DictReader(csv_file)
rows = list(reader)
self.assertEqual(reader.fieldnames, expected_headers)
self.assertEqual(len(rows), 1000)
self.assertEqual({int(row["UnitId"]) for row in rows}, {1, 2, 3, 4, 5})
self.assertIn("HR:0", {row["Address"] for row in rows})
self.assertIn("IR:0", {row["Address"] for row in rows})
self.assertEqual({row["DataType"] for row in rows}, {"uint16"})
def test_api_payload_exposes_documented_shape(self):
payload = self.simulator.build_api_payload()
self.assertEqual(set(payload), {"timestamp", "summary", "wells", "sample"})
self.assertEqual(payload["summary"]["total"], 1000)
self.assertEqual(payload["summary"]["wells"], 5)
self.assertEqual(len(payload["wells"]), 5)
self.assertGreater(len(payload["sample"]), 0)
self.assertIn("address", payload["sample"][0])
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff