#!/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 = """
Generador de variables petroleras de alta y baja frecuencia para pruebas de escala
| Pozo | Unit ID | Tags | HF | LF |
|---|
| Pozo | Tag | Dir. | Valor |
|---|