feat(simulator): add scalable Modbus load simulator #13
@@ -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 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **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. |
|
||||
Usá Python 3.8+ y fijá la versión de `pymodbus`:
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 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
|
||||
python -m venv venv
|
||||
source venv/bin/activate # En Linux/WSL/macOS
|
||||
# o: venv\Scripts\activate en Windows
|
||||
source venv/bin/activate # Linux/WSL/macOS
|
||||
# o: venv\Scripts\activate # Windows
|
||||
|
||||
pip install "pymodbus==3.6.9"
|
||||
```
|
||||
|
||||
### 2. Dependencias de Python
|
||||
Instala las dependencias necesarias:
|
||||
```bash
|
||||
pip install pymodbus python-snap7
|
||||
```
|
||||
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.
|
||||
|
||||
### 3. Requisito del Sistema para Siemens S7 (`snap7`)
|
||||
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:
|
||||
## Ejecución
|
||||
|
||||
#### En Linux (Ubuntu / WSL / Debian):
|
||||
```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:
|
||||
Desde la raíz del repositorio:
|
||||
|
||||
```bash
|
||||
python tests/simulators/unified_simulator.py
|
||||
```
|
||||
|
||||
### Comportamiento al iniciar:
|
||||
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.
|
||||
Al iniciar, el script:
|
||||
|
||||
---
|
||||
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):**
|
||||
* `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`.
|
||||
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.
|
||||
|
||||
### 2. Siemens S7 (Puerto `1102`, Rack 0, Slot 1)
|
||||
## API HTTP
|
||||
|
||||
* **Data Block 1 (DB1):**
|
||||
* `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:
|
||||
`GET http://127.0.0.1:8085/api/data` devuelve un snapshot con esta forma:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-06-23 09:30:15",
|
||||
"modbus": {
|
||||
"running": true,
|
||||
"holding_registers": [4832, 1289, 7891, 5421, 3312],
|
||||
"input_registers": [12015, 8431, 14201]
|
||||
"timestamp": "2026-06-25 15:00:00",
|
||||
"summary": {
|
||||
"total": 1000,
|
||||
"hf": 400,
|
||||
"lf": 600,
|
||||
"wells": 5,
|
||||
"changes_last_cycle": 400,
|
||||
"modbus_running": true
|
||||
},
|
||||
"s7": {
|
||||
"running": true,
|
||||
"db1_dbw0": 18451,
|
||||
"db1_dbw2": 11342
|
||||
},
|
||||
"http": {
|
||||
"running": true
|
||||
}
|
||||
"wells": [
|
||||
{ "code": "POZO-01", "unit_id": 1, "tags": 200, "hf": 80, "lf": 120 }
|
||||
],
|
||||
"sample": [
|
||||
{ "well": "POZO-01", "alias": "Presion_Cabezal_01", "address": "HR:0", "value": 1042, "unit": "psi" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## 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:
|
||||
- **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.
|
||||
- **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.
|
||||
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.
|
||||
|
||||
## Verificación segura
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
@@ -8,27 +8,27 @@ Este directorio contiene las herramientas de prueba y simulación para el ecosis
|
||||
- `compliance/`: Validaciones de cumplimiento ANH.
|
||||
- `modbus/`: Pruebas de integración Modbus TCP.
|
||||
- **`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).
|
||||
- `s7_sim.py`: Servidor Siemens S7 heredado (Puerto 1102).
|
||||
- **`utils/`**: Scripts de apoyo, mocks de backend y utilidades MQTT.
|
||||
|
||||
## 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
|
||||
python -m venv venv
|
||||
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
|
||||
|
||||
### Simulador Multiprotocolo Unificado (Recomendado)
|
||||
Inicia todos los simuladores (Modbus, S7) y el Panel Web (puerto 8085) en un solo comando:
|
||||
### Simulador de Carga Modbus TCP (Recomendado)
|
||||
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
|
||||
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).
|
||||
|
||||
75
tests/simulators/test_unified_simulator_contract.py
Normal file
75
tests/simulators/test_unified_simulator_contract.py
Normal 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
Reference in New Issue
Block a user