#!/usr/bin/env python3 """ Simulador de carga Modbus TCP para OmniOil. Genera ~1000 variables petroleras repartidas en N pozos (cada pozo = un unit_id Modbus sobre el mismo puerto), con dos clases de frecuencia (Alta / Baja). Emite un CSV listo para importar en el dashboard y expone un panel de monitoreo. """ import asyncio import csv import importlib.metadata as importlib_metadata import logging import os import random import sys import threading import time from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json logging.basicConfig( level=logging.INFO, format='%(asctime)s | %(levelname)s | %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) log = logging.getLogger("OmniOilSimulator") # --------------------------------------------------------- # DEPENDENCY CONTRACT # --------------------------------------------------------- REQUIRED_PYMODBUS_VERSION = "3.6.9" @dataclass(frozen=True) class PymodbusVersionStatus: is_compatible: bool installed_version: str reason: str install_hint: str def validate_pymodbus_version(installed_version: str) -> PymodbusVersionStatus: """Validate the supported pymodbus version for live register updates.""" install_hint = f'Install the compatible dependency with: pip install "pymodbus=={REQUIRED_PYMODBUS_VERSION}"' if installed_version == REQUIRED_PYMODBUS_VERSION: return PymodbusVersionStatus( True, installed_version, "pymodbus version supports live register updates through ModbusSlaveContext.setValues.", install_hint, ) return PymodbusVersionStatus( False, installed_version, "Unsupported pymodbus version for this simulator: live register updates require pymodbus==3.6.9.", install_hint, ) # Safe import for the optional pymodbus package. Runtime Modbus is intentionally # disabled unless the pinned version is installed; CSV and HTTP observability can # still be used without the TCP server. pymodbus_installed = False try: from pymodbus.server import StartAsyncTcpServer # ModbusSlaveContext + setValues en vivo SOLO funcionan en pymodbus < 3.11. # En 3.11+ migraron a SimData/SimDevice y el datastore dejó de actualizarse # en caliente, que es justo lo que un simulador necesita. Pin: 3.6.9. from pymodbus.datastore import ( ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext, ) pymodbus_status = validate_pymodbus_version(importlib_metadata.version("pymodbus")) if not pymodbus_status.is_compatible: raise ImportError(pymodbus_status.reason) pymodbus_installed = True except (ImportError, importlib_metadata.PackageNotFoundError) as exc: log.warning("⚠️ No se pudo importar la API Modbus esperada (pymodbus 3.6.x).") log.warning("👉 Instalá la versión compatible: pip install \"pymodbus==3.6.9\"") log.warning(" (pymodbus 3.11+ rompió la actualización de registros en vivo)") if str(exc): log.warning(" Detalle: %s", exc) # --------------------------------------------------------- # CONFIGURACIÓN DE LA CARGA # --------------------------------------------------------- MODBUS_HOST = "0.0.0.0" MODBUS_PORT = 5020 HTTP_PORT = 8085 WELL_COUNT = 5 # pozos = cantidad de unit_id Modbus (1..N) TAGS_PER_WELL = 200 # 5 pozos x 200 = 1000 variables HF_RATIO = 0.40 # 40% alta frecuencia, 60% baja HF_INTERVAL_MS = 1000 # cadencia de las variables de alta frecuencia LF_INTERVAL_MS = 30000 # cadencia de las variables de baja frecuencia BASE_TICK_S = 1.0 # resolución del loop de simulación CSV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "variables_import.csv") # --------------------------------------------------------- # MODELO DE VARIABLE # --------------------------------------------------------- @dataclass class SimVar: well_code: str # POZO-01 unit_id: int # unit_id Modbus del pozo alias: str # alias único dentro del pozo area: str # 'HR' (Holding) | 'IR' (Input) address: int # dirección Modbus (base 0) unit: str # unidad de ingeniería lo: float hi: float value: float volatility: float freq_ms: int # 1000 = HF, 30000 = LF last_step: float = 0.0 @property def is_hf(self) -> bool: return self.freq_ms <= HF_INTERVAL_MS def step(self) -> int: """Random-walk acotado con leve reversión a la media. Devuelve el u16.""" span = self.hi - self.lo center = (self.lo + self.hi) / 2 drift = (center - self.value) * 0.03 self.value += drift + random.gauss(0, span * self.volatility) self.value = max(self.lo, min(self.hi, self.value)) return int(round(self.value)) # Plantillas de arquetipos: (base_alias, unidad, lo, hi, volatilidad, area) # HF: variables de proceso/eléctricas que cambian rápido. HF_POOL = [ ("Presion_Cabezal", "psi", 850, 1450, 0.05, "HR"), ("Presion_Tubing", "psi", 600, 1150, 0.05, "HR"), ("Presion_Casing", "psi", 380, 880, 0.04, "HR"), ("Presion_Linea", "psi", 200, 700, 0.05, "HR"), ("Caudal_Bruto", "bbl_d", 250, 1150, 0.06, "HR"), ("Caudal_Gas", "mscf_d", 100, 900, 0.06, "HR"), ("Caudal_Agua", "bbl_d", 50, 600, 0.06, "HR"), ("Frecuencia_VSD", "Hz", 42, 58, 0.02, "IR"), ("Corriente_Motor", "A", 22, 88, 0.04, "IR"), ("Presion_Succion", "psi", 1100, 2400, 0.04, "IR"), ("Presion_Descarga", "psi", 1800, 3200, 0.04, "IR"), ("Vibracion", "mm_s", 0, 30, 0.08, "IR"), ] # LF: temperaturas, niveles, calidad, totalizadores: cambian lento. LF_POOL = [ ("Temp_Cabezal", "C", 48, 92, 0.02, "HR"), ("Temp_Motor", "C", 72, 118, 0.02, "IR"), ("Temp_Cojinete", "C", 40, 110, 0.02, "IR"), ("Nivel_Separador", "pct", 0, 100, 0.03, "HR"), ("Nivel_Tanque", "pct", 0, 100, 0.02, "HR"), ("Water_Cut", "pct", 5, 65, 0.02, "IR"), ("BSW", "pct", 0, 40, 0.02, "IR"), ("Densidad", "kg_m3", 800, 980, 0.01, "IR"), ("Voltaje_Motor", "V", 380, 480, 0.02, "IR"), ("Aislamiento_Motor", "Mohm", 0, 2000, 0.02, "IR"), ("Choke_Pos", "pct", 0, 100, 0.01, "HR"), ("GOR", "scf_bbl",200, 2000, 0.02, "HR"), ] def build_variables() -> list: """Genera la lista plana de SimVar para todos los pozos.""" variables = [] hf_per_well = round(TAGS_PER_WELL * HF_RATIO) lf_per_well = TAGS_PER_WELL - hf_per_well for w in range(1, WELL_COUNT + 1): well_code = f"POZO-{w:02d}" hr_addr = 0 # base-0: el edge lee la dirección de wire tal cual (0,1,2,...) ir_addr = 0 usage = {} # base_alias -> contador, para aliases únicos por pozo def make(pool, idx, freq_ms): nonlocal hr_addr, ir_addr base, unit, lo, hi, vol, area = pool[idx % len(pool)] usage[base] = usage.get(base, 0) + 1 alias = f"{base}_{usage[base]:02d}" if area == "HR": addr = hr_addr hr_addr += 1 else: addr = ir_addr ir_addr += 1 start = random.uniform(lo, hi) return SimVar(well_code, w, alias, area, addr, unit, lo, hi, start, vol, freq_ms) for i in range(hf_per_well): variables.append(make(HF_POOL, i, HF_INTERVAL_MS)) for i in range(lf_per_well): variables.append(make(LF_POOL, i, LF_INTERVAL_MS)) return variables VARS = build_variables() # Índices para acceso rápido por pozo/área # BANKS[unit_id][area] = lista de SimVar ordenada por address base 0 BANKS = {} for v in VARS: BANKS.setdefault(v.unit_id, {"HR": [], "IR": []})[v.area].append(v) for unit in BANKS: for area in ("HR", "IR"): BANKS[unit][area].sort(key=lambda x: x.address) # --------------------------------------------------------- # ESTADO COMPARTIDO (HTTP threads <-> async loop) # --------------------------------------------------------- class SimulatorState: def __init__(self): self.lock = threading.Lock() self.last_update = time.strftime('%Y-%m-%d %H:%M:%S') self.modbus_running = False self.http_running = False self.changes_last_cycle = 0 self.active_sessions = {} # session_id -> last_request_timestamp state = SimulatorState() modbus_context = None # --------------------------------------------------------- # CSV DE IMPORTACIÓN (esquema ModbusTcpRecord del backend) # --------------------------------------------------------- def write_import_csv(): fields = [ "WellCode", "DeviceName", "Host", "Port", "UnitId", "VariableAlias", "Address", "DataType", "LastCalibrationDate", "NextCalibrationDate", "CalibrationCertificateUrl", ] with open(CSV_PATH, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fields) writer.writeheader() for v in VARS: writer.writerow({ "WellCode": v.well_code, "DeviceName": f"PLC-{v.well_code}", "Host": "127.0.0.1", "Port": MODBUS_PORT, "UnitId": v.unit_id, "VariableAlias": v.alias, "Address": f"{v.area}:{v.address}", "DataType": "uint16", "LastCalibrationDate": "", "NextCalibrationDate": "", "CalibrationCertificateUrl": "", }) return CSV_PATH # --------------------------------------------------------- # HTTP SERVER + DASHBOARD AGREGADO # --------------------------------------------------------- HTML_TEMPLATE = """ OmniOil | Simulador de Carga Modbus

