Compare commits

...

34 Commits

Author SHA1 Message Date
Victor Fraile Garcia
e7aa13c433 feat(ucepsa): add context-aware stop candidates shadow v0.3.1 2026-07-17 12:46:29 +02:00
Victor Fraile Garcia
73ce89be09 fix(ucepsa): keep open production contexts through activity tail 2026-07-17 12:01:14 +02:00
Victor Fraile Garcia
b2a09f3826 fix(ucepsa): read deployment mode from dict cursor 2026-07-17 11:47:18 +02:00
Victor Fraile Garcia
faf1804558 fix(ucepsa): rename reserved overlaps CTE 2026-07-17 11:29:39 +02:00
Victor Fraile Garcia
d83bacefc4 feat(ucepsa): add multi-source production context shadow v0.3 2026-07-17 11:20:39 +02:00
Victor Fraile Garcia
36d67d99b0 feat(ucepsa): add read-only Shop Floor session probe 2026-07-17 09:07:05 +02:00
Victor Fraile Garcia
4586e29afe feat(ucepsa): add power-aware LossIntelligence shadow v0.2 2026-07-16 18:33:11 +02:00
Victor Fraile Garcia
2d198e5ddb feat(ucepsa): add LossIntelligence shadow foundation 2026-07-16 12:27:29 +02:00
88c84f73f5 Añadir ucepsa/README.md 2026-07-16 09:11:42 +00:00
Victor Fraile Garcia
1381733157 docs: update UCEPSA Edge-OEE repository documentation 2026-06-25 19:13:20 +02:00
Victor Fraile Garcia
42dc050fc7 backup: UCEPSA Edge-OEE piloto productivo activo 2026-06-25 2026-06-25 18:51:33 +02:00
Victor Fraile Garcia
7c5ad7f5a3 feat(ucepsa): persist Odoo order_state for Grafana 2026-06-22 12:54:08 +02:00
Victor Fraile Garcia
cc951b713d Add Data Quality Contract cockpit 2026-05-30 20:30:27 +02:00
Victor Fraile Garcia
94fa0d7026 Add runbook for order report margin and cost readiness 2026-05-30 20:10:37 +02:00
Victor Fraile Garcia
206adbc8bb Add Margin Intelligence and Cost Readiness Cockpit 2026-05-30 20:05:42 +02:00
Victor Fraile Garcia
bff3fef40d Improve margin report status labels 2026-05-30 19:34:43 +02:00
Victor Fraile Garcia
4e3e3aef1b Add Docker Compose deployment for UCEPSA order report API 2026-05-28 22:29:10 +02:00
Victor Fraile Garcia
0ed753587b Add UCEPSA order execution report SQL views 2026-05-28 22:26:40 +02:00
Victor Fraile Garcia
90a0da730b Add UCEPSA order execution report HTML and PDF service 2026-05-28 22:24:21 +02:00
Victor Fraile Garcia
350e9a29c8 fix(ucepsa): scope Loss Intelligence telemetry alerts to active orders 2026-05-27 20:31:56 +02:00
Victor Fraile Garcia
939345753f docs(ucepsa): document current Edge-OEE stack status 2026-05-27 20:20:42 +02:00
Victor Fraile Garcia
00e71ecbf7 fix(ucepsa): deduplicate stale telemetry loss intelligence events 2026-05-27 20:05:34 +02:00
Victor Fraile Garcia
85d478b0df feat(ucepsa): add Loss Intelligence v0 SQL layer 2026-05-27 19:46:27 +02:00
Victor Fraile Garcia
69f8416808 fix(ucepsa): auto-refresh operator console safely 2026-05-27 14:38:46 +02:00
Victor Fraile Garcia
d525443de9 fix(ucepsa): simplify operator console tablet UI 2026-05-27 14:22:16 +02:00
Victor Fraile Garcia
94f6e9dc3f fix(ucepsa): show monitored machine in operator console 2026-05-27 14:07:02 +02:00
Victor Fraile Garcia
8bc9c73f28 fix(ucepsa): recreate Odoo reporting views for local sync 2026-05-27 13:29:57 +02:00
Victor Fraile Garcia
2c963be6cc fix(ucepsa): add unique indexes for Odoo sync upserts 2026-05-27 13:08:02 +02:00
Victor Fraile Garcia
94e7a3d27f fix(ucepsa): grant Grafana read access to Edge-OEE schemas 2026-05-27 12:29:42 +02:00
Victor Fraile Garcia
0e4871d908 fix(ucepsa): recreate fast machine state view from latest snapshot 2026-05-27 11:57:43 +02:00
Victor Fraile Garcia
0061c6a616 fix(ucepsa): add latest machine state fast compatibility view 2026-05-27 11:47:13 +02:00
Victor Fraile Garcia
a2b5d7dfb5 docs(ucepsa): update Edge-OEE VM IP to 17.126.1.70 2026-05-27 00:49:59 +02:00
Victor Fraile Garcia
58a10d1c37 fix(ucepsa): enable MQTT authentication in Edge-OEE sink 2026-05-27 00:38:37 +02:00
29b0e0456b feat(ucepsa): add Edge-OEE local stack sources 2026-05-26 16:29:34 +00:00
150 changed files with 54488 additions and 0 deletions

48
.gitignore vendored Normal file
View File

@ -0,0 +1,48 @@
# --- MESAVAULT / UCEPSA runtime & secrets ---
**/.env
**/.env.*
!**/.env.example
!**/.env.*.example
# Runtime / data
**/backups/
**/logs/
**/data/
**/postgres/data/
**/grafana/data/
**/mosquitto/data/
**/mosquitto/log/
**/__pycache__/
**/*.pyc
**/*.pyo
**/*.log
# Dumps / databases
**/*.dump
**/*.backup
**/*.sql.gz
**/*.tar
**/*.tar.gz
**/*.zip
# Certificates / keys / tokens
**/*.pem
**/*.key
**/*.crt
**/acme.json
**/id_rsa
**/id_rsa.pub
**/id_ed25519
**/id_ed25519.pub
**/passwords
# Odoo runtime
**/filestore/
**/enterprise/
**/odoo-data/
**/odoo-db-data/
# Editors
.vscode/
.idea/
*.swp

1
ucepsa/README.md Normal file
View File

@ -0,0 +1 @@
UCEPSA

67
ucepsa/edge-oee-demo/.gitignore vendored Normal file
View File

@ -0,0 +1,67 @@
# Secrets
.env
.env.*
**/.env
**/.env.*
!**/.env.example
# Python
**/__pycache__/
**/*.pyc
**/.venv/
**/venv/
# Logs
*.log
logs/
**/logs/
# Runtime data / volumes
data/
volumes/
postgres_data/
grafana/data/
mosquitto/data/
mosquitto/log/
minio/data/
# Backups and dumps with data
backups/
*.dump
*.backup
*.tar
*.tar.gz
*.tgz
*.zip
*.gz
*.sql.gz
# Keys / certificates
*.pem
*.key
*.crt
*.p12
id_rsa*
id_ed25519*
# Local env files
**/.env.local
**/*.env.local
# Backup / old files
*.bak
*.bak.*
*.bak_*
*_OLD
# Python cache
**/__pycache__/
**/*.pyc
# Grafana runtime data
grafana/data/
**/grafana/data/
# Audit packages
*_audit_*.tgz
*_audit_*.zip

View File

@ -0,0 +1,112 @@
# UCEPSA — MESAVAULT Edge-OEE
Repositorio del piloto productivo MESAVAULT Edge-OEE en UCEPSA.
## Estado actual
El sistema está en estado de piloto productivo activo.
Incluye:
- captura real de máquina mediante RevPi y nodos WISE;
- lectura energética mediante Eastron SDM630;
- publicación MQTT de telemetría;
- persistencia en PostgreSQL;
- integración con Odoo PROD mediante order_state;
- control automático de balizas;
- panel de operador para justificación de paros;
- dashboard Grafana con informe de paros, energía, estado máquina y capas de análisis.
## Máquinas incluidas
- CORT-00
- CORT-01
- CORT-02
## Servicios principales
En servidor mv-edge-oee:
- mv_ucepsa_mosquitto
- mv_ucepsa_postgres_hot
- mv_ucepsa_edge_oee_mqtt_pg_sink
- mv_ucepsa_odoo_order_state_publisher
- mv_ucepsa_odoo_order_state_pg_sink
- mv_ucepsa_operator_stop_console
- mv_ucepsa_grafana
En RevPi:
- mesavault-edge-oee-revpi
- mesavault-beacon-controller
## Integración Odoo
La integración con Odoo se realiza mediante un publicador externo:
tools/odoo_order_state_publisher.py
Este servicio consulta Odoo PROD y publica un mensaje MQTT retained por máquina:
vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/CORT-00
vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/CORT-01
vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/CORT-02
La regla validada es:
progress_count = 1 -> orden cargada correctamente
Validación real realizada:
CORT-00
WH/MO/00090
[6] BOLSA BD 22X30 TPTE
state = loaded
## Panel de operador
El panel de operador permite justificar paros detectados automáticamente.
Servicio:
mv_ucepsa_operator_stop_console
Acceso:
http://17.126.1.70:8088
Acceso por máquina:
http://17.126.1.70:8088/?machine_id=CORT-00
http://17.126.1.70:8088/?machine_id=CORT-01
http://17.126.1.70:8088/?machine_id=CORT-02
## Estructura del repositorio
services/ servicios Python/Docker
tools/ herramientas de sincronización, diagnóstico y publicación
sql/ migraciones, vistas y funciones PostgreSQL
grafana/ dashboards, scripts e import/export
deploy/ucepsa-local/ docker-compose y plantillas de entorno
revpi/ código y unidades systemd de RevPi
db/schema/ export de esquema PostgreSQL sin datos
docs/ documentación técnica y runbooks
env-examples/ ejemplos de variables sin secretos
## No versionar
No deben versionarse:
- .env reales;
- .env.local;
- contraseñas;
- tokens;
- claves privadas;
- dumps PostgreSQL con datos;
- volúmenes Docker;
- logs;
- backups comprimidos;
- datos de Grafana;
- datos de PostgreSQL.
Las credenciales deben mantenerse fuera del repositorio.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
version: "3.8"
services:
oee-demo-generator:
build:
context: ./services/oee-demo-generator
container_name: edge_oee_demo_generator
env_file:
- .env
networks:
- platform_net
restart: "no"
edge-oee-mqtt-pg-sink:
build:
context: ./services/edge-oee-mqtt-pg-sink
container_name: edge_oee_mqtt_pg_sink
env_file:
- .env.sink
environment:
PYTHONUNBUFFERED: "1"
networks:
- platform_net
restart: unless-stopped
operator-stop-console:
build:
context: ./services/operator-stop-console
container_name: edge_oee_operator_stop_console
env_file:
- .env.operator
environment:
PYTHONUNBUFFERED: "1"
ports:
- "100.116.75.30:8088:8080"
networks:
- platform_net
restart: unless-stopped
odoo-workorders-sync:
build:
context: ./services/odoo-workorders-sync
container_name: edge_oee_odoo_workorders_sync
env_file:
- .env.odoo-sync
environment:
PYTHONUNBUFFERED: "1"
networks:
- platform_net
restart: unless-stopped
networks:
platform_net:
external:
name: ${PLATFORM_NET}

View File

@ -0,0 +1,53 @@
# Identidad
TENANT=ucepsa
SITE=ucepsa_onpremise
VERTICAL=edge_oee
MACHINE_ID=CORT-01
ASSET=revpi_oee_node_01
# PostgreSQL MESAVAULT
POSTGRES_DB=mesavault_ucepsa_hot
POSTGRES_USER=mesavault
# Grafana
GRAFANA_ADMIN_USER=admin
# MQTT local
MQTT_PORT=1883
# MQTT credentials for local Edge-OEE sink
MQTT_USER_REVPI=revpi_ucepsa_edge_oee_01
POSTGRES_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
# PostgreSQL writer for Odoo sync
# Odoo UCEPSA
#ODOO_URL=https://17.126.1.61
ODOO_URL=http://17.126.1.61:8069
ODOO_DB=ucepsa_prod
ODOO_USER=__SET_IN_ENV_OR_VAULTWARDEN__
ODOO_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
ODOO_WORKCENTER_CODE=CORT-01
ODOO_INSTANCE=odoo_ucepsa_prod
ODOO_SYNC_INTERVAL_SECONDS=30
ODOO_WORKORDER_LIMIT=100
ODOO_WORKORDER_STATES=waiting,ready,progress,done
# PostgreSQL writer for Odoo sync
ODOO_SYNC_PGUSER=edge_oee_odoo_sync_writer
ODOO_SYNC_PGPASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
# Operator stop console
OPERATOR_PGUSER=edge_oee_operator_writer
OPERATOR_PGPASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
OPERATOR_APP_PORT=8080
OPERATOR_HOST_PORT=8088
OPERATOR_DEFAULT_MACHINE_ID=CORT-01
OPERATOR_DEFAULT_ASSET=revpi_oee_node_01
OPERATOR_ALLOWED_MACHINES=CORT-00,CORT-01,CORT-02,CORT-03,CORT-04,CORT-05,CORT-06,CORT-08,CORT-09,CORT-10,CORT-FAS1,CORT-FAS2
# Operator console auto refresh
OPERATOR_AUTO_REFRESH_SECONDS=5
MQTT_PASSWORD_REVPI=__SET_IN_ENV_OR_VAULTWARDEN__
GRAFANA_ADMIN_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__

View File

@ -0,0 +1,33 @@
services:
mv_ucepsa_edge_oee_mqtt_pg_sink:
build:
context: ./services/edge-oee-mqtt-pg-sink
container_name: mv_ucepsa_edge_oee_mqtt_pg_sink
restart: unless-stopped
environment:
PYTHONUNBUFFERED: "1"
MQTT_HOST: mv_ucepsa_mosquitto
MQTT_PORT: 1883
MQTT_USER: ${MQTT_USER_REVPI}
MQTT_PASSWORD: ${MQTT_PASSWORD_REVPI}
MQTT_TOPIC: vertical/ucepsa/ucepsa_onpremise/edge_oee/revpi_oee_node_01/+
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD}
TENANT: ucepsa
SITE: ucepsa_onpremise
VERTICAL: edge_oee
ASSET: revpi_oee_node_01
MACHINE_ID: CORT-01
VALUE_HOUR_EUR: 36
TZ: Europe/Madrid
depends_on:
- mv_ucepsa_postgres_hot
- mv_ucepsa_mosquitto
networks:
- mv_ucepsa_net

View File

@ -0,0 +1,35 @@
services:
mv_ucepsa_odoo_workorders_sync:
build:
context: ./services/odoo-workorders-sync
container_name: mv_ucepsa_odoo_workorders_sync
restart: unless-stopped
environment:
PYTHONUNBUFFERED: "1"
ODOO_URL: ${ODOO_URL}
ODOO_DB: ${ODOO_DB}
ODOO_USER: ${ODOO_USER}
ODOO_PASSWORD: ${ODOO_PASSWORD}
ODOO_WORKCENTER_CODE: ${ODOO_WORKCENTER_CODE}
ODOO_INSTANCE: ${ODOO_INSTANCE}
ODOO_SYNC_INTERVAL_SECONDS: ${ODOO_SYNC_INTERVAL_SECONDS}
ODOO_WORKORDER_LIMIT: ${ODOO_WORKORDER_LIMIT}
ODOO_WORKORDER_STATES: ${ODOO_WORKORDER_STATES}
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${ODOO_SYNC_PGUSER}
PGPASSWORD: ${ODOO_SYNC_PGPASSWORD}
TENANT: ucepsa
SITE: ucepsa_test
MACHINE_ID: CORT-01
ASSET: revpi_oee_node_01
TARGET_CYCLE_RATE_PPM: 10
TZ: Europe/Madrid
depends_on:
- mv_ucepsa_postgres_hot
networks:
- mv_ucepsa_net

View File

@ -0,0 +1,28 @@
services:
mv_ucepsa_operator_stop_console:
build:
context: ./services/operator-stop-console
container_name: mv_ucepsa_operator_stop_console
restart: unless-stopped
ports:
- "${OPERATOR_HOST_PORT:-8088}:${OPERATOR_APP_PORT:-8080}"
environment:
PYTHONUNBUFFERED: "1"
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${OPERATOR_PGUSER}
PGPASSWORD: ${OPERATOR_PGPASSWORD}
APP_PORT: ${OPERATOR_APP_PORT:-8080}
APP_TITLE: "MESAVAULT Edge-OEE - Consola de paros UCEPSA"
DEFAULT_MACHINE_ID: ${OPERATOR_DEFAULT_MACHINE_ID:-CORT-01}
DEFAULT_ASSET: ${OPERATOR_DEFAULT_ASSET:-revpi_oee_node_01}
ALLOWED_MACHINES: ${OPERATOR_ALLOWED_MACHINES:-CORT-01}
AUTO_REFRESH_SECONDS: ${OPERATOR_AUTO_REFRESH_SECONDS:-5}
TZ: Europe/Madrid
depends_on:
- mv_ucepsa_postgres_hot
networks:
- mv_ucepsa_net

View File

@ -0,0 +1,25 @@
services:
order_report_api:
container_name: mv_ucepsa_order_report_api
build:
context: ./services/order-report-api
dockerfile: Dockerfile
image: mv_ucepsa_order_report_api:0.3
restart: unless-stopped
env_file:
- ./services/order-report-api/.env.local
ports:
- "8090:8090"
networks:
- ucepsa_edge_oee_net
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8090/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
ucepsa_edge_oee_net:
external: true
name: mv_ucepsa_net

View File

@ -0,0 +1,28 @@
services:
mv_ucepsa_odoo_order_state_pg_sink:
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
container_name: mv_ucepsa_odoo_order_state_pg_sink
restart: unless-stopped
env_file:
- .env
environment:
TENANT: ucepsa
SITE: ucepsa_onpremise
MQTT_HOST: mv_ucepsa_mosquitto
MQTT_PORT: 1883
MQTT_TOPIC: vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/+
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./tools:/app/tools:ro
working_dir: /app
command: python tools/order_state_pg_sink.py
networks:
- mv_ucepsa_net
networks:
mv_ucepsa_net:
external: true

View File

@ -0,0 +1,37 @@
services:
mv_ucepsa_odoo_order_state_publisher:
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
container_name: mv_ucepsa_odoo_order_state_publisher
restart: unless-stopped
env_file:
- .env
environment:
ODOO_DB_OVERRIDE: ucepsa_prod
SITE_OVERRIDE: ucepsa_onpremise
MQTT_HOST_OVERRIDE: mv_ucepsa_mosquitto
MQTT_USER: ${MQTT_USER_REVPI}
MQTT_PASSWORD: ${MQTT_PASSWORD_REVPI}
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
volumes:
- ./:/app:ro
working_dir: /app
command:
- python
- tools/odoo_order_state_publisher.py
- --publish
- --loop
- --interval
- "5"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- mv_ucepsa_net
networks:
mv_ucepsa_net:
external: true
name: mv_ucepsa_net

View File

@ -0,0 +1,35 @@
services:
mv_ucepsa_shopfloor_session_probe:
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
container_name: mv_ucepsa_shopfloor_session_probe
profiles:
- diagnostic
env_file:
- .env
environment:
ODOO_DB_OVERRIDE: ucepsa_prod
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
volumes:
- ./:/app:ro
- /tmp:/probe-output
working_dir: /app
command:
- python
- tools/odoo_shopfloor_session_probe.py
- --machine
- CORT-00
- --watch
- --duration-minutes
- "30"
- --interval
- "2"
- --output
- /probe-output/odoo_shopfloor_session_probe.jsonl
networks:
- mv_ucepsa_net
networks:
mv_ucepsa_net:
external: true
name: mv_ucepsa_net

View File

@ -0,0 +1,49 @@
services:
mv_ucepsa_postgres_hot:
image: postgres:16-alpine
container_name: mv_ucepsa_postgres_hot
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: Europe/Madrid
volumes:
- ./postgres/data:/var/lib/postgresql/data
networks:
- mv_ucepsa_net
mv_ucepsa_mosquitto:
image: eclipse-mosquitto:2
container_name: mv_ucepsa_mosquitto
restart: unless-stopped
ports:
- "1883:1883"
volumes:
- ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log
networks:
- mv_ucepsa_net
mv_ucepsa_grafana:
image: grafana/grafana-oss:latest
container_name: mv_ucepsa_grafana
restart: unless-stopped
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: "false"
TZ: Europe/Madrid
volumes:
- ./grafana/data:/var/lib/grafana
depends_on:
- mv_ucepsa_postgres_hot
networks:
- mv_ucepsa_net
networks:
mv_ucepsa_net:
name: mv_ucepsa_net

View File

@ -0,0 +1,37 @@
# Estado actual — MESAVAULT Edge-OEE UCEPSA
## VM
- Host: mv-edge-oee
- IP: 17.126.1.70
- Stack: Docker Compose
- Fecha: 2026-05-27
## Servicios activos
- PostgreSQL: mv_ucepsa_postgres_hot
- Mosquitto: mv_ucepsa_mosquitto
- Grafana: mv_ucepsa_grafana
- MQTT sink: mv_ucepsa_edge_oee_mqtt_pg_sink
- Odoo sync: mv_ucepsa_odoo_workorders_sync
- Consola de paros: mv_ucepsa_operator_stop_console
## Funcionalidad validada
- Odoo UCEPSA sincroniza órdenes de Cortadora 01.
- Grafana muestra Odoo vs máquina.
- Consola de paros permite clasificar paros.
- Loss Intelligence v0 creada mediante vistas SQL.
- RevPi validada previamente por OpenVPN, actualmente apagada.
## URLs
- Grafana: http://17.126.1.70:3000
- Consola: http://17.126.1.70:8088/?machine_id=CORT-01
## Pendiente
- Conectar RevPi físicamente o por VPN.
- Crear HTTPS / PWA para tablets.
- Preparar backup automático.
- Escalar a más cortadoras.

View File

@ -0,0 +1,41 @@
# MESAVAULT Edge-OEE UCEPSA Demo
Demo técnica y comercial de Edge-OEE para UCEPSA.
Objetivo:
Odoo Shop Floor dice qué orden está en curso.
MESAVAULT Edge-OEE muestra qué hizo realmente la máquina durante esa orden.
La demo convierte eventos de máquina en:
- pérdidas cuantificadas;
- coste económico;
- causas de paro;
- discrepancias Odoo/máquina;
- acciones recomendadas;
- justificación económica de mantenimiento, producción y calidad.
## Arquitectura
Esta vertical sigue el patrón MESAVAULT ya utilizado en otras verticales:
- platform-40 procesa;
- mv_postgres_hot conserva;
- Grafana visualiza;
- los servicios de vertical quedan en /srv/verticals;
- Gitea guarda SQL, scripts, dashboards y documentación técnica saneada.
## Estado
Fase actual: demo simulada sin hardware adicional.
Primer hito:
En Grafana se debe ver:
- una orden ficticia de Odoo;
- una línea temporal realista de máquina;
- preparación, marcha, microparos y paros;
- coste por causa;
- acción recomendada.

View File

@ -0,0 +1,29 @@
# ADR — Contexto productivo multifuente durante migraciones ERP
## Estado
Aceptado para UCEPSA en modo SHADOW.
## Problema
Una máquina puede producir legítimamente sin orden Odoo porque la planta mantiene
temporalmente actividad en el ERP anterior.
Tratar todo `RUNNING_WITHOUT_VALID_ORDER` como error o pérdida produciría falsos
positivos y dañaría la implantación.
## Decisión
MESAVAULT separa:
- verdad física: WISE/RevPi;
- contexto productivo: Odoo, legacy, manual, mantenimiento o prueba;
- pérdida: solo tras intersectar actividad, contexto confirmado y política económica.
## Consecuencias
- Odoo sigue siendo la fuente preferida.
- Legacy se acepta como excepción explícita.
- Sin contexto confirmado se genera una tarea de revisión, no un coste.
- Las balizas no cambian de semántica.
- El porcentaje Odoo/legacy/sin contexto se convierte en indicador de migración.

View File

@ -0,0 +1,95 @@
--- SCHEMAS ---
schema_name
-------------
mv_control
mv_elpicon
mv_hot
(3 rows)
--- TABLES ---
table_schema | table_name
--------------+-----------------------------
mv_control | asset_device_map
mv_control | drain_downlink_audit
mv_control | drain_schedule
mv_hot | aquaponics_latest
mv_hot | aquaponics_readings
mv_hot | climate_air_readings
mv_hot | climate_node_latest
mv_hot | climate_node_readings
mv_hot | drain_latest
mv_hot | drain_readings
mv_hot | edge_oee_data_quality_demo
mv_hot | edge_oee_eastron_readings
mv_hot | edge_oee_energy_demo
mv_hot | edge_oee_events_demo
mv_hot | edge_oee_loss_summary_demo
mv_hot | edge_oee_machine_stops
mv_hot | edge_oee_machine_targets
mv_hot | edge_oee_machines
mv_hot | edge_oee_node_events
mv_hot | edge_oee_orders_demo
mv_hot | edge_oee_quality_cases_demo
mv_hot | edge_oee_stop_causes
mv_hot | hydro_events
mv_hot | hydro_risk_history
mv_hot | terrace_regime_config
mv_hot | weather_alerts
mv_hot | weather_forecast_hourly
(27 rows)
--- VIEWS ---
table_schema | table_name
--------------+--------------------------------------
mv_elpicon | climate_air_latest
mv_elpicon | climate_air_readings
mv_elpicon | drain_events
mv_elpicon | drain_latest
mv_elpicon | drain_readings
mv_hot | aquaponics_dashboard
mv_hot | aquaponics_flow_dashboard
mv_hot | aquaponics_readings_clean
mv_hot | climate_air_latest
mv_hot | climate_node_dashboard
mv_hot | climate_node_latest_clean
mv_hot | climate_node_readings_clean
mv_hot | drain_events
mv_hot | edge_oee_actions_demo
mv_hot | edge_oee_data_quality_dashboard_demo
mv_hot | edge_oee_eastron_dashboard
mv_hot | edge_oee_eastron_diagnosis_latest
mv_hot | edge_oee_eastron_latest
mv_hot | edge_oee_executive_summary_demo
mv_hot | edge_oee_kpi_cards_demo
mv_hot | edge_oee_latest_machine_snapshot
mv_hot | edge_oee_live_telemetry
mv_hot | edge_oee_machine_state_latest
mv_hot | edge_oee_node_events_recent
mv_hot | edge_oee_open_stops
mv_hot | edge_oee_order_timeline_demo
mv_hot | edge_oee_pareto_demo
mv_hot | edge_oee_quality_case_demo
mv_hot | edge_oee_stop_cause_options
mv_hot | edge_oee_stop_summary_demo
mv_hot | edge_oee_stops_recent
mv_hot | hydro_combined_risk_now_v1
mv_hot | hydro_dashboard_v2
mv_hot | hydro_decision_now_v1
mv_hot | hydro_diagnosis_latest_v2
mv_hot | hydro_features_now_v1
mv_hot | hydro_weather_risk
mv_hot | terrace_exposure_v1
mv_hot | weather_local_regime_v1
(39 rows)
--- ROLES RELEVANTES ---
rolname
---------------------------
edge_oee_demo_writer
edge_oee_eastron_writer
edge_oee_mqtt_sink_writer
edge_oee_operator_writer
grafana_elpicon_ro
grafana_ucepsa_ro
(6 rows)

View File

@ -0,0 +1,421 @@
# Runbook — Order Report, Margin Intelligence y Cost Readiness Cockpit
## 1. Propósito
Este documento describe la capa de informes de ejecución de orden, Margin Intelligence y Cost Readiness Cockpit desarrollada para MESAVAULT Edge-OEE en UCEPSA.
El objetivo de esta capa es unir datos de Odoo, máquina, paros, energía, materia prima e ingreso comercial para generar informes técnicos y económico-industriales por orden de fabricación.
## 2. Componentes incluidos
La fase incluye:
- informe HTML por orden;
- informe PDF por orden;
- índice de órdenes;
- Margin Intelligence;
- Cost Readiness Cockpit;
- vistas SQL de ejecución de orden;
- vistas SQL de materia prima;
- vistas SQL de margen;
- vistas SQL de readiness económico;
- microservicio FastAPI;
- despliegue mediante Docker Compose.
## 3. URLs operativas
Servicio Order Report API:
```text
http://17.126.1.70:8090
```
Healthcheck:
```text
http://17.126.1.70:8090/health
```
Índice de órdenes:
```text
http://17.126.1.70:8090/orders.html
```
Cost Readiness Cockpit:
```text
http://17.126.1.70:8090/cost-readiness.html
```
Informe HTML de orden:
```text
http://17.126.1.70:8090/report.html?order_ref=WH%2FMO%2F00025
```
Informe PDF de orden:
```text
http://17.126.1.70:8090/report.pdf?order_ref=WH%2FMO%2F00025
```
## 4. Servicio Docker
Contenedor:
```text
mv_ucepsa_order_report_api
```
Compose:
```text
docker-compose.order-report.yml
```
Arranque:
```bash
cd /srv/mesavault/edge-oee-ucepsa
docker compose -f docker-compose.order-report.yml up -d --build
```
Parada:
```bash
cd /srv/mesavault/edge-oee-ucepsa
docker compose -f docker-compose.order-report.yml down
```
Comprobación:
```bash
curl http://127.0.0.1:8090/health
```
Logs:
```bash
docker logs --tail=120 mv_ucepsa_order_report_api
```
## 5. Ubicación runtime
Ruta runtime:
```text
/srv/mesavault/edge-oee-ucepsa/services/order-report-api
```
Archivos principales:
```text
services/order-report-api/Dockerfile
services/order-report-api/requirements.txt
services/order-report-api/app/main.py
services/order-report-api/app/templates/order_report.html
services/order-report-api/app/templates/orders_index.html
services/order-report-api/app/templates/cost_readiness.html
services/order-report-api/.env.local
```
El archivo `.env.local` contiene credenciales y no debe versionarse.
## 6. Ubicación en Gitea
Repositorio local:
```text
/srv/mesavault/40-clients
```
Ruta del proyecto:
```text
ucepsa/edge-oee-demo/
```
Archivos versionados relacionados:
```text
ucepsa/edge-oee-demo/services/order-report-api/
ucepsa/edge-oee-demo/deploy/ucepsa-local/docker-compose.order-report.yml
ucepsa/edge-oee-demo/sql/reports/070_order_execution_report_views.sql
ucepsa/edge-oee-demo/sql/reports/080_margin_intelligence_cost_readiness.sql
ucepsa/edge-oee-demo/docs/runbooks/080_order_report_margin_cost_readiness.md
```
## 7. Vistas SQL principales
### 7.1. Informe técnico de orden
```text
mv_reports_ucepsa_demo.order_execution_report_v1
mv_reports_ucepsa_demo.order_execution_stops_v1
mv_reports_ucepsa_demo.order_execution_stop_causes_v1
mv_reports_ucepsa_demo.order_execution_timeline_v1
mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1
mv_reports_ucepsa_demo.order_execution_data_quality_v1
mv_reports_ucepsa_demo.order_execution_report_full_v1
```
### 7.2. Margin Intelligence
```text
mv_edge_oee_ucepsa_demo.odoo_order_commercials
mv_edge_oee_ucepsa_demo.margin_cost_parameters
mv_edge_oee_ucepsa_demo.raw_material_cost_parameters
mv_reports_ucepsa_demo.order_raw_material_costs_v1
mv_reports_ucepsa_demo.order_raw_material_costs_summary_v1
mv_reports_ucepsa_demo.order_margin_report_v1
```
### 7.3. Cost Readiness
```text
mv_reports_ucepsa_demo.cost_readiness_orders_v1
mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
mv_reports_ucepsa_demo.cost_readiness_summary_v1
```
## 8. Orden piloto validada
Orden:
```text
WH/MO/00025
```
Pedido de venta:
```text
S00011
```
Cliente:
```text
FRAJUSEMA, S.L.
```
Producto:
```text
[220365] BOLSA BD DE 60x90 cms. G80 TRANSPARENTE
```
Resultado técnico validado:
```text
Duración: 35,83 min
Tiempo en marcha: 33,48 min
Tiempo parado: 2,35 min
Energía: 0,029 kWh
Ciclos reales: 63
Paros: 2
Coste paros: 1,41 €
Calidad técnica: FIABLE
```
Resultado económico actual:
```text
Ingreso neto: 1154,80 €
Materia prima: [120727] TUBO BD DE 60 cms. G80 TRANSPARENTE
Cantidad consumida: 138 kg
Coste materia prima: pendiente
Margen estimado: no calculable todavía
Motivo: falta coste €/kg de materia prima 120727
```
## 9. Criterio de margen
El sistema no calcula margen final cuando falta coste de materia prima.
Estados relevantes:
```text
REVENUE_LINKED
NO_REVENUE_LINKED
MISSING_RAW_MATERIAL_COST_PARAMETER
PARTIAL_MARGIN_MISSING_RAW_MATERIAL_COST
ESTIMATED_MARGIN_AVAILABLE
```
La decisión operativa actual es bloquear el margen estimado hasta que UCEPSA cargue costes reales de materia prima en Odoo o valide costes manuales por kg.
## 10. Cost Readiness Cockpit
El cockpit muestra:
- órdenes con ingreso;
- órdenes sin ingreso;
- órdenes con materia prima;
- órdenes sin materia prima;
- órdenes con coste material pendiente;
- materias primas que bloquean cálculo de margen;
- clientes y pedidos afectados;
- score de readiness económico.
Bloqueo principal actual:
```text
Falta coste €/kg para 120727 - [120727] TUBO BD DE 60 cms. G80 TRANSPARENTE
```
## 11. Comandos de validación
Resumen readiness:
```bash
docker exec -i mv_ucepsa_postgres_hot \
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT *
FROM mv_reports_ucepsa_demo.cost_readiness_summary_v1;
SQL
```
Readiness por orden:
```bash
docker exec -i mv_ucepsa_postgres_hot \
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT
odoo_order_ref,
sale_order_ref,
customer_name,
product_default_code,
revenue_net_eur,
cost_readiness_score,
cost_readiness_status,
cost_readiness_description
FROM mv_reports_ucepsa_demo.cost_readiness_orders_v1
ORDER BY cost_readiness_score ASC;
SQL
```
Materias primas pendientes:
```bash
docker exec -i mv_ucepsa_postgres_hot \
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT
raw_product_default_code,
raw_product_name,
affected_orders,
affected_customers,
total_consumed_qty,
raw_material_readiness_status,
recommended_action
FROM mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
ORDER BY affected_order_count DESC;
SQL
```
Informe de margen:
```bash
docker exec -i mv_ucepsa_postgres_hot \
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT
odoo_order_ref,
sale_order_ref,
customer_name,
revenue_net_eur,
raw_materials_summary,
material_cost_estimated_eur,
material_cost_source_status,
estimated_margin_eur,
estimated_margin_pct,
margin_status
FROM mv_reports_ucepsa_demo.order_margin_report_v1
WHERE odoo_order_ref = 'WH/MO/00025';
SQL
```
## 12. Cómo activar margen estimado en el futuro
Cuando UCEPSA tenga el coste real por kg del tubo `120727`, se podrá insertar en:
```text
mv_edge_oee_ucepsa_demo.raw_material_cost_parameters
```
Plantilla:
```sql
INSERT INTO mv_edge_oee_ucepsa_demo.raw_material_cost_parameters (
tenant,
site,
raw_product_default_code,
raw_product_name,
material_cost_eur_per_kg,
source,
notes
)
VALUES (
'ucepsa',
'ucepsa_test',
'120727',
'[120727] TUBO BD DE 60 cms. G80 TRANSPARENTE',
COSTE_REAL_EUR_KG,
'MANUAL_UCEPSA',
'Coste validado por UCEPSA para Margin Intelligence'
);
```
Tras insertar ese coste, `order_margin_report_v1` empezará a calcular:
```text
material_cost_estimated_eur
estimated_margin_eur
estimated_margin_pct
```
## 13. Riesgos y advertencias
No presentar `partial_margin_after_known_costs_eur` como margen real.
No introducir costes aproximados sin fuente validada.
No mezclar órdenes de prueba con datos productivos sin señalarlas.
No usar informes bloqueados por falta de coste como base para pricing.
No interpretar `qty_planned`, `qty_done` o ciclos como unidades comerciales hasta validar conversiones.
## 14. Estado actual
Estado funcional:
```text
Order Execution Report: operativo
Informe HTML/PDF: operativo
Margin Intelligence: operativo en modo parcial
Cost Readiness Cockpit: operativo
Margen estimado final: bloqueado hasta coste de materia prima
```
Snapshot recomendado:
```text
order-report-margin-cost-readiness-v01-ok
```
## 15. Accesos rápidos
```text
Orders:
http://17.126.1.70:8090/orders.html
Cost Readiness:
http://17.126.1.70:8090/cost-readiness.html
Informe HTML WH/MO/00025:
http://17.126.1.70:8090/report.html?order_ref=WH%2FMO%2F00025
Informe PDF WH/MO/00025:
http://17.126.1.70:8090/report.pdf?order_ref=WH%2FMO%2F00025
```

View File

@ -0,0 +1,120 @@
# UCEPSA — Despliegue LossIntelligence en modo sombra hasta octubre de 2026
## Objetivo
Construir y validar entre julio y septiembre la infraestructura reusable de MESAVAULT LossIntelligence sin convertir todavía los paros técnicos en pérdidas oficiales. En octubre se realizará el kickoff y se activará el Ledger con una fecha efectiva acordada.
## Regla de seguridad funcional
La captura raw no se modifica:
```text
WISE / RevPi / MQTT / PostgreSQL
-> evento físico
-> paro raw
```
LossIntelligence trabaja encima:
```text
paro raw
-> contexto Odoo histórico
-> política de elegibilidad
-> exclusión / agrupación / candidato
-> Loss Ledger
-> acción
-> prueba
-> resultado
```
Un paro raw no equivale automáticamente a pérdida.
## Estados
- `SHADOW`: infraestructura activa, resultados solo para validación interna.
- `ACTIVE`: Ledger oficial desde `actual_go_live_at`.
- `PAUSED`: captura conservada, promoción de nuevas pérdidas detenida.
- `CLOSED`: despliegue cerrado.
Octubre es una fecha prevista, no una activación automática. La activación requiere kickoff y actualización explícita de `actual_go_live_at`.
## Parámetros iniciales
- Coste de capacidad: `36 €/h`, provisional y pendiente de aprobación.
- Coste eléctrico variable: `0,128249 €/kWh`, derivado de la factura 6.1TD de junio de 2026.
- Bolsas por golpe: `1` o `2` según referencia. Debe configurarse por `product_id`.
- Cadencia objetivo: pendiente por referencia o familia. Hasta completarla no se activa pérdida por baja cadencia.
## Política inicial de paros
| Duración | Tratamiento en sombra |
|---|---|
| `<5 s` | Se conserva en raw y se excluye de la cola humana. |
| `5 s5 min` | Se agrupa por máquina, orden, ventana de 15 min y causa. |
| `>=5 min` | Se mantiene como candidato individual. |
| Paro abierto | Solo monitorización; no entra en Ledger. |
| Sin orden Odoo válida y coherente | Excluido. |
| Antes del go-live | Sombra, nunca pérdida oficial. |
Los umbrales deben validarse con producción antes del kickoff.
## Rutina de revisión futura
La reunión diaria de 1530 minutos entre dirección y jefe de producción será el punto de revisión. La cola oficial se limitará a cinco elementos:
1. pérdidas nuevas con mayor prioridad;
2. acciones cuyo plazo vence;
3. pruebas en curso que requieren decisión;
4. bloqueos de calidad de dato;
5. resultados que deben mantenerse, adaptarse, repetirse, descartarse o escalarse.
## Orden de implantación
1. Crear rama de trabajo en Gitea.
2. Tomar backup de PostgreSQL.
3. Aplicar migraciones 093096.
4. Validar que la cola oficial está vacía en modo `SHADOW`.
5. Actualizar el sink de `order_state` para guardar campos tipados y un hash histórico completo.
6. Observar datos durante al menos dos semanas.
7. Completar bolsas por golpe de las referencias activas.
8. Validar política de microparos y causas con producción.
9. Construir dashboard de sombra y panel de calidad de dato.
10. Preparar checklist de kickoff.
11. En octubre, acordar `actual_go_live_at` y cambiar `mode` a `ACTIVE`.
## Kill switch
Para detener la promoción oficial sin tocar la captura:
```sql
UPDATE mv_loss_intelligence.deployment_config
SET mode = 'PAUSED', updated_at = now()
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND scope_code = 'CUTTERS';
```
## Activación en kickoff
Ejemplo; sustituir fecha y hora por las acordadas:
```sql
UPDATE mv_loss_intelligence.deployment_config
SET
mode = 'ACTIVE',
actual_go_live_at = TIMESTAMPTZ '2026-10-05 06:00:00+02',
updated_at = now()
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND scope_code = 'CUTTERS';
```
La activación no debe hacerse antes de confirmar:
- Shop Floor estable;
- órdenes correctamente cargadas por máquina;
- causas y umbrales aceptados;
- coste/hora revisado;
- productos activos con bolsas por golpe;
- responsables y reunión diaria confirmados;
- backup y rollback disponibles.