OmniOil — Simulador de Carga Modbus

Generador de variables petroleras de alta y baja frecuencia para pruebas de escala

Última actualización: Conectando...
Variables
0
Alta Frecuencia
0
Baja Frecuencia
0
Pozos
0
Cambios/ciclo
0

Pozos / Devices

PozoUnit IDTagsHFLF

Muestra en vivo

PozoTagDir.Valor
Modbus TCP → host 127.0.0.1 puerto 5020 · un unit_id por pozo · CSV de import generado en simulators/variables_import.csv
""" def build_api_payload(): """Arma el snapshot agregado para el dashboard.""" wells = [] for unit in sorted(BANKS): well_vars = BANKS[unit]["HR"] + BANKS[unit]["IR"] hf = sum(1 for v in well_vars if v.is_hf) wells.append({ "code": well_vars[0].well_code if well_vars else f"unit-{unit}", "unit_id": unit, "tags": len(well_vars), "hf": hf, "lf": len(well_vars) - hf, }) sample = [] for v in random.sample(VARS, min(14, len(VARS))): sample.append({ "well": v.well_code, "alias": v.alias, "address": f"{v.area}:{v.address}", "value": int(round(v.value)), "unit": v.unit, }) with state.lock: ts = state.last_update changes = state.changes_last_cycle running = state.modbus_running hf_total = sum(1 for v in VARS if v.is_hf) return { "timestamp": ts, "summary": { "total": len(VARS), "hf": hf_total, "lf": len(VARS) - hf_total, "wells": len(BANKS), "changes_last_cycle": changes, "modbus_running": running, }, "wells": wells, "sample": sample, } class SimulatorHTTPHandler(BaseHTTPRequestHandler): def log_message(self, format, *args): pass def do_GET(self): clean_path = self.path.split('?', 1)[0] if clean_path in ('/', '/dashboard', '/dashboard/'): self.send_response(200) self.send_header('Content-type', 'text/html; charset=utf-8') self.end_headers() self.wfile.write(HTML_TEMPLATE.encode('utf-8')) elif clean_path == '/api/data': self.send_response(200) self.send_header('Content-type', 'application/json') self.send_header('Access-Control-Allow-Origin', '*') self.end_headers() self.wfile.write(json.dumps(build_api_payload()).encode('utf-8')) else: self.send_response(404) self.end_headers() self.wfile.write(b"404 Not Found") def start_http_server(host="0.0.0.0", port=HTTP_PORT): server = ThreadingHTTPServer((host, port), SimulatorHTTPHandler) state.http_running = True log.info(f"🌐 [HTTP] Panel de monitoreo activo en: http://localhost:{port}") threading.Thread(target=server.serve_forever, daemon=True).start() return server # --------------------------------------------------------- # MODBUS SERVER (un slave / unit_id por pozo) # --------------------------------------------------------- async def run_modbus_server(): global modbus_context if not pymodbus_installed: return slaves = {} for unit in sorted(BANKS): hr_vars = BANKS[unit]["HR"] ir_vars = BANKS[unit]["IR"] hr_size = max((v.address for v in hr_vars), default=-1) ir_size = max((v.address for v in ir_vars), default=-1) hr_values = [0] * (hr_size + 1) ir_values = [0] * (ir_size + 1) for v in hr_vars: hr_values[v.address] = int(v.value) for v in ir_vars: ir_values[v.address] = int(v.value) # zero_mode=True + bloque base 0 => wire address == índice == address de setValues slaves[unit] = ModbusSlaveContext( hr=ModbusSequentialDataBlock(0, hr_values), ir=ModbusSequentialDataBlock(0, ir_values), zero_mode=True, ) modbus_context = ModbusServerContext(slaves=slaves, single=False) log.info(f"🚀 [MODBUS] Servidor en {MODBUS_HOST}:{MODBUS_PORT} con {len(slaves)} pozos (unit_id {min(slaves)}..{max(slaves)})") state.modbus_running = True try: await StartAsyncTcpServer(modbus_context, address=(MODBUS_HOST, MODBUS_PORT)) except Exception as e: state.modbus_running = False log.error(f"❌ [MODBUS] Error iniciando servidor: {e}") # --------------------------------------------------------- # LOOP DE SIMULACIÓN # --------------------------------------------------------- async def update_simulation_loop(): log.info(f"📊 [DATA LOOP] Generando {len(VARS)} variables (tick base {BASE_TICK_S}s)...") cycle = 0 while True: try: now = time.time() changes = 0 dirty_banks = set() # (unit, area) con al menos un cambio este tick for v in VARS: if now - v.last_step >= v.freq_ms / 1000.0: v.last_step = now v.step() changes += 1 dirty_banks.add((v.unit_id, v.area)) # Volcar al datastore (setValues síncrono, base 0, por pozo/área) if state.modbus_running and modbus_context and dirty_banks: fc = {"HR": 3, "IR": 4} for unit, area in dirty_banks: bank = BANKS[unit][area] values = [0] * (max(v.address for v in bank) + 1) for v in bank: values[v.address] = int(round(v.value)) try: modbus_context[unit].setValues(fc[area], 0, values) except Exception as e: log.warning(f"setValues falló (unit {unit} {area}): {e}") with state.lock: state.last_update = time.strftime('%Y-%m-%d %H:%M:%S') state.changes_last_cycle = changes ts = state.last_update running = state.modbus_running cycle += 1 if cycle % 5 == 0: # resumen cada ~5 ticks hf = sum(1 for v in VARS if v.is_hf) status = "ON" if running else "OFF (pymodbus ausente)" print(f"[{ts}] Modbus {status} | {len(VARS)} vars ({hf} HF / {len(VARS)-hf} LF) " f"en {len(BANKS)} pozos | cambios este tick: {changes}") except Exception as e: log.error(f"Error en loop de simulación: {e}") await asyncio.sleep(BASE_TICK_S) # --------------------------------------------------------- # MAIN # --------------------------------------------------------- def print_summary(): hf = sum(1 for v in VARS if v.is_hf) print("\n" + "=" * 68) print(" SIMULADOR DE CARGA OMNIOIL") print("=" * 68) print(f" Pozos (unit_id) : {WELL_COUNT} ({min(BANKS)}..{max(BANKS)})") print(f" Variables totales : {len(VARS)}") print(f" Alta frecuencia (HF) : {hf} cada {HF_INTERVAL_MS} ms") print(f" Baja frecuencia (LF) : {len(VARS)-hf} cada {LF_INTERVAL_MS} ms") print(f" Modbus TCP : 127.0.0.1:{MODBUS_PORT} (un unit_id por pozo)") print(f" CSV de importación : {CSV_PATH}") print(f" Panel de monitoreo : http://localhost:{HTTP_PORT}") print("=" * 68 + "\n") async def main(): log.info("✨ Iniciando Simulador de Carga Modbus OmniOil ✨") write_import_csv() print_summary() http_server = start_http_server(port=HTTP_PORT) tasks = [update_simulation_loop()] if pymodbus_installed: tasks.append(run_modbus_server()) else: log.warning("Modbus desactivado: el panel y el CSV funcionan, pero no hay servidor TCP para el edge.") try: await asyncio.gather(*tasks) except Exception as e: log.error(f"Error en orquestador de tareas: {e}") finally: log.info("Deteniendo servidor HTTP...") http_server.shutdown() if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: log.info("👋 Simulador detenido por el usuario.") except Exception as e: log.error(f"💥 Error fatal en la ejecución: {e}") sys.exit(1)