View File

@ -0,0 +1,56 @@
# UCEPSA LossIntelligence SHADOW v0.2 — elegibilidad power-aware
## Problema corregido
En UCEPSA, cuando una cortadora no tiene trabajo se corta su alimentación. El último evento puede ser `machine_stopped` y el paro raw permanece abierto o se cierra varios días después. Esa duración física no equivale a pérdida.
La v0.2 calcula tiempo elegible mediante la intersección:
```text
paro raw
∩ intervalo de orden Odoo válida
∩ isla de telemetría real
= tiempo candidato a pérdida
```
Cuando desaparece la alimentación, dejan de llegar muestras y la isla de telemetría termina. El tiempo posterior no entra en LossIntelligence.
## Umbrales iniciales
- Frecuencia nominal de captura observada: alrededor de 5 s.
- Un hueco mayor de 90 s abre una nueva isla de telemetría.
- Se toleran 30 s después de la última muestra válida.
- Menos de 5 s sigue siendo ruido.
- Entre 5 s y 5 min se agrupa.
- A partir de 5 min se muestra individualmente.
Los valores son de modo sombra y deben revisarse antes del kickoff de octubre.
## Vistas nuevas
- `li_telemetry_coverage_v1`
- `li_stop_order_overlaps_v2`
- `li_stop_eligible_slices_v2`
- `li_stop_candidates_v2`
- `li_stop_exclusion_diagnostics_v2`
- `li_stop_groups_v2`
- `li_shadow_review_queue_v2`
- `li_operator_stop_queue_v2`
- `li_shadow_summary_v2`
- `li_eligibility_comparison_v1`
- `machine_operational_state_v1`
- `li_data_quality_v2`
## Regla de interfaz
Una máquina sin telemetría y sin orden debe mostrarse como `SIN PRODUCCIÓN`, no como `NO OK`.
Estados que sí requieren atención:
- máquina en marcha sin orden válida;
- orden válida sin telemetría;
- telemetría inválida o comunicación fallida con orden activa.
## Activación
La v0.2 no cambia `deployment_config.mode`. Permanece `SHADOW` y la cola oficial v2 debe devolver cero filas.

View File

@ -0,0 +1,93 @@
# UCEPSA — Descubrimiento de sesiones Odoo Shop Floor v0.1
## Objetivo
Identificar, sin escribir en Odoo, qué registros y campos cambian cuando una operaria:
1. se identifica en Shop Floor;
2. inicia una orden en una cortadora;
3. pausa la orden;
4. la reanuda;
5. termina o abandona la orden;
6. es sustituida por otra operaria.
El resultado servirá para diseñar el publicador productivo de sesiones Shop Floor de MESAVAULT.
## Hipótesis técnica que se debe verificar
La fuente candidata principal es `mrp.workcenter.productivity`.
Una sesión activa debería aparecer como una fila con:
- `workorder_id`;
- `workcenter_id`;
- `date_start`;
- `date_end` vacío mientras sigue activa;
- `user_id` u otro campo de identidad;
- una pérdida/tipo compatible con tiempo productivo.
No se dará esta hipótesis por válida hasta observarla en UCEPSA PROD.
## Seguridad
La sonda:
- solo usa XML-RPC de lectura;
- no invoca `create`, `write`, `unlink` ni botones de Odoo;
- no publica MQTT;
- no modifica PostgreSQL;
- no altera balizas;
- no modifica Shop Floor.
## Secuencia recomendada
### 1. Descubrimiento estático
Ejecutar `--discover` y conservar la salida.
### 2. Instantánea inicial
Con CORT-00 sin una prueba en curso, ejecutar `--snapshot`.
### 3. Vigilancia
Iniciar `--watch` durante 30 minutos.
### 4. Acciones en Shop Floor
Anotar la hora local exacta de cada acción:
- T0: operaria se identifica;
- T1: abre CORT-00;
- T2: inicia la orden;
- T3: pausa;
- T4: reanuda;
- T5: finaliza;
- T6: otra operaria inicia o se incorpora, si el flujo lo permite.
Esperar al menos 2030 segundos entre acciones para distinguirlas con claridad.
### 5. Cierre
Detener la sonda o esperar a que termine y copiar:
- salida de descubrimiento;
- instantánea inicial;
- JSONL de vigilancia;
- lista manual T0T6;
- orden, producto y máquina utilizados.
## Criterios de aceptación
Se considera fuente oficial viable si se puede reconstruir de forma inequívoca:
- operaria;
- máquina;
- orden de trabajo;
- orden de fabricación;
- inicio;
- pausa/cierre;
- reanudación;
- relevo.
Debe confirmarse además que una sesión abierta tiene un estado detectable y que una pausa o fin deja una marca histórica, no solo un estado actual.

View File

@ -0,0 +1,80 @@
# UCEPSA — LossIntelligence Production Context SHADOW v0.3
## Decisión de arquitectura
Durante la migración conviven dos vías válidas:
1. Odoo Shop Floor, estándar preferido.
2. Producción legacy o manual autorizada.
`máquina en marcha + sin orden Odoo` no se convierte automáticamente en pérdida.
Primero se clasifica el contexto:
- ODOO_CONTEXT;
- LEGACY_AUTHORIZED;
- MANUAL_AUTHORIZED;
- MAINTENANCE;
- TEST_SETUP;
- UNCONFIRMED_CONTEXT;
- CONTEXT_CONFLICT.
## Protección de las balizas
Esta versión no modifica:
- `beacon_controller.py`;
- el publicador `order_state`;
- DO0 azul;
- DO1 rojo.
Azul fijo continúa significando exclusivamente orden Odoo válida.
Una producción legacy puede seguir provocando azul intermitente porque está fuera del
flujo Odoo, aunque MESAVAULT la reconozca como legacy autorizada.
## Flujo preventivo recomendado
Antes de producir por el ERP anterior, producción abre un contexto:
```bash
python tools/li_context_admin.py open \
--machine CORT-02 \
--source LEGACY_ERP \
--external-order-ref LEGACY-1234 \
--product-name "Referencia legacy" \
--authorized-by "Juan Pablo" \
--notes "Producción autorizada fuera de Odoo durante migración"
```
Al finalizar:
```bash
python tools/li_context_admin.py close \
--session-id 1 \
--closed-by "Juan Pablo"
```
## Flujo retrospectivo
Si no se abrió previamente, la vista `li_context_review_queue_v1` muestra un único
bloque físico. Tras confirmar el intervalo se crea una sesión retrospectiva.
## Seguridad SHADOW
Mientras `deployment_config.mode = SHADOW`, la herramienta fuerza:
```text
official_eligible = false
```
Por tanto, ningún contexto manual puede crear por sí solo una pérdida económica oficial.
## Qué no resuelve todavía
- La identidad activa de la operaria en el flujo personalizado de Shop Floor.
- El cierre real con declaración de producción.
- La extensión de los paros v2 para usar contexto legacy.
La siguiente subfase será `v0.3.1`: cruzar los paros con los contextos validados tras
comprobar que la apertura/cierre legacy funciona correctamente.

View File

@ -0,0 +1,58 @@
# UCEPSA — LossIntelligence Context-Aware Stops SHADOW v0.3.1
## Objetivo
Cruzar los paros físicos de WISE/RevPi con:
- cobertura válida de telemetría;
- contexto Odoo válido;
- producción legacy autorizada;
- producción manual autorizada.
## Seguridad
La migración 102 solo crea vistas paralelas.
No modifica:
- `li_stop_candidates_v2`;
- `li_stop_groups_v2`;
- `li_shadow_review_queue_v2`;
- `li_operator_stop_queue_v2`;
- `loss_ledger`;
- Odoo;
- RevPi;
- nodos WISE;
- balizas.
`official_ledger_eligible` queda forzado a `false`.
## Vistas nuevas
- `li_stop_context_slices_v1`
- `li_stop_candidates_context_v1`
- `li_stop_groups_context_v1`
- `li_shadow_review_queue_context_v1`
- `li_shadow_review_backlog_context_v1`
- `li_stop_context_diagnostics_v1`
## Comportamiento esperado durante la prueba
CORT-00:
- sesión legacy 1 abierta;
- un paro abierto se muestra como `OPEN_MONITORING`;
- al cerrar físicamente el paro, se convierte en candidato si supera el ruido.
CORT-02:
- `WH/MO/00081`;
- paros de 25 segundos deben quedar como ruido;
- paros superiores al umbral deben agruparse o mostrarse individualmente.
## Cola diaria
La cola diaria context-aware está limitada a las últimas 24 horas para evitar que
los falsos positivos históricos dominen la revisión.
El backlog histórico sigue disponible en una vista separada.

View File

@ -0,0 +1,22 @@
ODOO_URL=https://17.126.1.61
ODOO_DB=ucepsa_test_jp
ODOO_USER=mesavault_sync@local
ODOO_PASSWORD=CHANGE_ME
ODOO_WORKCENTER_CODE=CORT-01
ODOO_INSTANCE=odoo_ucepsa_test
ODOO_SYNC_INTERVAL_SECONDS=30
ODOO_WORKORDER_LIMIT=50
ODOO_WORKORDER_STATES=waiting,ready,progress,done
TENANT=ucepsa
SITE=ucepsa_test
MACHINE_ID=CORT-01
ASSET=revpi_oee_node_01
TARGET_CYCLE_RATE_PPM=10
PGHOST=mv_ucepsa_postgres_hot
PGPORT=5432
PGDATABASE=mesavault_ucepsa_hot
PGUSER=edge_oee_odoo_sync_writer
PGPASSWORD=CHANGE_ME

View File

@ -0,0 +1,9 @@
PGHOST=mv_ucepsa_postgres_hot
PGPORT=5432
PGDATABASE=mesavault_ucepsa_hot
PGUSER=edge_oee_operator_writer
PGPASSWORD=CHANGE_ME
APP_PORT=8080
APP_TITLE=MESAVAULT Edge-OEE - Consola de paros UCEPSA
DEFAULT_MACHINE_ID=CORT-01

View File

@ -0,0 +1,18 @@
MQTT_HOST=mv_ucepsa_mosquitto
MQTT_PORT=1883
MQTT_USER=revpi_ucepsa_edge_oee_01
MQTT_PASSWORD=CHANGE_ME
MQTT_TOPIC=vertical/ucepsa/ucepsa_test/edge_oee/+/+
PGHOST=mv_ucepsa_postgres_hot
PGPORT=5432
PGDATABASE=mesavault_ucepsa_hot
PGUSER=edge_oee_mqtt_sink_writer
PGPASSWORD=CHANGE_ME
TENANT=ucepsa
SITE=ucepsa_test
VERTICAL=edge_oee
ASSET=revpi_oee_node_01
MACHINE_ID=CORT-01
VALUE_HOUR_EUR=36

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,524 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode()
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
body = resp.read().decode()
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode(), file=sys.stderr)
raise
datasources = api("GET", "/api/datasources")
print("Datasources encontrados:")
for d in datasources:
print(f"- name={d.get('name')} type={d.get('type')} uid={d.get('uid')} url={d.get('url')} database={d.get('database')}")
pg = next(
(
d for d in datasources
if d.get("type") in ("postgres", "grafana-postgresql-datasource")
or "postgres" in (d.get("type") or "").lower()
or "postgres" in (d.get("name") or "").lower()
or "mesavault" in (d.get("name") or "").lower()
),
None,
)
if not pg:
print("ERROR: no encuentro datasource PostgreSQL/MESAVAULT en Grafana", file=sys.stderr)
sys.exit(1)
DS = {
"type": "postgres",
"uid": pg["uid"],
}
def target(sql, ref="A", fmt="table"):
return {
"refId": ref,
"datasource": DS,
"format": fmt,
"rawQuery": True,
"rawSql": sql,
"editorMode": "code",
}
def stat_panel(panel_id, title, x, y, w, h, sql, unit="none", mappings=None):
field_defaults = {
"unit": unit,
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": None},
],
},
}
if mappings:
field_defaults["mappings"] = mappings
return {
"id": panel_id,
"type": "stat",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql)],
"options": {
"reduceOptions": {
"values": False,
"calcs": ["lastNotNull"],
"fields": "",
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
},
"fieldConfig": {
"defaults": field_defaults,
"overrides": [],
},
}
def timeseries_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "timeseries",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="time_series")],
"fieldConfig": {
"defaults": {
"unit": unit,
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 1,
"fillOpacity": 5,
"showPoints": "never",
},
},
"overrides": [],
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": True,
},
"tooltip": {
"mode": "multi",
"sort": "none",
},
},
}
def table_panel(panel_id, title, x, y, w, h, sql):
return {
"id": panel_id,
"type": "table",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql)],
"options": {
"showHeader": True,
"cellHeight": "sm",
},
"fieldConfig": {
"defaults": {},
"overrides": [],
},
}
comm_mapping = [
{
"type": "value",
"options": {
"1": {"text": "OK", "color": "green"},
"0": {"text": "NO OK", "color": "red"},
},
}
]
run_mapping = [
{
"type": "value",
"options": {
"1": {"text": "EN MARCHA", "color": "green"},
"0": {"text": "PARADA", "color": "yellow"},
},
}
]
dashboard = {
"id": None,
"uid": DASHBOARD_UID,
"title": "MESAVAULT Edge-OEE — Máquina",
"tags": ["mesavault", "ucepsa", "edge-oee"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "5s",
"time": {
"from": "now-30m",
"to": "now",
},
"templating": {
"list": [
{
"name": "machine_id",
"type": "query",
"label": "Máquina",
"datasource": DS,
"refresh": 1,
"sort": 1,
"multi": False,
"includeAll": False,
"query": "SELECT machine_id FROM mv_reports_ucepsa_demo.machine_state_latest_multi ORDER BY machine_id",
"current": {
"text": "CORT-00",
"value": "CORT-00",
"selected": True,
},
}
]
},
"panels": [
stat_panel(
1,
"Comunicación",
0,
0,
6,
4,
"""
SELECT
CASE
WHEN age_s < 30 AND comm_ok = true AND COALESCE(sample_valid, true) = true THEN 1
ELSE 0
END AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
mappings=comm_mapping,
),
stat_panel(
2,
"Edad último dato",
6,
0,
6,
4,
"""
SELECT age_s AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="s",
),
stat_panel(
3,
"Estado máquina",
12,
0,
6,
4,
"""
SELECT
CASE WHEN machine_running = true THEN 1 ELSE 0 END AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
mappings=run_mapping,
),
stat_panel(
4,
"Ciclos/min",
18,
0,
6,
4,
"""
SELECT cycle_rate_ppm AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="cpm",
),
stat_panel(
5,
"Potencia total",
0,
4,
6,
4,
"""
SELECT power_total_kw AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="kwatt",
),
stat_panel(
6,
"Energía importada",
6,
4,
6,
4,
"""
SELECT import_kwh AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="kwatth",
),
stat_panel(
7,
"Frecuencia",
12,
4,
6,
4,
"""
SELECT frequency_hz AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="hertz",
),
stat_panel(
8,
"Pulsos acumulados",
18,
4,
6,
4,
"""
SELECT cycle_pulse_count AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="none",
),
timeseries_panel(
9,
"Potencia por fase",
0,
8,
12,
8,
"""
SELECT
ts AS "time",
active_power_l1_w AS "L1 W",
active_power_l2_w AS "L2 W",
active_power_l3_w AS "L3 W",
active_power_w AS "Total W"
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
WHERE $__timeFilter(ts)
AND site = 'ucepsa_onpremise'
AND machine_id = '$machine_id'
ORDER BY ts
""",
unit="watt",
),
timeseries_panel(
10,
"Tensiones L1/L2/L3",
12,
8,
12,
8,
"""
SELECT
ts AS "time",
voltage_l1_v AS "L1 V",
voltage_l2_v AS "L2 V",
voltage_l3_v AS "L3 V"
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
WHERE $__timeFilter(ts)
AND site = 'ucepsa_onpremise'
AND machine_id = '$machine_id'
ORDER BY ts
""",
unit="volt",
),
timeseries_panel(
11,
"Corrientes L1/L2/L3",
0,
16,
12,
8,
"""
SELECT
ts AS "time",
current_l1_a AS "L1 A",
current_l2_a AS "L2 A",
current_l3_a AS "L3 A"
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
WHERE $__timeFilter(ts)
AND site = 'ucepsa_onpremise'
AND machine_id = '$machine_id'
ORDER BY ts
""",
unit="amp",
),
table_panel(
12,
"Última lectura trifásica",
12,
16,
12,
8,
"""
SELECT
machine_id,
round(age_s::numeric, 1) AS age_s,
comm_ok,
sample_valid,
energy_map,
machine_running,
cycle_input_state,
cycle_pulse_count,
wise_cycle_counter_raw,
round(voltage_l1_v::numeric, 2) AS v_l1,
round(voltage_l2_v::numeric, 2) AS v_l2,
round(voltage_l3_v::numeric, 2) AS v_l3,
round(current_l1_a::numeric, 3) AS i_l1,
round(current_l2_a::numeric, 3) AS i_l2,
round(current_l3_a::numeric, 3) AS i_l3,
round(power_total_kw::numeric, 4) AS kw_total,
digital_inputs
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
),
table_panel(
13,
"Odoo vs máquina",
0,
24,
12,
7,
"""
SELECT
machine_id,
order_ref,
product_name,
odoo_state,
machine_running,
telemetry_age_s,
cycle_rate_ppm,
status_check,
diagnostic_message
FROM mv_reports_ucepsa_demo.odoo_vs_machine_demo
WHERE machine_id = '$machine_id'
""",
),
table_panel(
14,
"Loss Intelligence",
12,
24,
12,
7,
"""
SELECT
priority_rank,
priority_score,
priority_level,
machine_id,
order_ref,
loss_type,
severity,
duration_min,
cost_eur,
status,
description,
recommended_action
FROM mv_reports_ucepsa_demo.loss_priority_board
WHERE machine_id = '$machine_id'
ORDER BY priority_rank
""",
),
],
}
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Create MESAVAULT Edge-OEE machine dashboard with machine selector",
}
result = api("POST", "/api/dashboards/db", payload)
print("Dashboard creado/actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
raise
def walk_panels(panels):
for p in panels or []:
yield p
for child in walk_panels(p.get("panels", [])):
yield child
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
dashboard = doc["dashboard"]
meta = doc.get("meta", {})
changed = 0
for panel in walk_panels(dashboard.get("panels", [])):
if panel.get("title") in ("Pareto causas de paro", "Pareto causas clasificadas"):
panel["title"] = "Pareto causas clasificadas por minutos"
for target in panel.get("targets", []):
target["rawSql"] = """
SELECT
cause_name AS "Causa",
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'CLASSIFIED'
AND status = 'CLOSED'
GROUP BY cause_name
ORDER BY SUM(duration_min) DESC
LIMIT 10
"""
target["format"] = "table"
panel.setdefault("fieldConfig", {}).setdefault("defaults", {})["unit"] = "min"
changed += 1
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Clean stop cause pareto panel",
}
if meta.get("folderUid"):
payload["folderUid"] = meta["folderUid"]
result = api("POST", "/api/dashboards/db", payload)
print(f"Paneles modificados: {changed}")
print("Dashboard actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -0,0 +1,507 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
STOP_PANEL_IDS = set(range(300, 320))
STOP_PANEL_TITLES = {
"Informe de paros",
"Paros abiertos",
"Pendientes clasificar",
"Nº paros",
"Minutos paro",
"Coste paros",
"Duración media",
"Pareto causas de paro",
"Minutos de paro por hora",
"Paros pendientes de clasificación",
"Detalle completo de paros",
}
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
raise
def flatten_panels(panels):
result = []
for p in panels or []:
result.append(p)
if p.get("panels"):
result.extend(flatten_panels(p.get("panels")))
return result
def find_datasource_from_dashboard(dashboard):
for panel in flatten_panels(dashboard.get("panels", [])):
ds = panel.get("datasource")
if isinstance(ds, dict) and ds.get("uid"):
return {
"type": ds.get("type", "postgres"),
"uid": ds["uid"],
}
for target in panel.get("targets", []) or []:
tds = target.get("datasource")
if isinstance(tds, dict) and tds.get("uid"):
return {
"type": tds.get("type", "postgres"),
"uid": tds["uid"],
}
return None
def find_datasource_fallback():
datasources = api("GET", "/api/datasources")
for d in datasources:
if (
d.get("type") in ("postgres", "grafana-postgresql-datasource")
or "postgres" in (d.get("type") or "").lower()
or "postgres" in (d.get("name") or "").lower()
or "mesavault" in (d.get("name") or "").lower()
):
return {
"type": d.get("type", "postgres"),
"uid": d["uid"],
}
print("Datasources encontrados:")
for d in datasources:
print(f"- name={d.get('name')} type={d.get('type')} uid={d.get('uid')}")
return None
def target(sql, ref="A", fmt="table"):
return {
"refId": ref,
"datasource": DS,
"format": fmt,
"rawQuery": True,
"rawSql": sql,
"editorMode": "code",
}
def stat_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "stat",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="table")],
"options": {
"reduceOptions": {
"values": False,
"calcs": ["lastNotNull"],
"fields": "",
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
},
"fieldConfig": {
"defaults": {
"unit": unit,
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": None},
{"color": "yellow", "value": 1},
{"color": "red", "value": 5},
],
},
},
"overrides": [],
},
}
def table_panel(panel_id, title, x, y, w, h, sql):
return {
"id": panel_id,
"type": "table",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="table")],
"options": {
"showHeader": True,
"cellHeight": "sm",
},
"fieldConfig": {
"defaults": {},
"overrides": [],
},
}
def timeseries_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "timeseries",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="time_series")],
"fieldConfig": {
"defaults": {
"unit": unit,
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 1,
"fillOpacity": 5,
"showPoints": "never",
},
},
"overrides": [],
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": True,
},
"tooltip": {
"mode": "multi",
"sort": "none",
},
},
}
def barchart_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "barchart",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="table")],
"fieldConfig": {
"defaults": {
"unit": unit,
},
"overrides": [],
},
"options": {
"orientation": "horizontal",
"xField": "Causa",
"showValue": "always",
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": True,
},
},
}
def max_y(panels):
y = 0
for p in panels:
gp = p.get("gridPos") or {}
y = max(y, int(gp.get("y", 0)) + int(gp.get("h", 1)))
return y
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
dashboard = doc["dashboard"]
meta = doc.get("meta", {})
DS = find_datasource_from_dashboard(dashboard) or find_datasource_fallback()
if not DS:
print("ERROR: no encuentro datasource PostgreSQL/MESAVAULT", file=sys.stderr)
sys.exit(1)
print(f"Datasource usado: type={DS.get('type')} uid={DS.get('uid')}")
existing = dashboard.get("panels", [])
filtered = []
for p in existing:
title = p.get("title")
pid = p.get("id")
if pid in STOP_PANEL_IDS or title in STOP_PANEL_TITLES:
continue
filtered.append(p)
base_y = max_y(filtered) + 1
row = {
"id": 300,
"type": "row",
"title": "Informe de paros",
"collapsed": False,
"gridPos": {"x": 0, "y": base_y, "w": 24, "h": 1},
"panels": [],
}
panels = [
row,
stat_panel(
301,
"Paros abiertos",
0,
base_y + 1,
4,
4,
"""
SELECT COUNT(*) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND status = 'OPEN'
""",
),
stat_panel(
302,
"Pendientes clasificar",
4,
base_y + 1,
4,
4,
"""
SELECT COUNT(*) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'PENDING'
""",
),
stat_panel(
303,
"Nº paros",
8,
base_y + 1,
4,
4,
"""
SELECT COUNT(*) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
),
stat_panel(
304,
"Minutos paro",
12,
base_y + 1,
4,
4,
"""
SELECT COALESCE(SUM(duration_min), 0) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
unit="min",
),
stat_panel(
305,
"Coste paros",
16,
base_y + 1,
4,
4,
"""
SELECT COALESCE(SUM(cost_eur), 0) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
unit="currencyEUR",
),
stat_panel(
306,
"Duración media",
20,
base_y + 1,
4,
4,
"""
SELECT COALESCE(AVG(duration_min), 0) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
unit="min",
),
barchart_panel(
307,
"Pareto causas de paro",
0,
base_y + 5,
12,
8,
"""
SELECT
cause_name AS "Causa",
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos",
COUNT(*) AS "Nº paros",
ROUND(SUM(cost_eur)::numeric, 2) AS "Coste €"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
GROUP BY cause_name
ORDER BY SUM(duration_min) DESC
LIMIT 10
""",
unit="min",
),
timeseries_panel(
308,
"Minutos de paro por hora",
12,
base_y + 5,
12,
8,
"""
SELECT
date_trunc('hour', started_at) AS "time",
SUM(duration_min) AS "Minutos de paro"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
GROUP BY 1
ORDER BY 1
""",
unit="min",
),
table_panel(
309,
"Paros pendientes de clasificación",
0,
base_y + 13,
24,
6,
"""
SELECT
id AS "ID",
started_at AS "Inicio",
ROUND(duration_min::numeric, 2) AS "Min",
cost_eur AS "Coste €",
status AS "Estado",
classification_status AS "Clasificación",
cause_name AS "Causa",
operator_note AS "Nota operario"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'PENDING'
ORDER BY started_at DESC
""",
),
table_panel(
310,
"Detalle completo de paros",
0,
base_y + 19,
24,
10,
"""
SELECT
id AS "ID",
machine_id AS "Máquina",
started_at AS "Inicio",
ended_at AS "Fin",
duration_min AS "Min",
cost_eur AS "Coste €",
stop_status_label AS "Estado",
classification_status AS "Clasificación",
cause_code AS "Código causa",
cause_name AS "Causa",
start_event_name AS "Evento inicio",
end_event_name AS "Evento fin",
operator_note AS "Nota operario"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
ORDER BY started_at DESC
LIMIT 100
""",
),
]
dashboard["panels"] = filtered + panels
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Add stop report section to machine dashboard",
}
if meta.get("folderUid"):
payload["folderUid"] = meta["folderUid"]
result = api("POST", "/api/dashboards/db", payload)
print("Dashboard actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -0,0 +1,151 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
raise
def all_panels(panels):
out = []
for p in panels or []:
out.append(p)
out.extend(all_panels(p.get("panels", [])))
return out
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
dashboard = doc["dashboard"]
meta = doc.get("meta", {})
changed = 0
for panel in all_panels(dashboard.get("panels", [])):
title = panel.get("title", "")
if title == "Nº paros":
panel["title"] = "Nº paros en rango"
changed += 1
elif title == "Minutos paro":
panel["title"] = "Minutos paro en rango"
changed += 1
elif title == "Coste paros":
panel["title"] = "Coste paros en rango"
changed += 1
elif title == "Duración media":
panel["title"] = "Duración media en rango"
changed += 1
elif title == "Pareto causas de paro":
panel["title"] = "Pareto causas clasificadas"
for target in panel.get("targets", []):
target["rawSql"] = """
SELECT
cause_name AS "Causa",
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos",
COUNT(*) AS "Nº paros",
ROUND(SUM(cost_eur)::numeric, 2) AS "Coste €"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'CLASSIFIED'
GROUP BY cause_name
ORDER BY SUM(duration_min) DESC
LIMIT 10
"""
target["format"] = "table"
changed += 1
elif title == "Paros pendientes de clasificación":
# Dejamos este panel sin filtro temporal: es backlog operativo.
for target in panel.get("targets", []):
target["rawSql"] = """
SELECT
id AS "ID",
started_at AS "Inicio",
ROUND(duration_min::numeric, 2) AS "Min",
cost_eur AS "Coste €",
status AS "Estado",
classification_status AS "Clasificación",
cause_name AS "Causa",
operator_note AS "Nota operario"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'PENDING'
ORDER BY started_at DESC
"""
changed += 1
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Fix stop report pareto and clarify time-scoped panels",
}
if meta.get("folderUid"):
payload["folderUid"] = meta["folderUid"]
result = api("POST", "/api/dashboards/db", payload)
print(f"Paneles modificados: {changed}")
print("Dashboard actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
cd /srv/verticals/edge-oee-ucepsa-demo
echo "--- Regenerando datos Edge-OEE demo ---"
./ops/run_oee_demo_generator.sh
echo "--- Validando datos Edge-OEE demo ---"
./ops/validate_edge_oee_demo.sh
echo "--- Demo preparada ---"
echo "Abrir Grafana y usar rango:"
echo "From: now/d+7h"
echo "To: now/d+14h"

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
cd /srv/verticals/edge-oee-ucepsa-demo
docker-compose run --rm oee-demo-generator

View File

@ -0,0 +1,193 @@
\pset pager off
\echo '================================================'
\echo '1. VISTAS CREADAS'
\echo '================================================'
SELECT
to_regclass(
'mv_reports_ucepsa_prod.li_stop_context_slices_v1'
) AS slices_view,
to_regclass(
'mv_reports_ucepsa_prod.li_stop_candidates_context_v1'
) AS candidates_view,
to_regclass(
'mv_reports_ucepsa_prod.li_stop_groups_context_v1'
) AS groups_view,
to_regclass(
'mv_reports_ucepsa_prod.li_shadow_review_queue_context_v1'
) AS shadow_queue_view,
to_regclass(
'mv_reports_ucepsa_prod.li_stop_context_diagnostics_v1'
) AS diagnostics_view;
\echo '================================================'
\echo '2. INVARIANTES: TODAS DEBEN DEVOLVER 0'
\echo '================================================'
SELECT COUNT(*) AS invalid_slice_intervals
FROM mv_reports_ucepsa_prod.li_stop_context_slices_v1
WHERE segment_to <= segment_from
OR segment_duration_s <= 0;
SELECT COUNT(*) AS candidate_duration_overflow
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
WHERE context_segment_duration_s > raw_duration_s + 0.01
OR eligible_duration_s > context_segment_duration_s + 0.01
OR eligible_duration_s < 0;
SELECT COUNT(*) AS open_stops_marked_eligible
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
WHERE source_status = 'OPEN'
AND technically_eligible = true;
SELECT COUNT(*) AS official_rows_in_parallel_v1
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
WHERE official_ledger_eligible = true;
SELECT COUNT(*) AS official_group_rows_in_parallel_v1
FROM mv_reports_ucepsa_prod.li_stop_groups_context_v1
WHERE official_ledger_eligible = true;
\echo '================================================'
\echo '3. SEGURIDAD SHADOW Y COLA OFICIAL ANTERIOR'
\echo '================================================'
SELECT
mode,
actual_go_live_at
FROM mv_loss_intelligence.deployment_config
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND scope_code = 'CUTTERS'
ORDER BY id DESC
LIMIT 1;
SELECT COUNT(*) AS existing_official_operator_queue_rows
FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v2;
\echo '================================================'
\echo '4. POLÍTICA DE PAROS'
\echo '================================================'
SELECT
policy_code,
noise_below_s,
aggregate_below_s,
operator_individual_from_s,
aggregation_window_min,
require_valid_order,
require_closed_event,
require_telemetry_coverage,
telemetry_gap_s,
telemetry_tail_s
FROM mv_loss_intelligence.loss_policies
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND policy_code = 'CUTTER_STOPS_V1'
AND is_active = true
ORDER BY id DESC
LIMIT 1;
\echo '================================================'
\echo '5. DIAGNÓSTICO ÚLTIMAS 24 HORAS'
\echo '================================================'
SELECT *
FROM mv_reports_ucepsa_prod.li_stop_context_diagnostics_v1;
\echo '================================================'
\echo '6. CORT-00 — SESIÓN LEGACY 1'
\echo '================================================'
SELECT
source_stop_id,
source_status,
started_at,
ended_at,
ROUND(eligible_duration_s / 60.0, 2)
AS eligible_duration_min,
context_status,
primary_context_source,
primary_context_session_id,
eligibility_status,
technically_eligible,
shadow_capacity_loss_eur,
official_ledger_eligible
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
WHERE machine_id = 'CORT-00'
AND primary_context_session_id = 1
ORDER BY source_stop_id, started_at;
\echo '================================================'
\echo '7. CORT-02 — ORDEN WH/MO/00081'
\echo '================================================'
SELECT
source_stop_id,
source_status,
started_at,
ended_at,
ROUND(eligible_duration_s, 2) AS eligible_duration_s,
context_status,
primary_context_source,
order_ref,
eligibility_status,
technically_eligible,
shadow_capacity_loss_eur,
official_ledger_eligible
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
WHERE machine_id = 'CORT-02'
AND order_ref = 'WH/MO/00081'
ORDER BY source_stop_id, started_at;
\echo '================================================'
\echo '8. COLA SHADOW CONTEXT-AWARE ÚLTIMAS 24 HORAS'
\echo '================================================'
SELECT
shadow_rank,
source_key,
machine_id,
context_status,
primary_context_source,
primary_context_session_id,
order_ref,
event_count,
started_at,
ended_at,
duration_min,
capacity_loss_eur,
preliminary_priority,
queue_status,
official_ledger_eligible,
source_stop_ids
FROM mv_reports_ucepsa_prod.li_shadow_review_queue_context_v1
ORDER BY shadow_rank
LIMIT 30;
\echo '================================================'
\echo '9. CONFLICTOS RECIENTES'
\echo '================================================'
SELECT
source_stop_id,
machine_id,
started_at,
ended_at,
context_count,
context_sources,
eligibility_status
FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1
WHERE raw_started_at >= now() - interval '24 hours'
AND context_count > 1
ORDER BY machine_id, started_at;
\echo '================================================'
\echo '10. CONFIRMACIÓN DE NO MODIFICACIÓN'
\echo '================================================'
SELECT
'INFO' AS status,
'102 crea vistas paralelas; no reemplaza v2, no escribe en loss_ledger y no modifica Odoo, WISE, RevPi ni balizas.'
AS description;

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
docker exec -i mv_postgres_hot sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< /srv/verticals/edge-oee-ucepsa-demo/ops/validate_edge_oee_demo.sql

View File

@ -0,0 +1,42 @@
\echo '--- KPI CARDS ---'
SELECT *
FROM mv_hot.edge_oee_kpi_cards_demo
ORDER BY sort_order;
\echo '--- PARETO ---'
SELECT
ranking,
cause,
hours_month,
monthly_cost_eur,
recommended_action
FROM mv_hot.edge_oee_pareto_demo
ORDER BY ranking;
\echo '--- TIMELINE OF00481 ---'
SELECT
time,
time_end,
order_id,
machine_name,
source,
state,
detail,
duration_min,
cost_eur
FROM mv_hot.edge_oee_order_timeline_demo
WHERE order_id = 'OF00481'
ORDER BY time, sort_order;
\echo '--- DATA QUALITY ---'
SELECT *
FROM mv_hot.edge_oee_data_quality_dashboard_demo
ORDER BY sort_order;
\echo '--- ACTIONS ---'
SELECT *
FROM mv_hot.edge_oee_actions_demo;
\echo '--- QUALITY CASE ---'
SELECT *
FROM mv_hot.edge_oee_quality_case_demo;

View File

@ -0,0 +1,53 @@
\pset pager off
\echo '=== 1. Configuración de despliegue ==='
SELECT tenant, site, scope_code, mode, planned_go_live_date, actual_go_live_at,
review_owner_primary, review_owner_secondary, daily_review_limit
FROM mv_loss_intelligence.deployment_config
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise';
\echo '=== 2. Parámetros económicos ==='
SELECT parameter_code, parameter_value, unit, confidence, includes_energy,
valid_from, source_ref, approved_by
FROM mv_loss_intelligence.economic_parameters
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise' AND is_active = true
ORDER BY parameter_code, valid_from DESC;
\echo '=== 3. Calidad de dato ==='
SELECT * FROM mv_reports_ucepsa_prod.li_data_quality_v1 ORDER BY check_code;
\echo '=== 4. Intervalos Odoo más recientes ==='
SELECT machine_id, effective_from, effective_to, odoo_state,
order_present, order_coherent, production_order, product_id, product,
qty_production
FROM mv_reports_ucepsa_prod.li_order_state_intervals_v1
ORDER BY effective_from DESC
LIMIT 20;
\echo '=== 5. Resumen de elegibilidad en sombra ==='
SELECT *
FROM mv_reports_ucepsa_prod.li_shadow_summary_v1
ORDER BY machine_id, eligibility_status;
\echo '=== 6. Primeros candidatos de sombra ==='
SELECT shadow_rank, machine_id, order_ref, product_name, cause_name,
event_count, started_at, duration_min, capacity_loss_eur,
queue_status
FROM mv_reports_ucepsa_prod.li_shadow_review_queue_v1
ORDER BY shadow_rank
LIMIT 20;
\echo '=== 7. Cola oficial: debe estar vacía en SHADOW ==='
SELECT COUNT(*) AS official_operator_queue_rows
FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v1;
\echo '=== 8. Cobertura bolsas/golpe y cadencia ==='
SELECT *
FROM mv_reports_ucepsa_prod.li_product_cycle_coverage_v1
ORDER BY latest_stop_started_at DESC NULLS LAST;
\echo '=== 9. Catálogo de acciones ==='
SELECT action_id, source_cause_code, action_type, action_candidate,
suggested_owner, test_window, success_criteria
FROM mv_loss_intelligence.action_catalog
WHERE catalog_code = 'LI-CUTTER-STOPS' AND is_active = true
ORDER BY source_cause_code, action_id;

View File

@ -0,0 +1,73 @@
\pset pager off
\echo '=== 1. Política telemetry-aware ==='
SELECT policy_code, require_telemetry_coverage, telemetry_gap_s, telemetry_tail_s,
noise_below_s, operator_individual_from_s
FROM mv_loss_intelligence.loss_policies
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise' AND policy_code = 'CUTTER_STOPS_V1';
\echo '=== 2. Estado operativo power-aware ==='
SELECT machine_id, operational_state, machine_state_label, telemetry_fresh,
round(age_s::numeric, 1) AS age_s, machine_running,
order_present, order_coherent, production_order
FROM mv_reports_ucepsa_prod.machine_operational_state_v1
ORDER BY machine_id;
\echo '=== 3. Islas de telemetría más recientes ==='
SELECT machine_id, coverage_from, coverage_to, sample_count, sampled_span_s
FROM mv_reports_ucepsa_prod.li_telemetry_coverage_v1
ORDER BY coverage_to DESC
LIMIT 15;
\echo '=== 4. Invariantes temporales: debe devolver 0 ==='
SELECT COUNT(*) AS invalid_slice_rows
FROM mv_reports_ucepsa_prod.li_stop_eligible_slices_v2
WHERE eligible_from < order_overlap_from
OR eligible_to > order_overlap_to
OR eligible_from < telemetry_coverage_from
OR eligible_to > telemetry_coverage_to
OR eligible_to <= eligible_from
OR eligible_duration_s <= 0;
\echo '=== 5. Duración elegible nunca supera la raw: debe devolver 0 ==='
SELECT COUNT(*) AS duration_overflow_rows
FROM mv_reports_ucepsa_prod.li_stop_candidates_v2
WHERE eligible_duration_s > raw_duration_s + 0.01
OR eligible_duration_s > order_overlap_duration_s + 0.01;
\echo '=== 6. Comparación v0.1 frente a v0.2 ==='
SELECT * FROM mv_reports_ucepsa_prod.li_eligibility_comparison_v1;
\echo '=== 7. Resumen v0.2 ==='
SELECT *
FROM mv_reports_ucepsa_prod.li_shadow_summary_v2
ORDER BY machine_id, eligibility_status;
\echo '=== 8. Primeros candidatos power-aware ==='
SELECT shadow_rank, machine_id, order_ref, product_name, event_count,
started_at, ended_at, duration_min, capacity_loss_eur,
order_clipped, telemetry_clipped, crosses_order_boundary, queue_status
FROM mv_reports_ucepsa_prod.li_shadow_review_queue_v2
ORDER BY shadow_rank
LIMIT 25;
\echo '=== 9. Diagnóstico de exclusiones ==='
SELECT diagnostic_status, machine_id, COUNT(*) AS stop_count,
ROUND(SUM(raw_duration_min)::numeric, 2) AS raw_minutes
FROM mv_reports_ucepsa_prod.li_stop_exclusion_diagnostics_v2
GROUP BY diagnostic_status, machine_id
ORDER BY machine_id, diagnostic_status;
\echo '=== 10. Cola oficial v2: debe estar vacía en SHADOW ==='
SELECT COUNT(*) AS official_operator_queue_v2_rows
FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v2;
\echo '=== 11. Calidad de dato v2 ==='
SELECT * FROM mv_reports_ucepsa_prod.li_data_quality_v2
ORDER BY check_code;
\echo '=== 12. Candidatos v2 de más de 12 h para revisión manual ==='
SELECT shadow_rank, machine_id, order_ref, started_at, ended_at,
duration_min, telemetry_clipped, order_clipped
FROM mv_reports_ucepsa_prod.li_shadow_review_queue_v2
WHERE duration_min >= 720
ORDER BY duration_min DESC;

View File

@ -0,0 +1,128 @@
\pset pager off
\echo '=== 1. Política de contexto ==='
SELECT
policy_code,
activity_gap_s,
activity_tail_s,
stale_open_context_s,
allow_legacy_context,
require_legacy_authorization,
allow_inferred_for_official
FROM mv_loss_intelligence.production_context_policies
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
ORDER BY id DESC;
\echo '=== 2. Seguridad SHADOW ==='
SELECT
mode,
actual_go_live_at
FROM mv_loss_intelligence.deployment_config
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND scope_code = 'CUTTERS';
SELECT count(*) AS official_context_sessions_in_shadow
FROM mv_loss_intelligence.production_context_sessions s
JOIN mv_loss_intelligence.deployment_config d
ON d.tenant = s.tenant
AND d.site = s.site
AND d.scope_code = 'CUTTERS'
WHERE d.mode = 'SHADOW'
AND s.official_eligible = true;
\echo '=== 3. Estado actual de migración ==='
SELECT
machine_id,
operational_state,
migration_context_state,
migration_context_label,
telemetry_fresh,
machine_running,
order_present,
order_coherent,
production_order,
explicit_context_source,
explicit_order_ref,
explicit_context_started_at
FROM mv_reports_ucepsa_prod.machine_migration_context_state_v1
ORDER BY machine_id;
\echo '=== 4. Contextos explícitos ==='
SELECT
context_session_id,
machine_id,
source_type,
source_ref,
external_order_ref,
product_name,
operator_employee_id,
operator_name,
started_at,
ended_at,
status,
authorization_status,
confidence,
official_eligible,
authorized_by,
closed_by
FROM mv_loss_intelligence.production_context_sessions
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
ORDER BY context_session_id DESC
LIMIT 30;
\echo '=== 5. Invariantes de actividad: deben devolver 0 ==='
SELECT count(*) AS invalid_activity_rows
FROM mv_reports_ucepsa_prod.li_machine_running_activity_v1
WHERE activity_to <= activity_from
OR activity_duration_s <= 0;
SELECT count(*) AS invalid_context_segment_rows
FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1
WHERE segment_to <= segment_from
OR segment_duration_s <= 0;
\echo '=== 6. Los segmentos no superan la actividad: debe devolver 0 ==='
WITH totals AS (
SELECT
machine_id,
activity_island_id,
max(activity_duration_s) AS activity_duration_s,
sum(segment_duration_s) AS segmented_duration_s
FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1
GROUP BY machine_id, activity_island_id
)
SELECT count(*) AS segment_overflow_rows
FROM totals
WHERE segmented_duration_s > activity_duration_s + 0.01;
\echo '=== 7. Cobertura de contexto últimos 30 días ==='
SELECT *
FROM mv_reports_ucepsa_prod.li_production_context_coverage_v1
ORDER BY machine_id, hours DESC;
\echo '=== 8. Cola de revisión de contexto ==='
SELECT
review_rank,
context_review_key,
machine_id,
started_at,
ended_at,
duration_min,
context_status,
allowed_decisions
FROM mv_reports_ucepsa_prod.li_context_review_queue_v1
ORDER BY review_rank
LIMIT 30;
\echo '=== 9. Calidad de dato v3 ==='
SELECT *
FROM mv_reports_ucepsa_prod.li_data_quality_v3
ORDER BY check_code;
\echo '=== 10. Las balizas no forman parte de esta migración ==='
SELECT
'INFO' AS status,
'099/100 no modifican beacon_controller, order_state publisher ni lógica DO0/DO1.' AS description;

View File

@ -0,0 +1,93 @@
--- /mnt/data/ucepsa_repo/40-clients/ucepsa/edge-oee-demo/tools/order_state_pg_sink.py 2026-07-16 09:11:42.000000000 +0000
+++ /mnt/data/ucepsa_lossintelligence_patch/tools/order_state_pg_sink.py 2026-07-16 10:10:45.826794132 +0000
@@ -67,8 +67,15 @@
"odoo_state": payload.get("odoo_state"),
"reason": payload.get("reason"),
"workorder_id": payload.get("workorder_id"),
+ "production_id": payload.get("production_id"),
"production_order": payload.get("production_order"),
+ "product_id": payload.get("product_id"),
"product": payload.get("product"),
+ "qty_production": payload.get("qty_production"),
+ "date_start": payload.get("date_start"),
+ "date_finished": payload.get("date_finished"),
+ "workorder_name": payload.get("workorder_name"),
+ "workorder_state": payload.get("workorder_state"),
"waiting_count": diagnostics.get("waiting_count"),
"ready_count": diagnostics.get("ready_count"),
"progress_count": diagnostics.get("progress_count"),
@@ -133,8 +140,15 @@
"odoo_state": payload.get("odoo_state"),
"reason": payload.get("reason"),
"workorder_id": str(payload.get("workorder_id")) if payload.get("workorder_id") is not None else None,
+ "workorder_name": payload.get("workorder_name"),
+ "workorder_state": payload.get("workorder_state"),
+ "production_id": to_int(payload.get("production_id")),
"production_order": payload.get("production_order"),
+ "product_id": to_int(payload.get("product_id")),
"product": payload.get("product"),
+ "qty_production": payload.get("qty_production"),
+ "date_start": parse_ts(payload.get("date_start")),
+ "date_finished": parse_ts(payload.get("date_finished")),
"waiting_count": to_int(diagnostics.get("waiting_count")),
"ready_count": to_int(diagnostics.get("ready_count")),
"progress_count": to_int(diagnostics.get("progress_count")),
@@ -149,7 +163,9 @@
tenant, site, machine_id, topic, mqtt_ts, received_at,
source, workcenter_id, workcenter_name, workcenter_display_name,
order_present, order_coherent, odoo_state, reason,
- workorder_id, production_order, product,
+ workorder_id, workorder_name, workorder_state,
+ production_id, production_order, product_id, product,
+ qty_production, date_start, date_finished,
waiting_count, ready_count, progress_count,
payload_hash, payload_json
)
@@ -157,7 +173,9 @@
%(tenant)s, %(site)s, %(machine_id)s, %(topic)s, %(mqtt_ts)s, now(),
%(source)s, %(workcenter_id)s, %(workcenter_name)s, %(workcenter_display_name)s,
%(order_present)s, %(order_coherent)s, %(odoo_state)s, %(reason)s,
- %(workorder_id)s, %(production_order)s, %(product)s,
+ %(workorder_id)s, %(workorder_name)s, %(workorder_state)s,
+ %(production_id)s, %(production_order)s, %(product_id)s, %(product)s,
+ %(qty_production)s, %(date_start)s, %(date_finished)s,
%(waiting_count)s, %(ready_count)s, %(progress_count)s,
%(payload_hash)s, %(payload_json)s
)
@@ -175,8 +193,15 @@
odoo_state = EXCLUDED.odoo_state,
reason = EXCLUDED.reason,
workorder_id = EXCLUDED.workorder_id,
+ workorder_name = EXCLUDED.workorder_name,
+ workorder_state = EXCLUDED.workorder_state,
+ production_id = EXCLUDED.production_id,
production_order = EXCLUDED.production_order,
+ product_id = EXCLUDED.product_id,
product = EXCLUDED.product,
+ qty_production = EXCLUDED.qty_production,
+ date_start = EXCLUDED.date_start,
+ date_finished = EXCLUDED.date_finished,
waiting_count = EXCLUDED.waiting_count,
ready_count = EXCLUDED.ready_count,
progress_count = EXCLUDED.progress_count,
@@ -191,14 +216,18 @@
INSERT INTO mv_hot.odoo_order_state_events (
tenant, site, machine_id, topic, mqtt_ts, received_at,
odoo_state, order_present, order_coherent,
- production_order, product, reason,
+ workorder_id, workorder_name, workorder_state,
+ production_id, production_order, product_id, product,
+ qty_production, date_start, date_finished, reason,
waiting_count, ready_count, progress_count,
payload_hash, payload_json
)
VALUES (
%(tenant)s, %(site)s, %(machine_id)s, %(topic)s, %(mqtt_ts)s, now(),
%(odoo_state)s, %(order_present)s, %(order_coherent)s,
- %(production_order)s, %(product)s, %(reason)s,
+ %(workorder_id)s, %(workorder_name)s, %(workorder_state)s,
+ %(production_id)s, %(production_order)s, %(product_id)s, %(product)s,
+ %(qty_production)s, %(date_start)s, %(date_finished)s, %(reason)s,
%(waiting_count)s, %(ready_count)s, %(progress_count)s,
%(payload_hash)s, %(payload_json)s
)

View File

@ -0,0 +1,30 @@
MQTT_HOST=17.126.1.70
MQTT_PORT=1883
MQTT_USER=revpi_ucepsa_edge_oee_01
MQTT_PASSWORD=***REDACTED***
MQTT_CLIENT_ID=ucepsa_edge_oee_revpi_cutters_node_01
TENANT=ucepsa
SITE=ucepsa_test
VERTICAL=edge_oee
ASSET=revpi_oee_node_01
MQTT_TOPIC_TELEMETRY=vertical/ucepsa/ucepsa_test/edge_oee/revpi_oee_node_01/telemetry
MQTT_TOPIC_STATUS=vertical/ucepsa/ucepsa_test/edge_oee/revpi_oee_node_01/status
MQTT_TOPIC_EVENT=vertical/ucepsa/ucepsa_test/edge_oee/revpi_oee_node_01/event
MODBUS_PORT=/dev/ttyRS485
MODBUS_SLAVE=1
MODBUS_BAUD=9600
MODBUS_PARITY=N
MODBUS_STOPBITS=1
MODBUS_BYTESIZE=8
MODBUS_TIMEOUT=1.5
DI_AUTO_INPUT=I_1
DI_CYCLE_INPUT=I_2
DI_DEBOUNCE_MS=50
CYCLE_RATE_WINDOW_SECONDS=60
PUBLISH_INTERVAL_SECONDS=10
MACHINE_ID=CORT-01

View File

@ -0,0 +1,41 @@
# RevPi Edge-OEE Inventory
## Identificación
Hostname:
IP:
Modelo RevPi:
Sistema operativo:
Kernel:
## Red
Interfaz Ethernet:
IP:
Gateway:
DNS:
## Serie / RS-485
Dispositivos detectados:
- /dev/...
## Uso previsto
Captura industrial real para demo MESAVAULT Edge-OEE.
Primera señal objetivo:
- Eastron Modbus RTU
- lectura de tensión
- corriente
- potencia
- kWh

View File

@ -0,0 +1,474 @@
#!/usr/bin/env python3
import json
import os
import time
from datetime import datetime
from pathlib import Path
import paho.mqtt.client as mqtt
from pymodbus.client import ModbusTcpClient
DO0_BLUE_COIL = 16
DO1_RED_COIL = 17
TELEMETRY_TTL_S = 30
ORDER_TTL_S = 30
BLINK_PERIOD_S = 1.0
OFFLINE_LOG_INTERVAL_S = 30
WISE_NODES_FILE = Path("wise_nodes.json")
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
def now_ts():
return time.time()
def load_nodes():
data = json.loads(WISE_NODES_FILE.read_text())
nodes = {}
for node in data.get("nodes", []):
if not node.get("enabled", True):
continue
machine_id = node["machine_id"]
host = (
node.get("wise_ip")
or node.get("wise_host")
or node.get("host")
or node.get("ip")
)
if not host:
raise RuntimeError(f"Nodo sin IP: {node}")
nodes[machine_id] = {
"machine_id": machine_id,
"source_node": node.get("source_node", ""),
"host": host,
"port": int(node.get("wise_port", 502)),
"unit": int(node.get("unit", 1)),
}
return nodes
def bool_from_payload(value):
return bool(value)
def order_is_fresh(order):
if not order:
return False
return (now_ts() - order.get("_rx_ts", 0)) <= ORDER_TTL_S
def telemetry_is_fresh(telemetry):
if not telemetry:
return False
return (now_ts() - telemetry.get("_rx_ts", 0)) <= TELEMETRY_TTL_S
class WiseOutputs:
def __init__(self, nodes):
self.nodes = nodes
self.clients = {}
self.last_written = {}
self.last_error_ts = {}
self.offline_until = {}
self.offline_retry_s = float(os.getenv("WISE_OFFLINE_RETRY_S", "15"))
def get_client(self, machine_id):
client = self.clients.get(machine_id)
if client is not None:
return client
node = self.nodes[machine_id]
client = ModbusTcpClient(node["host"], port=node["port"], timeout=2)
if not client.connect():
try:
client.close()
except Exception:
pass
raise RuntimeError(f"No conecta con {machine_id} {node['host']}:{node['port']}")
self.clients[machine_id] = client
return client
def close_client(self, machine_id):
client = self.clients.pop(machine_id, None)
if client is not None:
try:
client.close()
except Exception:
pass
def in_offline_backoff(self, machine_id):
return now_ts() < self.offline_until.get(machine_id, 0)
def mark_offline(self, machine_id):
self.offline_until[machine_id] = now_ts() + self.offline_retry_s
self.last_written.pop(machine_id, None)
def write_coil(self, machine_id, address, value):
node = self.nodes[machine_id]
client = self.get_client(machine_id)
rr = client.write_coil(
address=address,
value=bool(value),
device_id=node["unit"],
)
if rr.isError():
self.close_client(machine_id)
raise RuntimeError(f"Error escribiendo {machine_id} coil {address}: {rr}")
def set_beacon(self, machine_id, blue_on, red_on):
do0 = bool(blue_on)
# Roja con contacto NC:
# rojo ON -> DO1 OFF
# rojo OFF -> DO1 ON
do1 = not bool(red_on)
current = (do0, do1)
previous = self.last_written.get(machine_id)
if previous == current:
return
self.write_coil(machine_id, DO0_BLUE_COIL, do0)
self.write_coil(machine_id, DO1_RED_COIL, do1)
self.last_written[machine_id] = current
self.offline_until.pop(machine_id, None)
if os.getenv("BEACON_LOG_WRITES", "0").lower() in ("1", "true", "yes", "on"):
print(
f"BEACON {machine_id} "
f"blue_on={blue_on} red_on={red_on} "
f"DO0={do0} DO1={do1}",
flush=True,
)
def log_offline_error(self, machine_id, reason):
now = now_ts()
last = self.last_error_ts.get(machine_id, 0)
if now - last >= OFFLINE_LOG_INTERVAL_S:
self.last_error_ts[machine_id] = now
print(f"WISE_OFFLINE {machine_id} {reason}", flush=True)
def safe_connected_only(self):
for machine_id in list(self.clients.keys()):
try:
self.set_beacon(machine_id, blue_on=False, red_on=True)
except Exception as e:
print(f"ERROR dejando safe {machine_id}: {e}", flush=True)
self.close_client(machine_id)
def close_all(self):
for machine_id in list(self.clients.keys()):
self.close_client(machine_id)
def mode_to_bool(mode, blink_on):
if mode == "ON":
return True
if mode == "OFF":
return False
if mode == "BLINK":
return blink_on
return False
def decide_modes(machine_id, telemetry, order):
"""
Devuelve:
blue_mode, red_mode, reason, should_write
should_write=False significa:
máquina probablemente sin corriente y sin orden cargada.
No intentamos escribir en WISE para evitar ruido.
"""
tel_fresh = telemetry_is_fresh(telemetry)
ord_fresh = order_is_fresh(order)
order_present = False
order_coherent = False
force_machine_fault = False
if ord_fresh:
order_present = bool_from_payload(order.get("order_present", False))
order_coherent = bool_from_payload(order.get("order_coherent", False))
force_machine_fault = bool_from_payload(order.get("force_machine_fault", False))
order_valid_loaded = order_present and order_coherent
if not tel_fresh and not order_present:
return "OFF", "OFF", "machine_power_off_or_wise_offline_without_order", False
if not tel_fresh and order_valid_loaded:
return "ON", "BLINK", "order_loaded_but_no_telemetry_or_wise_offline", True
if not tel_fresh and order_present and not order_coherent:
return "BLINK", "BLINK", "odoo_incoherent_but_no_telemetry_or_wise_offline", True
comm_ok = bool_from_payload(telemetry.get("comm_ok", False))
sample_valid = bool_from_payload(telemetry.get("sample_valid", False))
if not comm_ok or not sample_valid:
return "OFF", "BLINK", "telemetry_invalid", True
machine_running = bool_from_payload(telemetry.get("machine_running", False))
last_cycle_age_s = telemetry.get("last_cycle_age_s")
machine_fault = force_machine_fault
if machine_running and isinstance(last_cycle_age_s, (int, float)) and last_cycle_age_s > 20:
machine_fault = True
odoo_incoherent = False
if machine_running and not order_valid_loaded:
odoo_incoherent = True
if order_present and not order_coherent:
odoo_incoherent = True
if machine_fault and odoo_incoherent:
return "BLINK", "BLINK", "machine_fault_and_odoo_incoherent", True
if machine_fault and order_valid_loaded:
return "ON", "BLINK", "machine_fault_with_valid_order", True
if odoo_incoherent and machine_running:
return "BLINK", "OFF", "machine_running_without_valid_odoo_order", True
if odoo_incoherent and not machine_running:
return "BLINK", "ON", "odoo_incoherent_machine_stopped", True
if order_valid_loaded and machine_running:
return "ON", "OFF", "valid_order_and_machine_running", True
if order_valid_loaded and not machine_running:
return "ON", "ON", "valid_order_and_machine_stopped", True
return "OFF", "ON", "no_valid_order_machine_stopped", True
def main():
env = read_env()
nodes = load_nodes()
machine_filter_raw = os.getenv("BEACON_MACHINE_FILTER", "").strip()
if machine_filter_raw:
allowed = {x.strip() for x in machine_filter_raw.split(",") if x.strip()}
nodes = {mid: node for mid, node in nodes.items() if mid in allowed}
if not nodes:
raise RuntimeError(
f"BEACON_MACHINE_FILTER={machine_filter_raw} no coincide con ningún nodo"
)
print(
f"Filtro de máquinas activo: {', '.join(nodes.keys())}",
flush=True,
)
mqtt_host = (
os.getenv("MQTT_HOST_OVERRIDE")
or env.get("MQTT_HOST")
or "127.0.0.1"
)
mqtt_port = int(
os.getenv("MQTT_PORT_OVERRIDE")
or env.get("MQTT_PORT")
or "1883"
)
mqtt_user = (
os.getenv("MQTT_USER_OVERRIDE")
or os.getenv("MQTT_USERNAME_OVERRIDE")
or env.get("MQTT_USER")
or env.get("MQTT_USERNAME")
or env.get("MQTT_USER_REVPI")
)
mqtt_password = (
os.getenv("MQTT_PASSWORD_OVERRIDE")
or os.getenv("MQTT_PASS_OVERRIDE")
or env.get("MQTT_PASSWORD")
or env.get("MQTT_PASS")
or env.get("MQTT_PASSWORD_REVPI")
)
tenant = env.get("TENANT", "ucepsa")
site = env.get("SITE", "ucepsa_onpremise")
vertical = env.get("VERTICAL", "edge_oee")
telemetry_topic = f"vertical/{tenant}/{site}/{vertical}/+/telemetry"
order_topic = f"vertical/{tenant}/{site}/{vertical}/order_state/+"
latest_telemetry = {}
latest_order = {}
last_logged_order = {}
outputs = WiseOutputs(nodes)
def on_connect(client, userdata, flags, rc, properties=None):
print(f"MQTT conectado rc={rc}", flush=True)
if rc == 0:
client.subscribe(telemetry_topic)
client.subscribe(order_topic)
print(f"SUB {telemetry_topic}", flush=True)
print(f"SUB {order_topic}", flush=True)
def on_disconnect(client, userdata, rc, properties=None):
print(f"MQTT desconectado rc={rc}", flush=True)
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload.decode("utf-8"))
payload["_rx_ts"] = now_ts()
topic = msg.topic
if "/order_state/" in topic:
machine_id = payload.get("machine_id") or topic.rsplit("/", 1)[-1]
latest_order[machine_id] = payload
diagnostics = payload.get("diagnostics") or {}
order_signature = (
bool(payload.get("order_present", False)),
bool(payload.get("order_coherent", False)),
payload.get("odoo_state"),
payload.get("workorder_id"),
payload.get("production_order"),
payload.get("product"),
diagnostics.get("waiting_count"),
diagnostics.get("ready_count"),
diagnostics.get("progress_count"),
)
if last_logged_order.get(machine_id) != order_signature:
last_logged_order[machine_id] = order_signature
print(
f"ORDER_CHANGE {machine_id} "
f"present={payload.get('order_present')} "
f"coherent={payload.get('order_coherent')} "
f"state={payload.get('odoo_state')} "
f"mo={payload.get('production_order')} "
f"product={payload.get('product')} "
f"diag={diagnostics}",
flush=True,
)
elif topic.endswith("/telemetry"):
machine_id = payload.get("machine_id")
if machine_id:
latest_telemetry[machine_id] = payload
except Exception as e:
print(f"ERROR procesando MQTT {msg.topic}: {e}", flush=True)
client = mqtt.Client(client_id="mesavault_beacon_controller_power_aware")
if mqtt_user:
client.username_pw_set(mqtt_user, mqtt_password)
print(f"MQTT auth user={mqtt_user}", flush=True)
else:
print("WARN: MQTT sin usuario", flush=True)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
client.connect(mqtt_host, mqtt_port, 60)
client.loop_start()
print("Controlador de balizas power-aware arrancado", flush=True)
print(f"Máquinas: {', '.join(nodes.keys())}", flush=True)
print("Ctrl+C para parar", flush=True)
last_reason = {}
try:
while True:
blink_on = int(time.time() / BLINK_PERIOD_S) % 2 == 0
for machine_id in nodes:
telemetry = latest_telemetry.get(machine_id)
order = latest_order.get(machine_id)
blue_mode, red_mode, reason, should_write = decide_modes(
machine_id,
telemetry,
order,
)
if last_reason.get(machine_id) != reason:
last_reason[machine_id] = reason
print(
f"STATE {machine_id} "
f"blue={blue_mode} red={red_mode} "
f"write={should_write} reason={reason}",
flush=True,
)
if not should_write:
continue
blue_on = mode_to_bool(blue_mode, blink_on)
red_on = mode_to_bool(red_mode, blink_on)
if outputs.in_offline_backoff(machine_id):
continue
try:
outputs.set_beacon(
machine_id,
blue_on=blue_on,
red_on=red_on,
)
except Exception as e:
outputs.close_client(machine_id)
outputs.mark_offline(machine_id)
outputs.log_offline_error(machine_id, str(e))
time.sleep(0.5)
except KeyboardInterrupt:
print("Parando controlador de balizas", flush=True)
finally:
outputs.safe_connected_only()
outputs.close_all()
client.loop_stop()
client.disconnect()
print("Controlador parado", flush=True)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,182 @@
#!/usr/bin/env python3
import json
import sys
import time
from pathlib import Path
from pymodbus.client import ModbusTcpClient
UNIT = 1
DO0_BLUE_COIL = 16
DO1_RED_COIL = 17
WISE_NODES_FILE = Path("wise_nodes.json")
def load_nodes():
data = json.loads(WISE_NODES_FILE.read_text())
nodes = data.get("nodes", [])
result = {}
for node in nodes:
machine_id = node.get("machine_id")
host = node.get("wise_ip") or node.get("wise_host") or node.get("host") or node.get("ip")
if machine_id and host:
result[machine_id] = {
"machine_id": machine_id,
"source_node": node.get("source_node", ""),
"host": host,
}
return result
def write_coil_checked(client, address, value):
rr = client.write_coil(address=address, value=value, device_id=UNIT)
if rr.isError():
raise RuntimeError(f"Error escribiendo coil {address}: {rr}")
def read_outputs(client):
rr = client.read_coils(address=DO0_BLUE_COIL, count=2, device_id=UNIT)
if rr.isError():
raise RuntimeError(f"Error leyendo coils DO: {rr}")
do0 = bool(rr.bits[0])
do1 = bool(rr.bits[1])
blue_on = do0
red_on = not do1
return do0, do1, blue_on, red_on
def set_beacon(client, blue_on, red_on):
# DO0 gobierna azul directamente.
write_coil_checked(client, DO0_BLUE_COIL, bool(blue_on))
# DO1 gobierna roja invertida por contacto NC.
# red_on=True -> DO1 False
# red_on=False -> DO1 True
write_coil_checked(client, DO1_RED_COIL, not bool(red_on))
def print_state(client, label):
do0, do1, blue_on, red_on = read_outputs(client)
print(
f"{label}: "
f"DO0_BLUE={do0} DO1_RED={do1} "
f"=> blue_on={blue_on} red_on={red_on}"
)
def apply_mode(client, mode, seconds):
blink_period = 1.0
end = time.time() + seconds
if mode == "safe":
set_beacon(client, blue_on=False, red_on=True)
print_state(client, "safe")
time.sleep(seconds)
elif mode == "ok_running":
set_beacon(client, blue_on=True, red_on=False)
print_state(client, "ok_running")
time.sleep(seconds)
elif mode == "ok_stopped":
set_beacon(client, blue_on=True, red_on=True)
print_state(client, "ok_stopped")
time.sleep(seconds)
elif mode == "odoo_bad_running":
print("odoo_bad_running: azul intermitente, rojo apagado")
while time.time() < end:
set_beacon(client, blue_on=True, red_on=False)
time.sleep(blink_period)
set_beacon(client, blue_on=False, red_on=False)
time.sleep(blink_period)
elif mode == "machine_fault":
print("machine_fault: azul fijo, rojo intermitente")
while time.time() < end:
set_beacon(client, blue_on=True, red_on=True)
time.sleep(blink_period)
set_beacon(client, blue_on=True, red_on=False)
time.sleep(blink_period)
elif mode == "odoo_bad_and_fault":
print("odoo_bad_and_fault: azul intermitente, rojo intermitente")
while time.time() < end:
set_beacon(client, blue_on=True, red_on=True)
time.sleep(blink_period)
set_beacon(client, blue_on=False, red_on=False)
time.sleep(blink_period)
elif mode == "system_fault":
print("system_fault: azul apagado, rojo intermitente")
while time.time() < end:
set_beacon(client, blue_on=False, red_on=True)
time.sleep(blink_period)
set_beacon(client, blue_on=False, red_on=False)
time.sleep(blink_period)
else:
raise ValueError(f"Modo no reconocido: {mode}")
def test_machine(machine_id, host, mode, seconds):
print(f"Probando {machine_id} en {host} modo={mode} durante {seconds}s")
client = ModbusTcpClient(host, port=502, timeout=3)
if not client.connect():
raise RuntimeError(f"No conecta con {machine_id} {host}:502")
try:
apply_mode(client, mode, seconds)
print_state(client, "estado final antes de safe")
# Dejamos siempre estado seguro al terminar la prueba manual.
set_beacon(client, blue_on=False, red_on=True)
print_state(client, "safe final")
finally:
client.close()
def main():
if len(sys.argv) < 3:
print("Uso:")
print(" python beacon_manual_test.py CORT-00 safe 5")
print(" python beacon_manual_test.py CORT-00 ok_running 5")
print(" python beacon_manual_test.py CORT-00 ok_stopped 5")
print(" python beacon_manual_test.py CORT-00 odoo_bad_running 10")
print(" python beacon_manual_test.py CORT-00 machine_fault 10")
print(" python beacon_manual_test.py CORT-00 odoo_bad_and_fault 10")
print(" python beacon_manual_test.py CORT-00 system_fault 10")
print(" python beacon_manual_test.py all safe 5")
raise SystemExit(1)
target = sys.argv[1]
mode = sys.argv[2]
seconds = float(sys.argv[3]) if len(sys.argv) >= 4 else 5.0
nodes = load_nodes()
if target == "all":
for machine_id, node in nodes.items():
test_machine(machine_id, node["host"], mode, seconds)
return
if target not in nodes:
raise SystemExit(f"No encuentro {target} en wise_nodes.json")
node = nodes[target]
test_machine(target, node["host"], mode, seconds)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
echo "--- HOSTNAME ---"
hostname
echo "--- OS ---"
cat /etc/os-release || true
echo "--- KERNEL ---"
uname -a
echo "--- IP ---"
ip -br a
echo "--- ROUTES ---"
ip route
echo "--- SERIAL DEVICES ---"
ls -l /dev/tty* | head -n 80
echo "--- DMESG TTY ---"
dmesg | grep -i tty || true
echo "--- USB ---"
lsusb || true

View File

@ -0,0 +1,62 @@
#!/usr/bin/env python3
import json
from pathlib import Path
import paho.mqtt.client as mqtt
def read_env(path=".env"):
env = {}
p = Path(path)
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
def load_machine_ids():
data = json.loads(Path("wise_nodes.json").read_text())
return [
n["machine_id"]
for n in data.get("nodes", [])
if n.get("enabled", True)
]
env = read_env()
mqtt_host = env.get("MQTT_HOST", "127.0.0.1")
mqtt_port = int(env.get("MQTT_PORT", "1883"))
mqtt_user = env.get("MQTT_USERNAME") or env.get("MQTT_USER")
mqtt_password = env.get("MQTT_PASSWORD")
tenant = env.get("TENANT", "ucepsa")
site = env.get("SITE", "ucepsa_onpremise")
vertical = env.get("VERTICAL", "edge_oee")
client = mqtt.Client(client_id="clear_order_state_retained")
if mqtt_user:
client.username_pw_set(mqtt_user, mqtt_password)
client.connect(mqtt_host, mqtt_port, 60)
client.loop_start()
for machine_id in load_machine_ids():
topic = f"vertical/{tenant}/{site}/{vertical}/order_state/{machine_id}"
# Payload vacío + retain=True borra el mensaje retenido del broker.
info = client.publish(topic, payload=b"", qos=0, retain=True)
info.wait_for_publish(timeout=5)
print(f"Retained eliminado: {topic}")
client.loop_stop()
client.disconnect()
print("OK: estados simulados de Odoo eliminados")

View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
import struct
from pymodbus.client import ModbusTcpClient
WISE_IP = "17.126.1.201"
UNIT = 1
def f32_abcd(hi, lo):
raw = hi.to_bytes(2, "big") + lo.to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_holding(client, start, count, label):
rr = client.read_holding_registers(address=start, count=count, device_id=UNIT)
if rr.isError():
print(f"{label}: ERROR {rr}")
return None
print(f"{label}: {rr.registers}")
return rr.registers
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
if not client.connect():
raise SystemExit(f"No conecta con WISE {WISE_IP}:502")
try:
print("CORT-01 / wise-cort-01 /", WISE_IP)
data = read_holding(client, 1000, 20, "DATA 1000..1019")
bit_err = read_holding(client, 1100, 20, "BIT ERR 1100..1119")
word_err = read_holding(client, 1200, 20, "WORD ERR 1200..1219")
if data:
print("\nValores float interpretados:")
labels = [
("voltage_l1_v", 0),
("current_l1_a", 2),
("active_power_w", 4),
("apparent_power_va", 6),
("reactive_power_var", 8),
("power_factor", 10),
("frequency_hz", 12),
("import_kwh", 14),
("export_kwh", 16),
("total_active_energy_kwh", 18),
]
for name, offset in labels:
print(f"{name:28s}: {f32_abcd(data[offset], data[offset+1]):.6f}")
finally:
client.close()

View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
import time
import signal
import sys
from collections import deque
import revpimodio2
RUNNING = True
def handle_signal(signum, frame):
global RUNNING
RUNNING = False
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
def main():
rpi = revpimodio2.RevPiModIO(autorefresh=True)
auto_io = getattr(rpi.io, "I_1")
cycle_io = getattr(rpi.io, "I_2")
cycle_pulse_count = 0
previous_cycle = bool(cycle_io.value)
last_edge_ts = 0.0
last_cycle_ts = None
debounce_s = 0.05
pulse_window_s = 60.0
recent_pulses = deque()
print("[START] DI probe")
print("I_1 = machine_auto_signal")
print("I_2 = cycle_pulse")
print("Pulsa CTRL+C para salir")
try:
last_print = 0.0
while RUNNING:
now = time.monotonic()
machine_auto_signal = bool(auto_io.value)
current_cycle = bool(cycle_io.value)
# Flanco ascendente en I_2
if current_cycle and not previous_cycle:
if now - last_edge_ts >= debounce_s:
cycle_pulse_count += 1
last_edge_ts = now
last_cycle_ts = now
recent_pulses.append(now)
previous_cycle = current_cycle
# Limpiar pulsos fuera de la ventana
while recent_pulses and now - recent_pulses[0] > pulse_window_s:
recent_pulses.popleft()
cycle_rate_ppm = len(recent_pulses) * (60.0 / pulse_window_s)
if last_cycle_ts is None:
last_cycle_age_s = None
else:
last_cycle_age_s = round(now - last_cycle_ts, 1)
# Para la demo, machine_running se asocia al interruptor I_1.
# Más adelante podemos hacerlo depender también de pulsos recientes.
machine_running = machine_auto_signal
if now - last_print >= 0.5:
last_print = now
print(
"auto={auto} running={running} cycle_input={cycle} "
"pulse_count={count} cycle_rate_ppm={rate:.1f} last_cycle_age_s={age}".format(
auto=int(machine_auto_signal),
running=int(machine_running),
cycle=int(current_cycle),
count=cycle_pulse_count,
rate=cycle_rate_ppm,
age=last_cycle_age_s,
),
flush=True,
)
time.sleep(0.02)
finally:
rpi.exit()
print("[STOP] DI probe")
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
import argparse
import json
import struct
import sys
from datetime import datetime, timezone
from pymodbus.client import ModbusSerialClient
def decode_float_abcd(registers):
if len(registers) != 2:
raise ValueError(f"Expected 2 registers, got {len(registers)}")
raw = registers[0].to_bytes(2, "big") + registers[1].to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_float(client, address, device_id):
rr = client.read_input_registers(
address=address,
count=2,
device_id=device_id,
)
if rr.isError():
raise RuntimeError(f"Modbus error at address {address}: {rr}")
return decode_float_abcd(rr.registers)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--port", default="/dev/ttyRS485")
parser.add_argument("--slave", type=int, default=1)
parser.add_argument("--baud", type=int, default=9600)
parser.add_argument("--parity", default="N")
parser.add_argument("--stopbits", type=int, default=1)
parser.add_argument("--bytesize", type=int, default=8)
parser.add_argument("--timeout", type=float, default=1.5)
args = parser.parse_args()
client = ModbusSerialClient(
port=args.port,
baudrate=args.baud,
parity=args.parity,
stopbits=args.stopbits,
bytesize=args.bytesize,
timeout=args.timeout,
)
if not client.connect():
print("[ERROR] Cannot open Modbus serial port", file=sys.stderr)
return 2
try:
voltage_v = read_float(client, 0, args.slave)
current_a = read_float(client, 6, args.slave)
active_power_w = read_float(client, 12, args.slave)
frequency_hz = read_float(client, 70, args.slave)
import_kwh = read_float(client, 72, args.slave)
export_kwh = read_float(client, 74, args.slave)
payload = {
"ts": datetime.now(timezone.utc).isoformat(),
"source": "revpi_sdm120m",
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{args.slave}",
"port": args.port,
"slave": args.slave,
"baud": args.baud,
"voltage_l1_v": round(voltage_v, 3),
"current_l1_a": round(current_a, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 3),
"export_kwh": round(export_kwh, 3),
"comm_ok": True,
}
print(json.dumps(payload, indent=2, ensure_ascii=False))
return 0
except Exception as exc:
error_payload = {
"ts": datetime.now(timezone.utc).isoformat(),
"source": "revpi_sdm120m",
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{args.slave}",
"port": args.port,
"slave": args.slave,
"baud": args.baud,
"comm_ok": False,
"error_text": f"{type(exc).__name__}: {exc}",
}
print(json.dumps(error_payload, indent=2, ensure_ascii=False))
return 1
finally:
client.close()
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,205 @@
#!/usr/bin/env python3
import json
import os
import struct
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
import paho.mqtt.client as mqtt
from pymodbus.client import ModbusSerialClient
BASE_DIR = Path(__file__).resolve().parent
ENV_FILE = BASE_DIR / ".env"
START_TIME = time.monotonic()
def load_env_file(path: Path):
if not path.exists():
return
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def getenv(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Missing required environment variable: {name}", file=sys.stderr)
sys.exit(2)
return value
def decode_float_abcd(registers):
raw = registers[0].to_bytes(2, "big") + registers[1].to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_float(client, address, device_id):
rr = client.read_input_registers(
address=address,
count=2,
device_id=device_id,
)
if rr.isError():
raise RuntimeError(f"Modbus error at address {address}: {rr}")
return decode_float_abcd(rr.registers)
def read_sdm120m():
port = getenv("MODBUS_PORT", "/dev/ttyRS485")
slave = int(getenv("MODBUS_SLAVE", "1"))
baud = int(getenv("MODBUS_BAUD", "9600"))
parity = getenv("MODBUS_PARITY", "N")
stopbits = int(getenv("MODBUS_STOPBITS", "1"))
bytesize = int(getenv("MODBUS_BYTESIZE", "8"))
timeout = float(getenv("MODBUS_TIMEOUT", "1.5"))
client = ModbusSerialClient(
port=port,
baudrate=baud,
parity=parity,
stopbits=stopbits,
bytesize=bytesize,
timeout=timeout,
)
if not client.connect():
raise RuntimeError(f"Cannot open Modbus serial port {port}")
try:
voltage_v = read_float(client, 0, slave)
current_a = read_float(client, 6, slave)
active_power_w = read_float(client, 12, slave)
frequency_hz = read_float(client, 70, slave)
import_kwh = read_float(client, 72, slave)
export_kwh = read_float(client, 74, slave)
return {
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{slave}",
"modbus_port": port,
"modbus_slave": slave,
"modbus_baud": baud,
"voltage_l1_v": round(voltage_v, 3),
"current_l1_a": round(current_a, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 3),
"export_kwh": round(export_kwh, 3),
"comm_ok": True,
"sample_valid": True,
"error_text": None,
}
finally:
client.close()
def build_payload():
tenant = getenv("TENANT", "ucepsa")
site = getenv("SITE", "demo_edge_oee")
vertical = getenv("VERTICAL", "edge_oee")
asset = getenv("ASSET", "revpi_oee_node_01")
base = {
"asset": asset,
"tenant": tenant,
"site": site,
"vertical": vertical,
"node_type": "revpi_sdm120m_oee_gateway",
"fw": "0.1.0",
"ts": datetime.now(timezone.utc).isoformat(),
"uptime_s": round(time.monotonic() - START_TIME, 1),
"machine_id": None,
"machine_running": None,
"cycle_pulse_count": None,
"cycle_rate_hz": None,
"digital_inputs": {},
}
try:
data = read_sdm120m()
base.update(data)
except Exception as exc:
base.update({
"comm_ok": False,
"sample_valid": False,
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{getenv('MODBUS_SLAVE', '1')}",
"voltage_l1_v": None,
"current_l1_a": None,
"power_total_kw": None,
"frequency_hz": None,
"import_kwh": None,
"export_kwh": None,
"error_text": f"{type(exc).__name__}: {exc}",
})
return base
def publish_once(payload):
mqtt_host = getenv("MQTT_HOST", required=True)
mqtt_port = int(getenv("MQTT_PORT", "1883"))
mqtt_user = getenv("MQTT_USER", required=True)
mqtt_password = getenv("MQTT_PASSWORD", required=True)
mqtt_client_id = getenv("MQTT_CLIENT_ID", required=True)
topic_telemetry = getenv("MQTT_TOPIC_TELEMETRY", required=True)
topic_status = getenv("MQTT_TOPIC_STATUS", required=True)
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=mqtt_client_id,
clean_session=True,
)
client.username_pw_set(mqtt_user, mqtt_password)
client.will_set(topic_status, payload="offline", qos=0, retain=True)
client.connect(mqtt_host, mqtt_port, keepalive=30)
client.loop_start()
client.publish(topic_status, payload="online", qos=0, retain=True)
result = client.publish(
topic_telemetry,
payload=json.dumps(payload, ensure_ascii=False),
qos=0,
retain=False,
)
result.wait_for_publish(timeout=5)
client.loop_stop()
client.disconnect()
if result.rc != mqtt.MQTT_ERR_SUCCESS:
raise RuntimeError(f"MQTT publish failed rc={result.rc}")
def main():
load_env_file(ENV_FILE)
payload = build_payload()
print(json.dumps(payload, indent=2, ensure_ascii=False))
publish_once(payload)
print("[OK] MQTT telemetry published")
return 0 if payload.get("comm_ok") else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,419 @@
#!/usr/bin/env python3
import json
import os
import signal
import struct
import sys
import time
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
import paho.mqtt.client as mqtt
import revpimodio2
from pymodbus.client import ModbusSerialClient
BASE_DIR = Path(__file__).resolve().parent
ENV_FILE = BASE_DIR / ".env"
START_TIME = time.monotonic()
RUNNING = True
def handle_signal(signum, frame):
global RUNNING
RUNNING = False
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
def utc_now_iso():
return datetime.now(timezone.utc).isoformat()
def load_env_file(path: Path):
if not path.exists():
return
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def getenv(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Missing required environment variable: {name}", file=sys.stderr, flush=True)
sys.exit(2)
return value
def decode_float_abcd(registers):
raw = registers[0].to_bytes(2, "big") + registers[1].to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_float(client, address, device_id):
rr = client.read_input_registers(
address=address,
count=2,
device_id=device_id,
)
if rr.isError():
raise RuntimeError(f"Modbus error at address {address}: {rr}")
return decode_float_abcd(rr.registers)
def read_sdm120m():
port = getenv("MODBUS_PORT", "/dev/ttyRS485")
slave = int(getenv("MODBUS_SLAVE", "1"))
baud = int(getenv("MODBUS_BAUD", "9600"))
parity = getenv("MODBUS_PARITY", "N")
stopbits = int(getenv("MODBUS_STOPBITS", "1"))
bytesize = int(getenv("MODBUS_BYTESIZE", "8"))
timeout = float(getenv("MODBUS_TIMEOUT", "1.5"))
client = ModbusSerialClient(
port=port,
baudrate=baud,
parity=parity,
stopbits=stopbits,
bytesize=bytesize,
timeout=timeout,
)
if not client.connect():
raise RuntimeError(f"Cannot open Modbus serial port {port}")
try:
voltage_v = read_float(client, 0, slave)
current_a = read_float(client, 6, slave)
active_power_w = read_float(client, 12, slave)
frequency_hz = read_float(client, 70, slave)
import_kwh = read_float(client, 72, slave)
export_kwh = read_float(client, 74, slave)
return {
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{slave}",
"modbus_port": port,
"modbus_slave": slave,
"modbus_baud": baud,
"voltage_l1_v": round(voltage_v, 3),
"current_l1_a": round(current_a, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 3),
"export_kwh": round(export_kwh, 3),
"comm_ok": True,
"sample_valid": True,
"error_text": None,
}
finally:
client.close()
class DigitalInputTracker:
def __init__(self):
self.auto_input_name = getenv("DI_AUTO_INPUT", "I_1")
self.cycle_input_name = getenv("DI_CYCLE_INPUT", "I_2")
self.debounce_s = float(getenv("DI_DEBOUNCE_MS", "50")) / 1000.0
self.window_s = float(getenv("CYCLE_RATE_WINDOW_SECONDS", "60"))
self.rpi = revpimodio2.RevPiModIO(autorefresh=True)
self.auto_io = getattr(self.rpi.io, self.auto_input_name)
self.cycle_io = getattr(self.rpi.io, self.cycle_input_name)
now = time.monotonic()
# Estado estable de I_1 con antirrebote.
self.stable_auto = bool(self.auto_io.value)
self.raw_auto = self.stable_auto
self.raw_auto_since = now
# Pulsos de ciclo I_2.
self.cycle_pulse_count = 0
self.previous_cycle = bool(self.cycle_io.value)
self.last_edge_ts = 0.0
self.last_cycle_ts = None
self.recent_pulses = deque()
self.pending_events = []
def update(self):
now = time.monotonic()
# --- I_1: estado automático con antirrebote y evento inmediato ---
raw_auto = bool(self.auto_io.value)
if raw_auto != self.raw_auto:
self.raw_auto = raw_auto
self.raw_auto_since = now
elif raw_auto != self.stable_auto and (now - self.raw_auto_since) >= self.debounce_s:
previous = self.stable_auto
self.stable_auto = raw_auto
event_name = "machine_started" if self.stable_auto else "machine_stopped"
self.pending_events.append({
"event_type": "machine_state_changed",
"event_name": event_name,
"previous_auto_signal": previous,
"new_auto_signal": self.stable_auto,
"machine_running": self.stable_auto,
"reason_pending": not self.stable_auto,
"event_monotonic_ts": now,
})
# --- I_2: flanco ascendente de ciclo con antirrebote ---
current_cycle = bool(self.cycle_io.value)
if current_cycle and not self.previous_cycle:
if now - self.last_edge_ts >= self.debounce_s:
self.cycle_pulse_count += 1
self.last_edge_ts = now
self.last_cycle_ts = now
self.recent_pulses.append(now)
self.previous_cycle = current_cycle
while self.recent_pulses and now - self.recent_pulses[0] > self.window_s:
self.recent_pulses.popleft()
def snapshot(self):
now = time.monotonic()
while self.recent_pulses and now - self.recent_pulses[0] > self.window_s:
self.recent_pulses.popleft()
cycle_rate_ppm = len(self.recent_pulses) * (60.0 / self.window_s)
if self.last_cycle_ts is None:
last_cycle_age_s = None
else:
last_cycle_age_s = round(now - self.last_cycle_ts, 1)
current_cycle = bool(self.cycle_io.value)
return {
"io_ok": True,
"machine_auto_signal": self.stable_auto,
"machine_running": self.stable_auto,
"cycle_input_state": current_cycle,
"cycle_pulse_count": self.cycle_pulse_count,
"cycle_rate_ppm": round(cycle_rate_ppm, 2),
"last_cycle_age_s": last_cycle_age_s,
"digital_inputs": {
self.auto_input_name: self.stable_auto,
self.cycle_input_name: current_cycle,
},
}
def pop_events(self):
events = list(self.pending_events)
self.pending_events.clear()
return events
def close(self):
try:
self.rpi.exit()
except Exception:
pass
def base_payload():
return {
"asset": getenv("ASSET", "revpi_oee_node_01"),
"tenant": getenv("TENANT", "ucepsa"),
"site": getenv("SITE", "demo_edge_oee"),
"vertical": getenv("VERTICAL", "edge_oee"),
"node_type": getenv("NODE_TYPE", "revpi_sdm120m_oee_gateway"),
"fw": getenv("FW", "0.1.0"),
"source": "revpi_edge_oee_node",
"ts": utc_now_iso(),
"uptime_s": round(time.monotonic() - START_TIME, 1),
"machine_id": getenv("MACHINE_ID", "CORT-01"),
"order_id": None,
}
def build_telemetry_payload(di_tracker):
payload = base_payload()
payload.update(di_tracker.snapshot())
try:
payload.update(read_sdm120m())
except Exception as exc:
payload.update({
"comm_ok": False,
"sample_valid": False,
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{getenv('MODBUS_SLAVE', '1')}",
"voltage_l1_v": None,
"current_l1_a": None,
"power_total_kw": None,
"frequency_hz": None,
"import_kwh": None,
"export_kwh": None,
"error_text": f"{type(exc).__name__}: {exc}",
})
return payload
def build_event_payload(raw_event, di_tracker):
payload = base_payload()
payload.update(di_tracker.snapshot())
payload.update({
"event_type": raw_event["event_type"],
"event_name": raw_event["event_name"],
"previous_auto_signal": raw_event["previous_auto_signal"],
"new_auto_signal": raw_event["new_auto_signal"],
"machine_running": raw_event["machine_running"],
"reason_pending": raw_event["reason_pending"],
"event_ts": utc_now_iso(),
"event_source": "digital_input",
"event_input": getenv("DI_AUTO_INPUT", "I_1"),
})
return payload
def create_mqtt_client():
mqtt_host = getenv("MQTT_HOST", required=True)
mqtt_port = int(getenv("MQTT_PORT", "1883"))
mqtt_user = getenv("MQTT_USER", required=True)
mqtt_password = getenv("MQTT_PASSWORD", required=True)
mqtt_client_id = getenv("MQTT_CLIENT_ID", required=True)
topic_status = getenv("MQTT_TOPIC_STATUS", required=True)
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=mqtt_client_id,
clean_session=True,
)
client.username_pw_set(mqtt_user, mqtt_password)
client.will_set(topic_status, payload="offline", qos=0, retain=True)
client.connect(mqtt_host, mqtt_port, keepalive=30)
client.loop_start()
client.publish(topic_status, payload="online", qos=0, retain=True)
return client
def publish_json(client, topic, payload, retain=False):
result = client.publish(
topic,
payload=json.dumps(payload, ensure_ascii=False),
qos=0,
retain=retain,
)
result.wait_for_publish(timeout=5)
if result.rc != mqtt.MQTT_ERR_SUCCESS:
raise RuntimeError(f"MQTT publish failed rc={result.rc}")
def publish_loop():
topic_telemetry = getenv("MQTT_TOPIC_TELEMETRY", required=True)
topic_event = getenv("MQTT_TOPIC_EVENT", required=True)
topic_status = getenv("MQTT_TOPIC_STATUS", required=True)
interval_s = float(getenv("PUBLISH_INTERVAL_SECONDS", "10"))
di_tracker = DigitalInputTracker()
client = create_mqtt_client()
print("[START] RevPi Edge-OEE MQTT loop", flush=True)
print(f"[CONFIG] topic_telemetry={topic_telemetry}", flush=True)
print(f"[CONFIG] topic_event={topic_event}", flush=True)
print(f"[CONFIG] interval_s={interval_s}", flush=True)
print(f"[CONFIG] auto_input={di_tracker.auto_input_name}", flush=True)
print(f"[CONFIG] cycle_input={di_tracker.cycle_input_name}", flush=True)
next_publish = time.monotonic()
try:
while RUNNING:
di_tracker.update()
# Eventos inmediatos por cambio de I_1.
for raw_event in di_tracker.pop_events():
event_payload = build_event_payload(raw_event, di_tracker)
publish_json(client, topic_event, event_payload, retain=False)
print(
"[EVENT] "
f"{event_payload.get('event_name')} "
f"previous={event_payload.get('previous_auto_signal')} "
f"new={event_payload.get('new_auto_signal')} "
f"pulses={event_payload.get('cycle_pulse_count')} "
f"rate_ppm={event_payload.get('cycle_rate_ppm')}",
flush=True,
)
# Telemetría periódica.
now = time.monotonic()
if now >= next_publish:
telemetry_payload = build_telemetry_payload(di_tracker)
publish_json(client, topic_telemetry, telemetry_payload, retain=False)
print(
"[OK] telemetry "
f"auto={telemetry_payload.get('machine_auto_signal')} "
f"running={telemetry_payload.get('machine_running')} "
f"pulses={telemetry_payload.get('cycle_pulse_count')} "
f"rate_ppm={telemetry_payload.get('cycle_rate_ppm')} "
f"voltage={telemetry_payload.get('voltage_l1_v')} "
f"power_kw={telemetry_payload.get('power_total_kw')}",
flush=True,
)
next_publish = now + interval_s
time.sleep(0.02)
finally:
try:
client.publish(topic_status, payload="offline", qos=0, retain=True)
time.sleep(0.2)
except Exception:
pass
try:
client.loop_stop()
client.disconnect()
except Exception:
pass
di_tracker.close()
print("[STOP] RevPi Edge-OEE MQTT loop", flush=True)
def main():
load_env_file(ENV_FILE)
publish_loop()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,53 @@
#!/usr/bin/env python3
import time
from pymodbus.client import ModbusTcpClient
WISE_IP = "10.43.54.249"
UNIT = 1
def u32_lo_hi(lo, hi):
return (lo & 0xFFFF) + ((hi & 0xFFFF) << 16)
client = ModbusTcpClient(WISE_IP, port=502, timeout=2)
if not client.connect():
raise RuntimeError("No conecta con WISE")
last_di1_counter = None
try:
print("Leyendo contador DI1 por Modbus/TCP. Pulsa DI1 y observa cambios.")
while True:
rr = client.read_holding_registers(
address=0,
count=4,
device_id=UNIT,
)
if rr.isError():
print("ERROR:", rr)
time.sleep(1)
continue
regs = rr.registers
di0_counter = u32_lo_hi(regs[0], regs[1])
di1_counter = u32_lo_hi(regs[2], regs[3])
if di1_counter != last_di1_counter:
print(
f"DI0_counter={di0_counter} "
f"DI1_counter={di1_counter} "
f"raw={regs}"
)
last_di1_counter = di1_counter
time.sleep(0.2)
except KeyboardInterrupt:
print("Parado")
finally:
client.close()

View File

@ -0,0 +1,212 @@
#!/usr/bin/env python3
import csv
import math
import signal
import struct
import time
from datetime import datetime
from pathlib import Path
from pymodbus.client import ModbusTcpClient
WISE_IP = "17.126.1.200"
UNIT = 1
INTERVAL_S = 2.0
DATA_BASE = 1000
DATA_COUNT = 20
WORD_ERR_BASE = 1200
WORD_ERR_COUNT = 20
LOG_DIR = Path("logs")
LOG_DIR.mkdir(exist_ok=True)
csv_path = LOG_DIR / f"cort01_eastron_continuo_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
running = True
def stop_handler(signum, frame):
global running
running = False
signal.signal(signal.SIGINT, stop_handler)
signal.signal(signal.SIGTERM, stop_handler)
def f32_abcd(hi, lo):
raw = hi.to_bytes(2, "big") + lo.to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_holding(client, addr, count):
rr = client.read_holding_registers(address=addr, count=count, device_id=UNIT)
if rr.isError():
raise RuntimeError(rr)
return rr.registers
def valid_number(x):
return isinstance(x, float) and math.isfinite(x)
def open_client():
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
if not client.connect():
raise RuntimeError(f"No conecta con WISE {WISE_IP}:502")
return client
fields = [
"local_ts",
"ok",
"fail_reason",
"voltage_l1_v",
"current_l1_a",
"active_power_w",
"apparent_power_va",
"reactive_power_var",
"power_factor",
"frequency_hz",
"import_kwh",
"export_kwh",
"total_active_energy_kwh",
"word_err_nonzero",
"word_err_values",
]
ok_count = 0
fail_count = 0
client = None
print(f"Monitor continuo CORT-01 / wise-cort-01 / {WISE_IP}")
print(f"CSV: {csv_path}")
print("Ctrl+C para parar")
print()
with csv_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
while running:
ts = datetime.now().isoformat(timespec="seconds")
row = {
"local_ts": ts,
"ok": False,
"fail_reason": "",
"voltage_l1_v": "",
"current_l1_a": "",
"active_power_w": "",
"apparent_power_va": "",
"reactive_power_var": "",
"power_factor": "",
"frequency_hz": "",
"import_kwh": "",
"export_kwh": "",
"total_active_energy_kwh": "",
"word_err_nonzero": "",
"word_err_values": "",
}
try:
if client is None:
client = open_client()
data = read_holding(client, DATA_BASE, DATA_COUNT)
word_err = read_holding(client, WORD_ERR_BASE, WORD_ERR_COUNT)
voltage_l1_v = f32_abcd(data[0], data[1])
current_l1_a = f32_abcd(data[2], data[3])
active_power_w = f32_abcd(data[4], data[5])
apparent_power_va = f32_abcd(data[6], data[7])
reactive_power_var = f32_abcd(data[8], data[9])
power_factor = f32_abcd(data[10], data[11])
frequency_hz = f32_abcd(data[12], data[13])
import_kwh = f32_abcd(data[14], data[15])
export_kwh = f32_abcd(data[16], data[17])
total_active_energy_kwh = f32_abcd(data[18], data[19])
non_zero_errs = [(WORD_ERR_BASE + i, v) for i, v in enumerate(word_err) if v != 0]
values_ok = (
valid_number(voltage_l1_v)
and valid_number(current_l1_a)
and valid_number(active_power_w)
and valid_number(frequency_hz)
and 150 <= voltage_l1_v <= 260
and 45 <= frequency_hz <= 55
and current_l1_a >= 0
and len(non_zero_errs) == 0
)
row.update({
"ok": values_ok,
"voltage_l1_v": round(voltage_l1_v, 4),
"current_l1_a": round(current_l1_a, 6),
"active_power_w": round(active_power_w, 4),
"apparent_power_va": round(apparent_power_va, 4),
"reactive_power_var": round(reactive_power_var, 4),
"power_factor": round(power_factor, 6),
"frequency_hz": round(frequency_hz, 4),
"import_kwh": round(import_kwh, 6),
"export_kwh": round(export_kwh, 6),
"total_active_energy_kwh": round(total_active_energy_kwh, 6),
"word_err_nonzero": len(non_zero_errs),
"word_err_values": str(non_zero_errs),
})
if values_ok:
ok_count += 1
status = "OK"
else:
fail_count += 1
status = "BAD"
row["fail_reason"] = "valores_fuera_rango_o_word_err"
print(
f"{ts} {status} "
f"OK={ok_count} FAIL={fail_count} "
f"V={voltage_l1_v:7.2f} "
f"I={current_l1_a:7.3f} "
f"P={active_power_w:8.2f} "
f"F={frequency_hz:6.2f} "
f"kWh={import_kwh:.4f} "
f"WORD_ERR={non_zero_errs}"
)
except Exception as e:
fail_count += 1
row["ok"] = False
row["fail_reason"] = str(e)
print(
f"{ts} ERROR "
f"OK={ok_count} FAIL={fail_count} "
f"{e}"
)
if client is not None:
try:
client.close()
except Exception:
pass
client = None
writer.writerow(row)
f.flush()
time.sleep(INTERVAL_S)
if client is not None:
client.close()
total = ok_count + fail_count
print()
print("Resumen")
print("-------")
print(f"OK={ok_count}")
print(f"FAIL={fail_count}")
if total:
print(f"Disponibilidad lectura={ok_count / total * 100:.2f}%")
print(f"CSV guardado en: {csv_path}")

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
from pymodbus.client import ModbusTcpClient
NODES = [
{"machine_id": "CORT-01", "source_node": "wise-cort-00", "ip": "10.43.54.249"},
{"machine_id": "CORT-00", "source_node": "wise-cort-01", "ip": "10.43.54.251"},
{"machine_id": "CORT-02", "source_node": "wise-cort-02", "ip": "10.43.54.55"},
]
UNIT = 1
def u32_lo_hi(lo, hi):
return (lo & 0xFFFF) + ((hi & 0xFFFF) << 16)
for node in NODES:
print()
print("==================================================")
print(node["machine_id"], node["source_node"], node["ip"])
print("==================================================")
client = ModbusTcpClient(node["ip"], port=502, timeout=3)
if not client.connect():
print("ERROR: no conecta")
continue
try:
rr_di = client.read_discrete_inputs(address=0, count=2, device_id=UNIT)
if rr_di.isError():
print("ERROR DI:", rr_di)
else:
print("DI0 marcha:", bool(rr_di.bits[0]))
print("DI1 estado:", bool(rr_di.bits[1]))
rr_cnt = client.read_holding_registers(address=0, count=4, device_id=UNIT)
if rr_cnt.isError():
print("ERROR counter:", rr_cnt)
else:
regs = rr_cnt.registers
di0_counter = u32_lo_hi(regs[0], regs[1])
di1_counter = u32_lo_hi(regs[2], regs[3])
print("DI0 counter:", di0_counter)
print("DI1 counter:", di1_counter)
print("raw counter:", regs)
rr_energy = client.read_holding_registers(address=1000, count=20, device_id=UNIT)
if rr_energy.isError():
print("ERROR energy:", rr_energy)
else:
print("Energy registers OK, first values:", rr_energy.registers[:6])
finally:
client.close()

View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
import struct
from pymodbus.client import ModbusTcpClient
WISE_IP = "17.126.1.201"
UNIT = 1
DATA_BASE = 1000 # WISE 4X 1001 en base cero
DATA_COUNT = 20
ERROR_BASE = 1200 # WISE 4X 1201 en base cero, error codes COM1 expansion word
ERROR_COUNT = 20
def f32_abcd(hi, lo):
raw = hi.to_bytes(2, "big") + lo.to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_holding(client, start, count):
rr = client.read_holding_registers(address=start, count=count, device_id=UNIT)
if rr.isError():
raise RuntimeError(rr)
return rr.registers
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
if not client.connect():
raise RuntimeError(f"No conecta con WISE {WISE_IP}:502")
try:
print("==================================================")
print("CORT-01 / wise-cort-01 /", WISE_IP)
print("==================================================")
regs = read_holding(client, DATA_BASE, DATA_COUNT)
print("\nRegistros mapeados Eastron en WISE")
print("----------------------------------")
for i in range(0, len(regs), 2):
addr1 = DATA_BASE + i
addr2 = DATA_BASE + i + 1
hi = regs[i]
lo = regs[i + 1]
val = f32_abcd(hi, lo)
print(f"{addr1}/{addr2}: raw=({hi},{lo}) float={val:.6f}")
print("\nInterpretación según mapeo actual")
print("---------------------------------")
labels = [
("voltage_l1_v", 0),
("current_l1_a", 2),
("active_power_w", 4),
("apparent_power_va", 6),
("reactive_power_var", 8),
("power_factor", 10),
("frequency_hz", 12),
("import_kwh", 14),
("export_kwh", 16),
("total_active_energy_kwh", 18),
]
for name, offset in labels:
if offset + 1 < len(regs):
print(f"{name:28s}: {f32_abcd(regs[offset], regs[offset + 1]):.6f}")
print("\nRegistros de error COM1 Expansion Word")
print("--------------------------------------")
try:
errs = read_holding(client, ERROR_BASE, ERROR_COUNT)
non_zero = False
for i, value in enumerate(errs):
if value != 0:
non_zero = True
print(f"ERROR_REG {ERROR_BASE + i}: {value}")
if not non_zero:
print("Todos los registros de error están a 0")
except Exception as e:
print("No se pudieron leer registros de error:", e)
finally:
client.close()

View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
import struct
from pymodbus.client import ModbusTcpClient
WISE_IP = "17.126.1.202" # ajusta si cambia
UNIT = 1
def f32(hi, lo):
return struct.unpack(">f", hi.to_bytes(2, "big") + lo.to_bytes(2, "big"))[0]
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
if not client.connect():
raise SystemExit("No conecta con WISE")
try:
rr = client.read_holding_registers(address=1000, count=34, device_id=UNIT)
if rr.isError():
raise SystemExit(rr)
r = rr.registers
values = {
"V_L1_N": f32(r[0], r[1]),
"V_L2_N": f32(r[2], r[3]),
"V_L3_N": f32(r[4], r[5]),
"I_L1": f32(r[6], r[7]),
"I_L2": f32(r[8], r[9]),
"I_L3": f32(r[10], r[11]),
"P_L1_W": f32(r[12], r[13]),
"P_L2_W": f32(r[14], r[15]),
"P_L3_W": f32(r[16], r[17]),
"P_TOTAL_W": f32(r[18], r[19]),
"S_TOTAL_VA": f32(r[20], r[21]),
"Q_TOTAL_VAR": f32(r[22], r[23]),
"PF_TOTAL": f32(r[24], r[25]),
"FREQ_HZ": f32(r[26], r[27]),
"IMPORT_KWH": f32(r[28], r[29]),
"EXPORT_KWH": f32(r[30], r[31]),
"TOTAL_ACTIVE_KWH": f32(r[32], r[33]),
}
for k, v in values.items():
print(f"{k:18s}: {v:.4f}")
finally:
client.close()

View File

@ -0,0 +1,97 @@
#!/usr/bin/env python3
import argparse
import struct
from pymodbus.client import ModbusSerialClient
def regs_to_bytes(regs, mode):
a = regs[0]
b = regs[1]
ab = a.to_bytes(2, "big")
cd = b.to_bytes(2, "big")
if mode == "ABCD":
return ab + cd
if mode == "CDAB":
return cd + ab
if mode == "BADC":
return ab[::-1] + cd[::-1]
if mode == "DCBA":
return cd[::-1] + ab[::-1]
raise ValueError(mode)
def decode_float_candidates(regs):
out = {}
for mode in ["ABCD", "CDAB", "BADC", "DCBA"]:
try:
raw = regs_to_bytes(regs, mode)
out[mode] = struct.unpack(">f", raw)[0]
except Exception as exc:
out[mode] = f"ERR: {exc}"
return out
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--port", default="/dev/ttyRS485")
parser.add_argument("--slave", type=int, default=1)
parser.add_argument("--baud", type=int, default=9600)
parser.add_argument("--parity", default="N", choices=["N", "E", "O"])
parser.add_argument("--stopbits", type=int, default=1)
parser.add_argument("--bytesize", type=int, default=8)
parser.add_argument("--address", type=int, default=0)
parser.add_argument("--count", type=int, default=2)
parser.add_argument("--timeout", type=float, default=1.5)
args = parser.parse_args()
print("--- MODBUS RTU PROBE ---")
print(f"port={args.port}")
print(f"slave={args.slave}")
print(f"baud={args.baud}")
print(f"parity={args.parity}")
print(f"address={args.address}")
print(f"count={args.count}")
client = ModbusSerialClient(
port=args.port,
baudrate=args.baud,
parity=args.parity,
stopbits=args.stopbits,
bytesize=args.bytesize,
timeout=args.timeout,
)
if not client.connect():
print("[ERROR] Cannot open serial port")
return 2
try:
rr = client.read_input_registers(
address=args.address,
count=args.count,
device_id=args.slave,
)
if rr.isError():
print(f"[ERROR] Modbus error: {rr}")
return 3
regs = rr.registers
print(f"registers={regs}")
print("float_candidates:")
for mode, value in decode_float_candidates(regs).items():
print(f" {mode}: {value}")
return 0
except Exception as exc:
print(f"[ERROR] No Modbus response: {type(exc).__name__}: {exc}")
return 3
finally:
client.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
from pymodbus.client import ModbusTcpClient
WISE_IP = "10.43.54.249"
WISE_PORT = 502
UNIT = 1
def read_holding(client, start, count):
rr = client.read_holding_registers(
address=start,
count=count,
device_id=UNIT,
)
if rr.isError():
raise RuntimeError(rr)
return rr.registers
def u32_hi_lo(hi, lo):
return ((hi & 0xFFFF) << 16) | (lo & 0xFFFF)
def u32_lo_hi(hi, lo):
return ((lo & 0xFFFF) << 16) | (hi & 0xFFFF)
def print_block(name, start, regs):
print(f"\n{name} start={start}")
print("16-bit:")
for i, value in enumerate(regs):
print(f"addr {start+i:4d}: {value}")
print("\n32-bit pairs:")
for i in range(0, len(regs) - 1, 2):
a = start + i
hi = regs[i]
lo = regs[i + 1]
print(
f"addr {a:4d}/{a+1:4d}: "
f"hi_lo={u32_hi_lo(hi, lo)} "
f"lo_hi={u32_lo_hi(hi, lo)}"
)
def snapshot(client):
return {
"base0": read_holding(client, 0, 12),
"base1": read_holding(client, 1, 12),
}
def main():
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=3)
if not client.connect():
raise RuntimeError("No conecta con WISE")
try:
print("Snapshot inicial")
before = snapshot(client)
for name, regs in before.items():
start = 0 if name == "base0" else 1
print_block(name, start, regs)
input("\nPulsa DI1 exactamente 10 veces y después pulsa ENTER...")
print("\nSnapshot final")
after = snapshot(client)
for name, regs in after.items():
start = 0 if name == "base0" else 1
print_block(name, start, regs)
print("\nDeltas 16-bit:")
for name in before:
start = 0 if name == "base0" else 1
for i, (b, a) in enumerate(zip(before[name], after[name])):
if a != b:
delta = (a - b) & 0xFFFF
print(f"{name} addr {start+i}: before={b} after={a} delta={delta}")
print("\nDeltas 32-bit:")
for name in before:
start = 0 if name == "base0" else 1
bregs = before[name]
aregs = after[name]
for i in range(0, len(bregs) - 1, 2):
b_hi_lo = u32_hi_lo(bregs[i], bregs[i + 1])
a_hi_lo = u32_hi_lo(aregs[i], aregs[i + 1])
d_hi_lo = (a_hi_lo - b_hi_lo) & 0xFFFFFFFF
b_lo_hi = u32_lo_hi(bregs[i], bregs[i + 1])
a_lo_hi = u32_lo_hi(aregs[i], aregs[i + 1])
d_lo_hi = (a_lo_hi - b_lo_hi) & 0xFFFFFFFF
if d_hi_lo or d_lo_hi:
print(
f"{name} addr {start+i}/{start+i+1}: "
f"delta_hi_lo={d_hi_lo} "
f"delta_lo_hi={d_lo_hi}"
)
finally:
client.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,120 @@
#!/usr/bin/env python3
import argparse
import struct
from pymodbus.client import ModbusTcpClient
def call_with_unit(fn, unit, **kwargs):
"""
Compatible con varias versiones de pymodbus:
- pymodbus 3.x usa device_id
- versiones anteriores pueden usar slave o unit
"""
last_error = None
for key in ("device_id", "slave", "unit"):
try:
return fn(**kwargs, **{key: unit})
except TypeError as e:
last_error = e
raise last_error
def f32_abcd(reg_hi, reg_lo):
raw = reg_hi.to_bytes(2, "big") + reg_lo.to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def print_holding(client, unit, start, count):
print(f"\n=== HOLDING REGISTERS start={start}, count={count}, unit={unit} ===")
try:
rr = call_with_unit(
client.read_holding_registers,
unit,
address=start,
count=count
)
except Exception as e:
print("EXCEPTION:", repr(e))
return
if rr.isError():
print("MODBUS ERROR:", rr)
return
regs = rr.registers
for i, value in enumerate(regs):
print(f"{start + i}: {value}")
print("\nFloat ABCD por parejas:")
for i in range(0, len(regs) - 1, 2):
try:
value = f32_abcd(regs[i], regs[i + 1])
print(f"{start + i}/{start + i + 1}: {value:.6f}")
except Exception as e:
print(f"{start + i}/{start + i + 1}: ERROR {e}")
def print_bits(client, unit, kind, start, count):
print(f"\n=== {kind.upper()} start={start}, count={count}, unit={unit} ===")
fn = client.read_discrete_inputs if kind == "discrete" else client.read_coils
try:
rr = call_with_unit(
fn,
unit,
address=start,
count=count
)
except Exception as e:
print("EXCEPTION:", repr(e))
return
if rr.isError():
print("MODBUS ERROR:", rr)
return
bits = rr.bits[:count]
for i, value in enumerate(bits):
print(f"{start + i}: {int(value)}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, default=502)
parser.add_argument("--unit", type=int, default=1)
args = parser.parse_args()
client = ModbusTcpClient(args.host, port=args.port, timeout=3)
if not client.connect():
print(f"ERROR: no conecta con {args.host}:{args.port}")
return
print(f"OK: conectado a {args.host}:{args.port}")
try:
# El WISE muestra Mapping Address(4X) 1001.
# Probamos base cero y base uno.
print_holding(client, args.unit, 1000, 20)
print_holding(client, args.unit, 1001, 20)
# Intentos típicos para DI por Modbus/TCP.
# Luego afinamos según Bit Status del WISE.
print_bits(client, args.unit, "discrete", 0, 16)
print_bits(client, args.unit, "discrete", 1, 16)
print_bits(client, args.unit, "coils", 0, 16)
print_bits(client, args.unit, "coils", 1, 16)
finally:
client.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,129 @@
#!/usr/bin/env python3
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import paho.mqtt.client as mqtt
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def build_payload(machine_id, mode):
base = {
"machine_id": machine_id,
"ts": utc_now(),
"source": "odoo_simulator",
}
if mode == "loaded":
base.update({
"order_present": True,
"order_coherent": True,
"odoo_state": "ready",
"workorder_id": 1000,
"production_order": f"SIM-{machine_id}-OK",
"product": "Orden simulada correcta",
})
elif mode == "no_order":
base.update({
"order_present": False,
"order_coherent": False,
"odoo_state": "none",
"workorder_id": None,
"production_order": None,
"product": None,
})
elif mode == "bad":
base.update({
"order_present": True,
"order_coherent": False,
"odoo_state": "wrong_machine",
"workorder_id": 9999,
"production_order": f"SIM-{machine_id}-BAD",
"product": "Orden simulada incoherente",
"reason": "Orden asignada a otra máquina o estado no válido",
})
elif mode == "fault":
base.update({
"order_present": True,
"order_coherent": True,
"odoo_state": "ready",
"workorder_id": 1001,
"production_order": f"SIM-{machine_id}-FAULT",
"product": "Orden simulada con fallo máquina",
"force_machine_fault": True,
})
else:
raise SystemExit(f"Modo no reconocido: {mode}")
return base
def main():
if len(sys.argv) != 3:
print("Uso:")
print(" python publish_order_state.py CORT-00 loaded")
print(" python publish_order_state.py CORT-00 no_order")
print(" python publish_order_state.py CORT-00 bad")
print(" python publish_order_state.py CORT-00 fault")
raise SystemExit(1)
machine_id = sys.argv[1]
mode = sys.argv[2]
env = read_env()
mqtt_host = env.get("MQTT_HOST", "127.0.0.1")
mqtt_port = int(env.get("MQTT_PORT", "1883"))
mqtt_user = env.get("MQTT_USERNAME") or env.get("MQTT_USER")
mqtt_password = env.get("MQTT_PASSWORD")
tenant = env.get("TENANT", "ucepsa")
site = env.get("SITE", "ucepsa_onpremise")
vertical = env.get("VERTICAL", "edge_oee")
topic = f"vertical/{tenant}/{site}/{vertical}/order_state/{machine_id}"
payload = build_payload(machine_id, mode)
client = mqtt.Client(client_id=f"order_state_sim_{machine_id}")
if mqtt_user:
client.username_pw_set(mqtt_user, mqtt_password)
client.connect(mqtt_host, mqtt_port, 60)
client.loop_start()
info = client.publish(topic, json.dumps(payload, separators=(",", ":")), qos=0, retain=True)
info.wait_for_publish(timeout=5)
client.loop_stop()
client.disconnect()
print(f"Publicado en {topic}")
print(json.dumps(payload, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,125 @@
#!/usr/bin/env python3
import argparse
import time
from pymodbus.client import ModbusTcpClient
def read_block(client, kind, start, count, unit):
fn = client.read_holding_registers if kind == "holding" else client.read_input_registers
try:
rr = fn(address=start, count=count, device_id=unit)
except Exception:
return None
if rr.isError():
return None
return rr.registers
def snapshot(client, unit):
ranges = [
("holding", 0, 200),
("holding", 1000, 100),
("holding", 2000, 200),
("holding", 3000, 200),
("holding", 4000, 200),
("input", 0, 200),
("input", 1000, 100),
("input", 2000, 200),
("input", 3000, 200),
("input", 4000, 200),
]
data = {}
for kind, start, count in ranges:
regs = read_block(client, kind, start, count, unit)
if regs is None:
continue
for i, value in enumerate(regs):
data[(kind, start + i)] = value
return data
def u16_delta(before, after):
return (after - before) & 0xFFFF
def u32(hi, lo):
return ((hi & 0xFFFF) << 16) | (lo & 0xFFFF)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", required=True)
parser.add_argument("--unit", type=int, default=1)
parser.add_argument("--expected", type=int, default=10)
args = parser.parse_args()
client = ModbusTcpClient(args.host, port=502, timeout=2)
if not client.connect():
raise RuntimeError("No conecta con WISE")
try:
print("Tomando snapshot inicial...")
before = snapshot(client, args.unit)
print(f"\nAhora pulsa DI1 exactamente {args.expected} veces.")
input("Cuando termines, pulsa ENTER aquí...")
time.sleep(0.5)
print("Tomando snapshot final...")
after = snapshot(client, args.unit)
print("\nCambios 16-bit detectados:")
changes = []
for key, before_value in before.items():
if key not in after:
continue
after_value = after[key]
if after_value != before_value:
delta = u16_delta(before_value, after_value)
closeness = abs(delta - args.expected)
changes.append((closeness, key, before_value, after_value, delta))
for closeness, key, b, a, delta in sorted(changes, key=lambda x: x[0])[:40]:
kind, addr = key
print(f"{kind:7s} addr={addr:5d} before={b:6d} after={a:6d} delta={delta}")
print("\nCambios 32-bit por parejas:")
pair_changes = []
keys = sorted(before.keys())
for kind, addr in keys:
key_hi = (kind, addr)
key_lo = (kind, addr + 1)
if key_lo not in before or key_hi not in after or key_lo not in after:
continue
before_u32 = u32(before[key_hi], before[key_lo])
after_u32 = u32(after[key_hi], after[key_lo])
if before_u32 != after_u32:
delta = (after_u32 - before_u32) & 0xFFFFFFFF
closeness = abs(delta - args.expected)
pair_changes.append((closeness, kind, addr, before_u32, after_u32, delta))
for closeness, kind, addr, b, a, delta in sorted(pair_changes, key=lambda x: x[0])[:40]:
print(f"{kind:7s} addr={addr:5d}/{addr+1:5d} before={b} after={a} delta={delta}")
finally:
client.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
import os
import socket
import time
import sys
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
ENV_PATH = BASE_DIR / ".env"
if ENV_PATH.exists():
for raw in ENV_PATH.read_text().splitlines():
line = raw.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
host = os.environ.get("MQTT_HOST", "17.126.1.70")
port = int(os.environ.get("MQTT_PORT", "1883"))
deadline = time.time() + 120
while time.time() < deadline:
try:
with socket.create_connection((host, port), timeout=5):
print(f"[OK] MQTT reachable {host}:{port}")
sys.exit(0)
except OSError as exc:
print(f"[WAIT] MQTT {host}:{port} not reachable yet: {exc}")
time.sleep(5)
print(f"[ERROR] MQTT {host}:{port} not reachable after timeout")
sys.exit(1)

View File

@ -0,0 +1,105 @@
#!/usr/bin/env python3
import time
from datetime import datetime, timezone
from pymodbus.client import ModbusTcpClient
WISE_IP = "10.43.54.249"
WISE_PORT = 502
WISE_UNIT = 1
POLL_INTERVAL_S = 0.02
DEBOUNCE_S = 0.02
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def read_di(client):
rr = client.read_discrete_inputs(
address=0,
count=2,
device_id=WISE_UNIT,
)
if rr.isError():
raise RuntimeError(f"Error leyendo DI: {rr}")
di0 = bool(rr.bits[0])
di1 = bool(rr.bits[1])
return di0, di1
def main():
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=1)
if not client.connect():
raise RuntimeError(f"No conecta con WISE {WISE_IP}:{WISE_PORT}")
print(f"Conectado a WISE {WISE_IP}:{WISE_PORT}")
print("DI0 = marcha / automático")
print("DI1 = pulso sellador")
print("Prueba: empieza con DI1 abierto, pulsa y suelta claramente.\n")
last_di0 = None
last_di1 = None
last_rising_ts = 0.0
count = 0
loops = 0
stats_start = time.perf_counter()
max_loop_s = 0.0
try:
while True:
loop_start = time.perf_counter()
now = loop_start
di0, di1 = read_di(client)
if last_di0 is None:
last_di0 = di0
last_di1 = di1
print(f"{utc_now()} INIT DI0={int(di0)} DI1={int(di1)}")
if di0 != last_di0:
print(f"{utc_now()} CHANGE DI0 {int(last_di0)} -> {int(di0)}")
last_di0 = di0
if di1 != last_di1:
print(f"{utc_now()} CHANGE DI1 {int(last_di1)} -> {int(di1)}")
if di1 and not last_di1:
if (now - last_rising_ts) >= DEBOUNCE_S:
count += 1
last_rising_ts = now
print(f"{utc_now()} RISING DI1 count={count}")
last_di1 = di1
loops += 1
loop_s = time.perf_counter() - loop_start
max_loop_s = max(max_loop_s, loop_s)
if time.perf_counter() - stats_start >= 5.0:
elapsed = time.perf_counter() - stats_start
hz = loops / elapsed
print(f"{utc_now()} STATS poll_hz={hz:.1f} max_loop_ms={max_loop_s * 1000:.1f} count={count}")
loops = 0
stats_start = time.perf_counter()
max_loop_s = 0.0
sleep_s = max(0.0, POLL_INTERVAL_S - loop_s)
time.sleep(sleep_s)
except KeyboardInterrupt:
print(f"\nParado. Conteo final DI1={count}")
finally:
client.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,37 @@
{
"nodes": [
{
"enabled": true,
"machine_id": "CORT-01",
"source_node": "wise-cort-01",
"wise_ip": "17.126.1.201",
"wise_port": 502,
"unit": 1,
"holding_base": 1000,
"holding_count": 34,
"session_env": "WISE_SESSION_ID_CORT_01"
},
{
"enabled": true,
"machine_id": "CORT-00",
"source_node": "wise-cort-00",
"wise_ip": "17.126.1.200",
"wise_port": 502,
"unit": 1,
"holding_base": 1000,
"holding_count": 34,
"session_env": "WISE_SESSION_ID_CORT_00"
},
{
"enabled": true,
"machine_id": "CORT-02",
"source_node": "wise-cort-02",
"wise_ip": "17.126.1.202",
"wise_port": 502,
"unit": 1,
"holding_base": 1000,
"holding_count": 34,
"session_env": "WISE_SESSION_ID_CORT_02"
}
]
}

View File

@ -0,0 +1,222 @@
#!/usr/bin/env python3
import json
import struct
import time
from collections import deque
from datetime import datetime, timezone
from pymodbus.client import ModbusTcpClient
WISE_IP = "10.43.54.249"
WISE_PORT = 502
WISE_UNIT = 1
HOLDING_BASE = 1000
DI_BASE = 0
POLL_INTERVAL_S = 0.1
PUBLISH_INTERVAL_S = 2.0
DEBOUNCE_S = 0.05
RATE_WINDOW_S = 60.0
TENANT = "ucepsa"
SITE = "demo_edge_oee"
VERTICAL = "edge_oee"
ASSET = "revpi_oee_node_01"
MACHINE_ID = "CORT-01"
SOURCE_NODE = "wise-banco-01"
NODE_TYPE = "revpi_wise_oee_gateway"
FW = "0.1.0"
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def f32_abcd(reg_hi, reg_lo):
raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_holdings(client):
rr = client.read_holding_registers(
address=HOLDING_BASE,
count=20,
device_id=WISE_UNIT,
)
if rr.isError():
raise RuntimeError(f"Error leyendo holding registers: {rr}")
return rr.registers
def read_discrete_inputs(client):
rr = client.read_discrete_inputs(
address=DI_BASE,
count=2,
device_id=WISE_UNIT,
)
if rr.isError():
raise RuntimeError(f"Error leyendo discrete inputs: {rr}")
di0 = bool(rr.bits[0])
di1 = bool(rr.bits[1])
return di0, di1
def decode_energy(regs):
voltage_l1_v = f32_abcd(regs[0], regs[1])
current_l1_a = f32_abcd(regs[2], regs[3])
active_power_w = f32_abcd(regs[4], regs[5])
apparent_power_va = f32_abcd(regs[6], regs[7])
reactive_power_var = f32_abcd(regs[8], regs[9])
power_factor = f32_abcd(regs[10], regs[11])
frequency_hz = f32_abcd(regs[12], regs[13])
import_kwh = f32_abcd(regs[14], regs[15])
export_kwh = f32_abcd(regs[16], regs[17])
total_active_energy_kwh = f32_abcd(regs[18], regs[19])
return {
"voltage_l1_v": round(voltage_l1_v, 3),
"current_l1_a": round(current_l1_a, 6),
"active_power_w": round(active_power_w, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"apparent_power_va": round(apparent_power_va, 3),
"reactive_power_var": round(reactive_power_var, 3),
"power_factor": round(power_factor, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 6),
"export_kwh": round(export_kwh, 6),
"total_active_energy_kwh": round(total_active_energy_kwh, 6),
}
def main():
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=3)
cycle_pulses = deque()
cycle_pulse_count = 0
last_cycle_state = None
last_auto_signal = None
last_pulse_ts = 0.0
last_publish_ts = 0.0
print(f"Conectando con WISE {WISE_IP}:{WISE_PORT} unit={WISE_UNIT}")
while True:
now = time.time()
try:
if not client.connected:
if not client.connect():
raise RuntimeError("No conecta con WISE")
auto_signal, cycle_state = read_discrete_inputs(client)
if last_cycle_state is None:
last_cycle_state = cycle_state
rising_edge = cycle_state and not last_cycle_state
if rising_edge and (now - last_pulse_ts) >= DEBOUNCE_S:
cycle_pulse_count += 1
cycle_pulses.append(now)
last_pulse_ts = now
last_cycle_state = cycle_state
while cycle_pulses and (now - cycle_pulses[0]) > RATE_WINDOW_S:
cycle_pulses.popleft()
cycle_rate_ppm = len(cycle_pulses) * 60.0 / RATE_WINDOW_S
last_cycle_age_s = None if not cycle_pulses else round(now - cycle_pulses[-1], 3)
if last_auto_signal is not None and auto_signal != last_auto_signal:
event_name = "machine_started" if auto_signal else "machine_stopped"
event = {
"ts": utc_now(),
"tenant": TENANT,
"site": SITE,
"vertical": VERTICAL,
"asset": ASSET,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"event_type": "machine_state_changed",
"event_name": event_name,
"previous_auto_signal": last_auto_signal,
"new_auto_signal": auto_signal,
"machine_running": auto_signal,
"reason_pending": not auto_signal,
}
print("EVENT", json.dumps(event, ensure_ascii=False))
last_auto_signal = auto_signal
if (now - last_publish_ts) >= PUBLISH_INTERVAL_S:
regs = read_holdings(client)
energy = decode_energy(regs)
payload = {
"ts": utc_now(),
"asset": ASSET,
"tenant": TENANT,
"site": SITE,
"vertical": VERTICAL,
"node_type": NODE_TYPE,
"fw": FW,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"wise_host": WISE_IP,
"machine_auto_signal": auto_signal,
"machine_running": auto_signal,
"cycle_input_state": cycle_state,
"cycle_pulse_count": cycle_pulse_count,
"cycle_rate_ppm": round(cycle_rate_ppm, 3),
"last_cycle_age_s": last_cycle_age_s,
"digital_inputs": {
"DI0": auto_signal,
"DI1": cycle_state,
},
**energy,
"comm_ok": True,
"sample_valid": True,
}
print("TELEMETRY", json.dumps(payload, ensure_ascii=False))
last_publish_ts = now
except KeyboardInterrupt:
print("Parado por usuario")
break
except Exception as exc:
print("ERROR", json.dumps({
"ts": utc_now(),
"asset": ASSET,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"wise_host": WISE_IP,
"comm_ok": False,
"sample_valid": False,
"error": str(exc),
}, ensure_ascii=False))
try:
client.close()
except Exception:
pass
time.sleep(2)
time.sleep(POLL_INTERVAL_S)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,303 @@
#!/usr/bin/env python3
import json
import os
import struct
import time
import urllib.request
from collections import deque
from datetime import datetime, timezone
from pymodbus.client import ModbusTcpClient
WISE_IP = "10.43.54.249"
WISE_PORT = 502
WISE_UNIT = 1
WISE_DI_URL = f"http://{WISE_IP}/di_value/slot_0"
WISE_SESSION_ID = os.getenv("WISE_SESSION_ID", "").strip()
HOLDING_BASE = 1000
DI_POLL_INTERVAL_S = 0.2
ENERGY_INTERVAL_S = 2.0
PRINT_INTERVAL_S = 2.0
RATE_WINDOW_S = 60.0
TENANT = "ucepsa"
SITE = "demo_edge_oee"
VERTICAL = "edge_oee"
ASSET = "revpi_oee_node_01"
MACHINE_ID = "CORT-01"
SOURCE_NODE = "wise-banco-01"
NODE_TYPE = "revpi_wise_oee_gateway"
FW = "0.2.0"
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def f32_abcd(reg_hi, reg_lo):
raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_wise_di_api():
from urllib.error import HTTPError
if not WISE_SESSION_ID:
raise RuntimeError(
"Falta WISE_SESSION_ID. Ejecuta primero: export WISE_SESSION_ID"
)
req = urllib.request.Request(
WISE_DI_URL,
headers={
"Accept": "text/plain, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
"Referer": f"http://{WISE_IP}/config/index.html",
"User-Agent": "Mozilla/5.0",
"Cookie": f"adamsessionid={WISE_SESSION_ID}",
},
)
try:
with urllib.request.urlopen(req, timeout=2) as response:
text = response.read().decode("utf-8", errors="replace")
except HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(
f"WISE HTTP error {e.code}: {e.reason}. Body={body[:300]!r}"
)
data = json.loads(text)
if isinstance(data, dict) and data.get("Err"):
raise RuntimeError(
f"WISE API error Err={data.get('Err')} Msg={data.get('Msg')!r}"
)
channels = {int(item["Ch"]): item for item in data.get("DIVal", [])}
ch0 = channels.get(0, {})
ch1 = channels.get(1, {})
return {
"di0_stat": bool(ch0.get("Stat", 0)),
"di1_stat": bool(ch1.get("Stat", 0)),
"di1_counter_raw": int(ch1.get("CtFq", 0)),
"di1_counting": bool(ch1.get("Cnting", 0)),
"di1_mode": int(ch1.get("Md", -1)),
"raw": data,
}
def read_holdings(client):
if not client.connected:
if not client.connect():
raise RuntimeError("No conecta con WISE por Modbus/TCP")
rr = client.read_holding_registers(
address=HOLDING_BASE,
count=20,
device_id=WISE_UNIT,
)
if rr.isError():
raise RuntimeError(f"Error leyendo holding registers: {rr}")
return rr.registers
def decode_energy(regs):
active_power_w = f32_abcd(regs[4], regs[5])
return {
"voltage_l1_v": round(f32_abcd(regs[0], regs[1]), 3),
"current_l1_a": round(f32_abcd(regs[2], regs[3]), 6),
"active_power_w": round(active_power_w, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"apparent_power_va": round(f32_abcd(regs[6], regs[7]), 3),
"reactive_power_var": round(f32_abcd(regs[8], regs[9]), 3),
"power_factor": round(f32_abcd(regs[10], regs[11]), 6),
"frequency_hz": round(f32_abcd(regs[12], regs[13]), 3),
"import_kwh": round(f32_abcd(regs[14], regs[15]), 6),
"export_kwh": round(f32_abcd(regs[16], regs[17]), 6),
"total_active_energy_kwh": round(f32_abcd(regs[18], regs[19]), 6),
}
def counter_delta(previous, current):
if previous is None:
return 0, False
if current >= previous:
return current - previous, False
# Reset manual, reinicio del WISE o pérdida del contador.
return 0, True
def main():
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=3)
previous_wise_counter = None
cycle_pulse_count = 0
cycle_events = deque()
last_auto_signal = None
last_energy = {}
last_energy_ts = 0.0
last_print_ts = 0.0
last_delta = 0
counter_reset_detected = False
print(f"Leyendo WISE {WISE_IP}")
print(f"DI API: {WISE_DI_URL}")
print(f"Modbus/TCP: {WISE_IP}:{WISE_PORT}, holding base {HOLDING_BASE}")
print("DI0 = marcha / automático")
print("DI1 = contador de ciclos\n")
while True:
now = time.time()
try:
di = read_wise_di_api()
auto_signal = di["di0_stat"]
cycle_input_state = di["di1_stat"]
wise_counter = di["di1_counter_raw"]
counter_valid = di["di1_mode"] == 1 and di["di1_counting"]
delta, reset = counter_delta(previous_wise_counter, wise_counter)
counter_reset_detected = reset
if delta > 0:
cycle_pulse_count += delta
for _ in range(min(delta, 1000)):
cycle_events.append(now)
print(
"CYCLES",
json.dumps(
{
"ts": utc_now(),
"delta": delta,
"wise_counter": wise_counter,
"cycle_pulse_count": cycle_pulse_count,
},
ensure_ascii=False,
),
)
previous_wise_counter = wise_counter
last_delta = delta
while cycle_events and (now - cycle_events[0]) > RATE_WINDOW_S:
cycle_events.popleft()
cycle_rate_ppm = len(cycle_events) * 60.0 / RATE_WINDOW_S
last_cycle_age_s = None if not cycle_events else round(now - cycle_events[-1], 3)
if last_auto_signal is not None and auto_signal != last_auto_signal:
event_name = "machine_started" if auto_signal else "machine_stopped"
print(
"EVENT",
json.dumps(
{
"ts": utc_now(),
"tenant": TENANT,
"site": SITE,
"vertical": VERTICAL,
"asset": ASSET,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"event_type": "machine_state_changed",
"event_name": event_name,
"previous_auto_signal": last_auto_signal,
"new_auto_signal": auto_signal,
"machine_running": auto_signal,
"reason_pending": not auto_signal,
},
ensure_ascii=False,
),
)
last_auto_signal = auto_signal
if now - last_energy_ts >= ENERGY_INTERVAL_S:
regs = read_holdings(client)
last_energy = decode_energy(regs)
last_energy_ts = now
if now - last_print_ts >= PRINT_INTERVAL_S:
payload = {
"ts": utc_now(),
"asset": ASSET,
"tenant": TENANT,
"site": SITE,
"vertical": VERTICAL,
"node_type": NODE_TYPE,
"fw": FW,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"wise_host": WISE_IP,
"machine_auto_signal": auto_signal,
"machine_running": auto_signal,
"cycle_input_state": cycle_input_state,
"wise_cycle_counter_raw": wise_counter,
"cycle_delta_last": last_delta,
"cycle_pulse_count": cycle_pulse_count,
"cycle_rate_ppm": round(cycle_rate_ppm, 3),
"last_cycle_age_s": last_cycle_age_s,
"counter_valid": counter_valid,
"counter_reset_detected": counter_reset_detected,
"digital_inputs": {
"DI0": auto_signal,
"DI1": cycle_input_state,
},
**last_energy,
"comm_ok": True,
"sample_valid": True,
}
print("TELEMETRY", json.dumps(payload, ensure_ascii=False))
last_print_ts = now
except KeyboardInterrupt:
print("\nParado por usuario")
break
except Exception as exc:
print(
"ERROR",
json.dumps(
{
"ts": utc_now(),
"asset": ASSET,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"wise_host": WISE_IP,
"comm_ok": False,
"sample_valid": False,
"error": str(exc),
},
ensure_ascii=False,
),
)
try:
client.close()
except Exception:
pass
time.sleep(2)
time.sleep(DI_POLL_INTERVAL_S)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,491 @@
#!/usr/bin/env python3
import json
import os
import signal
import struct
import time
import urllib.request
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from urllib.error import HTTPError, URLError
import paho.mqtt.client as mqtt
from pymodbus.client import ModbusTcpClient
BASE_DIR = Path(__file__).resolve().parent
ENV_PATH = BASE_DIR / ".env"
NODES_PATH = BASE_DIR / "wise_nodes.json"
def load_env(path: Path):
if not path.exists():
return
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def env(name, default=None, required=False, cast=str):
value = os.environ.get(name, default)
if required and (value is None or str(value).strip() == ""):
raise RuntimeError(f"Falta variable obligatoria: {name}")
if value is None:
return None
return cast(value)
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def dumps(data):
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def f32_abcd(reg_hi, reg_lo):
raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def counter_delta(previous, current):
if previous is None:
return 0, False
if current >= previous:
return current - previous, False
return 0, True
def load_nodes():
data = json.loads(NODES_PATH.read_text())
nodes = [n for n in data.get("nodes", []) if n.get("enabled", True)]
if not nodes:
raise RuntimeError("No hay nodos WISE habilitados en wise_nodes.json")
return nodes
def make_mqtt_client():
host = env("MQTT_HOST", required=True)
port = env("MQTT_PORT", 1883, cast=int)
user = env("MQTT_USER", "")
password = env("MQTT_PASSWORD", "")
client_id = env("MQTT_CLIENT_ID", required=True)
try:
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id)
except AttributeError:
client = mqtt.Client(client_id=client_id)
if user:
client.username_pw_set(user, password)
client.connect(host, port, keepalive=60)
client.loop_start()
return client
def publish(client, topic, payload):
client.publish(topic, dumps(payload), qos=0, retain=False)
def read_wise_di_api(node):
wise_ip = node["wise_ip"]
session_env = node["session_env"]
session_id = os.environ.get(session_env, "").strip()
if not session_id:
raise RuntimeError(f"Falta {session_env}. Exporta la cookie adamsessionid del WISE.")
req = urllib.request.Request(
f"http://{wise_ip}/di_value/slot_0",
headers={
"Accept": "text/plain, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
"Referer": f"http://{wise_ip}/config/index.html",
"User-Agent": "Mozilla/5.0",
"Cookie": f"adamsessionid={session_id}",
},
)
try:
with urllib.request.urlopen(req, timeout=2) as response:
text = response.read().decode("utf-8", errors="replace")
except HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"WISE HTTP {e.code}: {e.reason}. Body={body[:200]!r}")
except URLError as e:
raise RuntimeError(f"WISE HTTP unreachable: {e}")
data = json.loads(text)
if isinstance(data, dict) and data.get("Err"):
raise RuntimeError(f"WISE API Err={data.get('Err')} Msg={data.get('Msg')!r}")
channels = {int(item["Ch"]): item for item in data.get("DIVal", [])}
ch0 = channels.get(0, {})
ch1 = channels.get(1, {})
return {
"di0_stat": bool(ch0.get("Stat", 0)),
"di1_stat": bool(ch1.get("Stat", 0)),
"di1_counter_raw": int(ch1.get("CtFq", 0)),
"di1_counting": bool(ch1.get("Cnting", 0)),
"di1_mode": int(ch1.get("Md", -1)),
}
def u32_lo_hi(lo, hi):
return (int(lo) & 0xFFFF) + ((int(hi) & 0xFFFF) << 16)
def read_wise_inputs_modbus(state, node):
client = ensure_modbus_client(state, node)
unit = int(node.get("unit", 1))
rr_di = client.read_discrete_inputs(
address=0,
count=2,
device_id=unit,
)
if rr_di.isError():
raise RuntimeError(f"Error leyendo DI status: {rr_di}")
rr_counter = client.read_holding_registers(
address=0,
count=4,
device_id=unit,
)
if rr_counter.isError():
raise RuntimeError(f"Error leyendo contadores DI: {rr_counter}")
regs = rr_counter.registers
di0_counter = u32_lo_hi(regs[0], regs[1])
di1_counter = u32_lo_hi(regs[2], regs[3])
return {
"di0_stat": bool(rr_di.bits[0]),
"di1_stat": bool(rr_di.bits[1]),
"di0_counter_raw": di0_counter,
"di1_counter_raw": di1_counter,
"di1_counting": True,
"di1_mode": 1,
}
def ensure_modbus_client(state, node):
client = state.get("modbus_client")
if client is None:
client = ModbusTcpClient(
node["wise_ip"],
port=int(node.get("wise_port", 502)),
timeout=3,
)
state["modbus_client"] = client
if not client.connected:
if not client.connect():
raise RuntimeError("No conecta con WISE por Modbus/TCP")
return client
def read_holdings(state, node):
client = ensure_modbus_client(state, node)
rr = client.read_holding_registers(
address=int(node.get("holding_base", 1000)),
count=int(node.get("holding_count", 20)),
device_id=int(node.get("unit", 1)),
)
if rr.isError():
raise RuntimeError(f"Error leyendo holding registers: {rr}")
return rr.registers
def decode_energy(regs):
if len(regs) < 34:
raise ValueError(f"Se esperaban 34 registros SDM630, recibidos {len(regs)}")
def v(offset):
return f32_abcd(regs[offset], regs[offset + 1])
voltage_l1_v = v(0)
voltage_l2_v = v(2)
voltage_l3_v = v(4)
current_l1_a = v(6)
current_l2_a = v(8)
current_l3_a = v(10)
active_power_l1_w = v(12)
active_power_l2_w = v(14)
active_power_l3_w = v(16)
active_power_w = v(18)
apparent_power_va = v(20)
reactive_power_var = v(22)
power_factor = v(24)
frequency_hz = v(26)
import_kwh = v(28)
export_kwh = v(30)
total_active_energy_kwh = v(32)
return {
"energy_meter_model": "eastron_sdm630",
"energy_map": "sdm630_3phase_v1",
"voltage_l1_v": round(voltage_l1_v, 3),
"voltage_l2_v": round(voltage_l2_v, 3),
"voltage_l3_v": round(voltage_l3_v, 3),
"current_l1_a": round(current_l1_a, 6),
"current_l2_a": round(current_l2_a, 6),
"current_l3_a": round(current_l3_a, 6),
"active_power_l1_w": round(active_power_l1_w, 3),
"active_power_l2_w": round(active_power_l2_w, 3),
"active_power_l3_w": round(active_power_l3_w, 3),
"active_power_w": round(active_power_w, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"apparent_power_va": round(apparent_power_va, 3),
"reactive_power_var": round(reactive_power_var, 3),
"power_factor": round(power_factor, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 6),
"export_kwh": round(export_kwh, 6),
"total_active_energy_kwh": round(total_active_energy_kwh, 6),
}
def base_payload(node):
return {
"tenant": env("TENANT", "ucepsa"),
"site": env("SITE", "ucepsa_test"),
"vertical": env("VERTICAL", "edge_oee"),
"asset": env("ASSET", "revpi_oee_node_01"),
"machine_id": node["machine_id"],
"source_node": node["source_node"],
"wise_host": node["wise_ip"],
"node_type": "revpi_wise_oee_gateway",
"fw": "0.3.0",
}
def init_state():
return {
"previous_counter": None,
"cycle_pulse_count": 0,
"cycle_events": deque(),
"last_auto_signal": None,
"last_energy": {},
"last_energy_ts": 0.0,
"last_publish_ts": 0.0,
"last_delta": 0,
"counter_reset_detected": False,
"modbus_client": None,
}
def main():
load_env(ENV_PATH)
nodes = load_nodes()
states = {node["source_node"]: init_state() for node in nodes}
telemetry_topic = env("MQTT_TOPIC_TELEMETRY", required=True)
status_topic = env("MQTT_TOPIC_STATUS", required=True)
event_topic = env("MQTT_TOPIC_EVENT", required=True)
publish_interval_s = env("PUBLISH_INTERVAL_SECONDS", 10, cast=float)
di_poll_interval_s = env("WISE_DI_POLL_INTERVAL_SECONDS", 0.5, cast=float)
energy_interval_s = env("WISE_ENERGY_INTERVAL_SECONDS", 2.0, cast=float)
rate_window_s = env("CYCLE_RATE_WINDOW_SECONDS", 60, cast=float)
mqtt_client = make_mqtt_client()
stop = {"value": False}
def on_stop(signum, frame):
stop["value"] = True
signal.signal(signal.SIGINT, on_stop)
signal.signal(signal.SIGTERM, on_stop)
print(f"Nodos WISE habilitados: {len(nodes)}")
for node in nodes:
print(f"- {node['machine_id']} {node['source_node']} {node['wise_ip']}")
publish(mqtt_client, status_topic, {
"ts": utc_now(),
"status": "started",
"asset": env("ASSET", "revpi_oee_node_01"),
"enabled_nodes": [
{
"machine_id": n["machine_id"],
"source_node": n["source_node"],
"wise_ip": n["wise_ip"],
}
for n in nodes
],
})
while not stop["value"]:
loop_start = time.time()
for node in nodes:
state = states[node["source_node"]]
now = time.time()
try:
di = read_wise_inputs_modbus(state, node)
auto_signal = di["di0_stat"]
cycle_input_state = di["di1_stat"]
wise_counter = di["di1_counter_raw"]
counter_valid = di["di1_mode"] == 1 and di["di1_counting"]
delta, reset = counter_delta(state["previous_counter"], wise_counter)
state["counter_reset_detected"] = reset
if delta > 0:
state["cycle_pulse_count"] += delta
for _ in range(min(delta, 1000)):
state["cycle_events"].append(now)
event = {
"ts": utc_now(),
**base_payload(node),
"event_type": "cycle_count_changed",
"delta": delta,
"wise_cycle_counter_raw": wise_counter,
"cycle_pulse_count": state["cycle_pulse_count"],
}
publish(mqtt_client, event_topic, event)
print("CYCLES", dumps(event))
state["previous_counter"] = wise_counter
state["last_delta"] = delta
while state["cycle_events"] and (now - state["cycle_events"][0]) > rate_window_s:
state["cycle_events"].popleft()
cycle_rate_ppm = len(state["cycle_events"]) * 60.0 / rate_window_s
last_cycle_age_s = None
if state["cycle_events"]:
last_cycle_age_s = round(now - state["cycle_events"][-1], 3)
if state["last_auto_signal"] is not None and auto_signal != state["last_auto_signal"]:
event_name = "machine_started" if auto_signal else "machine_stopped"
event = {
"ts": utc_now(),
**base_payload(node),
"event_type": "machine_state_changed",
"event_name": event_name,
"previous_auto_signal": state["last_auto_signal"],
"new_auto_signal": auto_signal,
"machine_running": auto_signal,
"reason_pending": not auto_signal,
}
publish(mqtt_client, event_topic, event)
print("EVENT", dumps(event))
state["last_auto_signal"] = auto_signal
if now - state["last_energy_ts"] >= energy_interval_s:
regs = read_holdings(state, node)
state["last_energy"] = decode_energy(regs)
state["last_energy_ts"] = now
if now - state["last_publish_ts"] >= publish_interval_s:
payload = {
"ts": utc_now(),
**base_payload(node),
"machine_auto_signal": auto_signal,
"machine_running": auto_signal,
"cycle_input_state": cycle_input_state,
"wise_cycle_counter_raw": wise_counter,
"cycle_delta_last": state["last_delta"],
"cycle_pulse_count": state["cycle_pulse_count"],
"cycle_rate_ppm": round(cycle_rate_ppm, 3),
"last_cycle_age_s": last_cycle_age_s,
"counter_valid": counter_valid,
"counter_reset_detected": state["counter_reset_detected"],
"digital_inputs": {
"DI0": auto_signal,
"DI1": cycle_input_state,
},
**state["last_energy"],
"comm_ok": True,
"sample_valid": True,
}
publish(mqtt_client, telemetry_topic, payload)
print("TELEMETRY", dumps(payload))
state["last_publish_ts"] = now
except Exception as exc:
error_payload = {
"ts": utc_now(),
**base_payload(node),
"comm_ok": False,
"sample_valid": False,
"error": str(exc),
}
publish(mqtt_client, status_topic, error_payload)
print("ERROR", dumps(error_payload))
client = state.get("modbus_client")
if client is not None:
try:
client.close()
except Exception:
pass
state["modbus_client"] = None
elapsed = time.time() - loop_start
time.sleep(max(0.05, di_poll_interval_s - elapsed))
publish(mqtt_client, status_topic, {
"ts": utc_now(),
"status": "stopped",
"asset": env("ASSET", "revpi_oee_node_01"),
})
for state in states.values():
client = state.get("modbus_client")
if client is not None:
try:
client.close()
except Exception:
pass
mqtt_client.loop_stop()
mqtt_client.disconnect()
print("Parado correctamente")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,2 @@
[sudo] password for pi:
^C^C^C^C^Csudo: a password is required

View File

@ -0,0 +1,2 @@
[sudo] password for pi:
^C^C^C^C^C^Csudo: a password is required

View File

@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["python", "main.py"]

View File

@ -0,0 +1,540 @@
#!/usr/bin/env python3
import json
import os
import signal
import sys
from datetime import datetime, timezone
import paho.mqtt.client as mqtt
import psycopg2
from psycopg2.extras import Json
RUNNING = True
def stop_handler(signum, frame):
global RUNNING
RUNNING = False
signal.signal(signal.SIGTERM, stop_handler)
signal.signal(signal.SIGINT, stop_handler)
def getenv(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Missing required env var: {name}", file=sys.stderr)
sys.exit(2)
return value
MQTT_HOST = getenv("MQTT_HOST", required=True)
MQTT_PORT = int(getenv("MQTT_PORT", "1883"))
MQTT_TOPIC = getenv("MQTT_TOPIC", required=True)
MQTT_USER = getenv("MQTT_USER")
MQTT_PASSWORD = getenv("MQTT_PASSWORD")
PGHOST = getenv("PGHOST", required=True)
PGPORT = int(getenv("PGPORT", "5432"))
PGDATABASE = getenv("PGDATABASE", required=True)
PGUSER = getenv("PGUSER", required=True)
PGPASSWORD = getenv("PGPASSWORD", required=True)
DEFAULT_TENANT = getenv("DEFAULT_TENANT", "ucepsa")
DEFAULT_SITE = getenv("DEFAULT_SITE", "demo_edge_oee")
DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01")
def num(payload, key):
value = payload.get(key)
if value is None or value == "":
return None
try:
return float(value)
except Exception:
return None
def bool_or_false(payload, key):
value = payload.get(key)
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "ok")
return bool(value)
def parse_ts(payload):
raw = payload.get("ts")
if not raw:
return datetime.now(timezone.utc)
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return datetime.now(timezone.utc)
def connect_pg():
return psycopg2.connect(
host=PGHOST,
port=PGPORT,
dbname=PGDATABASE,
user=PGUSER,
password=PGPASSWORD,
connect_timeout=5,
)
PG_CONN = connect_pg()
PG_CONN.autocommit = True
def ensure_pg():
global PG_CONN
try:
with PG_CONN.cursor() as cur:
cur.execute("SELECT 1;")
except Exception:
try:
PG_CONN.close()
except Exception:
pass
PG_CONN = connect_pg()
PG_CONN.autocommit = True
def insert_payload(topic, payload):
ensure_pg()
tenant = payload.get("tenant") or DEFAULT_TENANT
site = payload.get("site") or DEFAULT_SITE
asset = payload.get("asset") or DEFAULT_ASSET
machine_id = payload.get("machine_id") or None
order_id = payload.get("order_id") or None
comm_ok = bool_or_false(payload, "comm_ok")
error_text = payload.get("error_text")
sql = """
INSERT INTO mv_hot.edge_oee_eastron_readings (
ts,
tenant,
site,
asset,
source,
meter_model,
meter_id,
machine_id,
order_id,
voltage_l1_v,
voltage_l2_v,
voltage_l3_v,
current_l1_a,
current_l2_a,
current_l3_a,
power_l1_kw,
power_l2_kw,
power_l3_kw,
power_total_kw,
frequency_hz,
import_kwh,
export_kwh,
comm_ok,
error_text,
payload_json
)
VALUES (
%(ts)s,
%(tenant)s,
%(site)s,
%(asset)s,
%(source)s,
%(meter_model)s,
%(meter_id)s,
%(machine_id)s,
%(order_id)s,
%(voltage_l1_v)s,
%(voltage_l2_v)s,
%(voltage_l3_v)s,
%(current_l1_a)s,
%(current_l2_a)s,
%(current_l3_a)s,
%(power_l1_kw)s,
%(power_l2_kw)s,
%(power_l3_kw)s,
%(power_total_kw)s,
%(frequency_hz)s,
%(import_kwh)s,
%(export_kwh)s,
%(comm_ok)s,
%(error_text)s,
%(payload_json)s
);
"""
power_total_kw = num(payload, "power_total_kw")
params = {
"ts": parse_ts(payload),
"tenant": tenant,
"site": site,
"asset": asset,
"source": payload.get("source") or "mqtt_edge_oee_sink",
"meter_model": payload.get("meter_model"),
"meter_id": payload.get("meter_id"),
"machine_id": machine_id,
"order_id": order_id,
"voltage_l1_v": num(payload, "voltage_l1_v"),
"voltage_l2_v": num(payload, "voltage_l2_v"),
"voltage_l3_v": num(payload, "voltage_l3_v"),
"current_l1_a": num(payload, "current_l1_a"),
"current_l2_a": num(payload, "current_l2_a"),
"current_l3_a": num(payload, "current_l3_a"),
"power_l1_kw": num(payload, "power_l1_kw") if payload.get("power_l1_kw") is not None else power_total_kw,
"power_l2_kw": num(payload, "power_l2_kw"),
"power_l3_kw": num(payload, "power_l3_kw"),
"power_total_kw": power_total_kw,
"frequency_hz": num(payload, "frequency_hz"),
"import_kwh": num(payload, "import_kwh"),
"export_kwh": num(payload, "export_kwh"),
"comm_ok": comm_ok,
"error_text": error_text,
"payload_json": Json({
**payload,
"_mqtt_topic": topic,
"_ingested_by": "edge_oee_mqtt_pg_sink",
}),
}
with PG_CONN.cursor() as cur:
cur.execute(sql, params)
def insert_event(topic, payload):
ensure_pg()
tenant = payload.get("tenant") or DEFAULT_TENANT
site = payload.get("site") or DEFAULT_SITE
vertical = payload.get("vertical") or "edge_oee"
asset = payload.get("asset") or DEFAULT_ASSET
raw_ts = payload.get("event_ts") or payload.get("ts")
try:
event_ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00")) if raw_ts else datetime.now(timezone.utc)
except Exception:
event_ts = datetime.now(timezone.utc)
def to_bool(value):
if isinstance(value, bool):
return value
if value is None:
return None
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "ok")
return bool(value)
def to_int(value):
if value is None or value == "":
return None
try:
return int(value)
except Exception:
return None
def to_float(value):
if value is None or value == "":
return None
try:
return float(value)
except Exception:
return None
sql = """
INSERT INTO mv_hot.edge_oee_node_events (
event_ts,
topic,
tenant,
site,
vertical,
asset,
machine_id,
order_id,
event_type,
event_name,
previous_auto_signal,
new_auto_signal,
machine_running,
reason_pending,
cycle_pulse_count,
cycle_rate_ppm,
last_cycle_age_s,
payload_json
)
VALUES (
%(event_ts)s,
%(topic)s,
%(tenant)s,
%(site)s,
%(vertical)s,
%(asset)s,
%(machine_id)s,
%(order_id)s,
%(event_type)s,
%(event_name)s,
%(previous_auto_signal)s,
%(new_auto_signal)s,
%(machine_running)s,
%(reason_pending)s,
%(cycle_pulse_count)s,
%(cycle_rate_ppm)s,
%(last_cycle_age_s)s,
%(payload_json)s
);
"""
params = {
"event_ts": event_ts,
"topic": topic,
"tenant": tenant,
"site": site,
"vertical": vertical,
"asset": asset,
"machine_id": payload.get("machine_id") or None,
"order_id": payload.get("order_id") or None,
"event_type": payload.get("event_type") or "unknown",
"event_name": payload.get("event_name") or "unknown",
"previous_auto_signal": to_bool(payload.get("previous_auto_signal")),
"new_auto_signal": to_bool(payload.get("new_auto_signal")),
"machine_running": to_bool(payload.get("machine_running")),
"reason_pending": to_bool(payload.get("reason_pending")),
"cycle_pulse_count": to_int(payload.get("cycle_pulse_count")),
"cycle_rate_ppm": to_float(payload.get("cycle_rate_ppm")),
"last_cycle_age_s": to_float(payload.get("last_cycle_age_s")),
"payload_json": Json({
**payload,
"_mqtt_topic": topic,
"_ingested_by": "edge_oee_mqtt_pg_sink",
}),
}
with PG_CONN.cursor() as cur:
cur.execute(sql, params)
def process_machine_stop_event(topic, payload):
if payload.get("event_type") != "machine_state_changed":
return
event_name = payload.get("event_name")
if event_name not in ("machine_stopped", "machine_started"):
return
ensure_pg()
tenant = payload.get("tenant") or DEFAULT_TENANT
site = payload.get("site") or DEFAULT_SITE
vertical = payload.get("vertical") or "edge_oee"
asset = payload.get("asset") or DEFAULT_ASSET
machine_id = payload.get("machine_id") or None
order_id = payload.get("order_id") or None
raw_ts = payload.get("event_ts") or payload.get("ts")
try:
event_ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00")) if raw_ts else datetime.now(timezone.utc)
except Exception:
event_ts = datetime.now(timezone.utc)
if event_name == "machine_stopped":
sql = """
INSERT INTO mv_hot.edge_oee_machine_stops (
tenant,
site,
vertical,
asset,
machine_id,
order_id,
started_at,
status,
classification_status,
start_event_name,
start_payload_json
)
SELECT
%(tenant)s,
%(site)s,
%(vertical)s,
%(asset)s,
%(machine_id)s,
%(order_id)s,
%(started_at)s,
'OPEN',
'PENDING',
%(event_name)s,
%(payload_json)s
WHERE NOT EXISTS (
SELECT 1
FROM mv_hot.edge_oee_machine_stops
WHERE asset = %(asset)s
AND COALESCE(machine_id, '') = COALESCE(%(machine_id)s, '')
AND status = 'OPEN'
);
"""
params = {
"tenant": tenant,
"site": site,
"vertical": vertical,
"asset": asset,
"machine_id": machine_id,
"order_id": order_id,
"started_at": event_ts,
"event_name": event_name,
"payload_json": Json({
**payload,
"_mqtt_topic": topic,
"_stop_logic": "open_stop",
}),
}
with PG_CONN.cursor() as cur:
cur.execute(sql, params)
return
if event_name == "machine_started":
sql = """
WITH open_stop AS (
SELECT id, started_at, value_hour_eur
FROM mv_hot.edge_oee_machine_stops
WHERE asset = %(asset)s
AND COALESCE(machine_id, '') = COALESCE(%(machine_id)s, '')
AND status = 'OPEN'
ORDER BY started_at DESC
LIMIT 1
)
UPDATE mv_hot.edge_oee_machine_stops s
SET
ended_at = %(ended_at)s,
status = 'CLOSED',
duration_s = EXTRACT(EPOCH FROM (%(ended_at)s - s.started_at))::numeric(14,2),
cost_eur = ROUND(((EXTRACT(EPOCH FROM (%(ended_at)s - s.started_at)) / 3600.0) * s.value_hour_eur)::numeric, 2),
end_event_name = %(event_name)s,
end_payload_json = %(payload_json)s,
updated_at = now()
FROM open_stop
WHERE s.id = open_stop.id;
"""
params = {
"asset": asset,
"machine_id": machine_id,
"ended_at": event_ts,
"event_name": event_name,
"payload_json": Json({
**payload,
"_mqtt_topic": topic,
"_stop_logic": "close_stop",
}),
}
with PG_CONN.cursor() as cur:
cur.execute(sql, params)
return
def on_connect(client, userdata, flags, reason_code, properties=None):
print(f"[MQTT] connected reason_code={reason_code}")
client.subscribe(MQTT_TOPIC)
print(f"[MQTT] subscribed topic={MQTT_TOPIC}")
def on_message(client, userdata, msg):
try:
body = msg.payload.decode("utf-8")
payload = json.loads(body)
if msg.topic.endswith("/telemetry"):
insert_payload(msg.topic, payload)
asset = payload.get("asset")
voltage = payload.get("voltage_l1_v")
power = payload.get("power_total_kw")
comm_ok = payload.get("comm_ok")
pulses = payload.get("cycle_pulse_count")
rate = payload.get("cycle_rate_ppm")
print(
f"[OK][telemetry] topic={msg.topic} asset={asset} "
f"voltage={voltage} power_kw={power} pulses={pulses} rate_ppm={rate} comm_ok={comm_ok}",
flush=True,
)
elif msg.topic.endswith("/event"):
insert_event(msg.topic, payload)
process_machine_stop_event(msg.topic, payload)
print(
f"[OK][event] topic={msg.topic} "
f"event_name={payload.get('event_name')} "
f"asset={payload.get('asset')} "
f"previous={payload.get('previous_auto_signal')} "
f"new={payload.get('new_auto_signal')} "
f"pulses={payload.get('cycle_pulse_count')}",
flush=True,
)
else:
print(f"[WARN] Ignored topic={msg.topic}", flush=True)
except Exception as exc:
print(f"[ERROR] processing topic={msg.topic}: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
def main():
print("[START] edge_oee_mqtt_pg_sink")
print(f"[CONFIG] MQTT {MQTT_HOST}:{MQTT_PORT} topic={MQTT_TOPIC}")
print(f"[CONFIG] PostgreSQL {PGHOST}:{PGPORT}/{PGDATABASE} user={PGUSER}")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
if MQTT_USER:
client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
print(f"[CONFIG] MQTT auth user={MQTT_USER}", flush=True)
else:
print("[WARN] MQTT auth disabled: MQTT_USER not set", flush=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
client.loop_start()
try:
while RUNNING:
signal.pause()
except AttributeError:
import time
while RUNNING:
time.sleep(1)
finally:
client.loop_stop()
client.disconnect()
try:
PG_CONN.close()
except Exception:
pass
print("[STOP] edge_oee_mqtt_pg_sink")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,2 @@
paho-mqtt>=2,<3
psycopg2-binary==2.9.9

View File

@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["python", "main.py"]

View File

@ -0,0 +1,452 @@
#!/usr/bin/env python3
import os
import sys
import time
import xmlrpc.client
from datetime import datetime, timezone
import psycopg2
import psycopg2.extras
def env(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Missing required env var: {name}", file=sys.stderr, flush=True)
sys.exit(2)
return value
ODOO_URL = env("ODOO_URL", required=True).rstrip("/")
ODOO_DB = env("ODOO_DB", required=True)
ODOO_USER = env("ODOO_USER", required=True)
ODOO_PASSWORD = env("ODOO_PASSWORD", required=True)
ODOO_WORKCENTER_CODE = env("ODOO_WORKCENTER_CODE", "CORT-01")
ODOO_INSTANCE = env("ODOO_INSTANCE", "odoo_demo")
ODOO_SYNC_INTERVAL_SECONDS = int(env("ODOO_SYNC_INTERVAL_SECONDS", "30"))
ODOO_WORKORDER_LIMIT = int(env("ODOO_WORKORDER_LIMIT", "50"))
ODOO_WORKORDER_STATES = [
s.strip()
for s in env("ODOO_WORKORDER_STATES", "waiting,ready,progress,done").split(",")
if s.strip()
]
TENANT = env("TENANT", "ucepsa")
SITE = env("SITE", "demo_edge_oee")
MACHINE_ID = env("MACHINE_ID", "CORT-01")
ASSET = env("ASSET", "revpi_oee_node_01")
TARGET_CYCLE_RATE_PPM = float(env("TARGET_CYCLE_RATE_PPM", "10"))
PG_CONFIG = {
"host": env("PGHOST", required=True),
"port": int(env("PGPORT", "5432")),
"dbname": env("PGDATABASE", required=True),
"user": env("PGUSER", required=True),
"password": env("PGPASSWORD", required=True),
"connect_timeout": 5,
}
def parse_odoo_datetime(value):
if not value:
return None
# Odoo devuelve fechas en UTC como string naive: YYYY-MM-DD HH:MM:SS
if isinstance(value, str):
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
return None
def odoo_connect():
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASSWORD, {})
if not uid:
raise RuntimeError("Odoo authentication failed")
return uid, models
def existing_fields(models, uid, model, wanted):
fields = models.execute_kw(
ODOO_DB,
uid,
ODOO_PASSWORD,
model,
"fields_get",
[],
{"attributes": ["string", "type"]},
)
return [f for f in wanted if f in fields]
def search_read(models, uid, model, domain, wanted_fields, limit=20, order="id desc"):
fields = existing_fields(models, uid, model, wanted_fields)
return models.execute_kw(
ODOO_DB,
uid,
ODOO_PASSWORD,
model,
"search_read",
[domain],
{
"fields": fields,
"limit": limit,
"order": order,
},
)
def read_records(models, uid, model, ids, wanted_fields):
if not ids:
return []
fields = existing_fields(models, uid, model, wanted_fields)
return models.execute_kw(
ODOO_DB,
uid,
ODOO_PASSWORD,
model,
"read",
[ids],
{"fields": fields},
)
def get_workcenter(models, uid):
wc_fields = existing_fields(models, uid, "mrp.workcenter", ["id", "name", "code", "tag_ids"])
if "code" in wc_fields:
domain = ["|", ("code", "=", ODOO_WORKCENTER_CODE), ("name", "ilike", ODOO_WORKCENTER_CODE)]
else:
domain = [("name", "ilike", ODOO_WORKCENTER_CODE)]
workcenters = search_read(
models,
uid,
"mrp.workcenter",
domain,
["id", "name", "code", "tag_ids"],
limit=10,
order="id asc",
)
if not workcenters:
raise RuntimeError(f"No workcenter found for code/name {ODOO_WORKCENTER_CODE}")
# Preferencia exacta por code
for wc in workcenters:
if wc.get("code") == ODOO_WORKCENTER_CODE:
return wc
return workcenters[0]
def normalize_many2one(value):
if isinstance(value, list) and len(value) >= 2:
return value[0], value[1]
return None, None
def upsert_workcenter_map(conn, wc):
wc_id = wc.get("id")
wc_name = wc.get("name") or ODOO_WORKCENTER_CODE
wc_code = wc.get("code") or ODOO_WORKCENTER_CODE
sql = """
INSERT INTO mv_edge_oee_ucepsa_demo.odoo_workcenter_map (
tenant,
site,
odoo_instance,
odoo_workcenter_id,
odoo_workcenter_code,
odoo_workcenter_name,
machine_id,
asset,
is_active
)
VALUES (
%(tenant)s,
%(site)s,
%(odoo_instance)s,
%(odoo_workcenter_id)s,
%(odoo_workcenter_code)s,
%(odoo_workcenter_name)s,
%(machine_id)s,
%(asset)s,
true
)
ON CONFLICT (tenant, site, odoo_instance, odoo_workcenter_code)
DO UPDATE SET
odoo_workcenter_id = EXCLUDED.odoo_workcenter_id,
odoo_workcenter_name = EXCLUDED.odoo_workcenter_name,
machine_id = EXCLUDED.machine_id,
asset = EXCLUDED.asset,
is_active = true;
"""
params = {
"tenant": TENANT,
"site": SITE,
"odoo_instance": ODOO_INSTANCE,
"odoo_workcenter_id": wc_id,
"odoo_workcenter_code": wc_code,
"odoo_workcenter_name": wc_name,
"machine_id": MACHINE_ID,
"asset": ASSET,
}
with conn.cursor() as cur:
cur.execute(sql, params)
def fetch_workorders(models, uid, wc):
wc_id = wc["id"]
domain = [
("workcenter_id", "=", wc_id),
("state", "in", ODOO_WORKORDER_STATES),
]
workorders = search_read(
models,
uid,
"mrp.workorder",
domain,
[
"id",
"name",
"state",
"workcenter_id",
"production_id",
"product_id",
"date_start",
"date_finished",
"duration_expected",
"duration",
"qty_production",
],
limit=ODOO_WORKORDER_LIMIT,
order="id desc",
)
production_ids = []
for wo in workorders:
prod_id, _ = normalize_many2one(wo.get("production_id"))
if prod_id:
production_ids.append(prod_id)
productions = read_records(
models,
uid,
"mrp.production",
production_ids,
[
"id",
"name",
"state",
"product_id",
"product_qty",
"qty_producing",
"date_start",
"date_finished",
"workorder_ids",
],
)
productions_by_id = {p["id"]: p for p in productions}
return workorders, productions_by_id
def upsert_workorder(conn, wc, wo, productions_by_id):
wo_id = wo.get("id")
wo_name = wo.get("name")
wo_state = wo.get("state")
wc_id, wc_name = normalize_many2one(wo.get("workcenter_id"))
prod_id, prod_ref = normalize_many2one(wo.get("production_id"))
product_id, product_name = normalize_many2one(wo.get("product_id"))
production = productions_by_id.get(prod_id, {})
prod_state = production.get("state")
prod_product_id, prod_product_name = normalize_many2one(production.get("product_id"))
product = product_name or prod_product_name or "Producto sin nombre"
order_ref = prod_ref or production.get("name") or f"WO-{wo_id}"
qty_planned = wo.get("qty_production")
if qty_planned is None:
qty_planned = production.get("product_qty")
qty_done = production.get("qty_producing")
real_start = parse_odoo_datetime(wo.get("date_start"))
real_end = parse_odoo_datetime(wo.get("date_finished"))
if real_end is None and wo_state in ("done", "cancel"):
real_end = parse_odoo_datetime(production.get("date_finished"))
has_real_start = real_start is not None
# Si Odoo no tiene date_start, usamos now() solo en el primer insert.
# En updates posteriores, si sigue sin date_start real, conservamos el odoo_start anterior.
insert_start = real_start or datetime.now(timezone.utc)
wc_code = wc.get("code") or ODOO_WORKCENTER_CODE
wc_display_name = wc.get("name") or wc_name or ODOO_WORKCENTER_CODE
sql = """
INSERT INTO mv_edge_oee_ucepsa_demo.odoo_workorders_demo (
tenant,
site,
odoo_instance,
odoo_workorder_id,
odoo_production_id,
odoo_order_ref,
odoo_state,
odoo_workcenter_code,
odoo_workcenter_name,
machine_id,
asset,
product,
qty_planned,
qty_done,
uom,
planned_cycles,
target_cycle_rate_ppm,
odoo_start,
odoo_end,
notes,
snapshot_at
)
VALUES (
%(tenant)s,
%(site)s,
%(odoo_instance)s,
%(odoo_workorder_id)s,
%(odoo_production_id)s,
%(odoo_order_ref)s,
%(odoo_state)s,
%(odoo_workcenter_code)s,
%(odoo_workcenter_name)s,
%(machine_id)s,
%(asset)s,
%(product)s,
%(qty_planned)s,
%(qty_done)s,
%(uom)s,
%(planned_cycles)s,
%(target_cycle_rate_ppm)s,
%(odoo_start)s,
%(odoo_end)s,
%(notes)s,
now()
)
ON CONFLICT (tenant, site, odoo_instance, odoo_workorder_id)
WHERE odoo_workorder_id IS NOT NULL
DO UPDATE SET
odoo_production_id = EXCLUDED.odoo_production_id,
odoo_order_ref = EXCLUDED.odoo_order_ref,
odoo_state = EXCLUDED.odoo_state,
odoo_workcenter_code = EXCLUDED.odoo_workcenter_code,
odoo_workcenter_name = EXCLUDED.odoo_workcenter_name,
machine_id = EXCLUDED.machine_id,
asset = EXCLUDED.asset,
product = EXCLUDED.product,
qty_planned = EXCLUDED.qty_planned,
qty_done = EXCLUDED.qty_done,
uom = EXCLUDED.uom,
planned_cycles = EXCLUDED.planned_cycles,
target_cycle_rate_ppm = EXCLUDED.target_cycle_rate_ppm,
odoo_start = CASE
WHEN %(has_real_start)s THEN EXCLUDED.odoo_start
ELSE mv_edge_oee_ucepsa_demo.odoo_workorders_demo.odoo_start
END,
odoo_end = EXCLUDED.odoo_end,
notes = EXCLUDED.notes,
snapshot_at = now();
"""
params = {
"tenant": TENANT,
"site": SITE,
"odoo_instance": ODOO_INSTANCE,
"odoo_workorder_id": wo_id,
"odoo_production_id": prod_id,
"odoo_order_ref": order_ref,
"odoo_state": wo_state,
"odoo_workcenter_code": wc_code,
"odoo_workcenter_name": wc_display_name,
"machine_id": MACHINE_ID,
"asset": ASSET,
"product": product,
"qty_planned": qty_planned,
"qty_done": qty_done,
"uom": "unidades",
"planned_cycles": qty_planned,
"target_cycle_rate_ppm": TARGET_CYCLE_RATE_PPM,
"odoo_start": insert_start,
"odoo_end": real_end,
"notes": f"Sincronizado desde Odoo workorder {wo_id} / production {prod_id}. Production state: {prod_state}. Workorder name: {wo_name}.",
"has_real_start": has_real_start,
}
with conn.cursor() as cur:
cur.execute(sql, params)
def sync_once():
uid, models = odoo_connect()
wc = get_workcenter(models, uid)
with psycopg2.connect(**PG_CONFIG) as conn:
conn.autocommit = False
upsert_workcenter_map(conn, wc)
workorders, productions_by_id = fetch_workorders(models, uid, wc)
for wo in workorders:
upsert_workorder(conn, wc, wo, productions_by_id)
conn.commit()
print(
f"[OK] sync complete workcenter={wc.get('name')} code={wc.get('code')} "
f"workorders={len(workorders)}",
flush=True,
)
def main():
print("[START] odoo-workorders-sync", flush=True)
print(f"[CONFIG] ODOO_URL={ODOO_URL}", flush=True)
print(f"[CONFIG] ODOO_DB={ODOO_DB}", flush=True)
print(f"[CONFIG] ODOO_WORKCENTER_CODE={ODOO_WORKCENTER_CODE}", flush=True)
print(f"[CONFIG] INTERVAL={ODOO_SYNC_INTERVAL_SECONDS}s", flush=True)
run_once = env("RUN_ONCE", "0") == "1"
while True:
try:
sync_once()
except Exception as exc:
print(f"[ERROR] {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
if run_once:
break
time.sleep(ODOO_SYNC_INTERVAL_SECONDS)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,181 @@
#!/usr/bin/env python3
import os
import sys
import xmlrpc.client
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parents[2]
ENV_FILE = BASE_DIR / ".env.odoo-sync"
def load_env(path):
if not path.exists():
return
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def env(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Falta variable: {name}", file=sys.stderr)
sys.exit(2)
return value
def existing_fields(models, db, uid, password, model, wanted):
fields = models.execute_kw(
db,
uid,
password,
model,
"fields_get",
[],
{"attributes": ["string", "type"]},
)
return [f for f in wanted if f in fields]
def search_read(models, db, uid, password, model, domain, wanted_fields, limit=20):
fields = existing_fields(models, db, uid, password, model, wanted_fields)
return models.execute_kw(
db,
uid,
password,
model,
"search_read",
[domain],
{
"fields": fields,
"limit": limit,
"order": "id desc",
},
)
def main():
load_env(ENV_FILE)
url = env("ODOO_URL", required=True).rstrip("/")
db = env("ODOO_DB", required=True)
user = env("ODOO_USER", required=True)
password = env("ODOO_PASSWORD", required=True)
workcenter_code = env("ODOO_WORKCENTER_CODE", "CORT-01")
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
uid = common.authenticate(db, user, password, {})
if not uid:
print("[ERROR] Autenticación fallida")
return 1
print(f"[OK] Autenticado en Odoo uid={uid}")
print()
print("=== Centros de trabajo ===")
workcenters = search_read(
models,
db,
uid,
password,
"mrp.workcenter",
[],
["id", "name", "code", "tag_ids"],
limit=50,
)
for wc in workcenters:
print(wc)
print()
print(f"=== Centro de trabajo con código/nombre {workcenter_code} ===")
# Algunos Odoo tienen campo code, otros no. Probamos primero code si existe.
wc_fields = existing_fields(models, db, uid, password, "mrp.workcenter", ["id", "name", "code"])
if "code" in wc_fields:
domain = ["|", ("code", "=", workcenter_code), ("name", "ilike", workcenter_code)]
else:
domain = [("name", "ilike", workcenter_code)]
target_wcs = search_read(
models,
db,
uid,
password,
"mrp.workcenter",
domain,
["id", "name", "code"],
limit=10,
)
for wc in target_wcs:
print(wc)
print()
print("=== Órdenes de trabajo recientes ===")
workorders = search_read(
models,
db,
uid,
password,
"mrp.workorder",
[],
[
"id",
"name",
"state",
"workcenter_id",
"production_id",
"product_id",
"date_start",
"date_finished",
"duration_expected",
"duration",
"qty_production",
],
limit=20,
)
for wo in workorders:
print(wo)
print()
print("=== Órdenes de fabricación recientes ===")
productions = search_read(
models,
db,
uid,
password,
"mrp.production",
[],
[
"id",
"name",
"state",
"product_id",
"product_qty",
"qty_producing",
"date_start",
"date_finished",
"workorder_ids",
],
limit=20,
)
for mo in productions:
print(mo)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1 @@
psycopg2-binary==2.9.9

View File

@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["python", "main.py"]

View File

@ -0,0 +1,310 @@
import os
import sys
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import psycopg2
from psycopg2.extras import execute_values, Json
def getenv(name: str, default: str | None = None, required: bool = False) -> str:
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Missing required environment variable: {name}", file=sys.stderr)
sys.exit(1)
return value
PGHOST = getenv("PGHOST", required=True)
PGPORT = int(getenv("PGPORT", "5432"))
PGDATABASE = getenv("PGDATABASE", required=True)
PGUSER = getenv("PGUSER", required=True)
PGPASSWORD = getenv("PGPASSWORD", required=True)
TENANT = getenv("TENANT", "ucepsa")
SITE = getenv("SITE", "demo_edge_oee")
TIMEZONE = getenv("TIMEZONE", "Europe/Madrid")
ANCHOR_MODE = getenv("ANCHOR_MODE", "shift_today")
SCENARIO = getenv("SCENARIO", "standard")
VALUE_HOUR = 36.00
def local_base_time() -> datetime:
tz = ZoneInfo(TIMEZONE)
now = datetime.now(tz)
if ANCHOR_MODE == "rolling":
return now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=4)
return now.replace(hour=8, minute=0, second=0, microsecond=0)
def cost(duration_min: float, event_type: str) -> float:
if event_type == "RUNNING":
return 0.0
return round((duration_min / 60.0) * VALUE_HOUR, 2)
def minutes_between(start: datetime, end: datetime) -> float:
return round((end - start).total_seconds() / 60.0, 2)
def ts(base: datetime, minutes: float) -> datetime:
return base + timedelta(minutes=minutes)
def build_orders(base: datetime):
return [
(
"OF00481",
TENANT,
SITE,
"C0",
"Bolsa 300x400",
ts(base, 0),
ts(base, 210),
50000,
48900,
1100,
"unidades",
"Orden con arranque real retrasado 22 minutos.",
),
(
"OF00482",
TENANT,
SITE,
"C1",
"Bolsa 250x350",
ts(base, 15),
ts(base, 250),
42000,
41600,
400,
"unidades",
"Orden casi normal, usada como comparación.",
),
(
"OF00493",
TENANT,
SITE,
"C2",
"Bolsa 300x400",
ts(base, 60),
ts(base, 320),
52000,
50500,
1500,
"unidades",
"Orden asociada a caso ficticio de sellado débil.",
),
]
def build_events(base: datetime):
raw_events = [
("C0", "OF00481", "PREPARATION", "Búsqueda de bobina", 0, 22, "Odoo inicia la orden, pero la primera marcha real llega 22 minutos después."),
("C0", "OF00481", "RUNNING", "Producción", 22, 74, "Producción real."),
("C0", "OF00481", "MICROSTOP", "Microparos acumulados", 74, 78, "Microparos invisibles para un registro manual."),
("C0", "OF00481", "STOP", "Cambio bobina", 125, 138, "Paro por cambio de bobina."),
("C1", "OF00482", "PREPARATION", "Preparación normal", 15, 18, "Preparación corta, caso de comparación."),
("C1", "OF00482", "RUNNING", "Producción", 18, 160, "Producción estable."),
("C1", None, "RUNNING_WITHOUT_ORDER", "Máquina en marcha sin orden activa", 270, 296, "Brecha de dato: producción no imputada correctamente a Odoo."),
("C2", "OF00493", "PREPARATION", "Ajuste inicial de máquina", 60, 76, "Preparación superior a 15 minutos."),
("C2", "OF00493", "RUNNING", "Producción", 76, 100, "Producción real."),
("C2", "OF00493", "STOP", "Ajuste de sellado", 100, 112, "Paro vinculado a sellado."),
("C2", "OF00493", "RUNNING", "Producción", 112, 145, "Producción real."),
("C2", "OF00493", "MICROSTOP", "Microparos durante orden", 145, 148.5, "Microparos durante producto con reclamación."),
("C2", "OF00493", "STOP", "Ajuste de sellado", 200, 212, "Segundo ajuste de sellado."),
("C2", "OF00493", "STOP", "Ajuste de sellado", 244, 252, "Tercer ajuste de sellado."),
]
events = []
for machine_id, order_id, event_type, cause, start_min, end_min, notes in raw_events:
start = ts(base, start_min)
end = ts(base, end_min)
duration = minutes_between(start, end)
events.append(
(
TENANT,
SITE,
machine_id,
order_id,
event_type,
cause,
start,
end,
duration,
cost(duration, event_type),
"oee_demo_generator",
notes,
)
)
return events
def build_energy(base: datetime):
return [
(ts(base, 60), TENANT, SITE, "C2", "OF00493", 4.800, 102.100, "oee_demo_generator", Json({"note": "inicio orden sellado"})),
(ts(base, 120), TENANT, SITE, "C2", "OF00493", 5.150, 106.900, "oee_demo_generator", Json({"note": "energía sobre media durante ajustes"})),
(ts(base, 180), TENANT, SITE, "C2", "OF00493", 5.090, 112.050, "oee_demo_generator", Json({"note": "energía sobre media"})),
(ts(base, 240), TENANT, SITE, "C2", "OF00493", 5.200, 117.250, "oee_demo_generator", Json({"note": "ajuste sellado"})),
]
def main():
base = local_base_time()
machines = [
("C0", TENANT, SITE, "Cortadora 0", "corte", "cortadora", 36.00, 34.90, 3.23, 0.80, 1.25, "Máquina de alta carga. Valor hora demo prudente."),
("C1", TENANT, SITE, "Cortadora 1", "corte", "cortadora", 36.00, 34.90, 3.23, 0.80, 1.25, "Máquina de alta carga. Comparable con C0."),
("C2", TENANT, SITE, "Cortadora 2", "corte", "cortadora", 36.00, 35.40, 2.80, 0.80, 1.25, "Máquina crítica por carga y problemas de sellado."),
]
orders = build_orders(base)
events = build_events(base)
energy = build_energy(base)
losses = [
("Ajuste de sellado", 18.00, 36.00, 648.00, "Revisar mordazas, teflón, temperatura y presión", "Medio", "Alta", "C2"),
("Espera de material", 14.00, 36.00, 504.00, "Crear prealistamiento de bobina antes del turno", "Bajo", "Alta", "C0/C1/C2"),
("Microparos acumulados", 11.50, 36.00, 414.00, "Revisar fotocélula, tensión de material y cuchilla", "Bajo", "Alta", "C0/C2"),
("Cambio bobina/formato", 9.00, 36.00, 324.00, "Implantar checklist de cambio de bobina/formato", "Bajo", "Media", "C0/C1"),
("Arranque lento de orden", 6.00, 36.00, 216.00, "Preparar primera orden y material antes del inicio del turno", "Bajo", "Media", "C0/C2"),
("Paro sin causa", 5.00, 36.00, 180.00, "Mejorar registro de causas y disciplina de imputación", "Bajo", "Alta", "Todas"),
]
data_quality = [
("orders_started_no_run_10m", "Órdenes iniciadas sin marcha en 10 min", 6, None, "órdenes", "Warning", "Odoo registra inicio, pero la máquina no produce.", "El tiempo de preparación queda mezclado con tiempo productivo.", "Separar preparación, espera de material y marcha real.", 1),
("machine_running_no_order", "Máquina en marcha sin orden activa", 3, None, "eventos", "Warning", "Hay producción física sin orden asignada.", "Producción no imputada correctamente a Odoo.", "Bloquear o alertar producción sin orden activa.", 2),
("orders_closed_before_real_end", "Órdenes cerradas antes de fin real", 2, None, "órdenes", "Warning", "El cierre administrativo no coincide con la realidad física.", "Descuadre de tiempos, coste y trazabilidad.", "Comparar fin Odoo contra última marcha real.", 3),
("stops_without_cause", "Paros sin causa", 5, None, "h/mes", "Warning", "Hay pérdidas sin clasificación útil.", "No se puede decidir ninguna acción concreta.", "Simplificar selección de causas en planta.", 4),
("preparation_gt_15m", "Órdenes con preparación >15 min", 8, None, "órdenes", "Warning", "Arranques lentos repetidos.", "Pérdida de capacidad al inicio de orden o turno.", "Prealistamiento de bobinas, útiles y parámetros.", 5),
("energy_above_avg", "Órdenes con energía superior a media", 5, None, "órdenes", "Info", "Consumo superior al patrón esperado.", "Puede indicar ajustes, fricción, vacío, temperatura o régimen anómalo.", "Cruzar energía con producto, máquina y causa de paro.", 6),
]
quality_cases = [
(
"QC_SELLADO_OF00493",
"OF00493",
"C2",
"Bolsa 300x400",
"Sellado débil",
4,
32,
23,
2.80,
6.00,
"La reclamación no queda aislada: durante la orden aparecen ajustes de sellado, microparos, merma y energía superior a la media.",
"Revisar mordazas, teflón, temperatura, presión y procedimiento de arranque. Comprobar si el patrón se repite en C2 o con Bolsa 300x400.",
)
]
conn = psycopg2.connect(
host=PGHOST,
port=PGPORT,
dbname=PGDATABASE,
user=PGUSER,
password=PGPASSWORD,
)
with conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM mv_hot.edge_oee_energy_demo WHERE tenant = %s AND site = %s;", (TENANT, SITE))
cur.execute("DELETE FROM mv_hot.edge_oee_quality_cases_demo WHERE machine_id IN ('C0','C1','C2');")
cur.execute("DELETE FROM mv_hot.edge_oee_data_quality_demo;")
cur.execute("DELETE FROM mv_hot.edge_oee_loss_summary_demo;")
cur.execute("DELETE FROM mv_hot.edge_oee_events_demo WHERE tenant = %s AND site = %s;", (TENANT, SITE))
cur.execute("DELETE FROM mv_hot.edge_oee_orders_demo WHERE tenant = %s AND site = %s;", (TENANT, SITE))
cur.execute("DELETE FROM mv_hot.edge_oee_machines WHERE machine_id IN ('C0','C1','C2');")
execute_values(
cur,
"""
INSERT INTO mv_hot.edge_oee_machines
(machine_id, tenant, site, name, area, machine_type, value_hour_eur,
standard_kg_h, sale_price_eur_kg, transformation_cost_eur_kg,
raw_material_cost_eur_kg, notes)
VALUES %s
""",
machines,
)
execute_values(
cur,
"""
INSERT INTO mv_hot.edge_oee_orders_demo
(order_id, tenant, site, machine_id, product, odoo_start, odoo_end,
qty_planned, qty_done, scrap_qty, uom, commercial_note)
VALUES %s
""",
orders,
)
execute_values(
cur,
"""
INSERT INTO mv_hot.edge_oee_events_demo
(tenant, site, machine_id, order_id, event_type, cause, start_time,
end_time, duration_min, cost_eur, source, notes)
VALUES %s
""",
events,
)
execute_values(
cur,
"""
INSERT INTO mv_hot.edge_oee_loss_summary_demo
(cause, hours_month, value_hour_eur, monthly_cost_eur,
recommended_action, effort, priority, machine_focus)
VALUES %s
""",
losses,
)
execute_values(
cur,
"""
INSERT INTO mv_hot.edge_oee_data_quality_demo
(metric_id, metric, value_numeric, value_text, unit, status,
problem, likely_impact, recommended_action, sort_order)
VALUES %s
""",
data_quality,
)
execute_values(
cur,
"""
INSERT INTO mv_hot.edge_oee_quality_cases_demo
(case_id, order_id, machine_id, product, claim,
sealing_adjustment_stops, sealing_adjustment_min,
microstops_count, scrap_pct, energy_vs_avg_pct,
interpretation, recommended_action)
VALUES %s
""",
quality_cases,
)
execute_values(
cur,
"""
INSERT INTO mv_hot.edge_oee_energy_demo
(ts, tenant, site, machine_id, order_id, power_kw, kwh_total, source, payload_json)
VALUES %s
""",
energy,
)
conn.close()
print("[OK] Edge-OEE demo regenerated")
print(f"[OK] tenant={TENANT} site={SITE} scenario={SCENARIO}")
print(f"[OK] anchor_mode={ANCHOR_MODE} base={base.isoformat()}")
print(f"[OK] machines={len(machines)} orders={len(orders)} events={len(events)} energy_points={len(energy)}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1 @@
psycopg2-binary==2.9.9

View File

@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["gunicorn", "-b", "0.0.0.0:8080", "--workers", "2", "--threads", "4", "app:app"]

View File

@ -0,0 +1,701 @@
import json
import os
from datetime import datetime
import psycopg2
import psycopg2.extras
from flask import Flask, Response, redirect, render_template_string, request, url_for
def getenv(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
PG_CONFIG = {
"host": getenv("PGHOST", required=True),
"port": int(getenv("PGPORT", "5432")),
"dbname": getenv("PGDATABASE", required=True),
"user": getenv("PGUSER", required=True),
"password": getenv("PGPASSWORD", required=True),
"connect_timeout": 5,
}
APP_TITLE = getenv("APP_TITLE", "MESAVAULT Edge-OEE - Consola de paros UCEPSA")
DEFAULT_MACHINE_ID = getenv("DEFAULT_MACHINE_ID", "CORT-01")
DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01")
AUTO_REFRESH_SECONDS = int(getenv("AUTO_REFRESH_SECONDS", "5"))
ALLOWED_MACHINES = [
item.strip()
for item in getenv("ALLOWED_MACHINES", DEFAULT_MACHINE_ID).split(",")
if item.strip()
]
def resolve_machine_id(raw_value):
machine_id = (raw_value or DEFAULT_MACHINE_ID).strip()
if machine_id not in ALLOWED_MACHINES:
return DEFAULT_MACHINE_ID
return machine_id
app = Flask(__name__)
def get_conn():
return psycopg2.connect(**PG_CONFIG)
def fetch_pending_stops(machine_id):
sql = """
SELECT
id,
tenant,
site,
vertical,
asset,
machine_id,
order_id,
started_at,
ended_at,
status,
classification_status,
cause_code,
cause_name,
operator_note,
duration_min,
cost_eur
FROM mv_hot.edge_oee_stops_recent
WHERE classification_status = 'PENDING'
AND machine_id = %(machine_id)s
ORDER BY started_at DESC
LIMIT 50;
"""
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, {"machine_id": machine_id})
return cur.fetchall()
def fetch_causes():
sql = """
SELECT
cause_code,
cause_name,
description,
is_planned
FROM mv_hot.edge_oee_stop_cause_options
ORDER BY sort_order, cause_name;
"""
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql)
return cur.fetchall()
def classify_stop(stop_id, machine_id, cause_code, operator_note):
sql = """
UPDATE mv_hot.edge_oee_machine_stops
SET
cause_code = %(cause_code)s,
operator_note = %(operator_note)s,
classification_status = 'CLASSIFIED',
updated_at = now()
WHERE id = %(stop_id)s
AND machine_id = %(machine_id)s
AND classification_status = 'PENDING';
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
sql,
{
"stop_id": stop_id,
"machine_id": machine_id,
"cause_code": cause_code,
"operator_note": operator_note,
},
)
return cur.rowcount
def fmt_dt(value):
if not value:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value)
def fmt_num(value, decimals=2):
if value is None:
return "0"
try:
return f"{float(value):.{decimals}f}"
except Exception:
return str(value)
HTML = """
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{{ app_title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="manifest" href="{{ url_for('manifest', machine_id=selected_machine) }}">
<meta name="theme-color" content="#0f172a">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="MESAVAULT {{ selected_machine }}">
<link rel="icon" href="{{ url_for('pwa_icon') }}" type="image/svg+xml">
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--panel2: #1f2937;
--text: #e5e7eb;
--muted: #94a3b8;
--line: #334155;
--blue: #2563eb;
--blue2: #60a5fa;
--green: #22c55e;
--red: #ef4444;
--orange: #f97316;
--input: #020617;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
padding: 18px 22px;
border-bottom: 1px solid var(--line);
}
h1 {
margin: 0 0 6px 0;
font-size: 28px;
line-height: 1.1;
}
.subtitle {
color: var(--muted);
font-size: 15px;
}
main {
padding: 22px;
max-width: 1800px;
margin: 0 auto;
}
.machine-tabs {
display: flex;
gap: 8px;
margin: 0 0 22px 0;
flex-wrap: wrap;
}
.machine-tab {
padding: 9px 13px;
border-radius: 10px;
background: var(--panel2);
color: #dbeafe;
text-decoration: none;
border: 1px solid var(--line);
font-weight: 600;
font-size: 14px;
}
.machine-tab.active {
background: var(--blue);
color: white;
border-color: var(--blue2);
}
.cards {
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 22px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px;
min-height: 96px;
}
.card-title {
color: var(--muted);
font-size: 15px;
margin-bottom: 10px;
}
.card-value {
font-size: 34px;
line-height: 1;
font-weight: 700;
}
.card-subtitle {
margin-top: 8px;
color: var(--muted);
font-size: 14px;
}
.table-wrap {
overflow-x: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 1300px;
}
th {
background: var(--panel2);
color: white;
text-align: left;
font-size: 14px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
td {
padding: 12px 14px;
border-bottom: 1px solid #1e293b;
vertical-align: top;
font-size: 14px;
}
tr:last-child td {
border-bottom: none;
}
.machine-main {
font-weight: 700;
font-size: 16px;
}
.machine-sub {
color: var(--muted);
margin-top: 2px;
font-size: 13px;
}
.badge {
display: inline-block;
padding: 4px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
margin-right: 4px;
}
.badge-open {
background: rgba(239, 68, 68, 0.14);
color: #fecaca;
border-color: rgba(239, 68, 68, 0.45);
}
.badge-closed {
background: rgba(34, 197, 94, 0.14);
color: #bbf7d0;
border-color: rgba(34, 197, 94, 0.45);
}
.badge-pending {
background: rgba(249, 115, 22, 0.14);
color: #fed7aa;
border-color: rgba(249, 115, 22, 0.45);
margin-top: 5px;
}
select,
input[type="text"] {
width: 100%;
min-width: 210px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--input);
color: var(--text);
font-size: 14px;
}
button {
padding: 10px 18px;
border-radius: 8px;
border: 0;
background: var(--blue);
color: white;
font-weight: 700;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #1d4ed8;
}
.empty {
padding: 40px;
text-align: center;
color: var(--muted);
font-size: 18px;
}
.footer-note {
margin-top: 18px;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 900px) {
main {
padding: 14px;
}
.cards {
grid-template-columns: 1fr;
}
h1 {
font-size: 22px;
}
}
</style>
</head>
<body>
<header>
<h1>{{ app_title }}</h1>
<div class="subtitle">
Clasificación manual de paros detectados por Edge-OEE ·
Máquina seleccionada: <strong>{{ selected_machine }}</strong> ·
Última carga: {{ loaded_at }}
</div>
</header>
<main>
<div class="machine-tabs">
{% for machine in allowed_machines %}
<a
class="machine-tab {% if machine == selected_machine %}active{% endif %}"
href="{{ url_for('index', machine_id=machine) }}"
>
{{ machine }}
</a>
{% endfor %}
</div>
<div class="cards">
<div class="card">
<div class="card-title">Paros pendientes</div>
<div class="card-value">{{ pending_count }}</div>
</div>
<div class="card">
<div class="card-title">Máquina monitorizada</div>
<div class="card-value">{{ selected_machine }}</div>
<div class="card-subtitle">{{ default_asset }}</div>
</div>
</div>
<div class="table-wrap">
{% if stops %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Máquina</th>
<th>Inicio</th>
<th>Fin</th>
<th>Estado</th>
<th>Duración</th>
<th>Coste</th>
<th>Causa</th>
<th>Nota</th>
<th>Guardar</th>
</tr>
</thead>
<tbody>
{% for stop in stops %}
<tr>
<td>#{{ stop.id }}</td>
<td>
<div class="machine-main">{{ stop.machine_id }}</div>
<div class="machine-sub">{{ stop.asset }}</div>
</td>
<td>{{ fmt_dt(stop.started_at) }}</td>
<td>
{% if stop.ended_at %}
{{ fmt_dt(stop.ended_at) }}
{% else %}
Abierto
{% endif %}
</td>
<td>
{% if stop.status == "OPEN" %}
<span class="badge badge-open">OPEN</span>
{% else %}
<span class="badge badge-closed">{{ stop.status }}</span>
{% endif %}
<br>
<span class="badge badge-pending">{{ stop.classification_status }}</span>
</td>
<td>{{ fmt_num(stop.duration_min, 2) }} min</td>
<td>{{ fmt_num(stop.cost_eur, 2) }} </td>
<td>
<form method="post" action="{{ url_for('classify', stop_id=stop.id) }}">
<input type="hidden" name="machine_id" value="{{ selected_machine }}">
<select name="cause_code" required>
<option value="">Seleccionar causa...</option>
{% for cause in causes %}
<option value="{{ cause.cause_code }}">
{{ cause.cause_name }}
</option>
{% endfor %}
</select>
</td>
<td>
<input
type="text"
name="operator_note"
placeholder="Nota opcional"
value="{{ stop.operator_note or '' }}"
>
</td>
<td>
<button type="submit">Guardar</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">
No hay paros pendientes para {{ selected_machine }}.
</div>
{% endif %}
</div>
</main>
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js").catch(function (err) {
console.log("Service worker no registrado:", err);
});
});
}
</script>
<script>
(function () {
const refreshSeconds = Number("{{ auto_refresh_seconds }}");
const refreshMs = Math.max(refreshSeconds, 2) * 1000;
let formDirty = false;
document.addEventListener("input", function (event) {
if (event.target.closest("form")) {
formDirty = true;
}
});
document.addEventListener("change", function (event) {
if (event.target.closest("form")) {
formDirty = true;
}
});
function userIsEditing() {
const active = document.activeElement;
if (!active) return false;
const tag = active.tagName;
return tag === "INPUT" ||
tag === "SELECT" ||
tag === "TEXTAREA" ||
active.isContentEditable;
}
setInterval(function () {
if (document.hidden) return;
if (formDirty || userIsEditing()) return;
window.location.reload();
}, refreshMs);
})();
</script>
</body>
</html>
"""
@app.route("/", methods=["GET"])
def index():
selected_machine = resolve_machine_id(request.args.get("machine_id"))
stops = fetch_pending_stops(selected_machine)
causes = fetch_causes()
return render_template_string(
HTML,
app_title=APP_TITLE,
stops=stops,
causes=causes,
pending_count=len(stops),
selected_machine=selected_machine,
allowed_machines=ALLOWED_MACHINES,
default_machine=selected_machine,
default_asset=DEFAULT_ASSET,
auto_refresh_seconds=AUTO_REFRESH_SECONDS,
loaded_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
fmt_dt=fmt_dt,
fmt_num=fmt_num,
)
@app.route("/classify/<int:stop_id>", methods=["POST"])
def classify(stop_id):
selected_machine = resolve_machine_id(
request.form.get("machine_id") or request.args.get("machine_id")
)
cause_code = (request.form.get("cause_code") or "").strip()
operator_note = (request.form.get("operator_note") or "").strip()
if cause_code:
classify_stop(
stop_id=stop_id,
machine_id=selected_machine,
cause_code=cause_code,
operator_note=operator_note,
)
return redirect(url_for("index", machine_id=selected_machine))
@app.route("/health", methods=["GET"])
def health():
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1;")
cur.fetchone()
return {"status": "ok"}, 200
except Exception as exc:
return {"status": "error", "error": str(exc)}, 500
@app.route("/manifest.webmanifest", methods=["GET"])
def manifest():
selected_machine = resolve_machine_id(request.args.get("machine_id"))
payload = {
"name": f"MESAVAULT Edge-OEE {selected_machine}",
"short_name": selected_machine,
"description": "Consola de paros MESAVAULT Edge-OEE para UCEPSA",
"start_url": url_for("index", machine_id=selected_machine),
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"icons": [
{
"src": url_for("pwa_icon"),
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable",
}
],
}
return Response(
json.dumps(payload, ensure_ascii=False),
mimetype="application/manifest+json",
)
@app.route("/sw.js", methods=["GET"])
def service_worker():
js = """
self.addEventListener("install", function (event) {
self.skipWaiting();
});
self.addEventListener("activate", function (event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener("fetch", function (event) {
event.respondWith(fetch(event.request));
});
"""
return Response(
js,
mimetype="application/javascript",
headers={"Cache-Control": "no-cache"},
)
@app.route("/pwa-icon.svg", methods=["GET"])
def pwa_icon():
svg = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0f172a"/>
<path d="M96 328h320v48H96z" fill="#60a5fa"/>
<path d="M136 136h240v128H136z" fill="#2563eb"/>
<path d="M168 176h176v48H168z" fill="#dbeafe"/>
<circle cx="160" cy="392" r="28" fill="#22c55e"/>
<circle cx="352" cy="392" r="28" fill="#22c55e"/>
<text x="256" y="304" text-anchor="middle" font-size="64" font-family="Arial" font-weight="700" fill="#ffffff">OEE</text>
</svg>
"""
return Response(svg, mimetype="image/svg+xml")
if __name__ == "__main__":
port = int(getenv("APP_PORT", "8080"))
app.run(host="0.0.0.0", port=port)

View File

@ -0,0 +1,3 @@
Flask==3.0.3
gunicorn==22.0.0
psycopg2-binary==2.9.9

View File

@ -0,0 +1,8 @@
.env.local
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
.pytest_cache/
.mypy_cache/

View File

@ -0,0 +1,28 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
fonts-dejavu-core \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libharfbuzz-subset0 \
libffi8 \
libjpeg62-turbo \
libopenjp2-7 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8090
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090"]

View File

@ -0,0 +1,634 @@
import os
import re
from datetime import datetime
from decimal import Decimal
from typing import Any
import psycopg
from psycopg.rows import dict_row
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse, Response
from fastapi.encoders import jsonable_encoder
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from weasyprint import HTML
DATABASE_URL = os.getenv("DATABASE_URL")
REPORT_TITLE = os.getenv("REPORT_TITLE", "MESAVAULT Edge-OEE UCEPSA")
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL environment variable is required")
app = FastAPI(title="MESAVAULT Order Report API")
templates = Jinja2Templates(directory="/app/app/templates")
def safe_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")
return cleaned or "order-report"
def fmt_dt(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.strftime("%d/%m/%Y %H:%M:%S")
text = str(value)
text = re.sub(r"\.\d+", "", text)
text = text.replace("+02:00", "").replace("+01:00", "")
return text
def fmt_num(value: Any, decimals: int = 2) -> str:
if value is None:
return ""
try:
number = float(value)
return f"{number:.{decimals}f}"
except (ValueError, TypeError):
return str(value)
templates.env.filters["dt"] = fmt_dt
templates.env.filters["num"] = fmt_num
def fetch_one(query: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
with conn.cursor() as cur:
cur.execute(query, params)
return cur.fetchone()
def fetch_all(query: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
with conn.cursor() as cur:
cur.execute(query, params)
return list(cur.fetchall())
def build_report_context(order_ref: str) -> dict[str, Any]:
report = fetch_one(
"""
SELECT *
FROM mv_reports_ucepsa_demo.order_execution_report_full_v1
WHERE odoo_order_ref = %s
""",
(order_ref,),
)
if report is None:
raise HTTPException(status_code=404, detail=f"Order not found: {order_ref}")
stops = fetch_all(
"""
SELECT
stop_id,
stop_started_at,
stop_ended_at,
attributed_duration_min,
cause_code,
operator_note,
classification_status,
attributed_cost_eur,
stop_quality_status
FROM mv_reports_ucepsa_demo.order_execution_stops_v1
WHERE odoo_order_ref = %s
ORDER BY attributed_started_at
""",
(order_ref,),
)
timeline = fetch_all(
"""
SELECT
event_ts,
event_type,
event_label,
severity,
cause_code,
duration_min,
cost_eur,
description
FROM mv_reports_ucepsa_demo.order_execution_timeline_v1
WHERE odoo_order_ref = %s
ORDER BY event_ts, event_type
""",
(order_ref,),
)
margin = fetch_one(
"""
SELECT *
FROM mv_reports_ucepsa_demo.order_margin_report_v1
WHERE odoo_order_ref = %s
""",
(order_ref,),
)
raw_materials = fetch_all(
"""
SELECT
raw_product_default_code,
raw_product_name,
planned_qty,
consumed_qty,
raw_uom,
material_cost_eur_per_kg,
material_cost_estimated_eur_rounded,
material_cost_status
FROM mv_reports_ucepsa_demo.order_raw_material_costs_v1
WHERE odoo_order_ref = %s
ORDER BY raw_product_default_code, raw_product_name
""",
(order_ref,),
)
return {
"title": REPORT_TITLE,
"report": report,
"stops": stops,
"timeline": timeline,
"margin": margin,
"raw_materials": raw_materials,
}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/report")
def api_report(order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025")):
return jsonable_encoder(build_report_context(order_ref))
@app.get("/report.html", response_class=HTMLResponse)
def report_html(
request: Request,
order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025"),
):
context = build_report_context(order_ref)
context["request"] = request
return templates.TemplateResponse(
"order_report.html",
context,
)
@app.get("/report.pdf")
def report_pdf(order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025")):
context = build_report_context(order_ref)
template = templates.env.get_template("order_report.html")
html_content = template.render(**context)
pdf_bytes = HTML(
string=html_content,
base_url="/app/app",
).write_pdf()
filename = f"mesavault-order-report-{safe_filename(order_ref)}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{filename}"'
},
)
@app.get("/api/cost-readiness")
def api_cost_readiness(limit: int = Query(100, ge=1, le=500)):
summary = fetch_one(
"""
SELECT *
FROM mv_reports_ucepsa_demo.cost_readiness_summary_v1
""",
(),
)
orders = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
sale_order_ref,
customer_name,
product_default_code,
product_name,
machine_id,
revenue_net_eur,
raw_total_consumed_qty,
raw_materials_summary,
cost_readiness_score,
cost_readiness_status,
cost_readiness_description,
technical_quality_level,
final_report_status,
margin_status
FROM mv_reports_ucepsa_demo.cost_readiness_orders_v1
ORDER BY cost_readiness_score ASC, odoo_order_ref DESC
LIMIT %s
""",
(limit,),
)
raw_materials = fetch_all(
"""
SELECT
raw_product_default_code,
raw_product_name,
raw_uom,
affected_order_count,
affected_orders,
affected_sale_orders,
affected_customers,
total_consumed_qty,
current_material_cost_eur_per_kg,
raw_material_readiness_status,
recommended_action
FROM mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
ORDER BY affected_order_count DESC, raw_product_default_code
""",
(),
)
return jsonable_encoder(
{
"summary": summary or {},
"orders": orders,
"raw_materials": raw_materials,
}
)
@app.get("/cost-readiness.html", response_class=HTMLResponse)
def cost_readiness_html(request: Request, limit: int = Query(100, ge=1, le=500)):
summary = fetch_one(
"""
SELECT *
FROM mv_reports_ucepsa_demo.cost_readiness_summary_v1
""",
(),
)
orders = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
sale_order_ref,
customer_name,
product_default_code,
product_name,
machine_id,
revenue_net_eur,
raw_total_consumed_qty,
raw_materials_summary,
cost_readiness_score,
cost_readiness_status,
cost_readiness_description,
technical_quality_level,
final_report_status,
margin_status
FROM mv_reports_ucepsa_demo.cost_readiness_orders_v1
ORDER BY cost_readiness_score ASC, odoo_order_ref DESC
LIMIT %s
""",
(limit,),
)
raw_materials = fetch_all(
"""
SELECT
raw_product_default_code,
raw_product_name,
raw_uom,
affected_order_count,
affected_orders,
affected_sale_orders,
affected_customers,
total_consumed_qty,
current_material_cost_eur_per_kg,
raw_material_readiness_status,
recommended_action
FROM mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
ORDER BY affected_order_count DESC, raw_product_default_code
""",
(),
)
return templates.TemplateResponse(
"cost_readiness.html",
{
"request": request,
"title": REPORT_TITLE,
"summary": summary or {},
"orders": orders,
"raw_materials": raw_materials,
},
)
@app.get("/api/orders")
def api_orders(limit: int = Query(50, ge=1, le=200)):
rows = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
product,
machine_id,
start_ts,
end_ts,
window_min,
real_cycle_delta,
kwh_consumed,
stop_count,
stop_min,
technical_quality_level,
final_report_status
FROM mv_reports_ucepsa_demo.order_execution_report_full_v1
ORDER BY start_ts DESC
LIMIT %s
""",
(limit,),
)
return jsonable_encoder({"orders": rows})
@app.get("/orders.html", response_class=HTMLResponse)
def orders_html(request: Request, limit: int = Query(50, ge=1, le=200)):
orders = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
product,
machine_id,
start_ts,
end_ts,
window_min,
real_cycle_delta,
kwh_consumed,
stop_count,
stop_min,
technical_quality_level,
final_report_status
FROM mv_reports_ucepsa_demo.order_execution_report_full_v1
ORDER BY start_ts DESC
LIMIT %s
""",
(limit,),
)
return templates.TemplateResponse(
"orders_index.html",
{
"request": request,
"title": REPORT_TITLE,
"orders": orders,
},
)
@app.get("/api/data-quality-contract")
def api_data_quality_contract(limit: int = Query(100, ge=1, le=500)):
summary = fetch_one(
"""
SELECT
count(*) AS total_orders,
count(*) FILTER (WHERE contract_status = 'CONTRATO_CUMPLIDO') AS contract_ok_orders,
count(*) FILTER (WHERE contract_status = 'VALIDO_CON_OBSERVACIONES') AS contract_warn_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_CRITICO') AS blocked_critical_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_ALTO') AS blocked_high_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_MEDIO') AS blocked_medium_orders,
round(avg(contract_score), 2) AS avg_contract_score,
sum(critical_failures) AS total_critical_failures,
sum(high_failures) AS total_high_failures,
sum(medium_failures) AS total_medium_failures,
sum(total_issue_score) AS total_issue_score
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
""",
(),
)
orders = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
sale_order_ref,
customer_name,
product_default_code,
product_name,
machine_id,
contract_score,
contract_status,
failed_rules,
warn_rules,
critical_failures,
high_failures,
medium_failures,
main_blocker_rule_code,
main_blocker_rule_name,
main_blocker_domain,
main_blocker_severity,
owner_area_to_act,
main_blocker_finding,
main_recommended_action,
contract_summary
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
ORDER BY contract_score ASC, total_issue_score DESC, odoo_order_ref DESC
LIMIT %s
""",
(limit,),
)
owners = fetch_all(
"""
SELECT
owner_area,
failed_checks,
warn_checks,
critical_failed_checks,
high_failed_checks,
affected_orders,
total_issue_score,
affected_rules,
affected_order_refs
FROM mv_reports_ucepsa_demo.data_quality_contract_owner_summary_v1
WHERE failed_checks > 0
OR warn_checks > 0
ORDER BY total_issue_score DESC NULLS LAST, owner_area
""",
(),
)
rules = fetch_all(
"""
SELECT
rule_code,
rule_name,
domain,
severity,
owner_area,
check_status,
count(*) AS order_count,
sum(issue_score) AS total_issue_score,
string_agg(
DISTINCT odoo_order_ref,
', '
ORDER BY odoo_order_ref
) AS affected_orders
FROM mv_reports_ucepsa_demo.data_quality_contract_order_checks_v1
WHERE check_status IN ('FAIL', 'WARN')
GROUP BY
rule_code,
rule_name,
domain,
severity,
owner_area,
check_status
ORDER BY
total_issue_score DESC NULLS LAST,
rule_code,
check_status
""",
(),
)
return jsonable_encoder(
{
"summary": summary or {},
"orders": orders,
"owners": owners,
"rules": rules,
}
)
@app.get("/data-quality-contract.html", response_class=HTMLResponse)
def data_quality_contract_html(request: Request, limit: int = Query(100, ge=1, le=500)):
summary = fetch_one(
"""
SELECT
count(*) AS total_orders,
count(*) FILTER (WHERE contract_status = 'CONTRATO_CUMPLIDO') AS contract_ok_orders,
count(*) FILTER (WHERE contract_status = 'VALIDO_CON_OBSERVACIONES') AS contract_warn_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_CRITICO') AS blocked_critical_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_ALTO') AS blocked_high_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_MEDIO') AS blocked_medium_orders,
round(avg(contract_score), 2) AS avg_contract_score,
sum(critical_failures) AS total_critical_failures,
sum(high_failures) AS total_high_failures,
sum(medium_failures) AS total_medium_failures,
sum(total_issue_score) AS total_issue_score
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
""",
(),
)
orders = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
sale_order_ref,
customer_name,
product_default_code,
product_name,
machine_id,
contract_score,
contract_status,
failed_rules,
warn_rules,
critical_failures,
high_failures,
medium_failures,
main_blocker_rule_code,
main_blocker_rule_name,
main_blocker_domain,
main_blocker_severity,
owner_area_to_act,
main_blocker_finding,
main_recommended_action,
contract_summary
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
ORDER BY contract_score ASC, total_issue_score DESC, odoo_order_ref DESC
LIMIT %s
""",
(limit,),
)
owners = fetch_all(
"""
SELECT
owner_area,
failed_checks,
warn_checks,
critical_failed_checks,
high_failed_checks,
affected_orders,
total_issue_score,
affected_rules,
affected_order_refs
FROM mv_reports_ucepsa_demo.data_quality_contract_owner_summary_v1
WHERE failed_checks > 0
OR warn_checks > 0
ORDER BY total_issue_score DESC NULLS LAST, owner_area
""",
(),
)
rules = fetch_all(
"""
SELECT
rule_code,
rule_name,
domain,
severity,
owner_area,
check_status,
count(*) AS order_count,
sum(issue_score) AS total_issue_score,
string_agg(
DISTINCT odoo_order_ref,
', '
ORDER BY odoo_order_ref
) AS affected_orders
FROM mv_reports_ucepsa_demo.data_quality_contract_order_checks_v1
WHERE check_status IN ('FAIL', 'WARN')
GROUP BY
rule_code,
rule_name,
domain,
severity,
owner_area,
check_status
ORDER BY
total_issue_score DESC NULLS LAST,
rule_code,
check_status
""",
(),
)
return templates.TemplateResponse(
"data_quality_contract.html",
{
"request": request,
"title": REPORT_TITLE,
"summary": summary or {},
"orders": orders,
"owners": owners,
"rules": rules,
},
)

View File

@ -0,0 +1,253 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{{ title }} - Cost Readiness</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 28px;
color: #1f2933;
background: #f7f9fb;
font-size: 13px;
}
.page {
background: white;
padding: 28px;
border-radius: 12px;
max-width: 1600px;
margin: auto;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
h1 {
margin: 0 0 4px 0;
color: #0f172a;
}
h2 {
margin-top: 26px;
color: #0f172a;
border-bottom: 1px solid #cbd5e1;
padding-bottom: 6px;
}
.muted {
color: #64748b;
}
.grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
margin-top: 18px;
margin-bottom: 20px;
}
.card {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px;
background: #ffffff;
}
.label {
color: #64748b;
font-size: 12px;
margin-bottom: 6px;
}
.value {
color: #0f172a;
font-weight: bold;
font-size: 22px;
line-height: 1.1;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 14px;
font-size: 12px;
}
th {
background: #f1f5f9;
text-align: left;
padding: 8px;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 8px;
border-bottom: 1px solid #e2e8f0;
vertical-align: top;
}
.ok {
color: #166534;
font-weight: bold;
}
.warn {
color: #92400e;
font-weight: bold;
}
.bad {
color: #991b1b;
font-weight: bold;
}
.small {
font-size: 11px;
}
a {
color: #0f766e;
font-weight: bold;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.pill {
display: inline-block;
padding: 3px 7px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: bold;
}
</style>
</head>
<body>
<div class="page">
<h1>Cost Readiness Cockpit</h1>
<p class="muted">{{ title }}</p>
<p>
<a href="/data-quality-contract.html" target="_blank">Abrir Data Quality Contract</a>
</p>
<p>
Esta pantalla muestra qué órdenes están preparadas para cálculo de margen y qué datos lo bloquean.
No calcula margen real si falta coste de materia prima.
</p>
<div class="grid">
<div class="card">
<div class="label">Órdenes totales</div>
<div class="value">{{ summary.total_orders or 0 }}</div>
</div>
<div class="card">
<div class="label">Con ingreso</div>
<div class="value">{{ summary.orders_with_revenue or 0 }}</div>
</div>
<div class="card">
<div class="label">Sin ingreso</div>
<div class="value">{{ summary.orders_without_revenue or 0 }}</div>
</div>
<div class="card">
<div class="label">Sin coste material</div>
<div class="value">{{ summary.orders_missing_raw_material_cost or 0 }}</div>
</div>
<div class="card">
<div class="label">Listas para margen</div>
<div class="value">{{ summary.orders_ready_for_margin or 0 }}</div>
</div>
<div class="card">
<div class="label">Score medio</div>
<div class="value">{{ summary.avg_cost_readiness_score|num(2) if summary.avg_cost_readiness_score is not none else "0.00" }}</div>
</div>
</div>
<h2>1. Estado por orden</h2>
<table>
<tr>
<th>Orden</th>
<th>Pedido venta</th>
<th>Cliente</th>
<th>Producto</th>
<th>Máquina</th>
<th>Ingreso neto</th>
<th>Kg materia prima</th>
<th>Score</th>
<th>Estado</th>
<th>Bloqueo / acción</th>
<th>Informe</th>
</tr>
{% for order in orders %}
<tr>
<td>{{ order.odoo_order_ref }}</td>
<td>{{ order.sale_order_ref or "" }}</td>
<td>{{ order.customer_name or "" }}</td>
<td>
<strong>{{ order.product_default_code or "" }}</strong><br>
<span class="small">{{ order.product_name or "" }}</span>
</td>
<td>{{ order.machine_id }}</td>
<td>{{ order.revenue_net_eur|num(2) if order.revenue_net_eur is not none else "" }} €</td>
<td>{{ order.raw_total_consumed_qty|num(3) if order.raw_total_consumed_qty is not none else "" }}</td>
<td>
{% if order.cost_readiness_score >= 80 %}
<span class="ok">{{ order.cost_readiness_score }}</span>
{% elif order.cost_readiness_score >= 50 %}
<span class="warn">{{ order.cost_readiness_score }}</span>
{% else %}
<span class="bad">{{ order.cost_readiness_score }}</span>
{% endif %}
</td>
<td>
{% if order.cost_readiness_status == 'LISTO_PARA_MARGEN' %}
<span class="ok">Listo para margen</span>
{% elif order.cost_readiness_status == 'BLOQUEADO_FALTA_COSTE_MATERIA_PRIMA' %}
<span class="warn">Falta coste materia prima</span>
{% elif order.cost_readiness_status == 'BLOQUEADO_SIN_INGRESO' %}
<span class="bad">Sin ingreso</span>
{% elif order.cost_readiness_status == 'BLOQUEADO_DATOS_TECNICOS' %}
<span class="bad">Datos técnicos débiles</span>
{% else %}
<span class="pill">{{ order.cost_readiness_status }}</span>
{% endif %}
</td>
<td class="small">{{ order.cost_readiness_description }}</td>
<td>
<a href="/report.html?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">HTML</a>
|
<a href="/report.pdf?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">PDF</a>
</td>
</tr>
{% else %}
<tr><td colspan="11">No hay órdenes disponibles.</td></tr>
{% endfor %}
</table>
<h2>2. Materias primas pendientes de coste</h2>
<table>
<tr>
<th>Código</th>
<th>Materia prima</th>
<th>UdM</th>
<th>Órdenes afectadas</th>
<th>Pedidos venta</th>
<th>Clientes</th>
<th>Cantidad consumida</th>
<th>Coste €/kg actual</th>
<th>Estado</th>
<th>Acción recomendada</th>
</tr>
{% for raw in raw_materials %}
<tr>
<td>{{ raw.raw_product_default_code or "" }}</td>
<td>{{ raw.raw_product_name or "" }}</td>
<td>{{ raw.raw_uom or "" }}</td>
<td>
<strong>{{ raw.affected_order_count }}</strong><br>
<span class="small">{{ raw.affected_orders }}</span>
</td>
<td class="small">{{ raw.affected_sale_orders }}</td>
<td class="small">{{ raw.affected_customers }}</td>
<td>{{ raw.total_consumed_qty|num(3) }}</td>
<td>{{ raw.current_material_cost_eur_per_kg|num(4) if raw.current_material_cost_eur_per_kg is not none else "" }}</td>
<td>
{% if raw.raw_material_readiness_status == 'COSTE_DISPONIBLE' %}
<span class="ok">Coste disponible</span>
{% else %}
<span class="warn">Pendiente coste</span>
{% endif %}
</td>
<td>{{ raw.recommended_action }}</td>
</tr>
{% else %}
<tr><td colspan="10">No hay materias primas detectadas.</td></tr>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@ -0,0 +1,296 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{{ title }} - Data Quality Contract</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 28px;
color: #1f2933;
background: #f7f9fb;
font-size: 13px;
}
.page {
background: white;
padding: 28px;
border-radius: 12px;
max-width: 1700px;
margin: auto;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
h1 {
margin: 0 0 4px 0;
color: #0f172a;
}
h2 {
margin-top: 28px;
color: #0f172a;
border-bottom: 1px solid #cbd5e1;
padding-bottom: 6px;
}
.muted {
color: #64748b;
}
.grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
margin-top: 18px;
margin-bottom: 20px;
}
.card {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px;
background: #ffffff;
}
.label {
color: #64748b;
font-size: 12px;
margin-bottom: 6px;
}
.value {
color: #0f172a;
font-weight: bold;
font-size: 22px;
line-height: 1.1;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 14px;
font-size: 12px;
}
th {
background: #f1f5f9;
text-align: left;
padding: 8px;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 8px;
border-bottom: 1px solid #e2e8f0;
vertical-align: top;
}
.ok {
color: #166534;
font-weight: bold;
}
.warn {
color: #92400e;
font-weight: bold;
}
.bad {
color: #991b1b;
font-weight: bold;
}
.small {
font-size: 11px;
}
a {
color: #0f766e;
font-weight: bold;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.pill {
display: inline-block;
padding: 3px 7px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: bold;
}
</style>
</head>
<body>
<div class="page">
<h1>Data Quality Contract OdooMESAVAULT</h1>
<p class="muted">{{ title }}</p>
<p>
Esta pantalla resume si las órdenes cumplen el contrato mínimo de datos para informes técnicos,
margen industrial, pérdidas y readiness económico.
</p>
<p>
<a href="/orders.html" target="_blank">Órdenes</a>
&nbsp;|&nbsp;
<a href="/cost-readiness.html" target="_blank">Cost Readiness</a>
</p>
<div class="grid">
<div class="card">
<div class="label">Órdenes</div>
<div class="value">{{ summary.total_orders or 0 }}</div>
</div>
<div class="card">
<div class="label">Contrato cumplido</div>
<div class="value">{{ summary.contract_ok_orders or 0 }}</div>
</div>
<div class="card">
<div class="label">Con observaciones</div>
<div class="value">{{ summary.contract_warn_orders or 0 }}</div>
</div>
<div class="card">
<div class="label">Bloqueo crítico</div>
<div class="value">{{ summary.blocked_critical_orders or 0 }}</div>
</div>
<div class="card">
<div class="label">Fallos críticos</div>
<div class="value">{{ summary.total_critical_failures or 0 }}</div>
</div>
<div class="card">
<div class="label">Score medio</div>
<div class="value">{{ summary.avg_contract_score|num(2) if summary.avg_contract_score is not none else "0.00" }}</div>
</div>
</div>
<h2>1. Estado del contrato por orden</h2>
<table>
<tr>
<th>Orden</th>
<th>Pedido venta</th>
<th>Cliente</th>
<th>Producto</th>
<th>Máquina</th>
<th>Score</th>
<th>Estado contrato</th>
<th>Fallos</th>
<th>Bloqueo principal</th>
<th>Área responsable</th>
<th>Acción recomendada</th>
<th>Informe</th>
</tr>
{% for order in orders %}
<tr>
<td>{{ order.odoo_order_ref }}</td>
<td>{{ order.sale_order_ref or "" }}</td>
<td>{{ order.customer_name or "" }}</td>
<td>
<strong>{{ order.product_default_code or "" }}</strong><br>
<span class="small">{{ order.product_name or "" }}</span>
</td>
<td>{{ order.machine_id or "" }}</td>
<td>
{% if order.contract_score >= 80 %}
<span class="ok">{{ order.contract_score }}</span>
{% elif order.contract_score >= 50 %}
<span class="warn">{{ order.contract_score }}</span>
{% else %}
<span class="bad">{{ order.contract_score }}</span>
{% endif %}
</td>
<td>
{% if order.contract_status == 'CONTRATO_CUMPLIDO' %}
<span class="ok">Contrato cumplido</span>
{% elif order.contract_status == 'VALIDO_CON_OBSERVACIONES' %}
<span class="warn">Válido con observaciones</span>
{% elif order.contract_status == 'BLOQUEADO_CRITICO' %}
<span class="bad">Bloqueado crítico</span>
{% elif order.contract_status == 'BLOQUEADO_ALTO' %}
<span class="bad">Bloqueado alto</span>
{% elif order.contract_status == 'BLOQUEADO_MEDIO' %}
<span class="warn">Bloqueado medio</span>
{% else %}
<span class="pill">{{ order.contract_status }}</span>
{% endif %}
</td>
<td>
Críticos: {{ order.critical_failures }}<br>
Altos: {{ order.high_failures }}<br>
Medios: {{ order.medium_failures }}<br>
Avisos: {{ order.warn_rules }}
</td>
<td>
<strong>{{ order.main_blocker_rule_code or "" }}</strong><br>
<span class="small">{{ order.main_blocker_rule_name or "" }}</span><br>
<span class="small">{{ order.main_blocker_finding or "" }}</span>
</td>
<td>{{ order.owner_area_to_act or "" }}</td>
<td class="small">{{ order.main_recommended_action or "" }}</td>
<td>
<a href="/report.html?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">HTML</a>
|
<a href="/report.pdf?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">PDF</a>
</td>
</tr>
{% else %}
<tr><td colspan="12">No hay órdenes evaluadas.</td></tr>
{% endfor %}
</table>
<h2>2. Resumen por área responsable</h2>
<table>
<tr>
<th>Área</th>
<th>Fallos</th>
<th>Avisos</th>
<th>Fallos críticos</th>
<th>Fallos altos</th>
<th>Órdenes afectadas</th>
<th>Score incidencia</th>
<th>Reglas afectadas</th>
<th>Órdenes</th>
</tr>
{% for owner in owners %}
<tr>
<td>{{ owner.owner_area }}</td>
<td>{{ owner.failed_checks }}</td>
<td>{{ owner.warn_checks }}</td>
<td>{{ owner.critical_failed_checks }}</td>
<td>{{ owner.high_failed_checks }}</td>
<td>{{ owner.affected_orders }}</td>
<td>{{ owner.total_issue_score }}</td>
<td>{{ owner.affected_rules }}</td>
<td class="small">{{ owner.affected_order_refs }}</td>
</tr>
{% else %}
<tr><td colspan="9">Sin incidencias por área.</td></tr>
{% endfor %}
</table>
<h2>3. Reglas con incidencias</h2>
<table>
<tr>
<th>Regla</th>
<th>Nombre</th>
<th>Dominio</th>
<th>Severidad</th>
<th>Estado</th>
<th>Área</th>
<th>Órdenes afectadas</th>
<th>Score total</th>
<th>Órdenes</th>
</tr>
{% for rule in rules %}
<tr>
<td>{{ rule.rule_code }}</td>
<td>{{ rule.rule_name }}</td>
<td>{{ rule.domain }}</td>
<td>{{ rule.severity }}</td>
<td>
{% if rule.check_status == 'FAIL' %}
<span class="bad">FAIL</span>
{% elif rule.check_status == 'WARN' %}
<span class="warn">WARN</span>
{% else %}
{{ rule.check_status }}
{% endif %}
</td>
<td>{{ rule.owner_area }}</td>
<td>{{ rule.order_count }}</td>
<td>{{ rule.total_issue_score }}</td>
<td class="small">{{ rule.affected_orders }}</td>
</tr>
{% else %}
<tr><td colspan="9">Sin reglas con incidencias.</td></tr>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@ -0,0 +1,375 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{{ title }} - {{ report.odoo_order_ref }}</title>
<style>
@page {
size: A4 landscape;
margin: 12mm;
}
* {
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
margin: 0;
color: #1f2933;
background: #ffffff;
font-size: 11px;
}
.page {
background: white;
width: 100%;
}
h1 {
color: #0f172a;
font-size: 24px;
margin: 0 0 4px 0;
}
h2 {
color: #0f172a;
font-size: 15px;
margin: 18px 0 8px 0;
border-bottom: 1px solid #cbd5e1;
padding-bottom: 4px;
}
.muted {
color: #64748b;
}
.summary {
padding: 10px 12px;
background: #eef6f7;
border-left: 4px solid #0f766e;
margin: 12px 0 14px 0;
font-size: 12px;
line-height: 1.35;
}
.grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.card {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px;
background: #ffffff;
min-height: 52px;
}
.label {
font-size: 9px;
color: #64748b;
margin-bottom: 3px;
}
.value {
font-size: 15px;
font-weight: bold;
color: #0f172a;
line-height: 1.1;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 12px;
font-size: 10px;
table-layout: fixed;
}
th {
background: #f1f5f9;
text-align: left;
padding: 5px;
border-bottom: 1px solid #cbd5e1;
font-weight: bold;
}
td {
padding: 5px;
border-bottom: 1px solid #e2e8f0;
vertical-align: top;
word-break: break-word;
}
tr {
break-inside: avoid;
page-break-inside: avoid;
}
.kv-table th:first-child,
.kv-table td:first-child {
width: 24%;
}
.stop-table th:nth-child(1),
.stop-table td:nth-child(1) {
width: 5%;
}
.stop-table th:nth-child(2),
.stop-table td:nth-child(2),
.stop-table th:nth-child(3),
.stop-table td:nth-child(3) {
width: 15%;
white-space: nowrap;
}
.stop-table th:nth-child(4),
.stop-table td:nth-child(4) {
width: 9%;
}
.stop-table th:nth-child(5),
.stop-table td:nth-child(5) {
width: 14%;
}
.timeline-table th:nth-child(1),
.timeline-table td:nth-child(1) {
width: 15%;
white-space: nowrap;
}
.timeline-table th:nth-child(2),
.timeline-table td:nth-child(2) {
width: 13%;
}
.timeline-table th:nth-child(3),
.timeline-table td:nth-child(3) {
width: 13%;
}
.timeline-table th:nth-child(4),
.timeline-table td:nth-child(4) {
width: 7%;
}
.timeline-table th:nth-child(5),
.timeline-table td:nth-child(5) {
width: 11%;
}
.timeline-table th:nth-child(6),
.timeline-table td:nth-child(6),
.timeline-table th:nth-child(7),
.timeline-table td:nth-child(7) {
width: 7%;
}
.timeline-table th:nth-child(8),
.timeline-table td:nth-child(8) {
width: 27%;
}
.small {
font-size: 9px;
}
.section-break {
break-before: auto;
}
</style>
</head>
<body>
<div class="page">
<h1>Informe de ejecución de orden</h1>
<p class="muted">{{ title }}</p>
<div class="summary">
{{ report.executive_summary }}
</div>
<h2>1. Identificación</h2>
<table class="kv-table">
<tr><th>Campo</th><th>Valor</th></tr>
<tr><td>Orden</td><td>{{ report.odoo_order_ref }}</td></tr>
<tr><td>Workorder Odoo</td><td>{{ report.odoo_workorder_id }}</td></tr>
<tr><td>Production Odoo</td><td>{{ report.odoo_production_id }}</td></tr>
<tr><td>Estado Odoo</td><td>{{ report.odoo_state }}</td></tr>
<tr><td>Producto</td><td>{{ report.product }}</td></tr>
<tr><td>Máquina</td><td>{{ report.machine_id }}</td></tr>
<tr><td>Centro de trabajo</td><td>{{ report.odoo_workcenter_name }}</td></tr>
<tr><td>Inicio</td><td>{{ report.start_ts|dt }}</td></tr>
<tr><td>Fin</td><td>{{ report.end_ts|dt }}</td></tr>
</table>
<h2>2. Indicadores principales</h2>
<div class="grid">
<div class="card"><div class="label">Duración total</div><div class="value">{{ report.window_min|num(2) }} min</div></div>
<div class="card"><div class="label">Tiempo marcha</div><div class="value">{{ report.run_min|num(2) }} min</div></div>
<div class="card"><div class="label">Tiempo parado</div><div class="value">{{ report.stop_min|num(2) }} min</div></div>
<div class="card"><div class="label">Disponibilidad</div><div class="value">{{ report.availability_pct|num(2) }} %</div></div>
<div class="card"><div class="label">Ciclos reales</div><div class="value">{{ report.real_cycle_delta }}</div></div>
<div class="card"><div class="label">Ciclos/min marcha</div><div class="value">{{ report.cycles_per_run_min|num(2) }}</div></div>
<div class="card"><div class="label">Energía</div><div class="value">{{ report.kwh_consumed|num(3) }} kWh</div></div>
<div class="card"><div class="label">Coste paros</div><div class="value">{{ report.stop_cost_eur|num(2) }} €</div></div>
</div>
<h2>3. Energía y ciclos</h2>
<table class="kv-table">
<tr><th>Métrica</th><th>Valor</th></tr>
<tr><td>Muestras energía</td><td>{{ report.energy_samples }}</td></tr>
<tr><td>Primera muestra</td><td>{{ report.energy_first_ts|dt }}</td></tr>
<tr><td>Última muestra</td><td>{{ report.energy_last_ts|dt }}</td></tr>
<tr><td>kWh inicial</td><td>{{ report.start_import_kwh|num(3) }}</td></tr>
<tr><td>kWh final</td><td>{{ report.end_import_kwh|num(3) }}</td></tr>
<tr><td>Consumo</td><td>{{ report.kwh_consumed|num(3) }} kWh</td></tr>
<tr><td>Potencia media</td><td>{{ report.avg_kw|num(3) }} kW</td></tr>
<tr><td>Potencia máxima</td><td>{{ report.max_kw|num(3) }} kW</td></tr>
<tr><td>kWh/ciclo</td><td>{{ report.kwh_per_cycle|num(6) }}</td></tr>
</table>
<h2>4. Paros</h2>
<p><strong>Resumen por causa:</strong> {{ report.stop_causes_summary }}</p>
<table class="stop-table">
<tr>
<th>ID</th>
<th>Inicio</th>
<th>Fin</th>
<th>Duración min</th>
<th>Causa</th>
<th>Nota</th>
<th>Coste €</th>
<th>Estado</th>
</tr>
{% for stop in stops %}
<tr>
<td>{{ stop.stop_id }}</td>
<td>{{ stop.stop_started_at|dt }}</td>
<td>{{ stop.stop_ended_at|dt }}</td>
<td>{{ stop.attributed_duration_min|num(2) }}</td>
<td>{{ stop.cause_code }}</td>
<td>{{ stop.operator_note or "" }}</td>
<td>{{ stop.attributed_cost_eur|num(2) }}</td>
<td>{{ stop.stop_quality_status }}</td>
</tr>
{% else %}
<tr><td colspan="8">Sin paros imputados a la orden.</td></tr>
{% endfor %}
</table>
<h2>5. Calidad del dato</h2>
<table class="kv-table">
<tr><th>Métrica</th><th>Valor</th></tr>
<tr><td>Estado informe</td><td>{{ report.final_report_status }}</td></tr>
<tr><td>Score calidad técnica</td><td>{{ report.technical_quality_score }}</td></tr>
<tr><td>Nivel calidad técnica</td><td>{{ report.technical_quality_level }}</td></tr>
<tr><td>Cobertura telemetría</td><td>{{ report.telemetry_sample_coverage_pct|num(2) }} %</td></tr>
<tr><td>Gaps &gt; 30 s</td><td>{{ report.telemetry_gap_count_30s }}</td></tr>
<tr><td>Máximo gap</td><td>{{ report.max_sample_gap_s|num(2) }} s</td></tr>
<tr><td>Muestras no OK</td><td>{{ report.non_ok_samples }}</td></tr>
<tr><td>Conversión producción</td><td>{{ report.production_conversion_status }}</td></tr>
<tr><td>Acción recomendada</td><td>{{ report.recommended_action }}</td></tr>
</table>
<h2>6. Margin Intelligence</h2>
{% if margin %}
<div class="grid">
<div class="card"><div class="label">Ingreso neto</div><div class="value">{{ margin.revenue_net_eur|num(2) }} €</div></div>
<div class="card"><div class="label">Cliente</div><div class="value" style="font-size: 11px;">{{ margin.customer_name or "Sin cliente" }}</div></div>
<div class="card"><div class="label">Pedido venta</div><div class="value">{{ margin.sale_order_ref or "Sin pedido" }}</div></div>
<div class="card"><div class="label">Coste paros</div><div class="value">{{ margin.stop_cost_eur|num(2) }} €</div></div>
<div class="card"><div class="label">Coste material</div><div class="value">{{ margin.material_cost_estimated_eur|num(2) if margin.material_cost_estimated_eur is not none else "Pendiente" }}</div></div>
<div class="card"><div class="label">Coste conocido</div><div class="value">{{ margin.known_or_estimated_cost_eur|num(2) }} €</div></div>
<div class="card"><div class="label">Margen estimado</div><div class="value">{{ margin.estimated_margin_eur|num(2) if margin.estimated_margin_eur is not none else "No calculable" }}</div></div>
<div class="card"><div class="label">Estado margen</div><div class="value" style="font-size: 10px;">{% if margin.margin_status == 'PARTIAL_MARGIN_MISSING_RAW_MATERIAL_COST' %}Pendiente coste de materia prima{% elif margin.margin_status == 'ESTIMATED_MARGIN_AVAILABLE' %}Margen estimado disponible{% elif margin.margin_status == 'NO_MARGIN_WITHOUT_REVENUE' %}Sin ingreso de venta vinculado{% else %}{{ margin.margin_status }}{% endif %}</div></div>
</div>
<table class="kv-table">
<tr><th>Métrica</th><th>Valor</th></tr>
<tr><td>Ingreso neto venta</td><td>{{ margin.revenue_net_eur|num(2) if margin.revenue_net_eur is not none else "" }} €</td></tr>
<tr><td>Ingreso total venta</td><td>{{ margin.revenue_total_eur|num(2) if margin.revenue_total_eur is not none else "" }} €</td></tr>
<tr><td>Pedido venta</td><td>{{ margin.sale_order_ref or "" }}</td></tr>
<tr><td>Cliente</td><td>{{ margin.customer_name or "" }}</td></tr>
<tr><td>Materia prima consumida</td><td>{{ margin.raw_materials_summary or "Sin materia prima detectada" }}</td></tr>
<tr><td>Coste materia prima</td><td>{{ margin.material_cost_estimated_eur|num(2) if margin.material_cost_estimated_eur is not none else "Pendiente de coste" }}</td></tr>
<tr><td>Estado coste materia prima</td><td>{% if margin.material_cost_source_status == 'MISSING_RAW_MATERIAL_COST_PARAMETER' %}Pendiente coste de materia prima{% elif margin.material_cost_source_status == 'FROM_RAW_MATERIAL_COSTS' %}Calculado desde coste de materia prima{% elif margin.material_cost_source_status == 'FROM_ODOO_RAW_MATERIAL_VALUE' %}Calculado desde valoración Odoo{% else %}{{ margin.material_cost_source_status }}{% endif %}</td></tr>
<tr><td>Coste paros</td><td>{{ margin.stop_cost_eur|num(2) }} €</td></tr>
<tr><td>Coste conocido o estimado</td><td>{{ margin.known_or_estimated_cost_eur|num(2) }} €</td></tr>
<tr><td>Margen parcial tras costes conocidos</td><td>{{ margin.partial_margin_after_known_costs_eur|num(2) if margin.partial_margin_after_known_costs_eur is not none else "" }} €</td></tr>
<tr><td>Margen estimado</td><td>{{ margin.estimated_margin_eur|num(2) if margin.estimated_margin_eur is not none else "No calculable todavía" }}</td></tr>
<tr><td>% margen estimado</td><td>{{ margin.estimated_margin_pct|num(2) if margin.estimated_margin_pct is not none else "No calculable todavía" }}</td></tr>
<tr><td>Estado económico</td><td>{% if margin.margin_status == 'PARTIAL_MARGIN_MISSING_RAW_MATERIAL_COST' %}Pendiente coste de materia prima{% elif margin.margin_status == 'ESTIMATED_MARGIN_AVAILABLE' %}Margen estimado disponible{% elif margin.margin_status == 'NO_MARGIN_WITHOUT_REVENUE' %}Sin ingreso de venta vinculado{% else %}{{ margin.margin_status }}{% endif %}</td></tr>
</table>
<h2>7. Materias primas</h2>
<table>
<tr>
<th>Código</th>
<th>Materia prima</th>
<th>Cantidad prevista</th>
<th>Cantidad consumida</th>
<th>UdM</th>
<th>Coste €/kg</th>
<th>Coste estimado</th>
<th>Estado</th>
</tr>
{% for raw in raw_materials %}
<tr>
<td>{{ raw.raw_product_default_code or "" }}</td>
<td>{{ raw.raw_product_name or "" }}</td>
<td>{{ raw.planned_qty|num(3) if raw.planned_qty is not none else "" }}</td>
<td>{{ raw.consumed_qty|num(3) if raw.consumed_qty is not none else "" }}</td>
<td>{{ raw.raw_uom or "" }}</td>
<td>{{ raw.material_cost_eur_per_kg|num(4) if raw.material_cost_eur_per_kg is not none else "" }}</td>
<td>{{ raw.material_cost_estimated_eur_rounded|num(2) if raw.material_cost_estimated_eur_rounded is not none else "" }}</td>
<td>{% if raw.material_cost_status == 'MISSING_RAW_MATERIAL_COST_PARAMETER' %}Pendiente coste materia prima{% elif raw.material_cost_status == 'FROM_RAW_MATERIAL_COST_PARAMETER' %}Coste manual validado{% elif raw.material_cost_status == 'FROM_ODOO_STOCK_MOVE_VALUE' %}Coste desde Odoo{% else %}{{ raw.material_cost_status }}{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="8">Sin materias primas detectadas para la orden.</td></tr>
{% endfor %}
</table>
{% else %}
<p>No hay datos comerciales o económicos vinculados a esta orden.</p>
{% endif %}
<h2>8. Timeline</h2>
<table class="timeline-table">
<tr>
<th>Hora</th>
<th>Tipo</th>
<th>Evento</th>
<th>Nivel</th>
<th>Causa</th>
<th>Duración</th>
<th>Coste</th>
<th>Descripción</th>
</tr>
{% for event in timeline %}
<tr>
<td>{{ event.event_ts|dt }}</td>
<td>{{ event.event_type }}</td>
<td>{{ event.event_label }}</td>
<td>{{ event.severity }}</td>
<td>{{ event.cause_code or "" }}</td>
<td>{{ event.duration_min|num(2) if event.duration_min is not none else "" }}</td>
<td>{{ event.cost_eur|num(2) if event.cost_eur is not none else "" }}</td>
<td>{{ event.description }}</td>
</tr>
{% endfor %}
</table>
<p class="muted small">
Informe generado desde MESAVAULT Edge-OEE usando vistas SQL de PostgreSQL.
</p>
</div>
</body>
</html>

View File

@ -0,0 +1,123 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{{ title }} - Informes de orden</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 28px;
color: #1f2933;
background: #f7f9fb;
font-size: 13px;
}
.page {
background: white;
padding: 28px;
border-radius: 12px;
max-width: 1500px;
margin: auto;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
h1 {
margin-top: 0;
color: #0f172a;
}
.muted {
color: #64748b;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 18px;
}
th {
background: #f1f5f9;
text-align: left;
padding: 8px;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 8px;
border-bottom: 1px solid #e2e8f0;
vertical-align: top;
}
a {
color: #0f766e;
font-weight: bold;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.status {
font-weight: bold;
font-size: 12px;
}
.ok { color: #166534; }
.warn { color: #92400e; }
.bad { color: #991b1b; }
.small { font-size: 12px; }
</style>
</head>
<body>
<div class="page">
<h1>Informes de ejecución de orden</h1>
<p class="muted">{{ title }}</p>
<p>
<a href="/data-quality-contract.html" target="_blank">Abrir Data Quality Contract</a>
</p>
<p>
<a href="/cost-readiness.html" target="_blank">Abrir Cost Readiness Cockpit</a>
</p>
<table>
<tr>
<th>Orden</th>
<th>Estado</th>
<th>Producto</th>
<th>Máquina</th>
<th>Inicio</th>
<th>Fin</th>
<th>Duración min</th>
<th>Ciclos</th>
<th>kWh</th>
<th>Paros</th>
<th>Min paro</th>
<th>Calidad</th>
<th>Estado informe</th>
<th>Acciones</th>
</tr>
{% for order in orders %}
<tr>
<td>{{ order.odoo_order_ref }}</td>
<td>{{ order.odoo_state }}</td>
<td>{{ order.product }}</td>
<td>{{ order.machine_id }}</td>
<td>{{ order.start_ts|dt }}</td>
<td>{{ order.end_ts|dt }}</td>
<td>{{ order.window_min|num(2) }}</td>
<td>{{ order.real_cycle_delta }}</td>
<td>{{ order.kwh_consumed|num(3) }}</td>
<td>{{ order.stop_count }}</td>
<td>{{ order.stop_min|num(2) }}</td>
<td class="status {% if order.technical_quality_level == 'FIABLE' %}ok{% elif order.technical_quality_level == 'NO_DEFENDIBLE' %}bad{% else %}warn{% endif %}">
{{ order.technical_quality_level }}
</td>
<td class="small">{{ order.final_report_status }}</td>
<td>
<a href="/report.html?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">HTML</a>
&nbsp;|&nbsp;
<a href="/report.pdf?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">PDF</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="14">No hay órdenes disponibles.</td>
</tr>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
psycopg[binary]==3.2.3
jinja2==3.1.5
python-dotenv==1.0.1
weasyprint==63.1

View File

@ -0,0 +1,43 @@
-- =========================================================
-- MESAVAULT PostgreSQL governance baseline
-- Fase 1: creación de schemas sin mover tablas existentes
-- =========================================================
CREATE SCHEMA IF NOT EXISTS mv_core;
CREATE SCHEMA IF NOT EXISTS mv_raw;
CREATE SCHEMA IF NOT EXISTS mv_observability;
CREATE SCHEMA IF NOT EXISTS mv_edge_oee_ucepsa_demo;
CREATE SCHEMA IF NOT EXISTS mv_reports_ucepsa_demo;
CREATE SCHEMA IF NOT EXISTS mv_hydro_lab;
CREATE SCHEMA IF NOT EXISTS mv_weather_lab;
CREATE SCHEMA IF NOT EXISTS mv_sandbox;
COMMENT ON SCHEMA mv_core IS
'Catálogos comunes MESAVAULT: tenants, sites, assets, verticales, máquinas y metadatos compartidos.';
COMMENT ON SCHEMA mv_raw IS
'Capa raw: datos crudos de entrada, mensajes MQTT, eventos sin normalizar y dead letters. No debe ser consultada directamente por dashboards cliente.';
COMMENT ON SCHEMA mv_observability IS
'Observabilidad interna: salud de servicios, ingesta, latencias, errores y checks.';
COMMENT ON SCHEMA mv_edge_oee_ucepsa_demo IS
'Capa normalizada de la demo Edge-OEE UCEPSA. Inicialmente contiene vistas puente hacia mv_hot.';
COMMENT ON SCHEMA mv_reports_ucepsa_demo IS
'Capa de reporting para Grafana UCEPSA demo. Grafana debe consultar preferentemente este schema.';
COMMENT ON SCHEMA mv_hydro_lab IS
'Capa normalizada de laboratorio Hydro / Aquaponics.';
COMMENT ON SCHEMA mv_weather_lab IS
'Capa meteorológica de laboratorio.';
COMMENT ON SCHEMA mv_sandbox IS
'Zona temporal de pruebas. No usar para dashboards cliente ni datos productivos.';
-- Endurecimiento básico del schema public
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

View File

@ -0,0 +1,383 @@
CREATE SCHEMA IF NOT EXISTS mv_hot;
DROP VIEW IF EXISTS mv_hot.edge_oee_quality_case_demo CASCADE;
DROP VIEW IF EXISTS mv_hot.edge_oee_actions_demo CASCADE;
DROP VIEW IF EXISTS mv_hot.edge_oee_data_quality_dashboard_demo CASCADE;
DROP VIEW IF EXISTS mv_hot.edge_oee_order_timeline_demo CASCADE;
DROP VIEW IF EXISTS mv_hot.edge_oee_pareto_demo CASCADE;
DROP VIEW IF EXISTS mv_hot.edge_oee_kpi_cards_demo CASCADE;
DROP VIEW IF EXISTS mv_hot.edge_oee_executive_summary_demo CASCADE;
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_machines (
machine_id TEXT PRIMARY KEY,
tenant TEXT NOT NULL DEFAULT 'ucepsa',
site TEXT NOT NULL DEFAULT 'demo_edge_oee',
name TEXT NOT NULL,
area TEXT NOT NULL DEFAULT 'corte',
machine_type TEXT NOT NULL DEFAULT 'cortadora',
value_hour_eur NUMERIC(10,2) NOT NULL,
standard_kg_h NUMERIC(10,2),
sale_price_eur_kg NUMERIC(10,2),
transformation_cost_eur_kg NUMERIC(10,2),
raw_material_cost_eur_kg NUMERIC(10,2),
notes TEXT
);
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_orders_demo (
order_id TEXT PRIMARY KEY,
tenant TEXT NOT NULL DEFAULT 'ucepsa',
site TEXT NOT NULL DEFAULT 'demo_edge_oee',
machine_id TEXT NOT NULL REFERENCES mv_hot.edge_oee_machines(machine_id),
product TEXT NOT NULL,
odoo_start TIMESTAMPTZ NOT NULL,
odoo_end TIMESTAMPTZ NOT NULL,
qty_planned NUMERIC(14,2),
qty_done NUMERIC(14,2),
scrap_qty NUMERIC(14,2),
uom TEXT DEFAULT 'unidades',
commercial_note TEXT
);
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_events_demo (
event_id BIGSERIAL PRIMARY KEY,
tenant TEXT NOT NULL DEFAULT 'ucepsa',
site TEXT NOT NULL DEFAULT 'demo_edge_oee',
machine_id TEXT NOT NULL REFERENCES mv_hot.edge_oee_machines(machine_id),
order_id TEXT NULL REFERENCES mv_hot.edge_oee_orders_demo(order_id),
event_type TEXT NOT NULL,
cause TEXT NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
duration_min NUMERIC(10,2) NOT NULL,
cost_eur NUMERIC(10,2) NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'mesavault_demo',
notes TEXT
);
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_loss_summary_demo (
cause TEXT PRIMARY KEY,
hours_month NUMERIC(10,2) NOT NULL,
value_hour_eur NUMERIC(10,2) NOT NULL,
monthly_cost_eur NUMERIC(10,2) NOT NULL,
recommended_action TEXT NOT NULL,
effort TEXT,
priority TEXT,
machine_focus TEXT
);
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_data_quality_demo (
metric_id TEXT PRIMARY KEY,
metric TEXT NOT NULL,
value_numeric NUMERIC(10,2),
value_text TEXT,
unit TEXT,
status TEXT NOT NULL,
problem TEXT,
likely_impact TEXT,
recommended_action TEXT,
sort_order INT NOT NULL
);
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_quality_cases_demo (
case_id TEXT PRIMARY KEY,
order_id TEXT REFERENCES mv_hot.edge_oee_orders_demo(order_id),
machine_id TEXT REFERENCES mv_hot.edge_oee_machines(machine_id),
product TEXT,
claim TEXT,
sealing_adjustment_stops INT,
sealing_adjustment_min NUMERIC(10,2),
microstops_count INT,
scrap_pct NUMERIC(10,2),
energy_vs_avg_pct NUMERIC(10,2),
interpretation TEXT,
recommended_action TEXT
);
CREATE TABLE IF NOT EXISTS mv_hot.edge_oee_energy_demo (
ts TIMESTAMPTZ NOT NULL,
tenant TEXT NOT NULL DEFAULT 'ucepsa',
site TEXT NOT NULL DEFAULT 'demo_edge_oee',
machine_id TEXT NOT NULL REFERENCES mv_hot.edge_oee_machines(machine_id),
order_id TEXT NULL REFERENCES mv_hot.edge_oee_orders_demo(order_id),
power_kw NUMERIC(10,3),
kwh_total NUMERIC(14,3),
source TEXT DEFAULT 'demo',
payload_json JSONB DEFAULT '{}'::jsonb
);
TRUNCATE TABLE
mv_hot.edge_oee_energy_demo,
mv_hot.edge_oee_quality_cases_demo,
mv_hot.edge_oee_data_quality_demo,
mv_hot.edge_oee_loss_summary_demo,
mv_hot.edge_oee_events_demo,
mv_hot.edge_oee_orders_demo,
mv_hot.edge_oee_machines
RESTART IDENTITY CASCADE;
INSERT INTO mv_hot.edge_oee_machines
(machine_id, name, value_hour_eur, standard_kg_h, sale_price_eur_kg, transformation_cost_eur_kg, raw_material_cost_eur_kg, notes)
VALUES
('C0', 'Cortadora 0', 36.00, 34.90, 3.23, 0.80, 1.25, 'Máquina de alta carga. Valor hora demo prudente.'),
('C1', 'Cortadora 1', 36.00, 34.90, 3.23, 0.80, 1.25, 'Máquina de alta carga. Comparable con C0.'),
('C2', 'Cortadora 2', 36.00, 35.40, 2.80, 0.80, 1.25, 'Máquina crítica por carga y problemas de sellado.');
WITH d AS (
SELECT current_date::timestamp AS base
)
INSERT INTO mv_hot.edge_oee_orders_demo
(order_id, machine_id, product, odoo_start, odoo_end, qty_planned, qty_done, scrap_qty, uom, commercial_note)
SELECT 'OF00481', 'C0', 'Bolsa 300x400', base + interval '08 hours', base + interval '11 hours 30 minutes', 50000, 48900, 1100, 'unidades', 'Orden con arranque real retrasado 22 minutos.' FROM d
UNION ALL
SELECT 'OF00482', 'C1', 'Bolsa 250x350', base + interval '08 hours 15 minutes', base + interval '12 hours 10 minutes', 42000, 41600, 400, 'unidades', 'Orden casi normal, usada como comparación.' FROM d
UNION ALL
SELECT 'OF00493', 'C2', 'Bolsa 300x400', base + interval '09 hours', base + interval '13 hours 20 minutes', 52000, 50500, 1500, 'unidades', 'Orden asociada a caso ficticio de sellado débil.' FROM d;
WITH d AS (
SELECT current_date::timestamp AS base
)
INSERT INTO mv_hot.edge_oee_events_demo
(machine_id, order_id, event_type, cause, start_time, end_time, duration_min, cost_eur, notes)
SELECT 'C0','OF00481','PREPARATION','Búsqueda de bobina',base + interval '08 hours',base + interval '08 hours 22 minutes',22,13.20,'Odoo inicia a las 08:00, pero la primera marcha real llega a las 08:22.' FROM d
UNION ALL
SELECT 'C0','OF00481','RUNNING','Producción',base + interval '08 hours 22 minutes',base + interval '09 hours 14 minutes',52,0,'Producción real.' FROM d
UNION ALL
SELECT 'C0','OF00481','MICROSTOP','Microparos acumulados',base + interval '09 hours 14 minutes',base + interval '09 hours 18 minutes',4,2.40,'Microparos invisibles para un registro manual.' FROM d
UNION ALL
SELECT 'C0','OF00481','STOP','Cambio bobina',base + interval '10 hours 05 minutes',base + interval '10 hours 18 minutes',13,7.80,'Paro operativo por cambio de bobina.' FROM d
UNION ALL
SELECT 'C1','OF00482','PREPARATION','Preparación normal',base + interval '08 hours 15 minutes',base + interval '08 hours 18 minutes',3,1.80,'Preparación corta, caso de comparación.' FROM d
UNION ALL
SELECT 'C1','OF00482','RUNNING','Producción',base + interval '08 hours 18 minutes',base + interval '10 hours 40 minutes',142,0,'Producción estable.' FROM d
UNION ALL
SELECT 'C1',NULL,'RUNNING_WITHOUT_ORDER','Máquina en marcha sin orden activa',base + interval '12 hours 30 minutes',base + interval '12 hours 56 minutes',26,15.60,'Brecha de dato: producción no imputada a Odoo.' FROM d
UNION ALL
SELECT 'C2','OF00493','PREPARATION','Ajuste inicial de máquina',base + interval '09 hours',base + interval '09 hours 16 minutes',16,9.60,'Preparación superior a 15 minutos.' FROM d
UNION ALL
SELECT 'C2','OF00493','RUNNING','Producción',base + interval '09 hours 16 minutes',base + interval '09 hours 40 minutes',24,0,'Producción real.' FROM d
UNION ALL
SELECT 'C2','OF00493','STOP','Ajuste de sellado',base + interval '09 hours 40 minutes',base + interval '09 hours 52 minutes',12,7.20,'Paro vinculado a sellado.' FROM d
UNION ALL
SELECT 'C2','OF00493','RUNNING','Producción',base + interval '09 hours 52 minutes',base + interval '10 hours 25 minutes',33,0,'Producción real.' FROM d
UNION ALL
SELECT 'C2','OF00493','MICROSTOP','Microparos durante orden',base + interval '10 hours 25 minutes',base + interval '10 hours 28 minutes 30 seconds',3.5,2.10,'Microparos durante producto con reclamación.' FROM d
UNION ALL
SELECT 'C2','OF00493','STOP','Ajuste de sellado',base + interval '11 hours 20 minutes',base + interval '11 hours 32 minutes',12,7.20,'Segundo ajuste de sellado.' FROM d
UNION ALL
SELECT 'C2','OF00493','STOP','Ajuste de sellado',base + interval '12 hours 04 minutes',base + interval '12 hours 12 minutes',8,4.80,'Tercer ajuste de sellado.' FROM d;
INSERT INTO mv_hot.edge_oee_loss_summary_demo
(cause, hours_month, value_hour_eur, monthly_cost_eur, recommended_action, effort, priority, machine_focus)
VALUES
('Ajuste de sellado', 18.00, 36.00, 648.00, 'Revisar mordazas, teflón, temperatura y presión', 'Medio', 'Alta', 'C2'),
('Espera de material', 14.00, 36.00, 504.00, 'Crear prealistamiento de bobina antes del turno', 'Bajo', 'Alta', 'C0/C1/C2'),
('Microparos acumulados', 11.50, 36.00, 414.00, 'Revisar fotocélula, tensión de material y cuchilla', 'Bajo', 'Alta', 'C0/C2'),
('Cambio bobina/formato', 9.00, 36.00, 324.00, 'Implantar checklist de cambio de bobina/formato', 'Bajo', 'Media', 'C0/C1'),
('Arranque lento de orden', 6.00, 36.00, 216.00, 'Preparar primera orden y material antes del inicio del turno', 'Bajo', 'Media', 'C0/C2'),
('Paro sin causa', 5.00, 36.00, 180.00, 'Mejorar registro de causas y disciplina de imputación', 'Bajo', 'Alta', 'Todas');
INSERT INTO mv_hot.edge_oee_data_quality_demo
(metric_id, metric, value_numeric, value_text, unit, status, problem, likely_impact, recommended_action, sort_order)
VALUES
('orders_started_no_run_10m', 'Órdenes iniciadas sin marcha en 10 min', 6, NULL, 'órdenes', 'Warning', 'Odoo registra inicio, pero la máquina no produce.', 'El tiempo de preparación queda mezclado con tiempo productivo.', 'Separar preparación, espera de material y marcha real.', 1),
('machine_running_no_order', 'Máquina en marcha sin orden activa', 3, NULL, 'eventos', 'Warning', 'Hay producción física sin orden asignada.', 'Producción no imputada correctamente a Odoo.', 'Bloquear o alertar producción sin orden activa.', 2),
('orders_closed_before_real_end', 'Órdenes cerradas antes de fin real', 2, NULL, 'órdenes', 'Warning', 'El cierre administrativo no coincide con la realidad física.', 'Descuadre de tiempos, coste y trazabilidad.', 'Comparar fin Odoo contra última marcha real.', 3),
('stops_without_cause', 'Paros sin causa', 5, NULL, 'h/mes', 'Warning', 'Hay pérdidas sin clasificación útil.', 'No se puede decidir ninguna acción concreta.', 'Simplificar selección de causas en planta.', 4),
('preparation_gt_15m', 'Órdenes con preparación >15 min', 8, NULL, 'órdenes', 'Warning', 'Arranques lentos repetidos.', 'Pérdida de capacidad al inicio de orden o turno.', 'Prealistamiento de bobinas, útiles y parámetros.', 5),
('energy_above_avg', 'Órdenes con energía superior a media', 5, NULL, 'órdenes', 'Info', 'Consumo superior al patrón esperado.', 'Puede indicar ajustes, fricción, vacío, temperatura o régimen anómalo.', 'Cruzar energía con producto, máquina y causa de paro.', 6);
INSERT INTO mv_hot.edge_oee_quality_cases_demo
(case_id, order_id, machine_id, product, claim, sealing_adjustment_stops, sealing_adjustment_min, microstops_count, scrap_pct, energy_vs_avg_pct, interpretation, recommended_action)
VALUES
(
'QC_SELLADO_OF00493',
'OF00493',
'C2',
'Bolsa 300x400',
'Sellado débil',
4,
32,
23,
2.80,
6.00,
'La reclamación no queda aislada: durante la orden aparecen ajustes de sellado, microparos, merma y energía superior a la media.',
'Revisar mordazas, teflón, temperatura, presión y procedimiento de arranque. Comprobar si el patrón se repite en C2 o con Bolsa 300x400.'
);
WITH d AS (
SELECT current_date::timestamp AS base
)
INSERT INTO mv_hot.edge_oee_energy_demo
(ts, machine_id, order_id, power_kw, kwh_total, source, payload_json)
SELECT base + interval '09 hours', 'C2', 'OF00493', 4.800, 102.100, 'demo', '{"note":"inicio orden sellado"}'::jsonb FROM d
UNION ALL
SELECT base + interval '10 hours', 'C2', 'OF00493', 5.150, 106.900, 'demo', '{"note":"energía sobre media durante ajustes"}'::jsonb FROM d
UNION ALL
SELECT base + interval '11 hours', 'C2', 'OF00493', 5.090, 112.050, 'demo', '{"note":"energía sobre media"}'::jsonb FROM d
UNION ALL
SELECT base + interval '12 hours', 'C2', 'OF00493', 5.200, 117.250, 'demo', '{"note":"ajuste sellado"}'::jsonb FROM d;
CREATE OR REPLACE VIEW mv_hot.edge_oee_executive_summary_demo AS
WITH losses AS (
SELECT
SUM(hours_month) AS classified_loss_hours,
SUM(monthly_cost_eur) AS estimated_loss_cost_eur
FROM mv_hot.edge_oee_loss_summary_demo
),
principal AS (
SELECT cause
FROM mv_hot.edge_oee_loss_summary_demo
ORDER BY monthly_cost_eur DESC
LIMIT 1
)
SELECT
now() AS evaluated_at,
816.0::numeric(10,1) AS hours_analyzed,
losses.classified_loss_hours,
losses.estimated_loss_cost_eur,
principal.cause AS main_loss_cause,
'Cortadora 2'::text AS machine_with_more_losses,
5.0::numeric(10,1) AS unclassified_stop_hours,
7::int AS orders_with_odoo_machine_discrepancy
FROM losses, principal;
CREATE OR REPLACE VIEW mv_hot.edge_oee_kpi_cards_demo AS
SELECT 1 AS sort_order, 'Horas analizadas' AS kpi, hours_analyzed AS value, 'h' AS unit, 'OK' AS status
FROM mv_hot.edge_oee_executive_summary_demo
UNION ALL
SELECT 2, 'Pérdidas clasificadas', classified_loss_hours, 'h/mes', 'Warning'
FROM mv_hot.edge_oee_executive_summary_demo
UNION ALL
SELECT 3, 'Coste estimado de pérdidas', estimated_loss_cost_eur, '€/mes', 'Warning'
FROM mv_hot.edge_oee_executive_summary_demo
UNION ALL
SELECT 4, 'Paros sin causa', unclassified_stop_hours, 'h/mes', 'Warning'
FROM mv_hot.edge_oee_executive_summary_demo
UNION ALL
SELECT 5, 'Órdenes con discrepancia Odoo/máquina', orders_with_odoo_machine_discrepancy, 'órdenes', 'Warning'
FROM mv_hot.edge_oee_executive_summary_demo;
CREATE OR REPLACE VIEW mv_hot.edge_oee_pareto_demo AS
SELECT
RANK() OVER (ORDER BY monthly_cost_eur DESC) AS ranking,
cause,
hours_month,
value_hour_eur,
monthly_cost_eur,
recommended_action,
effort,
priority,
machine_focus
FROM mv_hot.edge_oee_loss_summary_demo
ORDER BY monthly_cost_eur DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_order_timeline_demo AS
SELECT
o.odoo_start AS time,
o.odoo_start + interval '1 minute' AS time_end,
o.order_id,
o.machine_id,
m.name AS machine_name,
o.product,
'ODOO' AS source,
'ODOO_START' AS state,
'Odoo inicia la orden' AS detail,
0::numeric AS duration_min,
0::numeric AS cost_eur,
1 AS sort_order
FROM mv_hot.edge_oee_orders_demo o
JOIN mv_hot.edge_oee_machines m ON m.machine_id = o.machine_id
UNION ALL
SELECT
e.start_time AS time,
e.end_time AS time_end,
e.order_id,
e.machine_id,
m.name AS machine_name,
COALESCE(o.product, 'Sin orden activa') AS product,
'MESAVAULT' AS source,
e.event_type AS state,
e.cause AS detail,
e.duration_min,
e.cost_eur,
2 AS sort_order
FROM mv_hot.edge_oee_events_demo e
JOIN mv_hot.edge_oee_machines m ON m.machine_id = e.machine_id
LEFT JOIN mv_hot.edge_oee_orders_demo o ON o.order_id = e.order_id
UNION ALL
SELECT
o.odoo_end AS time,
o.odoo_end + interval '1 minute' AS time_end,
o.order_id,
o.machine_id,
m.name AS machine_name,
o.product,
'ODOO' AS source,
'ODOO_END' AS state,
'Odoo finaliza la orden' AS detail,
0::numeric AS duration_min,
0::numeric AS cost_eur,
3 AS sort_order
FROM mv_hot.edge_oee_orders_demo o
JOIN mv_hot.edge_oee_machines m ON m.machine_id = o.machine_id;
CREATE OR REPLACE VIEW mv_hot.edge_oee_data_quality_dashboard_demo AS
SELECT
sort_order,
metric AS "Indicador",
COALESCE(value_numeric::text, value_text) AS "Valor",
unit AS "Unidad",
status AS "Estado",
problem AS "Qué está mal",
likely_impact AS "Impacto probable",
recommended_action AS "Qué revisar / acción"
FROM mv_hot.edge_oee_data_quality_demo
ORDER BY sort_order;
CREATE OR REPLACE VIEW mv_hot.edge_oee_actions_demo AS
SELECT
cause AS "Causa",
monthly_cost_eur AS "Impacto estimado €/mes",
effort AS "Esfuerzo",
priority AS "Prioridad",
recommended_action AS "Acción recomendada",
CASE
WHEN cause = 'Ajuste de sellado' THEN 4000::numeric
ELSE NULL::numeric
END AS "Inversión ejemplo €",
CASE
WHEN cause = 'Ajuste de sellado' THEN ROUND(4000::numeric / NULLIF(monthly_cost_eur, 0), 1)
ELSE NULL::numeric
END AS "Payback ejemplo meses"
FROM mv_hot.edge_oee_loss_summary_demo
ORDER BY monthly_cost_eur DESC;
CREATE OR REPLACE VIEW mv_hot.edge_oee_quality_case_demo AS
SELECT
qc.case_id,
qc.order_id,
m.name AS machine_name,
qc.product,
qc.claim,
qc.sealing_adjustment_stops,
qc.sealing_adjustment_min,
qc.microstops_count,
qc.scrap_pct,
qc.energy_vs_avg_pct,
qc.interpretation,
qc.recommended_action
FROM mv_hot.edge_oee_quality_cases_demo qc
JOIN mv_hot.edge_oee_machines m ON m.machine_id = qc.machine_id;
CREATE INDEX IF NOT EXISTS idx_edge_oee_events_demo_time
ON mv_hot.edge_oee_events_demo(start_time DESC);
CREATE INDEX IF NOT EXISTS idx_edge_oee_events_demo_machine_time
ON mv_hot.edge_oee_events_demo(machine_id, start_time DESC);
CREATE INDEX IF NOT EXISTS idx_edge_oee_energy_demo_time
ON mv_hot.edge_oee_energy_demo(ts DESC);

View File

@ -0,0 +1,94 @@
CREATE TABLE IF NOT EXISTS mv_core.tenants (
tenant TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
tenant_type TEXT NOT NULL DEFAULT 'demo',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS mv_core.sites (
tenant TEXT NOT NULL REFERENCES mv_core.tenants(tenant),
site TEXT NOT NULL,
display_name TEXT NOT NULL,
site_type TEXT NOT NULL DEFAULT 'demo',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant, site)
);
CREATE TABLE IF NOT EXISTS mv_core.verticals (
vertical TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE IF NOT EXISTS mv_core.assets (
tenant TEXT NOT NULL,
site TEXT NOT NULL,
vertical TEXT NOT NULL REFERENCES mv_core.verticals(vertical),
asset TEXT NOT NULL,
display_name TEXT NOT NULL,
asset_type TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant, site, vertical, asset),
FOREIGN KEY (tenant, site) REFERENCES mv_core.sites(tenant, site)
);
CREATE TABLE IF NOT EXISTS mv_core.machines (
tenant TEXT NOT NULL,
site TEXT NOT NULL,
machine_id TEXT NOT NULL,
display_name TEXT NOT NULL,
area TEXT,
machine_type TEXT,
asset TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant, site, machine_id),
FOREIGN KEY (tenant, site) REFERENCES mv_core.sites(tenant, site)
);
INSERT INTO mv_core.tenants (tenant, display_name, tenant_type)
VALUES
('ucepsa', 'UCEPSA', 'demo'),
('mesavault_lab', 'MESAVAULT Lab', 'lab')
ON CONFLICT (tenant) DO UPDATE SET
display_name = EXCLUDED.display_name,
tenant_type = EXCLUDED.tenant_type;
INSERT INTO mv_core.sites (tenant, site, display_name, site_type)
VALUES
('ucepsa', 'demo_edge_oee', 'Demo Edge-OEE UCEPSA', 'demo')
ON CONFLICT (tenant, site) DO UPDATE SET
display_name = EXCLUDED.display_name,
site_type = EXCLUDED.site_type;
INSERT INTO mv_core.verticals (vertical, display_name, description)
VALUES
('edge_oee', 'Edge-OEE', 'Captura de estado de máquina, ciclos, energía, paros y causas.'),
('aquaponics', 'Hydro / Aquaponics Climate', 'Vertical de hidroponía / acuaponía y clima.')
ON CONFLICT (vertical) DO UPDATE SET
display_name = EXCLUDED.display_name,
description = EXCLUDED.description;
INSERT INTO mv_core.assets (
tenant, site, vertical, asset, display_name, asset_type
)
VALUES
('ucepsa', 'demo_edge_oee', 'edge_oee', 'revpi_oee_node_01', 'RevPi Edge-OEE Node 01', 'revpi_gateway')
ON CONFLICT (tenant, site, vertical, asset) DO UPDATE SET
display_name = EXCLUDED.display_name,
asset_type = EXCLUDED.asset_type;
INSERT INTO mv_core.machines (
tenant, site, machine_id, display_name, area, machine_type, asset
)
VALUES
('ucepsa', 'demo_edge_oee', 'C_DEMO', 'Cortadora demo', 'corte', 'cortadora', 'revpi_oee_node_01')
ON CONFLICT (tenant, site, machine_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
area = EXCLUDED.area,
machine_type = EXCLUDED.machine_type,
asset = EXCLUDED.asset;

Some files were not shown because too many files have changed in this diff Show More