backup: UCEPSA Edge-OEE piloto productivo activo 2026-06-25
This commit is contained in:
parent
7c5ad7f5a3
commit
42dc050fc7
67
ucepsa/edge-oee-demo/.gitignore
vendored
Normal file
67
ucepsa/edge-oee-demo/.gitignore
vendored
Normal 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
|
||||||
8742
ucepsa/edge-oee-demo/db/schema/postgres_schema_latest.sql
Normal file
8742
ucepsa/edge-oee-demo/db/schema/postgres_schema_latest.sql
Normal file
File diff suppressed because it is too large
Load Diff
54
ucepsa/edge-oee-demo/deploy/ucepsa-local/.env.example
Normal file
54
ucepsa/edge-oee-demo/deploy/ucepsa-local/.env.example
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Identidad
|
||||||
|
TENANT=ucepsa
|
||||||
|
SITE=demo_edge_oee
|
||||||
|
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_test_jp
|
||||||
|
ODOO_USER=__SET_IN_ENV_OR_VAULTWARDEN__
|
||||||
|
ODOO_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
|
||||||
|
ODOO_WORKCENTER_CODE=CORT-01
|
||||||
|
ODOO_INSTANCE=odoo_ucepsa_test
|
||||||
|
|
||||||
|
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__
|
||||||
|
OPERATOR_DEFAULT_MACHINE=CORT-00
|
||||||
|
GRAFANA_ADMIN_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
49
ucepsa/edge-oee-demo/deploy/ucepsa-local/docker-compose.yml
Normal file
49
ucepsa/edge-oee-demo/deploy/ucepsa-local/docker-compose.yml
Normal 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
|
||||||
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
5677
ucepsa/edge-oee-demo/grafana/import/platform40-dashboard.raw.json
Normal file
5677
ucepsa/edge-oee-demo/grafana/import/platform40-dashboard.raw.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
524
ucepsa/edge-oee-demo/grafana/scripts/create_machine_dashboard.py
Executable file
524
ucepsa/edge-oee-demo/grafana/scripts/create_machine_dashboard.py
Executable 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}"))
|
||||||
114
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_pareto_clean.py
Executable file
114
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_pareto_clean.py
Executable 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}"))
|
||||||
507
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_stops.py
Executable file
507
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_stops.py
Executable 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}"))
|
||||||
151
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_stops_pareto.py
Executable file
151
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_stops_pareto.py
Executable 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}"))
|
||||||
30
ucepsa/edge-oee-demo/revpi/edge-oee/.env.example
Normal file
30
ucepsa/edge-oee-demo/revpi/edge-oee/.env.example
Normal 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
|
||||||
41
ucepsa/edge-oee-demo/revpi/edge-oee/REVPI-INVENTORY.md
Normal file
41
ucepsa/edge-oee-demo/revpi/edge-oee/REVPI-INVENTORY.md
Normal 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
|
||||||
474
ucepsa/edge-oee-demo/revpi/edge-oee/beacon_controller.py
Executable file
474
ucepsa/edge-oee-demo/revpi/edge-oee/beacon_controller.py
Executable 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()
|
||||||
182
ucepsa/edge-oee-demo/revpi/edge-oee/beacon_manual_test.py
Executable file
182
ucepsa/edge-oee-demo/revpi/edge-oee/beacon_manual_test.py
Executable 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()
|
||||||
26
ucepsa/edge-oee-demo/revpi/edge-oee/check_revpi_base.sh
Executable file
26
ucepsa/edge-oee-demo/revpi/edge-oee/check_revpi_base.sh
Executable 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
|
||||||
62
ucepsa/edge-oee-demo/revpi/edge-oee/clear_order_state_retained.py
Executable file
62
ucepsa/edge-oee-demo/revpi/edge-oee/clear_order_state_retained.py
Executable 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")
|
||||||
51
ucepsa/edge-oee-demo/revpi/edge-oee/debug_cort01_eastron.py
Executable file
51
ucepsa/edge-oee-demo/revpi/edge-oee/debug_cort01_eastron.py
Executable 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()
|
||||||
99
ucepsa/edge-oee-demo/revpi/edge-oee/di_probe.py
Executable file
99
ucepsa/edge-oee-demo/revpi/edge-oee/di_probe.py
Executable 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())
|
||||||
104
ucepsa/edge-oee-demo/revpi/edge-oee/eastron_reader.py
Executable file
104
ucepsa/edge-oee-demo/revpi/edge-oee/eastron_reader.py
Executable 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())
|
||||||
205
ucepsa/edge-oee-demo/revpi/edge-oee/eastron_reader_mqtt.py
Executable file
205
ucepsa/edge-oee-demo/revpi/edge-oee/eastron_reader_mqtt.py
Executable 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())
|
||||||
419
ucepsa/edge-oee-demo/revpi/edge-oee/eastron_reader_mqtt_loop.py
Executable file
419
ucepsa/edge-oee-demo/revpi/edge-oee/eastron_reader_mqtt_loop.py
Executable 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())
|
||||||
53
ucepsa/edge-oee-demo/revpi/edge-oee/live_wise_counter_modbus.py
Executable file
53
ucepsa/edge-oee-demo/revpi/edge-oee/live_wise_counter_modbus.py
Executable 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()
|
||||||
212
ucepsa/edge-oee-demo/revpi/edge-oee/monitor_cort01_eastron_continuo.py
Executable file
212
ucepsa/edge-oee-demo/revpi/edge-oee/monitor_cort01_eastron_continuo.py
Executable 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}")
|
||||||
55
ucepsa/edge-oee-demo/revpi/edge-oee/probe_3_wise_nodes.py
Executable file
55
ucepsa/edge-oee-demo/revpi/edge-oee/probe_3_wise_nodes.py
Executable 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()
|
||||||
85
ucepsa/edge-oee-demo/revpi/edge-oee/probe_cort01_eastron.py
Executable file
85
ucepsa/edge-oee-demo/revpi/edge-oee/probe_cort01_eastron.py
Executable 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()
|
||||||
|
|
||||||
51
ucepsa/edge-oee-demo/revpi/edge-oee/probe_cort01_sdm630_3phase.py
Executable file
51
ucepsa/edge-oee-demo/revpi/edge-oee/probe_cort01_sdm630_3phase.py
Executable 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()
|
||||||
97
ucepsa/edge-oee-demo/revpi/edge-oee/probe_modbus_float.py
Executable file
97
ucepsa/edge-oee-demo/revpi/edge-oee/probe_modbus_float.py
Executable 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())
|
||||||
114
ucepsa/edge-oee-demo/revpi/edge-oee/probe_wise_counter_modbus.py
Executable file
114
ucepsa/edge-oee-demo/revpi/edge-oee/probe_wise_counter_modbus.py
Executable 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()
|
||||||
120
ucepsa/edge-oee-demo/revpi/edge-oee/probe_wise_tcp.py
Executable file
120
ucepsa/edge-oee-demo/revpi/edge-oee/probe_wise_tcp.py
Executable 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()
|
||||||
129
ucepsa/edge-oee-demo/revpi/edge-oee/publish_order_state.py
Executable file
129
ucepsa/edge-oee-demo/revpi/edge-oee/publish_order_state.py
Executable 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()
|
||||||
125
ucepsa/edge-oee-demo/revpi/edge-oee/scan_wise_counter.py
Executable file
125
ucepsa/edge-oee-demo/revpi/edge-oee/scan_wise_counter.py
Executable 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()
|
||||||
33
ucepsa/edge-oee-demo/revpi/edge-oee/wait_mqtt.py
Executable file
33
ucepsa/edge-oee-demo/revpi/edge-oee/wait_mqtt.py
Executable 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)
|
||||||
105
ucepsa/edge-oee-demo/revpi/edge-oee/wise_di_edge_probe.py
Executable file
105
ucepsa/edge-oee-demo/revpi/edge-oee/wise_di_edge_probe.py
Executable 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()
|
||||||
37
ucepsa/edge-oee-demo/revpi/edge-oee/wise_nodes.json
Normal file
37
ucepsa/edge-oee-demo/revpi/edge-oee/wise_nodes.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
222
ucepsa/edge-oee-demo/revpi/edge-oee/wise_reader_local_loop.py
Executable file
222
ucepsa/edge-oee-demo/revpi/edge-oee/wise_reader_local_loop.py
Executable 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()
|
||||||
303
ucepsa/edge-oee-demo/revpi/edge-oee/wise_reader_local_loop_v2.py
Executable file
303
ucepsa/edge-oee-demo/revpi/edge-oee/wise_reader_local_loop_v2.py
Executable 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()
|
||||||
491
ucepsa/edge-oee-demo/revpi/edge-oee/wise_reader_mqtt_loop.py
Executable file
491
ucepsa/edge-oee-demo/revpi/edge-oee/wise_reader_mqtt_loop.py
Executable 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()
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
[sudo] password for pi:
|
||||||
|
^C^C^C^C^Csudo: a password is required
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
[sudo] password for pi:
|
||||||
|
^C^C^C^C^C^Csudo: a password is required
|
||||||
@ -1,278 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
|
|
||||||
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 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)
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
print(f"[OK] topic={msg.topic} asset={asset} voltage={voltage} power_kw={power} comm_ok={comm_ok}")
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[ERROR] processing topic={msg.topic}: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
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())
|
|
||||||
@ -1,410 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
|
|
||||||
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 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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
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())
|
|
||||||
121
ucepsa/edge-oee-demo/sql/065_fix_odoo_start_delay_scope.sql
Normal file
121
ucepsa/edge-oee-demo/sql/065_fix_odoo_start_delay_scope.sql
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.odoo_discrepancies_demo;
|
||||||
|
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.odoo_vs_machine_demo;
|
||||||
|
|
||||||
|
CREATE VIEW mv_reports_ucepsa_demo.odoo_vs_machine_demo AS
|
||||||
|
WITH odoo AS (
|
||||||
|
SELECT *
|
||||||
|
FROM mv_reports_ucepsa_demo.odoo_current_order_demo
|
||||||
|
),
|
||||||
|
machine AS (
|
||||||
|
SELECT *
|
||||||
|
FROM mv_reports_ucepsa_demo.latest_machine_state_fast
|
||||||
|
WHERE machine_id = 'CORT-01'
|
||||||
|
ORDER BY ingest_ts DESC
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
stops AS (
|
||||||
|
SELECT
|
||||||
|
o.odoo_workorder_id,
|
||||||
|
COUNT(s.*)::int AS stop_count,
|
||||||
|
COALESCE(ROUND(SUM(s.duration_min)::numeric, 2), 0) AS stop_min,
|
||||||
|
COALESCE(ROUND(SUM(s.cost_eur)::numeric, 2), 0) AS stop_cost_eur
|
||||||
|
FROM odoo o
|
||||||
|
LEFT JOIN mv_reports_ucepsa_demo.stops_recent s
|
||||||
|
ON s.machine_id = o.machine_id
|
||||||
|
AND s.started_at >= COALESCE(o.odoo_reference_start, o.odoo_start, o.snapshot_at)
|
||||||
|
AND s.started_at <= COALESCE(o.odoo_end, now())
|
||||||
|
GROUP BY o.odoo_workorder_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
o.odoo_workorder_id,
|
||||||
|
o.odoo_production_id,
|
||||||
|
o.odoo_order_ref,
|
||||||
|
o.odoo_state,
|
||||||
|
o.odoo_is_started,
|
||||||
|
o.product,
|
||||||
|
o.machine_id,
|
||||||
|
o.asset,
|
||||||
|
o.odoo_start,
|
||||||
|
o.odoo_reference_start,
|
||||||
|
o.odoo_end,
|
||||||
|
COALESCE(m.machine_running, false) AS machine_running,
|
||||||
|
m.age_s AS telemetry_age_s,
|
||||||
|
COALESCE(m.cycle_pulse_count, 0) AS cycle_pulse_count,
|
||||||
|
COALESCE(m.cycle_rate_ppm, 0) AS cycle_rate_ppm,
|
||||||
|
ma.first_machine_activity,
|
||||||
|
CASE
|
||||||
|
WHEN o.odoo_is_started = true
|
||||||
|
AND ma.first_machine_activity IS NOT NULL
|
||||||
|
AND COALESCE(o.odoo_reference_start, o.odoo_start) IS NOT NULL
|
||||||
|
THEN ROUND(
|
||||||
|
GREATEST(
|
||||||
|
EXTRACT(EPOCH FROM (ma.first_machine_activity - COALESCE(o.odoo_reference_start, o.odoo_start)))::numeric,
|
||||||
|
0
|
||||||
|
) / 60.0,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
ELSE NULL::numeric
|
||||||
|
END AS start_delay_min,
|
||||||
|
COALESCE(s.stop_count, 0) AS stop_count,
|
||||||
|
COALESCE(s.stop_min, 0) AS stop_min,
|
||||||
|
COALESCE(s.stop_cost_eur, 0) AS stop_cost_eur,
|
||||||
|
COALESCE(m.cycle_pulse_count, 0)::numeric AS real_cycles,
|
||||||
|
COALESCE(o.planned_cycles, o.qty_planned, 0)::numeric AS planned_cycles,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(o.planned_cycles, o.qty_planned, 0) > 0
|
||||||
|
THEN ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(m.cycle_pulse_count, 0)::numeric
|
||||||
|
/ COALESCE(o.planned_cycles, o.qty_planned, 0)::numeric
|
||||||
|
) * 100,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
ELSE 0::numeric
|
||||||
|
END AS cycle_completion_pct,
|
||||||
|
CASE
|
||||||
|
WHEN m.age_s IS NULL OR m.age_s > 120 THEN 'SIN_TELEMETRIA'
|
||||||
|
WHEN o.odoo_state = 'ready' AND COALESCE(m.machine_running, false) = true THEN 'MAQUINA_EN_MARCHA_SIN_ORDEN_INICIADA'
|
||||||
|
WHEN o.odoo_state = 'ready' AND COALESCE(m.machine_running, false) = false THEN 'ORDEN_PREPARADA_MAQUINA_PARADA'
|
||||||
|
WHEN o.odoo_state IN ('progress', 'in_progress') AND COALESCE(m.machine_running, false) = false THEN 'ORDEN_EN_CURSO_MAQUINA_PARADA'
|
||||||
|
ELSE 'OK'
|
||||||
|
END AS status_check
|
||||||
|
FROM odoo o
|
||||||
|
LEFT JOIN machine m ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
MIN(r.ingest_ts) AS first_machine_activity
|
||||||
|
FROM mv_hot.edge_oee_eastron_readings r
|
||||||
|
WHERE r.machine_id = o.machine_id
|
||||||
|
AND (r.payload_json ->> 'machine_running')::boolean = true
|
||||||
|
AND r.ingest_ts >= COALESCE(o.odoo_reference_start, o.odoo_start, o.snapshot_at)
|
||||||
|
AND r.ingest_ts <= COALESCE(o.odoo_end, now())
|
||||||
|
) ma ON true
|
||||||
|
LEFT JOIN stops s ON s.odoo_workorder_id = o.odoo_workorder_id;
|
||||||
|
|
||||||
|
CREATE VIEW mv_reports_ucepsa_demo.odoo_discrepancies_demo AS
|
||||||
|
SELECT
|
||||||
|
now() AS checked_at,
|
||||||
|
odoo_order_ref,
|
||||||
|
product,
|
||||||
|
machine_id,
|
||||||
|
status_check,
|
||||||
|
CASE
|
||||||
|
WHEN status_check = 'MAQUINA_EN_MARCHA_SIN_ORDEN_INICIADA'
|
||||||
|
THEN 'La máquina está en marcha, pero la orden de Odoo está preparada o en espera, no iniciada.'
|
||||||
|
WHEN status_check = 'ORDEN_PREPARADA_MAQUINA_PARADA'
|
||||||
|
THEN 'La orden está preparada/en espera y la máquina está parada. No hay desviación productiva activa.'
|
||||||
|
WHEN status_check = 'ORDEN_EN_CURSO_MAQUINA_PARADA'
|
||||||
|
THEN 'La orden está en curso en Odoo, pero la máquina está parada.'
|
||||||
|
WHEN status_check = 'SIN_TELEMETRIA'
|
||||||
|
THEN 'No hay telemetría reciente de máquina.'
|
||||||
|
ELSE 'Sin discrepancia relevante.'
|
||||||
|
END AS descripcion,
|
||||||
|
start_delay_min,
|
||||||
|
stop_count,
|
||||||
|
stop_min,
|
||||||
|
stop_cost_eur,
|
||||||
|
cycle_completion_pct
|
||||||
|
FROM mv_reports_ucepsa_demo.odoo_vs_machine_demo;
|
||||||
|
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_demo.odoo_vs_machine_demo TO grafana_ucepsa_ro;
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_demo.odoo_discrepancies_demo TO grafana_ucepsa_ro;
|
||||||
114
ucepsa/edge-oee-demo/sql/080_energy_3phase_views.sql
Normal file
114
ucepsa/edge-oee-demo/sql/080_energy_3phase_views.sql
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.machine_state_latest_multi;
|
||||||
|
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.energy_3phase_latest;
|
||||||
|
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.energy_3phase_timeseries;
|
||||||
|
|
||||||
|
CREATE VIEW mv_reports_ucepsa_demo.energy_3phase_timeseries AS
|
||||||
|
SELECT
|
||||||
|
r.ingest_ts AS ts,
|
||||||
|
r.ingest_ts,
|
||||||
|
r.tenant,
|
||||||
|
r.site,
|
||||||
|
r.asset,
|
||||||
|
r.machine_id,
|
||||||
|
EXTRACT(EPOCH FROM (now() - r.ingest_ts))::double precision AS age_s,
|
||||||
|
r.comm_ok,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN lower(r.payload_json ->> 'sample_valid') IN ('true','t','1','yes') THEN true
|
||||||
|
WHEN lower(r.payload_json ->> 'sample_valid') IN ('false','f','0','no') THEN false
|
||||||
|
ELSE NULL
|
||||||
|
END AS sample_valid,
|
||||||
|
|
||||||
|
r.payload_json ->> 'energy_meter_model' AS energy_meter_model,
|
||||||
|
r.payload_json ->> 'energy_map' AS energy_map,
|
||||||
|
|
||||||
|
r.voltage_l1_v,
|
||||||
|
NULLIF(r.payload_json ->> 'voltage_l2_v','')::double precision AS voltage_l2_v,
|
||||||
|
NULLIF(r.payload_json ->> 'voltage_l3_v','')::double precision AS voltage_l3_v,
|
||||||
|
|
||||||
|
r.current_l1_a,
|
||||||
|
NULLIF(r.payload_json ->> 'current_l2_a','')::double precision AS current_l2_a,
|
||||||
|
NULLIF(r.payload_json ->> 'current_l3_a','')::double precision AS current_l3_a,
|
||||||
|
|
||||||
|
NULLIF(r.payload_json ->> 'active_power_l1_w','')::double precision AS active_power_l1_w,
|
||||||
|
NULLIF(r.payload_json ->> 'active_power_l2_w','')::double precision AS active_power_l2_w,
|
||||||
|
NULLIF(r.payload_json ->> 'active_power_l3_w','')::double precision AS active_power_l3_w,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(r.payload_json ->> 'active_power_w','')::double precision,
|
||||||
|
r.power_total_kw * 1000.0
|
||||||
|
) AS active_power_w,
|
||||||
|
|
||||||
|
r.power_total_kw,
|
||||||
|
|
||||||
|
NULLIF(r.payload_json ->> 'apparent_power_va','')::double precision AS apparent_power_va,
|
||||||
|
NULLIF(r.payload_json ->> 'reactive_power_var','')::double precision AS reactive_power_var,
|
||||||
|
NULLIF(r.payload_json ->> 'power_factor','')::double precision AS power_factor,
|
||||||
|
|
||||||
|
r.frequency_hz,
|
||||||
|
r.import_kwh,
|
||||||
|
NULLIF(r.payload_json ->> 'export_kwh','')::double precision AS export_kwh,
|
||||||
|
NULLIF(r.payload_json ->> 'total_active_energy_kwh','')::double precision AS total_active_energy_kwh,
|
||||||
|
|
||||||
|
r.payload_json
|
||||||
|
FROM mv_hot.edge_oee_eastron_readings r;
|
||||||
|
|
||||||
|
CREATE VIEW mv_reports_ucepsa_demo.energy_3phase_latest AS
|
||||||
|
SELECT DISTINCT ON (machine_id)
|
||||||
|
*
|
||||||
|
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
|
||||||
|
WHERE site = 'ucepsa_onpremise'
|
||||||
|
ORDER BY machine_id, ingest_ts DESC;
|
||||||
|
|
||||||
|
CREATE VIEW mv_reports_ucepsa_demo.machine_state_latest_multi AS
|
||||||
|
SELECT
|
||||||
|
l.ingest_ts,
|
||||||
|
l.ts,
|
||||||
|
l.tenant,
|
||||||
|
l.site,
|
||||||
|
l.asset,
|
||||||
|
l.machine_id,
|
||||||
|
l.age_s,
|
||||||
|
l.comm_ok,
|
||||||
|
l.sample_valid,
|
||||||
|
l.energy_map,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN lower(l.payload_json ->> 'machine_running') IN ('true','t','1','yes') THEN true
|
||||||
|
WHEN lower(l.payload_json ->> 'machine_running') IN ('false','f','0','no') THEN false
|
||||||
|
ELSE false
|
||||||
|
END AS machine_running,
|
||||||
|
|
||||||
|
COALESCE(NULLIF(l.payload_json ->> 'cycle_pulse_count','')::numeric::bigint, 0) AS cycle_pulse_count,
|
||||||
|
COALESCE(NULLIF(l.payload_json ->> 'cycle_rate_ppm','')::double precision, 0.0) AS cycle_rate_ppm,
|
||||||
|
NULLIF(l.payload_json ->> 'wise_cycle_counter_raw','')::numeric::bigint AS wise_cycle_counter_raw,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN lower(l.payload_json ->> 'cycle_input_state') IN ('true','t','1','yes') THEN true
|
||||||
|
WHEN lower(l.payload_json ->> 'cycle_input_state') IN ('false','f','0','no') THEN false
|
||||||
|
ELSE NULL
|
||||||
|
END AS cycle_input_state,
|
||||||
|
|
||||||
|
l.payload_json -> 'digital_inputs' AS digital_inputs,
|
||||||
|
|
||||||
|
l.voltage_l1_v,
|
||||||
|
l.voltage_l2_v,
|
||||||
|
l.voltage_l3_v,
|
||||||
|
|
||||||
|
l.current_l1_a,
|
||||||
|
l.current_l2_a,
|
||||||
|
l.current_l3_a,
|
||||||
|
|
||||||
|
l.active_power_l1_w,
|
||||||
|
l.active_power_l2_w,
|
||||||
|
l.active_power_l3_w,
|
||||||
|
|
||||||
|
l.power_total_kw,
|
||||||
|
l.frequency_hz,
|
||||||
|
l.import_kwh,
|
||||||
|
l.payload_json
|
||||||
|
FROM mv_reports_ucepsa_demo.energy_3phase_latest l;
|
||||||
|
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_demo.energy_3phase_timeseries TO grafana_ucepsa_ro;
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_demo.energy_3phase_latest TO grafana_ucepsa_ro;
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_demo.machine_state_latest_multi TO grafana_ucepsa_ro;
|
||||||
81
ucepsa/edge-oee-demo/sql/082_machine_stops_grafana.sql
Normal file
81
ucepsa/edge-oee-demo/sql/082_machine_stops_grafana.sql
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.machine_stops_grafana AS
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.machine_id,
|
||||||
|
|
||||||
|
s.started_at,
|
||||||
|
s.ended_at,
|
||||||
|
COALESCE(s.ended_at, now()) AS effective_ended_at,
|
||||||
|
|
||||||
|
s.status,
|
||||||
|
s.classification_status,
|
||||||
|
|
||||||
|
s.start_event_name,
|
||||||
|
s.end_event_name,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
s.duration_s,
|
||||||
|
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
|
||||||
|
) AS duration_s_current,
|
||||||
|
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(
|
||||||
|
s.duration_s,
|
||||||
|
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
|
||||||
|
) / 60.0
|
||||||
|
)::numeric,
|
||||||
|
2
|
||||||
|
) AS duration_min,
|
||||||
|
|
||||||
|
COALESCE(s.cost_eur, 0)::numeric AS cost_eur,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
to_jsonb(s) ->> 'cause_code',
|
||||||
|
to_jsonb(s) ->> 'stop_cause_code',
|
||||||
|
to_jsonb(s) ->> 'reason_code',
|
||||||
|
CASE
|
||||||
|
WHEN s.classification_status = 'PENDING' THEN 'PENDIENTE'
|
||||||
|
ELSE 'SIN_CAUSA'
|
||||||
|
END
|
||||||
|
) AS cause_code,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
to_jsonb(s) ->> 'cause_name',
|
||||||
|
to_jsonb(s) ->> 'stop_cause_name',
|
||||||
|
to_jsonb(s) ->> 'reason_name',
|
||||||
|
CASE
|
||||||
|
WHEN s.classification_status = 'PENDING' THEN 'Pendiente de clasificar'
|
||||||
|
ELSE 'Sin causa registrada'
|
||||||
|
END
|
||||||
|
) AS cause_name,
|
||||||
|
|
||||||
|
to_jsonb(s) ->> 'operator_note' AS operator_note,
|
||||||
|
to_jsonb(s) ->> 'classified_by' AS classified_by,
|
||||||
|
to_jsonb(s) ->> 'order_ref' AS order_ref,
|
||||||
|
to_jsonb(s) ->> 'production_order' AS production_order,
|
||||||
|
|
||||||
|
CASE WHEN s.status = 'OPEN' THEN true ELSE false END AS is_open,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN s.classification_status = 'PENDING' THEN true
|
||||||
|
ELSE false
|
||||||
|
END AS is_pending_classification,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN s.status = 'OPEN' THEN 'ABIERTO'
|
||||||
|
WHEN s.classification_status = 'PENDING' THEN 'CERRADO SIN CLASIFICAR'
|
||||||
|
WHEN s.classification_status = 'CLASSIFIED' THEN 'CLASIFICADO'
|
||||||
|
ELSE COALESCE(s.status, 'SIN_ESTADO')
|
||||||
|
END AS stop_status_label,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN s.status = 'OPEN' THEN 3
|
||||||
|
WHEN s.classification_status = 'PENDING' THEN 2
|
||||||
|
WHEN s.classification_status = 'CLASSIFIED' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END AS stop_priority_score
|
||||||
|
|
||||||
|
FROM mv_hot.edge_oee_machine_stops s;
|
||||||
|
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_demo.machine_stops_grafana TO grafana_ucepsa_ro;
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.machine_stops_grafana AS
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.machine_id,
|
||||||
|
|
||||||
|
s.started_at,
|
||||||
|
s.ended_at,
|
||||||
|
COALESCE(s.ended_at, now()) AS effective_ended_at,
|
||||||
|
|
||||||
|
s.status,
|
||||||
|
s.classification_status,
|
||||||
|
|
||||||
|
s.start_event_name,
|
||||||
|
s.end_event_name,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
s.duration_s,
|
||||||
|
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
|
||||||
|
) AS duration_s_current,
|
||||||
|
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(
|
||||||
|
s.duration_s,
|
||||||
|
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
|
||||||
|
) / 60.0
|
||||||
|
)::numeric,
|
||||||
|
2
|
||||||
|
) AS duration_min,
|
||||||
|
|
||||||
|
COALESCE(s.cost_eur, 0)::numeric AS cost_eur,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
to_jsonb(s) ->> 'cause_code',
|
||||||
|
to_jsonb(s) ->> 'stop_cause_code',
|
||||||
|
to_jsonb(s) ->> 'reason_code',
|
||||||
|
to_jsonb(c) ->> 'code',
|
||||||
|
to_jsonb(c) ->> 'cause_code',
|
||||||
|
CASE
|
||||||
|
WHEN s.classification_status = 'PENDING' THEN 'PENDIENTE'
|
||||||
|
ELSE 'SIN_CAUSA'
|
||||||
|
END
|
||||||
|
) AS cause_code,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
to_jsonb(s) ->> 'cause_name',
|
||||||
|
to_jsonb(s) ->> 'stop_cause_name',
|
||||||
|
to_jsonb(s) ->> 'reason_name',
|
||||||
|
to_jsonb(c) ->> 'name',
|
||||||
|
to_jsonb(c) ->> 'description',
|
||||||
|
to_jsonb(c) ->> 'label',
|
||||||
|
CASE
|
||||||
|
WHEN s.classification_status = 'PENDING' THEN 'Pendiente de clasificar'
|
||||||
|
ELSE 'Sin causa registrada'
|
||||||
|
END
|
||||||
|
) AS cause_name,
|
||||||
|
|
||||||
|
to_jsonb(s) ->> 'operator_note' AS operator_note,
|
||||||
|
to_jsonb(s) ->> 'classified_by' AS classified_by,
|
||||||
|
to_jsonb(s) ->> 'classified_at' AS classified_at,
|
||||||
|
to_jsonb(s) ->> 'order_ref' AS order_ref,
|
||||||
|
to_jsonb(s) ->> 'production_order' AS production_order,
|
||||||
|
|
||||||
|
CASE WHEN s.status = 'OPEN' THEN true ELSE false END AS is_open,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN s.classification_status = 'PENDING' THEN true
|
||||||
|
ELSE false
|
||||||
|
END AS is_pending_classification,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN s.status = 'OPEN' THEN 'ABIERTO'
|
||||||
|
WHEN s.classification_status = 'PENDING' THEN 'CERRADO SIN CLASIFICAR'
|
||||||
|
WHEN s.classification_status = 'CLASSIFIED' THEN 'CLASIFICADO'
|
||||||
|
ELSE COALESCE(s.status, 'SIN_ESTADO')
|
||||||
|
END AS stop_status_label,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN s.status = 'OPEN' THEN 3
|
||||||
|
WHEN s.classification_status = 'PENDING' THEN 2
|
||||||
|
WHEN s.classification_status = 'CLASSIFIED' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END AS stop_priority_score
|
||||||
|
|
||||||
|
FROM mv_hot.edge_oee_machine_stops s
|
||||||
|
LEFT JOIN mv_hot.edge_oee_stop_causes c
|
||||||
|
ON (to_jsonb(c) ->> 'id') = COALESCE(
|
||||||
|
to_jsonb(s) ->> 'cause_id',
|
||||||
|
to_jsonb(s) ->> 'stop_cause_id',
|
||||||
|
to_jsonb(s) ->> 'reason_id'
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_demo.machine_stops_grafana TO grafana_ucepsa_ro;
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.machine_stops_grafana;
|
||||||
|
|
||||||
|
CREATE VIEW mv_reports_ucepsa_demo.machine_stops_grafana AS
|
||||||
|
WITH base AS (
|
||||||
|
SELECT
|
||||||
|
s.*,
|
||||||
|
to_jsonb(s) AS sj,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
to_jsonb(s) ->> 'cause_code',
|
||||||
|
to_jsonb(s) ->> 'stop_cause_code',
|
||||||
|
to_jsonb(s) ->> 'reason_code'
|
||||||
|
) AS raw_cause_code,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
to_jsonb(s) ->> 'cause_id',
|
||||||
|
to_jsonb(s) ->> 'stop_cause_id',
|
||||||
|
to_jsonb(s) ->> 'reason_id'
|
||||||
|
) AS raw_cause_id,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
to_jsonb(s) ->> 'cause_name',
|
||||||
|
to_jsonb(s) ->> 'stop_cause_name',
|
||||||
|
to_jsonb(s) ->> 'reason_name'
|
||||||
|
) AS raw_cause_name
|
||||||
|
FROM mv_hot.edge_oee_machine_stops s
|
||||||
|
),
|
||||||
|
base_with_cause AS (
|
||||||
|
SELECT
|
||||||
|
b.*,
|
||||||
|
cause.cj AS cause_json
|
||||||
|
FROM base b
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT to_jsonb(sc) AS cj
|
||||||
|
FROM mv_hot.edge_oee_stop_causes sc
|
||||||
|
WHERE
|
||||||
|
(
|
||||||
|
b.raw_cause_id IS NOT NULL
|
||||||
|
AND to_jsonb(sc) ->> 'id' = b.raw_cause_id
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
(
|
||||||
|
b.raw_cause_code IS NOT NULL
|
||||||
|
AND (
|
||||||
|
to_jsonb(sc) ->> 'code' = b.raw_cause_code
|
||||||
|
OR to_jsonb(sc) ->> 'cause_code' = b.raw_cause_code
|
||||||
|
OR to_jsonb(sc) ->> 'name' = b.raw_cause_code
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
) cause ON true
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
b.id,
|
||||||
|
b.machine_id,
|
||||||
|
|
||||||
|
b.started_at,
|
||||||
|
b.ended_at,
|
||||||
|
COALESCE(b.ended_at, now()) AS effective_ended_at,
|
||||||
|
|
||||||
|
b.status,
|
||||||
|
b.classification_status,
|
||||||
|
|
||||||
|
b.start_event_name,
|
||||||
|
b.end_event_name,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
b.duration_s,
|
||||||
|
EXTRACT(EPOCH FROM (COALESCE(b.ended_at, now()) - b.started_at))
|
||||||
|
) AS duration_s_current,
|
||||||
|
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(
|
||||||
|
b.duration_s,
|
||||||
|
EXTRACT(EPOCH FROM (COALESCE(b.ended_at, now()) - b.started_at))
|
||||||
|
) / 60.0
|
||||||
|
)::numeric,
|
||||||
|
2
|
||||||
|
) AS duration_min,
|
||||||
|
|
||||||
|
COALESCE(b.cost_eur, 0)::numeric AS cost_eur,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(b.raw_cause_code, ''),
|
||||||
|
NULLIF(b.cause_json ->> 'code', ''),
|
||||||
|
NULLIF(b.cause_json ->> 'cause_code', ''),
|
||||||
|
CASE
|
||||||
|
WHEN b.classification_status = 'PENDING' THEN 'PENDIENTE'
|
||||||
|
ELSE 'SIN_CAUSA'
|
||||||
|
END
|
||||||
|
) AS cause_code,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(b.raw_cause_name, ''),
|
||||||
|
NULLIF(b.cause_json ->> 'name', ''),
|
||||||
|
NULLIF(b.cause_json ->> 'description', ''),
|
||||||
|
NULLIF(b.cause_json ->> 'label', ''),
|
||||||
|
|
||||||
|
CASE COALESCE(NULLIF(b.raw_cause_code, ''), NULLIF(b.cause_json ->> 'code', ''))
|
||||||
|
WHEN 'cambio_bobina' THEN 'Cambio de bobina'
|
||||||
|
WHEN 'espera_instrucciones' THEN 'Espera de instrucciones'
|
||||||
|
WHEN 'fallo_empalme' THEN 'Fallo de empalme'
|
||||||
|
WHEN 'falta_material' THEN 'Falta de material'
|
||||||
|
WHEN 'ajuste_sellado' THEN 'Ajuste de sellado'
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN b.classification_status = 'PENDING' THEN 'Pendiente de clasificar'
|
||||||
|
ELSE 'Sin causa registrada'
|
||||||
|
END
|
||||||
|
) AS cause_name,
|
||||||
|
|
||||||
|
b.sj ->> 'operator_note' AS operator_note,
|
||||||
|
b.sj ->> 'classified_by' AS classified_by,
|
||||||
|
b.sj ->> 'classified_at' AS classified_at,
|
||||||
|
b.sj ->> 'order_ref' AS order_ref,
|
||||||
|
b.sj ->> 'production_order' AS production_order,
|
||||||
|
|
||||||
|
CASE WHEN b.status = 'OPEN' THEN true ELSE false END AS is_open,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN b.classification_status = 'PENDING' THEN true
|
||||||
|
ELSE false
|
||||||
|
END AS is_pending_classification,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN b.status = 'OPEN' THEN 'ABIERTO'
|
||||||
|
WHEN b.classification_status = 'PENDING' THEN 'CERRADO SIN CLASIFICAR'
|
||||||
|
WHEN b.classification_status = 'CLASSIFIED' THEN 'CLASIFICADO'
|
||||||
|
ELSE COALESCE(b.status, 'SIN_ESTADO')
|
||||||
|
END AS stop_status_label,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN b.status = 'OPEN' THEN 3
|
||||||
|
WHEN b.classification_status = 'PENDING' THEN 2
|
||||||
|
WHEN b.classification_status = 'CLASSIFIED' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END AS stop_priority_score
|
||||||
|
|
||||||
|
FROM base_with_cause b;
|
||||||
|
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_demo.machine_stops_grafana TO grafana_ucepsa_ro;
|
||||||
@ -1,885 +0,0 @@
|
|||||||
-- MESAVAULT Edge-OEE UCEPSA
|
|
||||||
-- Order execution report views
|
|
||||||
-- Generated from validated PostgreSQL views.
|
|
||||||
-- Contains no data and no secrets.
|
|
||||||
|
|
||||||
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_report_full_v1;
|
|
||||||
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_data_quality_v1;
|
|
||||||
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1;
|
|
||||||
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_timeline_v1;
|
|
||||||
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_stop_causes_v1;
|
|
||||||
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_stops_v1;
|
|
||||||
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_report_v1;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- PostgreSQL database dump
|
|
||||||
--
|
|
||||||
|
|
||||||
\restrict YbA0VcRIrZ1kSDSxIf4zctoL9eu8TYoJ5cvkbSD5WkFTQ1jRyH26snWSZehz5VJ
|
|
||||||
|
|
||||||
-- Dumped from database version 16.14
|
|
||||||
-- Dumped by pg_dump version 16.14
|
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
|
||||||
SET lock_timeout = 0;
|
|
||||||
SET idle_in_transaction_session_timeout = 0;
|
|
||||||
SET client_encoding = 'UTF8';
|
|
||||||
SET standard_conforming_strings = on;
|
|
||||||
SELECT pg_catalog.set_config('search_path', '', false);
|
|
||||||
SET check_function_bodies = false;
|
|
||||||
SET xmloption = content;
|
|
||||||
SET client_min_messages = warning;
|
|
||||||
SET row_security = off;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: order_execution_report_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE VIEW mv_reports_ucepsa_demo.order_execution_report_v1 AS
|
|
||||||
WITH orders AS (
|
|
||||||
SELECT o_1.id,
|
|
||||||
o_1.tenant,
|
|
||||||
o_1.site,
|
|
||||||
o_1.odoo_instance,
|
|
||||||
o_1.odoo_workorder_id,
|
|
||||||
o_1.odoo_production_id,
|
|
||||||
o_1.odoo_order_ref,
|
|
||||||
o_1.odoo_state,
|
|
||||||
o_1.odoo_workcenter_code,
|
|
||||||
o_1.odoo_workcenter_name,
|
|
||||||
o_1.machine_id,
|
|
||||||
o_1.asset,
|
|
||||||
o_1.product,
|
|
||||||
o_1.qty_planned,
|
|
||||||
o_1.qty_done,
|
|
||||||
o_1.uom,
|
|
||||||
o_1.planned_cycles,
|
|
||||||
o_1.target_cycle_rate_ppm,
|
|
||||||
o_1.odoo_start,
|
|
||||||
o_1.odoo_end,
|
|
||||||
o_1.notes,
|
|
||||||
o_1.snapshot_at,
|
|
||||||
o_1.odoo_start AS start_ts,
|
|
||||||
CASE
|
|
||||||
WHEN (o_1.odoo_state = ANY (ARRAY['done'::text, 'to_close'::text, 'cancel'::text])) THEN COALESCE(o_1.odoo_end, o_1.snapshot_at)
|
|
||||||
ELSE COALESCE(( SELECT max(r.ingest_ts) AS max
|
|
||||||
FROM mv_hot.edge_oee_eastron_readings r
|
|
||||||
WHERE ((r.machine_id = o_1.machine_id) AND (r.ingest_ts >= o_1.odoo_start) AND (r.comm_ok = true) AND (r.meter_model = 'Eastron SDM120-M'::text))), o_1.snapshot_at)
|
|
||||||
END AS end_ts
|
|
||||||
FROM mv_edge_oee_ucepsa_demo.odoo_workorders_demo o_1
|
|
||||||
WHERE ((o_1.odoo_instance = 'odoo_ucepsa_test'::text) AND (o_1.machine_id IS NOT NULL) AND (o_1.odoo_start IS NOT NULL))
|
|
||||||
), energy AS (
|
|
||||||
SELECT o_1.id AS order_row_id,
|
|
||||||
count(r.id) AS energy_samples,
|
|
||||||
min(r.ingest_ts) AS energy_first_ts,
|
|
||||||
max(r.ingest_ts) AS energy_last_ts,
|
|
||||||
min(r.import_kwh) AS start_import_kwh,
|
|
||||||
max(r.import_kwh) AS end_import_kwh,
|
|
||||||
round((max(r.import_kwh) - min(r.import_kwh)), 3) AS kwh_consumed,
|
|
||||||
round(avg(r.power_total_kw), 3) AS avg_kw,
|
|
||||||
round(max(r.power_total_kw), 3) AS max_kw
|
|
||||||
FROM (orders o_1
|
|
||||||
LEFT JOIN mv_hot.edge_oee_eastron_readings r ON (((r.machine_id = o_1.machine_id) AND (r.ingest_ts >= o_1.start_ts) AND (r.ingest_ts <= o_1.end_ts) AND (r.comm_ok = true) AND (r.meter_model = 'Eastron SDM120-M'::text))))
|
|
||||||
GROUP BY o_1.id
|
|
||||||
), cycles AS (
|
|
||||||
SELECT o_1.id AS order_row_id,
|
|
||||||
count(r.id) AS telemetry_samples,
|
|
||||||
min(r.ingest_ts) AS telemetry_first_ts,
|
|
||||||
max(r.ingest_ts) AS telemetry_last_ts,
|
|
||||||
min(((r.payload_json ->> 'cycle_pulse_count'::text))::bigint) AS min_cycle_pulse_count,
|
|
||||||
max(((r.payload_json ->> 'cycle_pulse_count'::text))::bigint) AS max_cycle_pulse_count,
|
|
||||||
GREATEST((max(((r.payload_json ->> 'cycle_pulse_count'::text))::bigint) - min(((r.payload_json ->> 'cycle_pulse_count'::text))::bigint)), (0)::bigint) AS real_cycle_delta,
|
|
||||||
round(avg(((r.payload_json ->> 'cycle_rate_ppm'::text))::numeric), 2) AS avg_cycle_rate_ppm,
|
|
||||||
round(avg(NULLIF(((r.payload_json ->> 'cycle_rate_ppm'::text))::numeric, (0)::numeric)), 2) AS avg_nonzero_cycle_rate_ppm
|
|
||||||
FROM (orders o_1
|
|
||||||
LEFT JOIN mv_hot.edge_oee_eastron_readings r ON (((r.machine_id = o_1.machine_id) AND (r.ingest_ts >= o_1.start_ts) AND (r.ingest_ts <= o_1.end_ts) AND (r.comm_ok = true) AND (r.meter_model = 'Eastron SDM120-M'::text) AND (r.payload_json ? 'cycle_pulse_count'::text))))
|
|
||||||
GROUP BY o_1.id
|
|
||||||
), stops AS (
|
|
||||||
SELECT o_1.id AS order_row_id,
|
|
||||||
count(ms.id) AS stop_count,
|
|
||||||
round((COALESCE(sum(GREATEST(EXTRACT(epoch FROM (LEAST(COALESCE(ms.ended_at, o_1.end_ts), o_1.end_ts) - GREATEST(ms.started_at, o_1.start_ts))), (0)::numeric)), (0)::numeric) / 60.0), 2) AS stop_min,
|
|
||||||
count(ms.id) FILTER (WHERE (ms.classification_status = 'PENDING'::text)) AS pending_stops,
|
|
||||||
count(ms.id) FILTER (WHERE (ms.classification_status = 'CLASSIFIED'::text)) AS classified_stops,
|
|
||||||
round(COALESCE(sum(((GREATEST(EXTRACT(epoch FROM (LEAST(COALESCE(ms.ended_at, o_1.end_ts), o_1.end_ts) - GREATEST(ms.started_at, o_1.start_ts))), (0)::numeric) / 3600.0) * ms.value_hour_eur)), (0)::numeric), 2) AS stop_cost_eur
|
|
||||||
FROM (orders o_1
|
|
||||||
LEFT JOIN mv_hot.edge_oee_machine_stops ms ON (((ms.machine_id = o_1.machine_id) AND (ms.started_at < o_1.end_ts) AND (COALESCE(ms.ended_at, now()) > o_1.start_ts))))
|
|
||||||
GROUP BY o_1.id
|
|
||||||
)
|
|
||||||
SELECT o.id AS order_row_id,
|
|
||||||
o.tenant,
|
|
||||||
o.site,
|
|
||||||
o.odoo_instance,
|
|
||||||
o.odoo_workorder_id,
|
|
||||||
o.odoo_production_id,
|
|
||||||
o.odoo_order_ref,
|
|
||||||
o.odoo_state,
|
|
||||||
o.odoo_workcenter_code,
|
|
||||||
o.odoo_workcenter_name,
|
|
||||||
o.machine_id,
|
|
||||||
o.asset,
|
|
||||||
o.product,
|
|
||||||
o.qty_planned,
|
|
||||||
o.qty_done,
|
|
||||||
o.uom,
|
|
||||||
o.planned_cycles,
|
|
||||||
o.target_cycle_rate_ppm,
|
|
||||||
o.start_ts,
|
|
||||||
o.end_ts,
|
|
||||||
round((EXTRACT(epoch FROM (o.end_ts - o.start_ts)) / 60.0), 2) AS window_min,
|
|
||||||
COALESCE(c.telemetry_samples, (0)::bigint) AS telemetry_samples,
|
|
||||||
c.telemetry_first_ts,
|
|
||||||
c.telemetry_last_ts,
|
|
||||||
COALESCE(c.min_cycle_pulse_count, (0)::bigint) AS min_cycle_pulse_count,
|
|
||||||
COALESCE(c.max_cycle_pulse_count, (0)::bigint) AS max_cycle_pulse_count,
|
|
||||||
COALESCE(c.real_cycle_delta, (0)::bigint) AS real_cycle_delta,
|
|
||||||
c.avg_cycle_rate_ppm,
|
|
||||||
c.avg_nonzero_cycle_rate_ppm,
|
|
||||||
COALESCE(e.energy_samples, (0)::bigint) AS energy_samples,
|
|
||||||
e.energy_first_ts,
|
|
||||||
e.energy_last_ts,
|
|
||||||
e.start_import_kwh,
|
|
||||||
e.end_import_kwh,
|
|
||||||
e.kwh_consumed,
|
|
||||||
e.avg_kw,
|
|
||||||
e.max_kw,
|
|
||||||
COALESCE(s.stop_count, (0)::bigint) AS stop_count,
|
|
||||||
COALESCE(s.stop_min, (0)::numeric) AS stop_min,
|
|
||||||
COALESCE(s.classified_stops, (0)::bigint) AS classified_stops,
|
|
||||||
COALESCE(s.pending_stops, (0)::bigint) AS pending_stops,
|
|
||||||
COALESCE(s.stop_cost_eur, (0)::numeric) AS stop_cost_eur,
|
|
||||||
CASE
|
|
||||||
WHEN (COALESCE(c.telemetry_samples, (0)::bigint) = 0) THEN 'SIN_TELEMETRIA_EN_VENTANA'::text
|
|
||||||
WHEN (COALESCE(e.energy_samples, (0)::bigint) = 0) THEN 'SIN_ENERGIA_EN_VENTANA'::text
|
|
||||||
WHEN (o.odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 'PARCIAL_ORDEN_EN_CURSO'::text
|
|
||||||
WHEN (COALESCE(s.pending_stops, (0)::bigint) > 0) THEN 'PARCIAL_PAROS_PENDIENTES'::text
|
|
||||||
ELSE 'CERRADO_CALCULABLE'::text
|
|
||||||
END AS report_status,
|
|
||||||
CASE
|
|
||||||
WHEN (COALESCE(c.telemetry_samples, (0)::bigint) = 0) THEN 'No hay telemetría Edge-OEE dentro de la ventana de la orden.'::text
|
|
||||||
WHEN (COALESCE(e.energy_samples, (0)::bigint) = 0) THEN 'No hay muestras energéticas válidas dentro de la ventana de la orden.'::text
|
|
||||||
WHEN (o.odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 'La orden sigue en curso. El informe es parcial.'::text
|
|
||||||
WHEN (COALESCE(s.pending_stops, (0)::bigint) > 0) THEN 'Existen paros pendientes de clasificación.'::text
|
|
||||||
ELSE 'Orden cerrada con datos suficientes para informe.'::text
|
|
||||||
END AS report_status_description
|
|
||||||
FROM (((orders o
|
|
||||||
LEFT JOIN cycles c ON ((c.order_row_id = o.id)))
|
|
||||||
LEFT JOIN energy e ON ((e.order_row_id = o.id)))
|
|
||||||
LEFT JOIN stops s ON ((s.order_row_id = o.id)));
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: order_execution_energy_timeseries_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE VIEW mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1 AS
|
|
||||||
WITH base AS (
|
|
||||||
SELECT r.order_row_id,
|
|
||||||
r.tenant,
|
|
||||||
r.site,
|
|
||||||
r.odoo_instance,
|
|
||||||
r.odoo_workorder_id,
|
|
||||||
r.odoo_production_id,
|
|
||||||
r.odoo_order_ref,
|
|
||||||
r.odoo_state,
|
|
||||||
r.product,
|
|
||||||
r.machine_id,
|
|
||||||
r.asset,
|
|
||||||
r.start_ts AS order_start_ts,
|
|
||||||
r.end_ts AS order_end_ts,
|
|
||||||
r.report_status,
|
|
||||||
e.id AS reading_id,
|
|
||||||
e.ts AS meter_ts,
|
|
||||||
e.ingest_ts AS "time",
|
|
||||||
e.voltage_l1_v,
|
|
||||||
e.current_l1_a,
|
|
||||||
e.power_total_kw,
|
|
||||||
e.frequency_hz,
|
|
||||||
e.import_kwh,
|
|
||||||
e.export_kwh,
|
|
||||||
e.comm_ok,
|
|
||||||
e.error_text,
|
|
||||||
CASE
|
|
||||||
WHEN (e.payload_json ? 'machine_running'::text) THEN ((e.payload_json ->> 'machine_running'::text))::boolean
|
|
||||||
ELSE NULL::boolean
|
|
||||||
END AS machine_running,
|
|
||||||
CASE
|
|
||||||
WHEN (e.payload_json ? 'machine_auto_signal'::text) THEN ((e.payload_json ->> 'machine_auto_signal'::text))::boolean
|
|
||||||
ELSE NULL::boolean
|
|
||||||
END AS machine_auto_signal,
|
|
||||||
CASE
|
|
||||||
WHEN (e.payload_json ? 'cycle_pulse_count'::text) THEN (NULLIF((e.payload_json ->> 'cycle_pulse_count'::text), ''::text))::bigint
|
|
||||||
ELSE NULL::bigint
|
|
||||||
END AS cycle_pulse_count,
|
|
||||||
CASE
|
|
||||||
WHEN (e.payload_json ? 'cycle_rate_ppm'::text) THEN (NULLIF((e.payload_json ->> 'cycle_rate_ppm'::text), ''::text))::numeric
|
|
||||||
ELSE NULL::numeric
|
|
||||||
END AS cycle_rate_ppm,
|
|
||||||
CASE
|
|
||||||
WHEN (e.payload_json ? 'last_cycle_age_s'::text) THEN (NULLIF((e.payload_json ->> 'last_cycle_age_s'::text), ''::text))::numeric
|
|
||||||
ELSE NULL::numeric
|
|
||||||
END AS last_cycle_age_s,
|
|
||||||
e.payload_json
|
|
||||||
FROM (mv_reports_ucepsa_demo.order_execution_report_v1 r
|
|
||||||
JOIN mv_hot.edge_oee_eastron_readings e ON (((e.machine_id = r.machine_id) AND (e.ingest_ts >= r.start_ts) AND (e.ingest_ts <= r.end_ts) AND (e.meter_model = 'Eastron SDM120-M'::text))))
|
|
||||||
), enriched AS (
|
|
||||||
SELECT b.order_row_id,
|
|
||||||
b.tenant,
|
|
||||||
b.site,
|
|
||||||
b.odoo_instance,
|
|
||||||
b.odoo_workorder_id,
|
|
||||||
b.odoo_production_id,
|
|
||||||
b.odoo_order_ref,
|
|
||||||
b.odoo_state,
|
|
||||||
b.product,
|
|
||||||
b.machine_id,
|
|
||||||
b.asset,
|
|
||||||
b.order_start_ts,
|
|
||||||
b.order_end_ts,
|
|
||||||
b.report_status,
|
|
||||||
b.reading_id,
|
|
||||||
b.meter_ts,
|
|
||||||
b."time",
|
|
||||||
b.voltage_l1_v,
|
|
||||||
b.current_l1_a,
|
|
||||||
b.power_total_kw,
|
|
||||||
b.frequency_hz,
|
|
||||||
b.import_kwh,
|
|
||||||
b.export_kwh,
|
|
||||||
b.comm_ok,
|
|
||||||
b.error_text,
|
|
||||||
b.machine_running,
|
|
||||||
b.machine_auto_signal,
|
|
||||||
b.cycle_pulse_count,
|
|
||||||
b.cycle_rate_ppm,
|
|
||||||
b.last_cycle_age_s,
|
|
||||||
b.payload_json,
|
|
||||||
round((EXTRACT(epoch FROM (b."time" - b.order_start_ts)) / 60.0), 2) AS minute_from_order_start,
|
|
||||||
round(EXTRACT(epoch FROM (b."time" - lag(b."time") OVER (PARTITION BY b.order_row_id ORDER BY b."time"))), 2) AS sample_gap_s,
|
|
||||||
min(b.import_kwh) FILTER (WHERE ((b.comm_ok = true) AND (b.import_kwh IS NOT NULL))) OVER (PARTITION BY b.order_row_id) AS order_start_import_kwh,
|
|
||||||
min(b.cycle_pulse_count) FILTER (WHERE (b.cycle_pulse_count IS NOT NULL)) OVER (PARTITION BY b.order_row_id) AS order_start_cycle_pulse_count
|
|
||||||
FROM base b
|
|
||||||
)
|
|
||||||
SELECT order_row_id,
|
|
||||||
tenant,
|
|
||||||
site,
|
|
||||||
odoo_instance,
|
|
||||||
odoo_workorder_id,
|
|
||||||
odoo_production_id,
|
|
||||||
odoo_order_ref,
|
|
||||||
odoo_state,
|
|
||||||
product,
|
|
||||||
machine_id,
|
|
||||||
asset,
|
|
||||||
order_start_ts,
|
|
||||||
order_end_ts,
|
|
||||||
report_status,
|
|
||||||
reading_id,
|
|
||||||
meter_ts,
|
|
||||||
"time",
|
|
||||||
minute_from_order_start,
|
|
||||||
sample_gap_s,
|
|
||||||
voltage_l1_v,
|
|
||||||
current_l1_a,
|
|
||||||
power_total_kw,
|
|
||||||
frequency_hz,
|
|
||||||
import_kwh,
|
|
||||||
export_kwh,
|
|
||||||
round(GREATEST((import_kwh - order_start_import_kwh), (0)::numeric), 3) AS kwh_from_order_start,
|
|
||||||
comm_ok,
|
|
||||||
error_text,
|
|
||||||
machine_running,
|
|
||||||
machine_auto_signal,
|
|
||||||
cycle_pulse_count,
|
|
||||||
GREATEST((cycle_pulse_count - order_start_cycle_pulse_count), (0)::bigint) AS cycles_from_order_start,
|
|
||||||
cycle_rate_ppm,
|
|
||||||
last_cycle_age_s,
|
|
||||||
CASE
|
|
||||||
WHEN (comm_ok = false) THEN 'BAD_COMMUNICATION'::text
|
|
||||||
WHEN ((sample_gap_s IS NOT NULL) AND (sample_gap_s > (30)::numeric)) THEN 'TELEMETRY_GAP'::text
|
|
||||||
WHEN (power_total_kw IS NULL) THEN 'NO_POWER_VALUE'::text
|
|
||||||
WHEN (import_kwh IS NULL) THEN 'NO_IMPORT_KWH'::text
|
|
||||||
ELSE 'OK'::text
|
|
||||||
END AS sample_quality_status,
|
|
||||||
payload_json
|
|
||||||
FROM enriched;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: order_execution_data_quality_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE VIEW mv_reports_ucepsa_demo.order_execution_data_quality_v1 AS
|
|
||||||
WITH base AS (
|
|
||||||
SELECT r.order_row_id,
|
|
||||||
r.tenant,
|
|
||||||
r.site,
|
|
||||||
r.odoo_instance,
|
|
||||||
r.odoo_workorder_id,
|
|
||||||
r.odoo_production_id,
|
|
||||||
r.odoo_order_ref,
|
|
||||||
r.odoo_state,
|
|
||||||
r.odoo_workcenter_code,
|
|
||||||
r.odoo_workcenter_name,
|
|
||||||
r.machine_id,
|
|
||||||
r.asset,
|
|
||||||
r.product,
|
|
||||||
r.qty_planned,
|
|
||||||
r.qty_done,
|
|
||||||
r.uom,
|
|
||||||
r.planned_cycles,
|
|
||||||
r.target_cycle_rate_ppm,
|
|
||||||
r.start_ts,
|
|
||||||
r.end_ts,
|
|
||||||
r.window_min,
|
|
||||||
r.telemetry_samples,
|
|
||||||
r.telemetry_first_ts,
|
|
||||||
r.telemetry_last_ts,
|
|
||||||
r.min_cycle_pulse_count,
|
|
||||||
r.max_cycle_pulse_count,
|
|
||||||
r.real_cycle_delta,
|
|
||||||
r.avg_cycle_rate_ppm,
|
|
||||||
r.avg_nonzero_cycle_rate_ppm,
|
|
||||||
r.energy_samples,
|
|
||||||
r.energy_first_ts,
|
|
||||||
r.energy_last_ts,
|
|
||||||
r.start_import_kwh,
|
|
||||||
r.end_import_kwh,
|
|
||||||
r.kwh_consumed,
|
|
||||||
r.avg_kw,
|
|
||||||
r.max_kw,
|
|
||||||
r.stop_count,
|
|
||||||
r.stop_min,
|
|
||||||
r.classified_stops,
|
|
||||||
r.pending_stops,
|
|
||||||
r.stop_cost_eur,
|
|
||||||
r.report_status,
|
|
||||||
r.report_status_description,
|
|
||||||
GREATEST(((floor((EXTRACT(epoch FROM (r.end_ts - r.start_ts)) / 10.0)))::integer + 1), 1) AS expected_samples_10s
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_report_v1 r
|
|
||||||
), gap_stats AS (
|
|
||||||
SELECT t.order_row_id,
|
|
||||||
count(*) FILTER (WHERE ((t.sample_gap_s IS NOT NULL) AND (t.sample_gap_s > (30)::numeric))) AS telemetry_gap_count_30s,
|
|
||||||
round(max(t.sample_gap_s), 2) AS max_sample_gap_s,
|
|
||||||
round(avg(t.sample_gap_s) FILTER (WHERE (t.sample_gap_s IS NOT NULL)), 2) AS avg_sample_gap_s,
|
|
||||||
count(*) FILTER (WHERE (t.sample_quality_status <> 'OK'::text)) AS non_ok_samples
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1 t
|
|
||||||
GROUP BY t.order_row_id
|
|
||||||
), scored AS (
|
|
||||||
SELECT b.order_row_id,
|
|
||||||
b.tenant,
|
|
||||||
b.site,
|
|
||||||
b.odoo_instance,
|
|
||||||
b.odoo_workorder_id,
|
|
||||||
b.odoo_production_id,
|
|
||||||
b.odoo_order_ref,
|
|
||||||
b.odoo_state,
|
|
||||||
b.product,
|
|
||||||
b.machine_id,
|
|
||||||
b.asset,
|
|
||||||
b.start_ts,
|
|
||||||
b.end_ts,
|
|
||||||
b.window_min,
|
|
||||||
b.qty_planned,
|
|
||||||
b.qty_done,
|
|
||||||
b.uom,
|
|
||||||
b.planned_cycles,
|
|
||||||
b.target_cycle_rate_ppm,
|
|
||||||
b.telemetry_samples,
|
|
||||||
b.energy_samples,
|
|
||||||
b.expected_samples_10s,
|
|
||||||
round((((COALESCE(b.telemetry_samples, (0)::bigint))::numeric / (NULLIF(b.expected_samples_10s, 0))::numeric) * 100.0), 2) AS telemetry_sample_coverage_pct,
|
|
||||||
b.telemetry_first_ts,
|
|
||||||
b.telemetry_last_ts,
|
|
||||||
b.energy_first_ts,
|
|
||||||
b.energy_last_ts,
|
|
||||||
b.real_cycle_delta,
|
|
||||||
b.avg_cycle_rate_ppm,
|
|
||||||
b.avg_nonzero_cycle_rate_ppm,
|
|
||||||
b.kwh_consumed,
|
|
||||||
b.avg_kw,
|
|
||||||
b.max_kw,
|
|
||||||
b.stop_count,
|
|
||||||
b.stop_min,
|
|
||||||
b.classified_stops,
|
|
||||||
b.pending_stops,
|
|
||||||
b.stop_cost_eur,
|
|
||||||
COALESCE(g.telemetry_gap_count_30s, (0)::bigint) AS telemetry_gap_count_30s,
|
|
||||||
COALESCE(g.max_sample_gap_s, (0)::numeric) AS max_sample_gap_s,
|
|
||||||
COALESCE(g.avg_sample_gap_s, (0)::numeric) AS avg_sample_gap_s,
|
|
||||||
COALESCE(g.non_ok_samples, (0)::bigint) AS non_ok_samples,
|
|
||||||
b.report_status,
|
|
||||||
b.report_status_description,
|
|
||||||
CASE
|
|
||||||
WHEN (COALESCE(b.planned_cycles, (0)::numeric) <= (0)::numeric) THEN 'SIN_OBJETIVO_CICLOS'::text
|
|
||||||
WHEN ((COALESCE(b.real_cycle_delta, (0)::bigint) = 0) AND (COALESCE(b.qty_done, (0)::numeric) > (0)::numeric)) THEN 'CANTIDAD_ODOO_SIN_CICLOS_EDGE'::text
|
|
||||||
WHEN ((COALESCE(b.real_cycle_delta, (0)::bigint) > 0) AND (COALESCE(b.planned_cycles, (0)::numeric) > (0)::numeric) AND ((((COALESCE(b.real_cycle_delta, (0)::bigint))::numeric / NULLIF(b.planned_cycles, (0)::numeric)) >= 0.5) AND (((COALESCE(b.real_cycle_delta, (0)::bigint))::numeric / NULLIF(b.planned_cycles, (0)::numeric)) <= 1.5))) THEN 'CICLOS_COHERENTES_CON_OBJETIVO'::text
|
|
||||||
ELSE 'REVISAR_CONVERSION_ODOO_CICLOS'::text
|
|
||||||
END AS production_conversion_status,
|
|
||||||
GREATEST(0, (((((((100 -
|
|
||||||
CASE
|
|
||||||
WHEN (COALESCE(b.telemetry_samples, (0)::bigint) = 0) THEN 60
|
|
||||||
ELSE 0
|
|
||||||
END) -
|
|
||||||
CASE
|
|
||||||
WHEN (COALESCE(b.energy_samples, (0)::bigint) = 0) THEN 50
|
|
||||||
ELSE 0
|
|
||||||
END) -
|
|
||||||
CASE
|
|
||||||
WHEN (b.odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 20
|
|
||||||
ELSE 0
|
|
||||||
END) -
|
|
||||||
CASE
|
|
||||||
WHEN (COALESCE(b.pending_stops, (0)::bigint) > 0) THEN 20
|
|
||||||
ELSE 0
|
|
||||||
END) -
|
|
||||||
CASE
|
|
||||||
WHEN (COALESCE(g.telemetry_gap_count_30s, (0)::bigint) > 0) THEN LEAST(((COALESCE(g.telemetry_gap_count_30s, (0)::bigint))::integer * 2), 15)
|
|
||||||
ELSE 0
|
|
||||||
END) -
|
|
||||||
CASE
|
|
||||||
WHEN (COALESCE(g.max_sample_gap_s, (0)::numeric) > (60)::numeric) THEN 10
|
|
||||||
ELSE 0
|
|
||||||
END) -
|
|
||||||
CASE
|
|
||||||
WHEN (COALESCE(g.non_ok_samples, (0)::bigint) > 0) THEN 10
|
|
||||||
ELSE 0
|
|
||||||
END)) AS technical_quality_score
|
|
||||||
FROM (base b
|
|
||||||
LEFT JOIN gap_stats g ON ((g.order_row_id = b.order_row_id)))
|
|
||||||
)
|
|
||||||
SELECT order_row_id,
|
|
||||||
tenant,
|
|
||||||
site,
|
|
||||||
odoo_instance,
|
|
||||||
odoo_workorder_id,
|
|
||||||
odoo_production_id,
|
|
||||||
odoo_order_ref,
|
|
||||||
odoo_state,
|
|
||||||
product,
|
|
||||||
machine_id,
|
|
||||||
asset,
|
|
||||||
start_ts,
|
|
||||||
end_ts,
|
|
||||||
window_min,
|
|
||||||
qty_planned,
|
|
||||||
qty_done,
|
|
||||||
uom,
|
|
||||||
planned_cycles,
|
|
||||||
target_cycle_rate_ppm,
|
|
||||||
telemetry_samples,
|
|
||||||
energy_samples,
|
|
||||||
expected_samples_10s,
|
|
||||||
telemetry_sample_coverage_pct,
|
|
||||||
telemetry_first_ts,
|
|
||||||
telemetry_last_ts,
|
|
||||||
energy_first_ts,
|
|
||||||
energy_last_ts,
|
|
||||||
real_cycle_delta,
|
|
||||||
avg_cycle_rate_ppm,
|
|
||||||
avg_nonzero_cycle_rate_ppm,
|
|
||||||
kwh_consumed,
|
|
||||||
avg_kw,
|
|
||||||
max_kw,
|
|
||||||
stop_count,
|
|
||||||
stop_min,
|
|
||||||
classified_stops,
|
|
||||||
pending_stops,
|
|
||||||
stop_cost_eur,
|
|
||||||
telemetry_gap_count_30s,
|
|
||||||
max_sample_gap_s,
|
|
||||||
avg_sample_gap_s,
|
|
||||||
non_ok_samples,
|
|
||||||
report_status,
|
|
||||||
report_status_description,
|
|
||||||
production_conversion_status,
|
|
||||||
technical_quality_score,
|
|
||||||
CASE
|
|
||||||
WHEN (technical_quality_score >= 90) THEN 'FIABLE'::text
|
|
||||||
WHEN (technical_quality_score >= 70) THEN 'ACEPTABLE_CON_OBSERVACIONES'::text
|
|
||||||
WHEN (technical_quality_score >= 40) THEN 'PARCIAL'::text
|
|
||||||
ELSE 'NO_DEFENDIBLE'::text
|
|
||||||
END AS technical_quality_level,
|
|
||||||
CASE
|
|
||||||
WHEN (COALESCE(telemetry_samples, (0)::bigint) = 0) THEN 'No hay telemetría Edge-OEE dentro de la ventana de la orden. El informe solo puede apoyarse en Odoo.'::text
|
|
||||||
WHEN (COALESCE(energy_samples, (0)::bigint) = 0) THEN 'Faltan muestras energéticas válidas dentro de la ventana de la orden.'::text
|
|
||||||
WHEN (odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 'La orden sigue abierta. El informe debe tratarse como parcial.'::text
|
|
||||||
WHEN (COALESCE(pending_stops, (0)::bigint) > 0) THEN 'Hay paros pendientes de clasificación. Conviene clasificarlos antes de cerrar el informe.'::text
|
|
||||||
WHEN (COALESCE(telemetry_gap_count_30s, (0)::bigint) > 0) THEN 'Hay gaps de telemetría superiores a 30 segundos. Revisar continuidad de señal.'::text
|
|
||||||
WHEN (production_conversion_status = 'REVISAR_CONVERSION_ODOO_CICLOS'::text) THEN 'El informe técnico es válido para paros, energía y ciclos reales; revisar la conversión entre cantidad Odoo y ciclos/golpes.'::text
|
|
||||||
ELSE 'Datos suficientes para generar informe técnico de orden.'::text
|
|
||||||
END AS recommended_action
|
|
||||||
FROM scored;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: order_execution_stops_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE VIEW mv_reports_ucepsa_demo.order_execution_stops_v1 AS
|
|
||||||
SELECT r.order_row_id,
|
|
||||||
r.tenant,
|
|
||||||
r.site,
|
|
||||||
r.odoo_instance,
|
|
||||||
r.odoo_workorder_id,
|
|
||||||
r.odoo_production_id,
|
|
||||||
r.odoo_order_ref,
|
|
||||||
r.odoo_state,
|
|
||||||
r.product,
|
|
||||||
r.machine_id,
|
|
||||||
r.asset,
|
|
||||||
r.start_ts AS order_start_ts,
|
|
||||||
r.end_ts AS order_end_ts,
|
|
||||||
r.report_status AS order_report_status,
|
|
||||||
ms.id AS stop_id,
|
|
||||||
ms.started_at AS stop_started_at,
|
|
||||||
ms.ended_at AS stop_ended_at,
|
|
||||||
GREATEST(ms.started_at, r.start_ts) AS attributed_started_at,
|
|
||||||
LEAST(COALESCE(ms.ended_at, r.end_ts), r.end_ts) AS attributed_ended_at,
|
|
||||||
round(ms.duration_s, 2) AS original_duration_s,
|
|
||||||
round((ms.duration_s / 60.0), 2) AS original_duration_min,
|
|
||||||
round(EXTRACT(epoch FROM (LEAST(COALESCE(ms.ended_at, r.end_ts), r.end_ts) - GREATEST(ms.started_at, r.start_ts))), 2) AS attributed_duration_s,
|
|
||||||
round((EXTRACT(epoch FROM (LEAST(COALESCE(ms.ended_at, r.end_ts), r.end_ts) - GREATEST(ms.started_at, r.start_ts))) / 60.0), 2) AS attributed_duration_min,
|
|
||||||
ms.status AS stop_status,
|
|
||||||
ms.classification_status,
|
|
||||||
ms.cause_code,
|
|
||||||
ms.operator_note,
|
|
||||||
ms.value_hour_eur,
|
|
||||||
round(((EXTRACT(epoch FROM (LEAST(COALESCE(ms.ended_at, r.end_ts), r.end_ts) - GREATEST(ms.started_at, r.start_ts))) / 3600.0) * ms.value_hour_eur), 2) AS attributed_cost_eur,
|
|
||||||
ms.start_event_name,
|
|
||||||
ms.end_event_name,
|
|
||||||
ms.created_at,
|
|
||||||
ms.updated_at,
|
|
||||||
CASE
|
|
||||||
WHEN (ms.status = 'OPEN'::text) THEN 'PARO_ABIERTO'::text
|
|
||||||
WHEN (ms.classification_status = 'PENDING'::text) THEN 'PENDIENTE_CLASIFICACION'::text
|
|
||||||
WHEN (ms.classification_status = 'CLASSIFIED'::text) THEN 'CLASIFICADO'::text
|
|
||||||
ELSE 'SIN_ESTADO_CLARO'::text
|
|
||||||
END AS stop_quality_status
|
|
||||||
FROM (mv_reports_ucepsa_demo.order_execution_report_v1 r
|
|
||||||
JOIN mv_hot.edge_oee_machine_stops ms ON (((ms.machine_id = r.machine_id) AND (ms.started_at < r.end_ts) AND (COALESCE(ms.ended_at, r.end_ts) > r.start_ts))))
|
|
||||||
WHERE (LEAST(COALESCE(ms.ended_at, r.end_ts), r.end_ts) > GREATEST(ms.started_at, r.start_ts));
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: order_execution_stop_causes_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE VIEW mv_reports_ucepsa_demo.order_execution_stop_causes_v1 AS
|
|
||||||
SELECT order_row_id,
|
|
||||||
tenant,
|
|
||||||
site,
|
|
||||||
odoo_instance,
|
|
||||||
odoo_workorder_id,
|
|
||||||
odoo_production_id,
|
|
||||||
odoo_order_ref,
|
|
||||||
product,
|
|
||||||
machine_id,
|
|
||||||
cause_code,
|
|
||||||
count(*) AS stop_count,
|
|
||||||
round(sum(attributed_duration_min), 2) AS total_stop_min,
|
|
||||||
round(sum(attributed_cost_eur), 2) AS total_stop_cost_eur,
|
|
||||||
count(*) FILTER (WHERE (classification_status = 'CLASSIFIED'::text)) AS classified_stop_count,
|
|
||||||
count(*) FILTER (WHERE (classification_status = 'PENDING'::text)) AS pending_stop_count,
|
|
||||||
min(stop_started_at) AS first_stop_at,
|
|
||||||
max(stop_ended_at) AS last_stop_at
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_stops_v1
|
|
||||||
GROUP BY order_row_id, tenant, site, odoo_instance, odoo_workorder_id, odoo_production_id, odoo_order_ref, product, machine_id, cause_code;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: order_execution_timeline_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE VIEW mv_reports_ucepsa_demo.order_execution_timeline_v1 AS
|
|
||||||
SELECT r.order_row_id,
|
|
||||||
r.tenant,
|
|
||||||
r.site,
|
|
||||||
r.odoo_instance,
|
|
||||||
r.odoo_workorder_id,
|
|
||||||
r.odoo_production_id,
|
|
||||||
r.odoo_order_ref,
|
|
||||||
r.product,
|
|
||||||
r.machine_id,
|
|
||||||
r.asset,
|
|
||||||
r.start_ts AS event_ts,
|
|
||||||
'ORDER_START'::text AS event_type,
|
|
||||||
'Inicio de orden'::text AS event_label,
|
|
||||||
'INFO'::text AS severity,
|
|
||||||
NULL::bigint AS stop_id,
|
|
||||||
NULL::bigint AS node_event_id,
|
|
||||||
NULL::text AS cause_code,
|
|
||||||
NULL::numeric AS duration_min,
|
|
||||||
NULL::numeric AS cost_eur,
|
|
||||||
'Inicio de la ventana de ejecución de la orden según Odoo.'::text AS description,
|
|
||||||
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_report_v1', 'odoo_state', r.odoo_state, 'qty_planned', r.qty_planned, 'qty_done', r.qty_done, 'uom', r.uom) AS evidence_json
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_report_v1 r
|
|
||||||
WHERE (r.start_ts IS NOT NULL)
|
|
||||||
UNION ALL
|
|
||||||
SELECT r.order_row_id,
|
|
||||||
r.tenant,
|
|
||||||
r.site,
|
|
||||||
r.odoo_instance,
|
|
||||||
r.odoo_workorder_id,
|
|
||||||
r.odoo_production_id,
|
|
||||||
r.odoo_order_ref,
|
|
||||||
r.product,
|
|
||||||
r.machine_id,
|
|
||||||
r.asset,
|
|
||||||
r.telemetry_first_ts AS event_ts,
|
|
||||||
'FIRST_TELEMETRY'::text AS event_type,
|
|
||||||
'Primera telemetría Edge-OEE'::text AS event_label,
|
|
||||||
'INFO'::text AS severity,
|
|
||||||
NULL::bigint AS stop_id,
|
|
||||||
NULL::bigint AS node_event_id,
|
|
||||||
NULL::text AS cause_code,
|
|
||||||
NULL::numeric AS duration_min,
|
|
||||||
NULL::numeric AS cost_eur,
|
|
||||||
'Primera muestra de telemetría recibida dentro de la ventana de la orden.'::text AS description,
|
|
||||||
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_report_v1', 'telemetry_samples', r.telemetry_samples, 'energy_samples', r.energy_samples) AS evidence_json
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_report_v1 r
|
|
||||||
WHERE (r.telemetry_first_ts IS NOT NULL)
|
|
||||||
UNION ALL
|
|
||||||
SELECT s.order_row_id,
|
|
||||||
s.tenant,
|
|
||||||
s.site,
|
|
||||||
s.odoo_instance,
|
|
||||||
s.odoo_workorder_id,
|
|
||||||
s.odoo_production_id,
|
|
||||||
s.odoo_order_ref,
|
|
||||||
s.product,
|
|
||||||
s.machine_id,
|
|
||||||
s.asset,
|
|
||||||
s.attributed_started_at AS event_ts,
|
|
||||||
'STOP_START'::text AS event_type,
|
|
||||||
'Inicio de paro'::text AS event_label,
|
|
||||||
CASE
|
|
||||||
WHEN (s.classification_status = 'PENDING'::text) THEN 'WARNING'::text
|
|
||||||
ELSE 'LOSS'::text
|
|
||||||
END AS severity,
|
|
||||||
s.stop_id,
|
|
||||||
NULL::bigint AS node_event_id,
|
|
||||||
s.cause_code,
|
|
||||||
s.attributed_duration_min AS duration_min,
|
|
||||||
s.attributed_cost_eur AS cost_eur,
|
|
||||||
concat('Inicio de paro imputado a la orden. Causa: ', COALESCE(s.cause_code, 'sin clasificar'::text), '. Duración imputada: ', COALESCE((s.attributed_duration_min)::text, '0'::text), ' min.') AS description,
|
|
||||||
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_stops_v1', 'stop_id', s.stop_id, 'classification_status', s.classification_status, 'operator_note', s.operator_note, 'original_started_at', s.stop_started_at, 'original_ended_at', s.stop_ended_at) AS evidence_json
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_stops_v1 s
|
|
||||||
UNION ALL
|
|
||||||
SELECT s.order_row_id,
|
|
||||||
s.tenant,
|
|
||||||
s.site,
|
|
||||||
s.odoo_instance,
|
|
||||||
s.odoo_workorder_id,
|
|
||||||
s.odoo_production_id,
|
|
||||||
s.odoo_order_ref,
|
|
||||||
s.product,
|
|
||||||
s.machine_id,
|
|
||||||
s.asset,
|
|
||||||
s.attributed_ended_at AS event_ts,
|
|
||||||
'STOP_END'::text AS event_type,
|
|
||||||
'Fin de paro'::text AS event_label,
|
|
||||||
CASE
|
|
||||||
WHEN (s.classification_status = 'PENDING'::text) THEN 'WARNING'::text
|
|
||||||
ELSE 'INFO'::text
|
|
||||||
END AS severity,
|
|
||||||
s.stop_id,
|
|
||||||
NULL::bigint AS node_event_id,
|
|
||||||
s.cause_code,
|
|
||||||
s.attributed_duration_min AS duration_min,
|
|
||||||
s.attributed_cost_eur AS cost_eur,
|
|
||||||
concat('Fin de paro imputado a la orden. Causa: ', COALESCE(s.cause_code, 'sin clasificar'::text), '. Coste imputado: ', COALESCE((s.attributed_cost_eur)::text, '0'::text), ' €.') AS description,
|
|
||||||
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_stops_v1', 'stop_id', s.stop_id, 'classification_status', s.classification_status, 'operator_note', s.operator_note, 'value_hour_eur', s.value_hour_eur) AS evidence_json
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_stops_v1 s
|
|
||||||
UNION ALL
|
|
||||||
SELECT r.order_row_id,
|
|
||||||
r.tenant,
|
|
||||||
r.site,
|
|
||||||
r.odoo_instance,
|
|
||||||
r.odoo_workorder_id,
|
|
||||||
r.odoo_production_id,
|
|
||||||
r.odoo_order_ref,
|
|
||||||
r.product,
|
|
||||||
r.machine_id,
|
|
||||||
r.asset,
|
|
||||||
r.telemetry_last_ts AS event_ts,
|
|
||||||
'LAST_TELEMETRY'::text AS event_type,
|
|
||||||
'Última telemetría Edge-OEE'::text AS event_label,
|
|
||||||
CASE
|
|
||||||
WHEN (r.report_status = 'PARCIAL_ORDEN_EN_CURSO'::text) THEN 'WARNING'::text
|
|
||||||
ELSE 'INFO'::text
|
|
||||||
END AS severity,
|
|
||||||
NULL::bigint AS stop_id,
|
|
||||||
NULL::bigint AS node_event_id,
|
|
||||||
NULL::text AS cause_code,
|
|
||||||
NULL::numeric AS duration_min,
|
|
||||||
NULL::numeric AS cost_eur,
|
|
||||||
'Última muestra de telemetría recibida dentro de la ventana analizada.'::text AS description,
|
|
||||||
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_report_v1', 'real_cycle_delta', r.real_cycle_delta, 'kwh_consumed', r.kwh_consumed, 'avg_kw', r.avg_kw, 'max_kw', r.max_kw) AS evidence_json
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_report_v1 r
|
|
||||||
WHERE (r.telemetry_last_ts IS NOT NULL)
|
|
||||||
UNION ALL
|
|
||||||
SELECT r.order_row_id,
|
|
||||||
r.tenant,
|
|
||||||
r.site,
|
|
||||||
r.odoo_instance,
|
|
||||||
r.odoo_workorder_id,
|
|
||||||
r.odoo_production_id,
|
|
||||||
r.odoo_order_ref,
|
|
||||||
r.product,
|
|
||||||
r.machine_id,
|
|
||||||
r.asset,
|
|
||||||
r.end_ts AS event_ts,
|
|
||||||
CASE
|
|
||||||
WHEN (r.odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 'REPORT_CUTOFF'::text
|
|
||||||
ELSE 'ORDER_END'::text
|
|
||||||
END AS event_type,
|
|
||||||
CASE
|
|
||||||
WHEN (r.odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 'Corte provisional del informe'::text
|
|
||||||
ELSE 'Fin de orden'::text
|
|
||||||
END AS event_label,
|
|
||||||
CASE
|
|
||||||
WHEN (r.report_status = 'CERRADO_CALCULABLE'::text) THEN 'INFO'::text
|
|
||||||
ELSE 'WARNING'::text
|
|
||||||
END AS severity,
|
|
||||||
NULL::bigint AS stop_id,
|
|
||||||
NULL::bigint AS node_event_id,
|
|
||||||
NULL::text AS cause_code,
|
|
||||||
NULL::numeric AS duration_min,
|
|
||||||
NULL::numeric AS cost_eur,
|
|
||||||
r.report_status_description AS description,
|
|
||||||
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_report_v1', 'report_status', r.report_status, 'window_min', r.window_min, 'stop_count', r.stop_count, 'stop_min', r.stop_min, 'stop_cost_eur', r.stop_cost_eur) AS evidence_json
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_report_v1 r
|
|
||||||
WHERE (r.end_ts IS NOT NULL);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: order_execution_report_full_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE VIEW mv_reports_ucepsa_demo.order_execution_report_full_v1 AS
|
|
||||||
WITH stop_causes_json AS (
|
|
||||||
SELECT order_execution_stop_causes_v1.order_row_id,
|
|
||||||
jsonb_agg(jsonb_build_object('cause_code', order_execution_stop_causes_v1.cause_code, 'stop_count', order_execution_stop_causes_v1.stop_count, 'total_stop_min', order_execution_stop_causes_v1.total_stop_min, 'total_stop_cost_eur', order_execution_stop_causes_v1.total_stop_cost_eur, 'classified_stop_count', order_execution_stop_causes_v1.classified_stop_count, 'pending_stop_count', order_execution_stop_causes_v1.pending_stop_count, 'first_stop_at', order_execution_stop_causes_v1.first_stop_at, 'last_stop_at', order_execution_stop_causes_v1.last_stop_at) ORDER BY order_execution_stop_causes_v1.total_stop_min DESC) AS stop_causes_json,
|
|
||||||
string_agg(concat(COALESCE(order_execution_stop_causes_v1.cause_code, 'sin_clasificar'::text), ': ', order_execution_stop_causes_v1.stop_count, ' paros / ', order_execution_stop_causes_v1.total_stop_min, ' min / ', order_execution_stop_causes_v1.total_stop_cost_eur, ' €'), '; '::text ORDER BY order_execution_stop_causes_v1.total_stop_min DESC) AS stop_causes_summary
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_stop_causes_v1
|
|
||||||
GROUP BY order_execution_stop_causes_v1.order_row_id
|
|
||||||
), stops_json AS (
|
|
||||||
SELECT order_execution_stops_v1.order_row_id,
|
|
||||||
jsonb_agg(jsonb_build_object('stop_id', order_execution_stops_v1.stop_id, 'stop_started_at', order_execution_stops_v1.stop_started_at, 'stop_ended_at', order_execution_stops_v1.stop_ended_at, 'attributed_started_at', order_execution_stops_v1.attributed_started_at, 'attributed_ended_at', order_execution_stops_v1.attributed_ended_at, 'attributed_duration_min', order_execution_stops_v1.attributed_duration_min, 'cause_code', order_execution_stops_v1.cause_code, 'operator_note', order_execution_stops_v1.operator_note, 'classification_status', order_execution_stops_v1.classification_status, 'attributed_cost_eur', order_execution_stops_v1.attributed_cost_eur, 'stop_quality_status', order_execution_stops_v1.stop_quality_status) ORDER BY order_execution_stops_v1.attributed_started_at) AS stops_json
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_stops_v1
|
|
||||||
GROUP BY order_execution_stops_v1.order_row_id
|
|
||||||
), timeline_json AS (
|
|
||||||
SELECT order_execution_timeline_v1.order_row_id,
|
|
||||||
jsonb_agg(jsonb_build_object('event_ts', order_execution_timeline_v1.event_ts, 'event_type', order_execution_timeline_v1.event_type, 'event_label', order_execution_timeline_v1.event_label, 'severity', order_execution_timeline_v1.severity, 'stop_id', order_execution_timeline_v1.stop_id, 'cause_code', order_execution_timeline_v1.cause_code, 'duration_min', order_execution_timeline_v1.duration_min, 'cost_eur', order_execution_timeline_v1.cost_eur, 'description', order_execution_timeline_v1.description) ORDER BY order_execution_timeline_v1.event_ts, order_execution_timeline_v1.event_type) AS timeline_json
|
|
||||||
FROM mv_reports_ucepsa_demo.order_execution_timeline_v1
|
|
||||||
GROUP BY order_execution_timeline_v1.order_row_id
|
|
||||||
)
|
|
||||||
SELECT r.order_row_id,
|
|
||||||
r.tenant,
|
|
||||||
r.site,
|
|
||||||
r.odoo_instance,
|
|
||||||
r.odoo_workorder_id,
|
|
||||||
r.odoo_production_id,
|
|
||||||
r.odoo_order_ref,
|
|
||||||
r.odoo_state,
|
|
||||||
r.odoo_workcenter_code,
|
|
||||||
r.odoo_workcenter_name,
|
|
||||||
r.machine_id,
|
|
||||||
r.asset,
|
|
||||||
r.product,
|
|
||||||
r.qty_planned,
|
|
||||||
r.qty_done,
|
|
||||||
r.uom,
|
|
||||||
r.planned_cycles,
|
|
||||||
r.target_cycle_rate_ppm,
|
|
||||||
r.start_ts,
|
|
||||||
r.end_ts,
|
|
||||||
r.window_min,
|
|
||||||
GREATEST((r.window_min - r.stop_min), (0)::numeric) AS run_min,
|
|
||||||
CASE
|
|
||||||
WHEN (r.window_min > (0)::numeric) THEN round(((r.stop_min / r.window_min) * 100.0), 2)
|
|
||||||
ELSE NULL::numeric
|
|
||||||
END AS stop_pct,
|
|
||||||
CASE
|
|
||||||
WHEN (r.window_min > (0)::numeric) THEN round(((GREATEST((r.window_min - r.stop_min), (0)::numeric) / r.window_min) * 100.0), 2)
|
|
||||||
ELSE NULL::numeric
|
|
||||||
END AS availability_pct,
|
|
||||||
r.telemetry_samples,
|
|
||||||
r.telemetry_first_ts,
|
|
||||||
r.telemetry_last_ts,
|
|
||||||
r.energy_samples,
|
|
||||||
r.energy_first_ts,
|
|
||||||
r.energy_last_ts,
|
|
||||||
r.start_import_kwh,
|
|
||||||
r.end_import_kwh,
|
|
||||||
r.kwh_consumed,
|
|
||||||
r.avg_kw,
|
|
||||||
r.max_kw,
|
|
||||||
r.min_cycle_pulse_count,
|
|
||||||
r.max_cycle_pulse_count,
|
|
||||||
r.real_cycle_delta,
|
|
||||||
r.avg_cycle_rate_ppm,
|
|
||||||
r.avg_nonzero_cycle_rate_ppm,
|
|
||||||
CASE
|
|
||||||
WHEN (r.real_cycle_delta > 0) THEN round((r.kwh_consumed / (NULLIF(r.real_cycle_delta, 0))::numeric), 6)
|
|
||||||
ELSE NULL::numeric
|
|
||||||
END AS kwh_per_cycle,
|
|
||||||
CASE
|
|
||||||
WHEN (GREATEST((r.window_min - r.stop_min), (0)::numeric) > (0)::numeric) THEN round(((r.real_cycle_delta)::numeric / NULLIF(GREATEST((r.window_min - r.stop_min), (0)::numeric), (0)::numeric)), 2)
|
|
||||||
ELSE NULL::numeric
|
|
||||||
END AS cycles_per_run_min,
|
|
||||||
r.stop_count,
|
|
||||||
r.stop_min,
|
|
||||||
r.classified_stops,
|
|
||||||
r.pending_stops,
|
|
||||||
r.stop_cost_eur,
|
|
||||||
COALESCE(sc.stop_causes_summary, 'Sin paros imputados'::text) AS stop_causes_summary,
|
|
||||||
COALESCE(sc.stop_causes_json, '[]'::jsonb) AS stop_causes_json,
|
|
||||||
COALESCE(sj.stops_json, '[]'::jsonb) AS stops_json,
|
|
||||||
COALESCE(tj.timeline_json, '[]'::jsonb) AS timeline_json,
|
|
||||||
q.expected_samples_10s,
|
|
||||||
q.telemetry_sample_coverage_pct,
|
|
||||||
q.telemetry_gap_count_30s,
|
|
||||||
q.max_sample_gap_s,
|
|
||||||
q.avg_sample_gap_s,
|
|
||||||
q.non_ok_samples,
|
|
||||||
q.production_conversion_status,
|
|
||||||
q.technical_quality_score,
|
|
||||||
q.technical_quality_level,
|
|
||||||
r.report_status,
|
|
||||||
r.report_status_description,
|
|
||||||
q.recommended_action,
|
|
||||||
CASE
|
|
||||||
WHEN ((r.report_status = 'CERRADO_CALCULABLE'::text) AND (q.technical_quality_level = 'FIABLE'::text) AND (q.production_conversion_status = 'CICLOS_COHERENTES_CON_OBJETIVO'::text)) THEN 'LISTO_INFORME_FINAL'::text
|
|
||||||
WHEN ((r.report_status = 'CERRADO_CALCULABLE'::text) AND (q.technical_quality_level = 'FIABLE'::text) AND (q.production_conversion_status = 'REVISAR_CONVERSION_ODOO_CICLOS'::text)) THEN 'LISTO_TECNICO_REVISAR_CONVERSION'::text
|
|
||||||
WHEN ((r.report_status = 'CERRADO_CALCULABLE'::text) AND (q.technical_quality_level = ANY (ARRAY['FIABLE'::text, 'ACEPTABLE_CON_OBSERVACIONES'::text]))) THEN 'LISTO_CON_OBSERVACIONES'::text
|
|
||||||
WHEN (r.report_status = 'PARCIAL_ORDEN_EN_CURSO'::text) THEN 'INFORME_PARCIAL_ORDEN_EN_CURSO'::text
|
|
||||||
WHEN (r.report_status = 'SIN_TELEMETRIA_EN_VENTANA'::text) THEN 'BLOQUEADO_SIN_TELEMETRIA'::text
|
|
||||||
WHEN (r.report_status = 'SIN_ENERGIA_EN_VENTANA'::text) THEN 'BLOQUEADO_SIN_ENERGIA'::text
|
|
||||||
WHEN (r.pending_stops > 0) THEN 'PENDIENTE_CLASIFICAR_PAROS'::text
|
|
||||||
ELSE 'REVISAR'::text
|
|
||||||
END AS final_report_status,
|
|
||||||
concat('Orden ', r.odoo_order_ref, ' en ', r.machine_id, ': duración ', r.window_min, ' min; ciclos reales ', r.real_cycle_delta, '; energía ', COALESCE((r.kwh_consumed)::text, 'sin dato'::text), ' kWh; paros ', r.stop_count, ' (', r.stop_min, ' min); coste estimado ', r.stop_cost_eur, ' €. Estado técnico: ', q.technical_quality_level, '.') AS executive_summary
|
|
||||||
FROM ((((mv_reports_ucepsa_demo.order_execution_report_v1 r
|
|
||||||
LEFT JOIN mv_reports_ucepsa_demo.order_execution_data_quality_v1 q ON ((q.order_row_id = r.order_row_id)))
|
|
||||||
LEFT JOIN stop_causes_json sc ON ((sc.order_row_id = r.order_row_id)))
|
|
||||||
LEFT JOIN stops_json sj ON ((sj.order_row_id = r.order_row_id)))
|
|
||||||
LEFT JOIN timeline_json tj ON ((tj.order_row_id = r.order_row_id)));
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- PostgreSQL database dump complete
|
|
||||||
--
|
|
||||||
|
|
||||||
\unrestrict YbA0VcRIrZ1kSDSxIf4zctoL9eu8TYoJ5cvkbSD5WkFTQ1jRyH26snWSZehz5VJ
|
|
||||||
|
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA mv_reports_ucepsa_demo TO grafana_ucepsa_ro;
|
|
||||||
|
|
||||||
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_report_v1 TO grafana_ucepsa_ro;
|
|
||||||
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_stops_v1 TO grafana_ucepsa_ro;
|
|
||||||
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_stop_causes_v1 TO grafana_ucepsa_ro;
|
|
||||||
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_timeline_v1 TO grafana_ucepsa_ro;
|
|
||||||
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1 TO grafana_ucepsa_ro;
|
|
||||||
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_data_quality_v1 TO grafana_ucepsa_ro;
|
|
||||||
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_report_full_v1 TO grafana_ucepsa_ro;
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
100
ucepsa/edge-oee-demo/sql/versions/090_margin_prod_foundation.sql
Normal file
100
ucepsa/edge-oee-demo/sql/versions/090_margin_prod_foundation.sql
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS mv_edge_oee_ucepsa_prod;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS mv_reports_ucepsa_prod;
|
||||||
|
|
||||||
|
-- Cache comercial/productiva de Odoo PROD.
|
||||||
|
-- Se clona estructura desde la tabla demo existente, pero se corrige la secuencia del id.
|
||||||
|
CREATE TABLE IF NOT EXISTS mv_edge_oee_ucepsa_prod.odoo_order_commercials
|
||||||
|
(
|
||||||
|
LIKE mv_edge_oee_ucepsa_demo.odoo_order_commercials
|
||||||
|
INCLUDING ALL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS mv_edge_oee_ucepsa_prod.odoo_order_commercials_id_seq
|
||||||
|
OWNED BY mv_edge_oee_ucepsa_prod.odoo_order_commercials.id;
|
||||||
|
|
||||||
|
ALTER TABLE mv_edge_oee_ucepsa_prod.odoo_order_commercials
|
||||||
|
ALTER COLUMN id SET DEFAULT nextval('mv_edge_oee_ucepsa_prod.odoo_order_commercials_id_seq'::regclass);
|
||||||
|
|
||||||
|
-- Parámetros económicos productivos.
|
||||||
|
CREATE TABLE IF NOT EXISTS mv_edge_oee_ucepsa_prod.margin_cost_parameters
|
||||||
|
(
|
||||||
|
LIKE mv_edge_oee_ucepsa_demo.margin_cost_parameters
|
||||||
|
INCLUDING ALL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mv_edge_oee_ucepsa_prod.raw_material_cost_parameters
|
||||||
|
(
|
||||||
|
LIKE mv_edge_oee_ucepsa_demo.raw_material_cost_parameters
|
||||||
|
INCLUDING ALL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mv_edge_oee_ucepsa_prod.data_quality_contract_rules
|
||||||
|
(
|
||||||
|
LIKE mv_edge_oee_ucepsa_demo.data_quality_contract_rules
|
||||||
|
INCLUDING ALL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Copia de reglas DQC demo a contexto productivo.
|
||||||
|
-- Mantiene la lógica, cambia site a ucepsa_onpremise.
|
||||||
|
INSERT INTO mv_edge_oee_ucepsa_prod.data_quality_contract_rules (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
rule_code,
|
||||||
|
rule_name,
|
||||||
|
domain,
|
||||||
|
dimension,
|
||||||
|
severity,
|
||||||
|
owner_area,
|
||||||
|
applies_to,
|
||||||
|
description,
|
||||||
|
expected_condition,
|
||||||
|
blocking_for,
|
||||||
|
is_active,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
tenant,
|
||||||
|
'ucepsa_onpremise' AS site,
|
||||||
|
rule_code,
|
||||||
|
rule_name,
|
||||||
|
domain,
|
||||||
|
dimension,
|
||||||
|
severity,
|
||||||
|
owner_area,
|
||||||
|
applies_to,
|
||||||
|
description,
|
||||||
|
expected_condition,
|
||||||
|
blocking_for,
|
||||||
|
is_active,
|
||||||
|
now()
|
||||||
|
FROM mv_edge_oee_ucepsa_demo.data_quality_contract_rules d
|
||||||
|
WHERE d.tenant = 'ucepsa'
|
||||||
|
AND d.site = 'ucepsa_test'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM mv_edge_oee_ucepsa_prod.data_quality_contract_rules p
|
||||||
|
WHERE p.tenant = d.tenant
|
||||||
|
AND p.site = 'ucepsa_onpremise'
|
||||||
|
AND p.rule_code = d.rule_code
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Permisos defensivos si los roles existen.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro') THEN
|
||||||
|
GRANT USAGE ON SCHEMA mv_edge_oee_ucepsa_prod TO grafana_ucepsa_ro;
|
||||||
|
GRANT USAGE ON SCHEMA mv_reports_ucepsa_prod TO grafana_ucepsa_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA mv_edge_oee_ucepsa_prod TO grafana_ucepsa_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA mv_reports_ucepsa_prod TO grafana_ucepsa_ro;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'edge_oee_odoo_sync_writer') THEN
|
||||||
|
GRANT USAGE ON SCHEMA mv_edge_oee_ucepsa_prod TO edge_oee_odoo_sync_writer;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA mv_edge_oee_ucepsa_prod TO edge_oee_odoo_sync_writer;
|
||||||
|
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA mv_edge_oee_ucepsa_prod TO edge_oee_odoo_sync_writer;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.current_order_margin_readiness_v1 AS
|
||||||
|
WITH current_orders AS (
|
||||||
|
SELECT
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
machine_id,
|
||||||
|
workcenter_id,
|
||||||
|
workcenter_name,
|
||||||
|
order_present,
|
||||||
|
order_coherent,
|
||||||
|
odoo_state AS order_state_from_shopfloor,
|
||||||
|
workorder_id AS odoo_workorder_id,
|
||||||
|
production_order AS odoo_order_ref,
|
||||||
|
product AS order_state_product,
|
||||||
|
waiting_count,
|
||||||
|
ready_count,
|
||||||
|
progress_count,
|
||||||
|
mqtt_ts,
|
||||||
|
received_at,
|
||||||
|
payload_json,
|
||||||
|
NULLIF(payload_json ->> 'production_id', '')::integer AS odoo_production_id,
|
||||||
|
NULLIF(payload_json ->> 'product_id', '')::integer AS order_state_product_id,
|
||||||
|
NULLIF(payload_json ->> 'qty_production', '')::numeric AS order_state_qty_production,
|
||||||
|
payload_json ->> 'date_start' AS order_state_date_start,
|
||||||
|
payload_json ->> 'date_finished' AS order_state_date_finished
|
||||||
|
FROM mv_hot.odoo_order_state_current
|
||||||
|
WHERE site = 'ucepsa_onpremise'
|
||||||
|
),
|
||||||
|
commercial AS (
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM mv_edge_oee_ucepsa_prod.odoo_order_commercials
|
||||||
|
WHERE site = 'ucepsa_onpremise'
|
||||||
|
AND odoo_instance = 'odoo_ucepsa_prod'
|
||||||
|
),
|
||||||
|
joined AS (
|
||||||
|
SELECT
|
||||||
|
co.tenant,
|
||||||
|
co.site,
|
||||||
|
co.machine_id,
|
||||||
|
co.workcenter_id,
|
||||||
|
co.workcenter_name,
|
||||||
|
co.order_present,
|
||||||
|
co.order_coherent,
|
||||||
|
co.order_state_from_shopfloor,
|
||||||
|
co.odoo_workorder_id,
|
||||||
|
co.odoo_production_id,
|
||||||
|
co.odoo_order_ref,
|
||||||
|
co.order_state_product,
|
||||||
|
co.order_state_product_id,
|
||||||
|
co.order_state_qty_production,
|
||||||
|
co.waiting_count,
|
||||||
|
co.ready_count,
|
||||||
|
co.progress_count,
|
||||||
|
co.mqtt_ts,
|
||||||
|
co.received_at,
|
||||||
|
|
||||||
|
c.odoo_instance,
|
||||||
|
c.odoo_state AS production_state,
|
||||||
|
c.odoo_origin,
|
||||||
|
c.product_id,
|
||||||
|
c.product_default_code,
|
||||||
|
COALESCE(c.product_name, co.order_state_product) AS product_name,
|
||||||
|
c.product_uom,
|
||||||
|
c.production_qty,
|
||||||
|
c.qty_producing,
|
||||||
|
c.production_uom,
|
||||||
|
c.sale_line_id,
|
||||||
|
c.sale_order_id,
|
||||||
|
c.sale_order_ref,
|
||||||
|
c.customer_id,
|
||||||
|
c.customer_name,
|
||||||
|
c.sale_state,
|
||||||
|
c.sale_date_order,
|
||||||
|
c.currency,
|
||||||
|
c.sale_line_qty,
|
||||||
|
c.sale_line_qty_delivered,
|
||||||
|
c.sale_price_unit,
|
||||||
|
c.sale_discount_pct,
|
||||||
|
c.revenue_net_eur,
|
||||||
|
c.revenue_total_eur,
|
||||||
|
c.raw_material_value_eur,
|
||||||
|
c.finished_value_eur,
|
||||||
|
c.material_cost_status,
|
||||||
|
jsonb_array_length(COALESCE(c.stock_raw_moves_json, '[]'::jsonb)) AS raw_material_line_count,
|
||||||
|
jsonb_array_length(COALESCE(c.stock_finished_moves_json, '[]'::jsonb)) AS finished_move_line_count,
|
||||||
|
c.stock_raw_moves_json,
|
||||||
|
c.stock_finished_moves_json,
|
||||||
|
c.snapshot_at AS commercial_snapshot_at
|
||||||
|
FROM current_orders co
|
||||||
|
LEFT JOIN commercial c
|
||||||
|
ON c.odoo_production_id = co.odoo_production_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
CASE
|
||||||
|
WHEN order_present IS FALSE THEN 'NO_ORDER_LOADED'
|
||||||
|
WHEN order_coherent IS FALSE THEN 'ODOO_ORDER_INCOHERENT'
|
||||||
|
WHEN odoo_production_id IS NULL THEN 'MISSING_PRODUCTION_ID_FROM_ORDER_STATE'
|
||||||
|
WHEN odoo_instance IS NULL THEN 'MISSING_COMMERCIAL_SNAPSHOT'
|
||||||
|
WHEN sale_order_ref IS NULL OR revenue_net_eur IS NULL THEN 'MISSING_REVENUE_LINK'
|
||||||
|
WHEN customer_name IS NULL THEN 'MISSING_CUSTOMER'
|
||||||
|
WHEN raw_material_line_count = 0 THEN 'MISSING_RAW_MATERIALS'
|
||||||
|
WHEN material_cost_status = 'UNAVAILABLE_OR_ZERO' THEN 'MISSING_MATERIAL_COST'
|
||||||
|
ELSE 'READY_FOR_MARGIN_BASE'
|
||||||
|
END AS margin_readiness_status,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN order_present IS FALSE THEN 'No hay orden cargada en Odoo Shop Floor para esta máquina.'
|
||||||
|
WHEN order_coherent IS FALSE THEN 'Hay estado Odoo incoherente para esta máquina.'
|
||||||
|
WHEN odoo_production_id IS NULL THEN 'El order_state no trae production_id; revisar publicador Odoo.'
|
||||||
|
WHEN odoo_instance IS NULL THEN 'La orden existe en order_state, pero aún no tiene snapshot comercial en Edge-OEE.'
|
||||||
|
WHEN sale_order_ref IS NULL OR revenue_net_eur IS NULL THEN 'Falta vínculo económico con pedido/línea de venta.'
|
||||||
|
WHEN customer_name IS NULL THEN 'Falta cliente asociado al pedido de venta.'
|
||||||
|
WHEN raw_material_line_count = 0 THEN 'No se detectan movimientos de materia prima para la orden.'
|
||||||
|
WHEN material_cost_status = 'UNAVAILABLE_OR_ZERO' THEN 'Hay materia prima, pero Odoo no aporta valoración económica útil. Cargar coste €/kg o corregir valoración.'
|
||||||
|
ELSE 'Datos comerciales mínimos disponibles para calcular margen base.'
|
||||||
|
END AS margin_readiness_description,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN order_present IS TRUE THEN 20 ELSE 0 END
|
||||||
|
+ CASE WHEN order_coherent IS TRUE THEN 15 ELSE 0 END
|
||||||
|
+ CASE WHEN odoo_instance IS NOT NULL THEN 15 ELSE 0 END
|
||||||
|
+ CASE WHEN sale_order_ref IS NOT NULL AND revenue_net_eur IS NOT NULL THEN 20 ELSE 0 END
|
||||||
|
+ CASE WHEN customer_name IS NOT NULL THEN 10 ELSE 0 END
|
||||||
|
+ CASE WHEN raw_material_line_count > 0 THEN 10 ELSE 0 END
|
||||||
|
+ CASE WHEN material_cost_status <> 'UNAVAILABLE_OR_ZERO' THEN 10 ELSE 0 END
|
||||||
|
AS margin_readiness_score
|
||||||
|
|
||||||
|
FROM joined;
|
||||||
|
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_prod.current_order_margin_readiness_v1 TO grafana_ucepsa_ro;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -0,0 +1,271 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1) Higiene de secuencias productivas
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS mv_edge_oee_ucepsa_prod.margin_cost_parameters_id_seq
|
||||||
|
OWNED BY mv_edge_oee_ucepsa_prod.margin_cost_parameters.id;
|
||||||
|
|
||||||
|
ALTER TABLE mv_edge_oee_ucepsa_prod.margin_cost_parameters
|
||||||
|
ALTER COLUMN id SET DEFAULT nextval('mv_edge_oee_ucepsa_prod.margin_cost_parameters_id_seq'::regclass);
|
||||||
|
|
||||||
|
SELECT setval(
|
||||||
|
'mv_edge_oee_ucepsa_prod.margin_cost_parameters_id_seq',
|
||||||
|
COALESCE((SELECT max(id) FROM mv_edge_oee_ucepsa_prod.margin_cost_parameters), 0) + 1,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS mv_edge_oee_ucepsa_prod.raw_material_cost_parameters_id_seq
|
||||||
|
OWNED BY mv_edge_oee_ucepsa_prod.raw_material_cost_parameters.id;
|
||||||
|
|
||||||
|
ALTER TABLE mv_edge_oee_ucepsa_prod.raw_material_cost_parameters
|
||||||
|
ALTER COLUMN id SET DEFAULT nextval('mv_edge_oee_ucepsa_prod.raw_material_cost_parameters_id_seq'::regclass);
|
||||||
|
|
||||||
|
SELECT setval(
|
||||||
|
'mv_edge_oee_ucepsa_prod.raw_material_cost_parameters_id_seq',
|
||||||
|
COALESCE((SELECT max(id) FROM mv_edge_oee_ucepsa_prod.raw_material_cost_parameters), 0) + 1,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS mv_edge_oee_ucepsa_prod.data_quality_contract_rules_id_seq
|
||||||
|
OWNED BY mv_edge_oee_ucepsa_prod.data_quality_contract_rules.id;
|
||||||
|
|
||||||
|
ALTER TABLE mv_edge_oee_ucepsa_prod.data_quality_contract_rules
|
||||||
|
ALTER COLUMN id SET DEFAULT nextval('mv_edge_oee_ucepsa_prod.data_quality_contract_rules_id_seq'::regclass);
|
||||||
|
|
||||||
|
SELECT setval(
|
||||||
|
'mv_edge_oee_ucepsa_prod.data_quality_contract_rules_id_seq',
|
||||||
|
COALESCE((SELECT max(id) FROM mv_edge_oee_ucepsa_prod.data_quality_contract_rules), 0) + 1,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2) Ampliar parámetros de coste para permitir match por ID Odoo
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE mv_edge_oee_ucepsa_prod.raw_material_cost_parameters
|
||||||
|
ADD COLUMN IF NOT EXISTS raw_product_id integer;
|
||||||
|
|
||||||
|
ALTER TABLE mv_edge_oee_ucepsa_prod.raw_material_cost_parameters
|
||||||
|
ADD COLUMN IF NOT EXISTS raw_uom text;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_raw_material_cost_params_prod_product_id
|
||||||
|
ON mv_edge_oee_ucepsa_prod.raw_material_cost_parameters(raw_product_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_raw_material_cost_params_prod_code
|
||||||
|
ON mv_edge_oee_ucepsa_prod.raw_material_cost_parameters(raw_product_default_code);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_raw_material_cost_params_prod_name
|
||||||
|
ON mv_edge_oee_ucepsa_prod.raw_material_cost_parameters(raw_product_name);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3) Vista productiva de líneas de materia prima
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.order_raw_material_costs_v1 AS
|
||||||
|
WITH raw_moves AS (
|
||||||
|
SELECT
|
||||||
|
c.id AS commercial_row_id,
|
||||||
|
c.tenant,
|
||||||
|
c.site,
|
||||||
|
c.odoo_instance,
|
||||||
|
c.odoo_production_id,
|
||||||
|
c.odoo_order_ref,
|
||||||
|
c.sale_order_ref,
|
||||||
|
c.customer_name,
|
||||||
|
c.product_default_code AS finished_product_default_code,
|
||||||
|
c.product_name AS finished_product_name,
|
||||||
|
|
||||||
|
NULLIF(rm.raw_move ->> 'id', '')::integer AS raw_move_id,
|
||||||
|
NULLIF((rm.raw_move -> 'product_id') ->> 0, '')::integer AS raw_product_id,
|
||||||
|
(rm.raw_move -> 'product_id') ->> 1 AS raw_product_name,
|
||||||
|
substring((rm.raw_move -> 'product_id') ->> 1, '\[([^\]]+)\]') AS raw_product_default_code,
|
||||||
|
|
||||||
|
NULLIF(rm.raw_move ->> 'product_uom_qty', '')::numeric AS planned_qty,
|
||||||
|
NULLIF(rm.raw_move ->> 'quantity', '')::numeric AS consumed_qty,
|
||||||
|
(rm.raw_move -> 'product_uom') ->> 1 AS raw_uom,
|
||||||
|
NULLIF(rm.raw_move ->> 'price_unit', '')::numeric AS odoo_price_unit,
|
||||||
|
NULLIF(rm.raw_move ->> 'value', '')::numeric AS odoo_value,
|
||||||
|
rm.raw_move ->> 'state' AS move_state,
|
||||||
|
rm.raw_move AS raw_move_json
|
||||||
|
FROM mv_edge_oee_ucepsa_prod.odoo_order_commercials c
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(c.stock_raw_moves_json) rm(raw_move)
|
||||||
|
),
|
||||||
|
costed AS (
|
||||||
|
SELECT
|
||||||
|
r.*,
|
||||||
|
p.id AS raw_material_cost_parameter_id,
|
||||||
|
p.material_cost_eur_per_kg,
|
||||||
|
p.source AS raw_material_cost_source,
|
||||||
|
p.notes AS raw_material_cost_notes,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(r.odoo_value, 0::numeric) <> 0::numeric
|
||||||
|
THEN abs(r.odoo_value)
|
||||||
|
WHEN lower(COALESCE(r.raw_uom, '')) = 'kg'
|
||||||
|
AND p.material_cost_eur_per_kg IS NOT NULL
|
||||||
|
AND r.consumed_qty IS NOT NULL
|
||||||
|
THEN r.consumed_qty * p.material_cost_eur_per_kg
|
||||||
|
ELSE NULL::numeric
|
||||||
|
END AS material_cost_estimated_eur,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(r.odoo_value, 0::numeric) <> 0::numeric
|
||||||
|
THEN 'FROM_ODOO_STOCK_MOVE_VALUE'
|
||||||
|
WHEN r.raw_product_id IS NULL
|
||||||
|
THEN 'MISSING_RAW_PRODUCT_ID'
|
||||||
|
WHEN lower(COALESCE(r.raw_uom, '')) <> 'kg'
|
||||||
|
THEN 'UNSUPPORTED_RAW_UOM'
|
||||||
|
WHEN p.material_cost_eur_per_kg IS NULL
|
||||||
|
THEN 'MISSING_RAW_MATERIAL_COST_PARAMETER'
|
||||||
|
WHEN r.consumed_qty IS NULL
|
||||||
|
THEN 'MISSING_CONSUMED_QTY'
|
||||||
|
ELSE 'FROM_RAW_MATERIAL_COST_PARAMETER'
|
||||||
|
END AS material_cost_status
|
||||||
|
|
||||||
|
FROM raw_moves r
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT p1.*
|
||||||
|
FROM mv_edge_oee_ucepsa_prod.raw_material_cost_parameters p1
|
||||||
|
WHERE p1.tenant = r.tenant
|
||||||
|
AND p1.site = r.site
|
||||||
|
AND p1.is_active = true
|
||||||
|
AND p1.valid_from <= now()
|
||||||
|
AND (p1.valid_to IS NULL OR p1.valid_to > now())
|
||||||
|
AND (
|
||||||
|
(p1.raw_product_id IS NOT NULL AND p1.raw_product_id = r.raw_product_id)
|
||||||
|
OR (p1.raw_product_id IS NULL AND p1.raw_product_default_code IS NOT NULL AND p1.raw_product_default_code = r.raw_product_default_code)
|
||||||
|
OR (p1.raw_product_id IS NULL AND p1.raw_product_default_code IS NULL AND p1.raw_product_name IS NOT NULL AND p1.raw_product_name = r.raw_product_name)
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN p1.raw_product_id IS NOT NULL AND p1.raw_product_id = r.raw_product_id THEN 1
|
||||||
|
WHEN p1.raw_product_default_code IS NOT NULL AND p1.raw_product_default_code = r.raw_product_default_code THEN 2
|
||||||
|
WHEN p1.raw_product_name IS NOT NULL AND p1.raw_product_name = r.raw_product_name THEN 3
|
||||||
|
ELSE 9
|
||||||
|
END,
|
||||||
|
p1.valid_from DESC,
|
||||||
|
p1.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) p ON true
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
commercial_row_id,
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
odoo_instance,
|
||||||
|
odoo_production_id,
|
||||||
|
odoo_order_ref,
|
||||||
|
sale_order_ref,
|
||||||
|
customer_name,
|
||||||
|
finished_product_default_code,
|
||||||
|
finished_product_name,
|
||||||
|
raw_move_id,
|
||||||
|
raw_product_id,
|
||||||
|
raw_product_name,
|
||||||
|
raw_product_default_code,
|
||||||
|
planned_qty,
|
||||||
|
consumed_qty,
|
||||||
|
raw_uom,
|
||||||
|
odoo_price_unit,
|
||||||
|
odoo_value,
|
||||||
|
move_state,
|
||||||
|
raw_material_cost_parameter_id,
|
||||||
|
material_cost_eur_per_kg,
|
||||||
|
raw_material_cost_source,
|
||||||
|
raw_material_cost_notes,
|
||||||
|
material_cost_estimated_eur,
|
||||||
|
round(material_cost_estimated_eur, 4) AS material_cost_estimated_eur_rounded,
|
||||||
|
material_cost_status,
|
||||||
|
raw_move_json
|
||||||
|
FROM costed;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4) Resumen de coste de materia prima por orden
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.order_raw_material_costs_summary_v1 AS
|
||||||
|
SELECT
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
odoo_instance,
|
||||||
|
odoo_production_id,
|
||||||
|
odoo_order_ref,
|
||||||
|
sale_order_ref,
|
||||||
|
customer_name,
|
||||||
|
finished_product_default_code,
|
||||||
|
finished_product_name,
|
||||||
|
count(*) AS raw_material_line_count,
|
||||||
|
round(sum(COALESCE(consumed_qty, 0::numeric)), 3) AS total_consumed_qty,
|
||||||
|
string_agg(
|
||||||
|
concat(
|
||||||
|
COALESCE(raw_product_default_code, raw_product_id::text, 'sin_codigo'),
|
||||||
|
' - ',
|
||||||
|
COALESCE(raw_product_name, 'sin_nombre'),
|
||||||
|
': ',
|
||||||
|
COALESCE(consumed_qty::text, 'sin_cantidad'),
|
||||||
|
' ',
|
||||||
|
COALESCE(raw_uom, '')
|
||||||
|
),
|
||||||
|
'; '
|
||||||
|
ORDER BY raw_product_name
|
||||||
|
) AS raw_materials_summary,
|
||||||
|
round(sum(COALESCE(material_cost_estimated_eur, 0::numeric)), 4) AS material_cost_estimated_eur,
|
||||||
|
count(*) FILTER (WHERE material_cost_estimated_eur IS NOT NULL) AS costed_raw_material_line_count,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE material_cost_status <> 'FROM_RAW_MATERIAL_COST_PARAMETER'
|
||||||
|
AND material_cost_status <> 'FROM_ODOO_STOCK_MOVE_VALUE'
|
||||||
|
) AS missing_or_invalid_raw_material_cost_count,
|
||||||
|
string_agg(DISTINCT material_cost_status, ', ' ORDER BY material_cost_status) AS material_cost_status_summary
|
||||||
|
FROM mv_reports_ucepsa_prod.order_raw_material_costs_v1
|
||||||
|
GROUP BY
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
odoo_instance,
|
||||||
|
odoo_production_id,
|
||||||
|
odoo_order_ref,
|
||||||
|
sale_order_ref,
|
||||||
|
customer_name,
|
||||||
|
finished_product_default_code,
|
||||||
|
finished_product_name;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 5) Vista de costes pendientes de parametrizar
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.raw_material_cost_requirements_v1 AS
|
||||||
|
SELECT
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
raw_product_id,
|
||||||
|
raw_product_default_code,
|
||||||
|
raw_product_name,
|
||||||
|
raw_uom,
|
||||||
|
count(*) AS affected_order_lines,
|
||||||
|
count(DISTINCT odoo_order_ref) AS affected_orders,
|
||||||
|
string_agg(DISTINCT odoo_order_ref, ', ' ORDER BY odoo_order_ref) AS affected_order_refs,
|
||||||
|
round(sum(COALESCE(consumed_qty, 0::numeric)), 3) AS total_consumed_qty,
|
||||||
|
string_agg(DISTINCT customer_name, ', ' ORDER BY customer_name) AS affected_customers,
|
||||||
|
max(material_cost_status) AS current_status,
|
||||||
|
'Cargar coste €/kg en mv_edge_oee_ucepsa_prod.raw_material_cost_parameters' AS recommended_action
|
||||||
|
FROM mv_reports_ucepsa_prod.order_raw_material_costs_v1
|
||||||
|
WHERE material_cost_status IN (
|
||||||
|
'MISSING_RAW_MATERIAL_COST_PARAMETER',
|
||||||
|
'MISSING_RAW_PRODUCT_ID',
|
||||||
|
'MISSING_CONSUMED_QTY',
|
||||||
|
'UNSUPPORTED_RAW_UOM'
|
||||||
|
)
|
||||||
|
GROUP BY
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
raw_product_id,
|
||||||
|
raw_product_default_code,
|
||||||
|
raw_product_name,
|
||||||
|
raw_uom;
|
||||||
|
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_prod.order_raw_material_costs_v1 TO grafana_ucepsa_ro;
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_prod.order_raw_material_costs_summary_v1 TO grafana_ucepsa_ro;
|
||||||
|
GRANT SELECT ON mv_reports_ucepsa_prod.raw_material_cost_requirements_v1 TO grafana_ucepsa_ro;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
92
ucepsa/edge-oee-demo/tools/mqtt_auth_probe.py
Executable file
92
ucepsa/edge-oee-demo/tools/mqtt_auth_probe.py
Executable file
@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
env = read_env()
|
||||||
|
|
||||||
|
host = os.getenv("MQTT_HOST_OVERRIDE") or env.get("MQTT_HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("MQTT_PORT_OVERRIDE") or env.get("MQTT_PORT", "1883"))
|
||||||
|
|
||||||
|
user = (
|
||||||
|
os.getenv("MQTT_USER_OVERRIDE")
|
||||||
|
or os.getenv("MQTT_USERNAME_OVERRIDE")
|
||||||
|
or os.getenv("MQTT_USER")
|
||||||
|
or os.getenv("MQTT_USERNAME")
|
||||||
|
or env.get("MQTT_USER")
|
||||||
|
or env.get("MQTT_USERNAME")
|
||||||
|
or env.get("MOSQUITTO_USER")
|
||||||
|
or env.get("MOSQUITTO_USERNAME")
|
||||||
|
)
|
||||||
|
|
||||||
|
password = (
|
||||||
|
os.getenv("MQTT_PASSWORD_OVERRIDE")
|
||||||
|
or os.getenv("MQTT_PASS_OVERRIDE")
|
||||||
|
or os.getenv("MQTT_PASSWORD")
|
||||||
|
or os.getenv("MQTT_PASS")
|
||||||
|
or env.get("MQTT_PASSWORD")
|
||||||
|
or env.get("MQTT_PASS")
|
||||||
|
or env.get("MOSQUITTO_PASSWORD")
|
||||||
|
or env.get("MOSQUITTO_PASS")
|
||||||
|
)
|
||||||
|
|
||||||
|
print("host =", host)
|
||||||
|
print("port =", port)
|
||||||
|
print("user_present =", bool(user))
|
||||||
|
print("password_present =", bool(password))
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"connect_rc": None,
|
||||||
|
"disconnect_rc": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def on_connect(client, userdata, flags, rc, properties=None):
|
||||||
|
result["connect_rc"] = rc
|
||||||
|
print("on_connect rc =", rc)
|
||||||
|
|
||||||
|
|
||||||
|
def on_disconnect(client, userdata, rc, properties=None):
|
||||||
|
result["disconnect_rc"] = rc
|
||||||
|
print("on_disconnect rc =", rc)
|
||||||
|
|
||||||
|
|
||||||
|
client = mqtt.Client(client_id="mqtt_auth_probe")
|
||||||
|
if user:
|
||||||
|
client.username_pw_set(user, password)
|
||||||
|
|
||||||
|
client.on_connect = on_connect
|
||||||
|
client.on_disconnect = on_disconnect
|
||||||
|
|
||||||
|
client.connect(host, port, 60)
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline and result["connect_rc"] is None:
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
client.loop_stop()
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
|
if result["connect_rc"] != 0:
|
||||||
|
raise SystemExit(f"MQTT auth fallida. connect_rc={result['connect_rc']}")
|
||||||
|
|
||||||
|
print("OK: MQTT conectado correctamente")
|
||||||
528
ucepsa/edge-oee-demo/tools/odoo_margin_commercial_sync.py
Executable file
528
ucepsa/edge-oee-demo/tools/odoo_margin_commercial_sync.py
Executable file
@ -0,0 +1,528 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import xmlrpc.client
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: psycopg2 no disponible en esta imagen Python: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
TENANT = os.getenv("TENANT_OVERRIDE", "ucepsa")
|
||||||
|
SITE = os.getenv("SITE_OVERRIDE", "ucepsa_onpremise")
|
||||||
|
ODOO_INSTANCE = os.getenv("ODOO_INSTANCE_OVERRIDE", "odoo_ucepsa_prod")
|
||||||
|
ODOO_DB = os.getenv("ODOO_DB_OVERRIDE") or os.getenv("ODOO_DB")
|
||||||
|
ODOO_URL = (os.getenv("ODOO_URL") or "").rstrip("/")
|
||||||
|
ODOO_USER = os.getenv("ODOO_USER")
|
||||||
|
ODOO_PASSWORD = os.getenv("ODOO_PASSWORD")
|
||||||
|
|
||||||
|
PGHOST = os.getenv("POSTGRES_HOST") or os.getenv("PGHOST") or "mv_ucepsa_postgres_hot"
|
||||||
|
PGPORT = int(os.getenv("POSTGRES_PORT") or os.getenv("PGPORT") or "5432")
|
||||||
|
PGDATABASE = os.getenv("POSTGRES_DB") or os.getenv("PGDATABASE")
|
||||||
|
PGUSER = os.getenv("POSTGRES_USER") or os.getenv("PGUSER")
|
||||||
|
PGPASSWORD = os.getenv("POSTGRES_PASSWORD") or os.getenv("PGPASSWORD")
|
||||||
|
|
||||||
|
|
||||||
|
def require_env():
|
||||||
|
missing = []
|
||||||
|
for name, value in {
|
||||||
|
"ODOO_URL": ODOO_URL,
|
||||||
|
"ODOO_DB/ODOO_DB_OVERRIDE": ODOO_DB,
|
||||||
|
"ODOO_USER": ODOO_USER,
|
||||||
|
"ODOO_PASSWORD": ODOO_PASSWORD,
|
||||||
|
"POSTGRES_DB": PGDATABASE,
|
||||||
|
"POSTGRES_USER": PGUSER,
|
||||||
|
"POSTGRES_PASSWORD": PGPASSWORD,
|
||||||
|
}.items():
|
||||||
|
if not value:
|
||||||
|
missing.append(name)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError("Faltan variables de entorno: " + ", ".join(missing))
|
||||||
|
|
||||||
|
|
||||||
|
def odoo_connect():
|
||||||
|
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common", allow_none=True)
|
||||||
|
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASSWORD, {})
|
||||||
|
if not uid:
|
||||||
|
raise RuntimeError("No se pudo autenticar contra Odoo")
|
||||||
|
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object", allow_none=True)
|
||||||
|
return uid, models
|
||||||
|
|
||||||
|
|
||||||
|
def odoo_call(models, uid, model, method, *args, **kwargs):
|
||||||
|
return models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, model, method, list(args), kwargs or {})
|
||||||
|
|
||||||
|
|
||||||
|
def pg_connect():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=PGHOST,
|
||||||
|
port=PGPORT,
|
||||||
|
dbname=PGDATABASE,
|
||||||
|
user=PGUSER,
|
||||||
|
password=PGPASSWORD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def as_id(value):
|
||||||
|
if isinstance(value, list) and value:
|
||||||
|
return value[0]
|
||||||
|
if isinstance(value, tuple) and value:
|
||||||
|
return value[0]
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def as_name(value):
|
||||||
|
if isinstance(value, list) and len(value) > 1:
|
||||||
|
return value[1]
|
||||||
|
if isinstance(value, tuple) and len(value) > 1:
|
||||||
|
return value[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_map_by_ids(models, uid, model, ids, fields):
|
||||||
|
ids = [int(x) for x in ids if x]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
rows = odoo_call(models, uid, model, "read", ids, fields=fields)
|
||||||
|
return {r["id"]: r for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_default_code(display_name):
|
||||||
|
if not display_name:
|
||||||
|
return None
|
||||||
|
if display_name.startswith("[") and "]" in display_name:
|
||||||
|
return display_name[1:display_name.index("]")]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_row(prod, product, sale_line, sale_order, raw_moves, finished_moves):
|
||||||
|
product_name = as_name(prod.get("product_id")) or (product or {}).get("display_name")
|
||||||
|
product_default_code = (product or {}).get("default_code") or extract_default_code(product_name)
|
||||||
|
|
||||||
|
product_standard_price = product.get("standard_price") if product else None
|
||||||
|
product_list_price = product.get("list_price") if product else None
|
||||||
|
product_uom = as_name((product or {}).get("uom_id")) or as_name(prod.get("product_uom_id"))
|
||||||
|
|
||||||
|
sale_order_ref = None
|
||||||
|
sale_order_id = None
|
||||||
|
customer_id = None
|
||||||
|
customer_name = None
|
||||||
|
sale_state = None
|
||||||
|
sale_date_order = None
|
||||||
|
currency = None
|
||||||
|
|
||||||
|
sale_line_qty = None
|
||||||
|
sale_line_qty_delivered = None
|
||||||
|
sale_price_unit = None
|
||||||
|
sale_discount_pct = None
|
||||||
|
revenue_net_eur = None
|
||||||
|
revenue_total_eur = None
|
||||||
|
sale_line_id = None
|
||||||
|
|
||||||
|
if sale_line:
|
||||||
|
sale_line_id = sale_line.get("id")
|
||||||
|
sale_line_qty = sale_line.get("product_uom_qty")
|
||||||
|
sale_line_qty_delivered = sale_line.get("qty_delivered")
|
||||||
|
sale_price_unit = sale_line.get("price_unit")
|
||||||
|
sale_discount_pct = sale_line.get("discount")
|
||||||
|
revenue_net_eur = sale_line.get("price_subtotal")
|
||||||
|
revenue_total_eur = sale_line.get("price_total")
|
||||||
|
|
||||||
|
if sale_order:
|
||||||
|
sale_order_id = sale_order.get("id")
|
||||||
|
sale_order_ref = sale_order.get("name")
|
||||||
|
sale_state = sale_order.get("state")
|
||||||
|
sale_date_order = sale_order.get("date_order")
|
||||||
|
customer_id = as_id(sale_order.get("partner_id"))
|
||||||
|
customer_name = as_name(sale_order.get("partner_id"))
|
||||||
|
currency = as_name(sale_order.get("currency_id"))
|
||||||
|
|
||||||
|
raw_material_value_eur = sum([float(m.get("value") or 0.0) for m in raw_moves])
|
||||||
|
finished_value_eur = sum([float(m.get("value") or 0.0) for m in finished_moves])
|
||||||
|
|
||||||
|
if raw_material_value_eur and raw_material_value_eur != 0:
|
||||||
|
material_cost_status = "FROM_ODOO_STOCK_MOVE_VALUE"
|
||||||
|
else:
|
||||||
|
material_cost_status = "UNAVAILABLE_OR_ZERO"
|
||||||
|
|
||||||
|
raw_json = {
|
||||||
|
"production": prod,
|
||||||
|
"product": product,
|
||||||
|
"sale_line": sale_line,
|
||||||
|
"sale_order": sale_order,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant": TENANT,
|
||||||
|
"site": SITE,
|
||||||
|
"odoo_instance": ODOO_INSTANCE,
|
||||||
|
"odoo_production_id": prod.get("id"),
|
||||||
|
"odoo_order_ref": prod.get("name"),
|
||||||
|
"odoo_origin": prod.get("origin"),
|
||||||
|
"odoo_state": prod.get("state"),
|
||||||
|
"product_id": as_id(prod.get("product_id")),
|
||||||
|
"product_default_code": product_default_code,
|
||||||
|
"product_name": product_name,
|
||||||
|
"product_uom": product_uom,
|
||||||
|
"product_standard_price": product_standard_price,
|
||||||
|
"product_list_price": product_list_price,
|
||||||
|
"production_qty": prod.get("product_qty"),
|
||||||
|
"qty_producing": prod.get("qty_producing"),
|
||||||
|
"production_uom": as_name(prod.get("product_uom_id")),
|
||||||
|
"sale_line_id": sale_line_id,
|
||||||
|
"sale_order_id": sale_order_id,
|
||||||
|
"sale_order_ref": sale_order_ref,
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"customer_name": customer_name,
|
||||||
|
"sale_state": sale_state,
|
||||||
|
"sale_date_order": sale_date_order,
|
||||||
|
"currency": currency,
|
||||||
|
"sale_line_qty": sale_line_qty,
|
||||||
|
"sale_line_qty_delivered": sale_line_qty_delivered,
|
||||||
|
"sale_price_unit": sale_price_unit,
|
||||||
|
"sale_discount_pct": sale_discount_pct,
|
||||||
|
"revenue_net_eur": revenue_net_eur,
|
||||||
|
"revenue_total_eur": revenue_total_eur,
|
||||||
|
"raw_material_value_eur": raw_material_value_eur,
|
||||||
|
"finished_value_eur": finished_value_eur,
|
||||||
|
"material_cost_status": material_cost_status,
|
||||||
|
"stock_raw_moves_json": raw_moves,
|
||||||
|
"stock_finished_moves_json": finished_moves,
|
||||||
|
"raw_json": raw_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_rows(conn, rows):
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
INSERT INTO mv_edge_oee_ucepsa_prod.odoo_order_commercials (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
odoo_instance,
|
||||||
|
odoo_production_id,
|
||||||
|
odoo_order_ref,
|
||||||
|
odoo_origin,
|
||||||
|
odoo_state,
|
||||||
|
product_id,
|
||||||
|
product_default_code,
|
||||||
|
product_name,
|
||||||
|
product_uom,
|
||||||
|
product_standard_price,
|
||||||
|
product_list_price,
|
||||||
|
production_qty,
|
||||||
|
qty_producing,
|
||||||
|
production_uom,
|
||||||
|
sale_line_id,
|
||||||
|
sale_order_id,
|
||||||
|
sale_order_ref,
|
||||||
|
customer_id,
|
||||||
|
customer_name,
|
||||||
|
sale_state,
|
||||||
|
sale_date_order,
|
||||||
|
currency,
|
||||||
|
sale_line_qty,
|
||||||
|
sale_line_qty_delivered,
|
||||||
|
sale_price_unit,
|
||||||
|
sale_discount_pct,
|
||||||
|
revenue_net_eur,
|
||||||
|
revenue_total_eur,
|
||||||
|
raw_material_value_eur,
|
||||||
|
finished_value_eur,
|
||||||
|
material_cost_status,
|
||||||
|
stock_raw_moves_json,
|
||||||
|
stock_finished_moves_json,
|
||||||
|
raw_json,
|
||||||
|
snapshot_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%(tenant)s,
|
||||||
|
%(site)s,
|
||||||
|
%(odoo_instance)s,
|
||||||
|
%(odoo_production_id)s,
|
||||||
|
%(odoo_order_ref)s,
|
||||||
|
%(odoo_origin)s,
|
||||||
|
%(odoo_state)s,
|
||||||
|
%(product_id)s,
|
||||||
|
%(product_default_code)s,
|
||||||
|
%(product_name)s,
|
||||||
|
%(product_uom)s,
|
||||||
|
%(product_standard_price)s,
|
||||||
|
%(product_list_price)s,
|
||||||
|
%(production_qty)s,
|
||||||
|
%(qty_producing)s,
|
||||||
|
%(production_uom)s,
|
||||||
|
%(sale_line_id)s,
|
||||||
|
%(sale_order_id)s,
|
||||||
|
%(sale_order_ref)s,
|
||||||
|
%(customer_id)s,
|
||||||
|
%(customer_name)s,
|
||||||
|
%(sale_state)s,
|
||||||
|
%(sale_date_order)s,
|
||||||
|
%(currency)s,
|
||||||
|
%(sale_line_qty)s,
|
||||||
|
%(sale_line_qty_delivered)s,
|
||||||
|
%(sale_price_unit)s,
|
||||||
|
%(sale_discount_pct)s,
|
||||||
|
%(revenue_net_eur)s,
|
||||||
|
%(revenue_total_eur)s,
|
||||||
|
%(raw_material_value_eur)s,
|
||||||
|
%(finished_value_eur)s,
|
||||||
|
%(material_cost_status)s,
|
||||||
|
%(stock_raw_moves_json)s,
|
||||||
|
%(stock_finished_moves_json)s,
|
||||||
|
%(raw_json)s,
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (odoo_instance, odoo_production_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
tenant = EXCLUDED.tenant,
|
||||||
|
site = EXCLUDED.site,
|
||||||
|
odoo_order_ref = EXCLUDED.odoo_order_ref,
|
||||||
|
odoo_origin = EXCLUDED.odoo_origin,
|
||||||
|
odoo_state = EXCLUDED.odoo_state,
|
||||||
|
product_id = EXCLUDED.product_id,
|
||||||
|
product_default_code = EXCLUDED.product_default_code,
|
||||||
|
product_name = EXCLUDED.product_name,
|
||||||
|
product_uom = EXCLUDED.product_uom,
|
||||||
|
product_standard_price = EXCLUDED.product_standard_price,
|
||||||
|
product_list_price = EXCLUDED.product_list_price,
|
||||||
|
production_qty = EXCLUDED.production_qty,
|
||||||
|
qty_producing = EXCLUDED.qty_producing,
|
||||||
|
production_uom = EXCLUDED.production_uom,
|
||||||
|
sale_line_id = EXCLUDED.sale_line_id,
|
||||||
|
sale_order_id = EXCLUDED.sale_order_id,
|
||||||
|
sale_order_ref = EXCLUDED.sale_order_ref,
|
||||||
|
customer_id = EXCLUDED.customer_id,
|
||||||
|
customer_name = EXCLUDED.customer_name,
|
||||||
|
sale_state = EXCLUDED.sale_state,
|
||||||
|
sale_date_order = EXCLUDED.sale_date_order,
|
||||||
|
currency = EXCLUDED.currency,
|
||||||
|
sale_line_qty = EXCLUDED.sale_line_qty,
|
||||||
|
sale_line_qty_delivered = EXCLUDED.sale_line_qty_delivered,
|
||||||
|
sale_price_unit = EXCLUDED.sale_price_unit,
|
||||||
|
sale_discount_pct = EXCLUDED.sale_discount_pct,
|
||||||
|
revenue_net_eur = EXCLUDED.revenue_net_eur,
|
||||||
|
revenue_total_eur = EXCLUDED.revenue_total_eur,
|
||||||
|
raw_material_value_eur = EXCLUDED.raw_material_value_eur,
|
||||||
|
finished_value_eur = EXCLUDED.finished_value_eur,
|
||||||
|
material_cost_status = EXCLUDED.material_cost_status,
|
||||||
|
stock_raw_moves_json = EXCLUDED.stock_raw_moves_json,
|
||||||
|
stock_finished_moves_json = EXCLUDED.stock_finished_moves_json,
|
||||||
|
raw_json = EXCLUDED.raw_json,
|
||||||
|
snapshot_at = now()
|
||||||
|
"""
|
||||||
|
|
||||||
|
prepared = []
|
||||||
|
for row in rows:
|
||||||
|
r = dict(row)
|
||||||
|
r["stock_raw_moves_json"] = psycopg2.extras.Json(r["stock_raw_moves_json"])
|
||||||
|
r["stock_finished_moves_json"] = psycopg2.extras.Json(r["stock_finished_moves_json"])
|
||||||
|
r["raw_json"] = psycopg2.extras.Json(r["raw_json"])
|
||||||
|
prepared.append(r)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
psycopg2.extras.execute_batch(cur, sql, prepared, page_size=50)
|
||||||
|
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--production-id", type=int, action="append", help="ID Odoo mrp.production concreto")
|
||||||
|
parser.add_argument("--order-ref", action="append", help="Referencia WH/MO/xxxxx concreta")
|
||||||
|
parser.add_argument("--limit", type=int, default=50)
|
||||||
|
parser.add_argument("--states", default="confirmed,progress,to_close,done", help="Estados mrp.production separados por coma")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
require_env()
|
||||||
|
|
||||||
|
uid, models = odoo_connect()
|
||||||
|
print(f"Conectado a Odoo: url={ODOO_URL} db={ODOO_DB} user={ODOO_USER} uid={uid}", flush=True)
|
||||||
|
|
||||||
|
domain = []
|
||||||
|
if args.production_id:
|
||||||
|
domain.append(("id", "in", args.production_id))
|
||||||
|
elif args.order_ref:
|
||||||
|
domain.append(("name", "in", args.order_ref))
|
||||||
|
else:
|
||||||
|
states = [s.strip() for s in args.states.split(",") if s.strip()]
|
||||||
|
domain.append(("state", "in", states))
|
||||||
|
|
||||||
|
prod_fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"state",
|
||||||
|
"origin",
|
||||||
|
"product_id",
|
||||||
|
"product_qty",
|
||||||
|
"qty_producing",
|
||||||
|
"product_uom_id",
|
||||||
|
"bom_id",
|
||||||
|
"move_raw_ids",
|
||||||
|
"move_finished_ids",
|
||||||
|
"sale_line_id",
|
||||||
|
"date_start",
|
||||||
|
"date_finished",
|
||||||
|
"company_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
production_ids = odoo_call(
|
||||||
|
models,
|
||||||
|
uid,
|
||||||
|
"mrp.production",
|
||||||
|
"search",
|
||||||
|
domain,
|
||||||
|
limit=args.limit,
|
||||||
|
order="id desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Producciones encontradas: {len(production_ids)} -> {production_ids}", flush=True)
|
||||||
|
|
||||||
|
if not production_ids:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
productions = odoo_call(models, uid, "mrp.production", "read", production_ids, fields=prod_fields)
|
||||||
|
|
||||||
|
product_ids = [as_id(p.get("product_id")) for p in productions if as_id(p.get("product_id"))]
|
||||||
|
sale_line_ids = [as_id(p.get("sale_line_id")) for p in productions if as_id(p.get("sale_line_id"))]
|
||||||
|
raw_move_ids = []
|
||||||
|
finished_move_ids = []
|
||||||
|
|
||||||
|
for p in productions:
|
||||||
|
raw_move_ids.extend(p.get("move_raw_ids") or [])
|
||||||
|
finished_move_ids.extend(p.get("move_finished_ids") or [])
|
||||||
|
|
||||||
|
products = read_map_by_ids(
|
||||||
|
models,
|
||||||
|
uid,
|
||||||
|
"product.product",
|
||||||
|
product_ids,
|
||||||
|
[
|
||||||
|
"id",
|
||||||
|
"display_name",
|
||||||
|
"default_code",
|
||||||
|
"standard_price",
|
||||||
|
"list_price",
|
||||||
|
"uom_id",
|
||||||
|
"categ_id",
|
||||||
|
"barcode",
|
||||||
|
"product_tmpl_id",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
sale_lines = read_map_by_ids(
|
||||||
|
models,
|
||||||
|
uid,
|
||||||
|
"sale.order.line",
|
||||||
|
sale_line_ids,
|
||||||
|
[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"order_id",
|
||||||
|
"product_id",
|
||||||
|
"product_uom_qty",
|
||||||
|
"qty_delivered",
|
||||||
|
"price_unit",
|
||||||
|
"discount",
|
||||||
|
"price_subtotal",
|
||||||
|
"price_total",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
sale_order_ids = [as_id(sl.get("order_id")) for sl in sale_lines.values() if as_id(sl.get("order_id"))]
|
||||||
|
|
||||||
|
sale_orders = read_map_by_ids(
|
||||||
|
models,
|
||||||
|
uid,
|
||||||
|
"sale.order",
|
||||||
|
sale_order_ids,
|
||||||
|
[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"state",
|
||||||
|
"origin",
|
||||||
|
"date_order",
|
||||||
|
"partner_id",
|
||||||
|
"currency_id",
|
||||||
|
"amount_total",
|
||||||
|
"amount_untaxed",
|
||||||
|
"client_order_ref",
|
||||||
|
"order_line",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
move_fields = [
|
||||||
|
"id",
|
||||||
|
"state",
|
||||||
|
"value",
|
||||||
|
"quantity",
|
||||||
|
"price_unit",
|
||||||
|
"product_id",
|
||||||
|
"product_uom",
|
||||||
|
"production_id",
|
||||||
|
"product_uom_qty",
|
||||||
|
"raw_material_production_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
raw_moves_by_id = read_map_by_ids(models, uid, "stock.move", raw_move_ids, move_fields)
|
||||||
|
finished_moves_by_id = read_map_by_ids(models, uid, "stock.move", finished_move_ids, move_fields)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for p in productions:
|
||||||
|
product = products.get(as_id(p.get("product_id")))
|
||||||
|
sale_line = sale_lines.get(as_id(p.get("sale_line_id")))
|
||||||
|
sale_order = sale_orders.get(as_id(sale_line.get("order_id"))) if sale_line else None
|
||||||
|
|
||||||
|
raw_moves = [raw_moves_by_id[mid] for mid in (p.get("move_raw_ids") or []) if mid in raw_moves_by_id]
|
||||||
|
finished_moves = [finished_moves_by_id[mid] for mid in (p.get("move_finished_ids") or []) if mid in finished_moves_by_id]
|
||||||
|
|
||||||
|
row = build_row(p, product, sale_line, sale_order, raw_moves, finished_moves)
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"odoo_order_ref": r["odoo_order_ref"],
|
||||||
|
"odoo_production_id": r["odoo_production_id"],
|
||||||
|
"product": r["product_name"],
|
||||||
|
"sale_order_ref": r["sale_order_ref"],
|
||||||
|
"customer_name": r["customer_name"],
|
||||||
|
"revenue_net_eur": r["revenue_net_eur"],
|
||||||
|
"raw_moves": len(r["stock_raw_moves_json"]),
|
||||||
|
"finished_moves": len(r["stock_finished_moves_json"]),
|
||||||
|
"material_cost_status": r["material_cost_status"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
), flush=True)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("DRY RUN: no se escribe en PostgreSQL", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with pg_connect() as conn:
|
||||||
|
count = upsert_rows(conn, rows)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print(f"Filas insertadas/actualizadas en mv_edge_oee_ucepsa_prod.odoo_order_commercials: {count}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
388
ucepsa/edge-oee-demo/tools/odoo_order_state_publisher.py
Executable file
388
ucepsa/edge-oee-demo/tools/odoo_order_state_publisher.py
Executable file
@ -0,0 +1,388 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import xmlrpc.client
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MACHINE_MAP = {
|
||||||
|
"CORT-00": "Cortadora 0",
|
||||||
|
"CORT-01": "Cortadora 01",
|
||||||
|
"CORT-02": "Cortadora 02",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_LOADED_STATES = ["progress"]
|
||||||
|
DIAGNOSTIC_STATES = ["waiting", "ready", "progress"]
|
||||||
|
|
||||||
|
|
||||||
|
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 m2o_id(value):
|
||||||
|
if isinstance(value, list) and len(value) >= 1:
|
||||||
|
return value[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def m2o_name(value):
|
||||||
|
if isinstance(value, list) and len(value) >= 2:
|
||||||
|
return value[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def connect_odoo(env):
|
||||||
|
url = env["ODOO_URL"].rstrip("/")
|
||||||
|
db = os.getenv("ODOO_DB_OVERRIDE") or env["ODOO_DB"]
|
||||||
|
user = env["ODOO_USER"]
|
||||||
|
password = env["ODOO_PASSWORD"]
|
||||||
|
|
||||||
|
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
|
||||||
|
uid = common.authenticate(db, user, password, {})
|
||||||
|
|
||||||
|
if not uid:
|
||||||
|
raise RuntimeError("Autenticación Odoo fallida")
|
||||||
|
|
||||||
|
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"db": db,
|
||||||
|
"user": user,
|
||||||
|
"password": password,
|
||||||
|
"uid": uid,
|
||||||
|
"models": models,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def existing_fields(odoo, model, wanted):
|
||||||
|
fields = odoo["models"].execute_kw(
|
||||||
|
odoo["db"],
|
||||||
|
odoo["uid"],
|
||||||
|
odoo["password"],
|
||||||
|
model,
|
||||||
|
"fields_get",
|
||||||
|
[],
|
||||||
|
{"attributes": ["string", "type"]},
|
||||||
|
)
|
||||||
|
return [f for f in wanted if f in fields]
|
||||||
|
|
||||||
|
|
||||||
|
def search_read(odoo, model, domain, wanted_fields, limit=50, order="id desc"):
|
||||||
|
fields = existing_fields(odoo, model, wanted_fields)
|
||||||
|
|
||||||
|
return odoo["models"].execute_kw(
|
||||||
|
odoo["db"],
|
||||||
|
odoo["uid"],
|
||||||
|
odoo["password"],
|
||||||
|
model,
|
||||||
|
"search_read",
|
||||||
|
[domain],
|
||||||
|
{
|
||||||
|
"fields": fields,
|
||||||
|
"limit": limit,
|
||||||
|
"order": order,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_workcenter(odoo, wc_name):
|
||||||
|
rows = search_read(
|
||||||
|
odoo,
|
||||||
|
"mrp.workcenter",
|
||||||
|
[("name", "=", wc_name)],
|
||||||
|
["id", "name", "code", "active", "display_name"],
|
||||||
|
limit=5,
|
||||||
|
order="id asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(rows) > 1:
|
||||||
|
# Esto no debería pasar con nombres exactos, pero lo devolvemos como incoherencia.
|
||||||
|
return {
|
||||||
|
"ambiguous": True,
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
row = rows[0]
|
||||||
|
row["ambiguous"] = False
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_workorders_for_wc(odoo, wc_id, states):
|
||||||
|
return search_read(
|
||||||
|
odoo,
|
||||||
|
"mrp.workorder",
|
||||||
|
[
|
||||||
|
("workcenter_id", "=", wc_id),
|
||||||
|
("state", "in", states),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"state",
|
||||||
|
"workcenter_id",
|
||||||
|
"production_id",
|
||||||
|
"product_id",
|
||||||
|
"date_start",
|
||||||
|
"date_finished",
|
||||||
|
"duration_expected",
|
||||||
|
"duration",
|
||||||
|
"qty_production",
|
||||||
|
],
|
||||||
|
limit=100,
|
||||||
|
order="id desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_order_state(machine_id, wc_name, wc, loaded_workorders, diagnostic_workorders):
|
||||||
|
ts = utc_now()
|
||||||
|
|
||||||
|
if wc is None:
|
||||||
|
return {
|
||||||
|
"machine_id": machine_id,
|
||||||
|
"ts": ts,
|
||||||
|
"source": "odoo_order_state_publisher",
|
||||||
|
"order_present": False,
|
||||||
|
"order_coherent": False,
|
||||||
|
"odoo_state": "workcenter_not_found",
|
||||||
|
"reason": f"No existe centro de trabajo exacto: {wc_name}",
|
||||||
|
"workcenter_name": wc_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if wc.get("ambiguous"):
|
||||||
|
return {
|
||||||
|
"machine_id": machine_id,
|
||||||
|
"ts": ts,
|
||||||
|
"source": "odoo_order_state_publisher",
|
||||||
|
"order_present": True,
|
||||||
|
"order_coherent": False,
|
||||||
|
"odoo_state": "ambiguous_workcenter",
|
||||||
|
"reason": f"Más de un centro de trabajo exacto para {wc_name}",
|
||||||
|
"workcenter_name": wc_name,
|
||||||
|
"workcenters": wc.get("rows", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
ready_count = sum(1 for wo in diagnostic_workorders if wo.get("state") == "ready")
|
||||||
|
waiting_count = sum(1 for wo in diagnostic_workorders if wo.get("state") == "waiting")
|
||||||
|
progress_count = len(loaded_workorders)
|
||||||
|
|
||||||
|
base = {
|
||||||
|
"machine_id": machine_id,
|
||||||
|
"ts": ts,
|
||||||
|
"source": "odoo_order_state_publisher",
|
||||||
|
"workcenter_id": wc.get("id"),
|
||||||
|
"workcenter_name": wc.get("name"),
|
||||||
|
"workcenter_display_name": wc.get("display_name"),
|
||||||
|
"loaded_state_rule": VALID_LOADED_STATES,
|
||||||
|
"diagnostics": {
|
||||||
|
"waiting_count": waiting_count,
|
||||||
|
"ready_count": ready_count,
|
||||||
|
"progress_count": progress_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if progress_count == 0:
|
||||||
|
base.update({
|
||||||
|
"order_present": False,
|
||||||
|
"order_coherent": False,
|
||||||
|
"odoo_state": "no_loaded_order",
|
||||||
|
"reason": "No hay orden en progress para esta cortadora",
|
||||||
|
"workorder_id": None,
|
||||||
|
"production_order": None,
|
||||||
|
"product": None,
|
||||||
|
})
|
||||||
|
return base
|
||||||
|
|
||||||
|
if progress_count > 1:
|
||||||
|
base.update({
|
||||||
|
"order_present": True,
|
||||||
|
"order_coherent": False,
|
||||||
|
"odoo_state": "multiple_loaded_orders",
|
||||||
|
"reason": "Hay más de una orden en progress para esta cortadora",
|
||||||
|
"workorders": [
|
||||||
|
{
|
||||||
|
"workorder_id": wo.get("id"),
|
||||||
|
"state": wo.get("state"),
|
||||||
|
"production_order": m2o_name(wo.get("production_id")),
|
||||||
|
"product": m2o_name(wo.get("product_id")),
|
||||||
|
"date_start": wo.get("date_start"),
|
||||||
|
"qty_production": wo.get("qty_production"),
|
||||||
|
}
|
||||||
|
for wo in loaded_workorders
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return base
|
||||||
|
|
||||||
|
wo = loaded_workorders[0]
|
||||||
|
|
||||||
|
base.update({
|
||||||
|
"order_present": True,
|
||||||
|
"order_coherent": True,
|
||||||
|
"odoo_state": "loaded",
|
||||||
|
"reason": "Existe exactamente una orden en progress para esta cortadora",
|
||||||
|
"workorder_id": wo.get("id"),
|
||||||
|
"workorder_name": wo.get("name"),
|
||||||
|
"workorder_state": wo.get("state"),
|
||||||
|
"production_id": m2o_id(wo.get("production_id")),
|
||||||
|
"production_order": m2o_name(wo.get("production_id")),
|
||||||
|
"product_id": m2o_id(wo.get("product_id")),
|
||||||
|
"product": m2o_name(wo.get("product_id")),
|
||||||
|
"date_start": wo.get("date_start"),
|
||||||
|
"date_finished": wo.get("date_finished"),
|
||||||
|
"qty_production": wo.get("qty_production"),
|
||||||
|
})
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def build_all_states(odoo):
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for machine_id, wc_name in MACHINE_MAP.items():
|
||||||
|
wc = get_workcenter(odoo, wc_name)
|
||||||
|
|
||||||
|
if wc is None or wc.get("ambiguous"):
|
||||||
|
result[machine_id] = build_order_state(machine_id, wc_name, wc, [], [])
|
||||||
|
continue
|
||||||
|
|
||||||
|
loaded_workorders = get_workorders_for_wc(odoo, wc["id"], VALID_LOADED_STATES)
|
||||||
|
diagnostic_workorders = get_workorders_for_wc(odoo, wc["id"], DIAGNOSTIC_STATES)
|
||||||
|
|
||||||
|
result[machine_id] = build_order_state(
|
||||||
|
machine_id,
|
||||||
|
wc_name,
|
||||||
|
wc,
|
||||||
|
loaded_workorders,
|
||||||
|
diagnostic_workorders,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def publish_mqtt(env, states):
|
||||||
|
try:
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError("Falta paho-mqtt. Instala dependencias o ejecuta dentro de contenedor.")
|
||||||
|
|
||||||
|
mqtt_host = os.getenv("MQTT_HOST_OVERRIDE") or env.get("MQTT_HOST", "127.0.0.1")
|
||||||
|
mqtt_port = int(os.getenv("MQTT_PORT_OVERRIDE") or env.get("MQTT_PORT", "1883"))
|
||||||
|
mqtt_user = (
|
||||||
|
os.getenv("MQTT_USER_OVERRIDE")
|
||||||
|
or os.getenv("MQTT_USERNAME_OVERRIDE")
|
||||||
|
or env.get("MQTT_USERNAME")
|
||||||
|
or env.get("MQTT_USER")
|
||||||
|
or env.get("MQTT_USER_REVPI")
|
||||||
|
or env.get("MOSQUITTO_USERNAME")
|
||||||
|
or env.get("MOSQUITTO_USER")
|
||||||
|
)
|
||||||
|
mqtt_password = (
|
||||||
|
os.getenv("MQTT_PASSWORD_OVERRIDE")
|
||||||
|
or os.getenv("MQTT_PASS_OVERRIDE")
|
||||||
|
or os.getenv("MQTT_PASSWORD")
|
||||||
|
or os.getenv("MQTT_PASS")
|
||||||
|
or env.get("MQTT_PASSWORD")
|
||||||
|
or env.get("MQTT_PASS")
|
||||||
|
or env.get("MQTT_PASSWORD_REVPI")
|
||||||
|
or env.get("MOSQUITTO_PASSWORD")
|
||||||
|
or env.get("MOSQUITTO_PASS")
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant = env.get("TENANT", "ucepsa")
|
||||||
|
site = os.getenv("SITE_OVERRIDE") or env.get("SITE", "ucepsa_onpremise")
|
||||||
|
vertical = env.get("VERTICAL", "edge_oee")
|
||||||
|
|
||||||
|
client = mqtt.Client(client_id="odoo_order_state_publisher")
|
||||||
|
|
||||||
|
if mqtt_user:
|
||||||
|
client.username_pw_set(mqtt_user, mqtt_password)
|
||||||
|
|
||||||
|
client.connect(mqtt_host, mqtt_port, 60)
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
for machine_id, payload in states.items():
|
||||||
|
topic = f"vertical/{tenant}/{site}/{vertical}/order_state/{machine_id}"
|
||||||
|
info = client.publish(
|
||||||
|
topic,
|
||||||
|
json.dumps(payload, separators=(",", ":"), ensure_ascii=False),
|
||||||
|
qos=0,
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
info.wait_for_publish(timeout=5)
|
||||||
|
print(f"MQTT published retained: {topic}")
|
||||||
|
|
||||||
|
client.loop_stop()
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--publish", action="store_true", help="Publica en MQTT retained")
|
||||||
|
parser.add_argument("--loop", action="store_true", help="Ejecuta en bucle")
|
||||||
|
parser.add_argument("--interval", type=float, default=5.0)
|
||||||
|
parser.add_argument("--json", action="store_true", help="Salida JSON compacta")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
env = read_env()
|
||||||
|
odoo = connect_odoo(env)
|
||||||
|
|
||||||
|
print(f"ODOO_URL={odoo['url']}", flush=True)
|
||||||
|
print(f"ODOO_DB={odoo['db']}", flush=True)
|
||||||
|
print(f"ODOO_USER={odoo['user']}", flush=True)
|
||||||
|
print(f"publish={args.publish}", flush=True)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
states = build_all_states(odoo)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(states, indent=2, ensure_ascii=False), flush=True)
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
print("=" * 100)
|
||||||
|
print(utc_now())
|
||||||
|
print("=" * 100)
|
||||||
|
for machine_id, payload in states.items():
|
||||||
|
print(
|
||||||
|
f"{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={payload.get('diagnostics')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.publish:
|
||||||
|
publish_mqtt(env, states)
|
||||||
|
|
||||||
|
if not args.loop:
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(args.interval)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
170
ucepsa/edge-oee-demo/tools/probe_odoo_cutters.py
Executable file
170
ucepsa/edge-oee-demo/tools/probe_odoo_cutters.py
Executable file
@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import xmlrpc.client
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
MACHINE_MAP = {
|
||||||
|
"CORT-00": "Cortadora 0",
|
||||||
|
"CORT-01": "Cortadora 01",
|
||||||
|
"CORT-02": "Cortadora 02",
|
||||||
|
}
|
||||||
|
|
||||||
|
WORKORDER_STATES = ["waiting", "ready", "progress", "done"]
|
||||||
|
|
||||||
|
|
||||||
|
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 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, fields, limit=20, order="id asc"):
|
||||||
|
fields = existing_fields(models, db, uid, password, model, fields)
|
||||||
|
return models.execute_kw(
|
||||||
|
db, uid, password,
|
||||||
|
model, "search_read",
|
||||||
|
[domain],
|
||||||
|
{"fields": fields, "limit": limit, "order": order},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
env = read_env()
|
||||||
|
|
||||||
|
url = env["ODOO_URL"].rstrip("/")
|
||||||
|
db = os.getenv("ODOO_DB_OVERRIDE") or env["ODOO_DB"]
|
||||||
|
user = env["ODOO_USER"]
|
||||||
|
password = env["ODOO_PASSWORD"]
|
||||||
|
|
||||||
|
print("ODOO_URL =", url)
|
||||||
|
print("ODOO_DB =", db)
|
||||||
|
print("ODOO_USER=", user)
|
||||||
|
print()
|
||||||
|
|
||||||
|
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
|
||||||
|
uid = common.authenticate(db, user, password, {})
|
||||||
|
if not uid:
|
||||||
|
raise SystemExit("ERROR: autenticación Odoo fallida")
|
||||||
|
|
||||||
|
print("uid =", uid)
|
||||||
|
print()
|
||||||
|
|
||||||
|
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
|
||||||
|
|
||||||
|
wc_fields = existing_fields(
|
||||||
|
models, db, uid, password,
|
||||||
|
"mrp.workcenter",
|
||||||
|
["id", "name", "code", "active", "display_name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
wo_fields = existing_fields(
|
||||||
|
models, db, uid, password,
|
||||||
|
"mrp.workorder",
|
||||||
|
[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"state",
|
||||||
|
"workcenter_id",
|
||||||
|
"production_id",
|
||||||
|
"product_id",
|
||||||
|
"date_start",
|
||||||
|
"date_finished",
|
||||||
|
"duration_expected",
|
||||||
|
"duration",
|
||||||
|
"qty_production",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Campos workcenter disponibles:", wc_fields)
|
||||||
|
print("Campos workorder disponibles :", wo_fields)
|
||||||
|
print()
|
||||||
|
|
||||||
|
for machine_id, wc_name in MACHINE_MAP.items():
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"{machine_id} -> {wc_name}")
|
||||||
|
|
||||||
|
workcenters = search_read(
|
||||||
|
models, db, uid, password,
|
||||||
|
"mrp.workcenter",
|
||||||
|
[("name", "=", wc_name)],
|
||||||
|
["id", "name", "code", "active", "display_name"],
|
||||||
|
limit=5,
|
||||||
|
order="id asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not workcenters:
|
||||||
|
print("ERROR: no se encuentra centro de trabajo exacto")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(workcenters) > 1:
|
||||||
|
print("AVISO: hay más de un centro exacto")
|
||||||
|
|
||||||
|
wc = workcenters[0]
|
||||||
|
wc_id = wc["id"]
|
||||||
|
print("Workcenter:", wc)
|
||||||
|
|
||||||
|
workorders = search_read(
|
||||||
|
models, db, uid, password,
|
||||||
|
"mrp.workorder",
|
||||||
|
[
|
||||||
|
("workcenter_id", "=", wc_id),
|
||||||
|
("state", "in", WORKORDER_STATES),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"state",
|
||||||
|
"workcenter_id",
|
||||||
|
"production_id",
|
||||||
|
"product_id",
|
||||||
|
"date_start",
|
||||||
|
"date_finished",
|
||||||
|
"duration_expected",
|
||||||
|
"duration",
|
||||||
|
"qty_production",
|
||||||
|
],
|
||||||
|
limit=20,
|
||||||
|
order="id desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not workorders:
|
||||||
|
print("Sin workorders candidatas")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"Workorders candidatas: {len(workorders)}")
|
||||||
|
for wo in workorders:
|
||||||
|
print(
|
||||||
|
" -",
|
||||||
|
{
|
||||||
|
"id": wo.get("id"),
|
||||||
|
"name": wo.get("name"),
|
||||||
|
"state": wo.get("state"),
|
||||||
|
"production_id": wo.get("production_id"),
|
||||||
|
"product_id": wo.get("product_id"),
|
||||||
|
"date_start": wo.get("date_start"),
|
||||||
|
"date_finished": wo.get("date_finished"),
|
||||||
|
"qty_production": wo.get("qty_production"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
94
ucepsa/edge-oee-demo/tools/read_order_state_retained.py
Executable file
94
ucepsa/edge-oee-demo/tools/read_order_state_retained.py
Executable file
@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
env = read_env()
|
||||||
|
|
||||||
|
mqtt_host = os.getenv("MQTT_HOST_OVERRIDE") or env.get("MQTT_HOST", "127.0.0.1")
|
||||||
|
mqtt_port = int(os.getenv("MQTT_PORT_OVERRIDE") or env.get("MQTT_PORT", "1883"))
|
||||||
|
mqtt_user = (
|
||||||
|
os.getenv("MQTT_USER_OVERRIDE")
|
||||||
|
or os.getenv("MQTT_USERNAME_OVERRIDE")
|
||||||
|
or env.get("MQTT_USERNAME")
|
||||||
|
or env.get("MQTT_USER")
|
||||||
|
or env.get("MQTT_USER_REVPI")
|
||||||
|
or env.get("MOSQUITTO_USERNAME")
|
||||||
|
or env.get("MOSQUITTO_USER")
|
||||||
|
)
|
||||||
|
mqtt_password = (
|
||||||
|
os.getenv("MQTT_PASSWORD_OVERRIDE")
|
||||||
|
or os.getenv("MQTT_PASS_OVERRIDE")
|
||||||
|
or os.getenv("MQTT_PASSWORD")
|
||||||
|
or os.getenv("MQTT_PASS")
|
||||||
|
or env.get("MQTT_PASSWORD")
|
||||||
|
or env.get("MQTT_PASS")
|
||||||
|
or env.get("MQTT_PASSWORD_REVPI")
|
||||||
|
or env.get("MOSQUITTO_PASSWORD")
|
||||||
|
or env.get("MOSQUITTO_PASS")
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant = env.get("TENANT", "ucepsa")
|
||||||
|
site = os.getenv("SITE_OVERRIDE") or env.get("SITE", "ucepsa_onpremise")
|
||||||
|
vertical = env.get("VERTICAL", "edge_oee")
|
||||||
|
|
||||||
|
topic = f"vertical/{tenant}/{site}/{vertical}/order_state/+"
|
||||||
|
|
||||||
|
messages = {}
|
||||||
|
|
||||||
|
|
||||||
|
def on_connect(client, userdata, flags, rc, properties=None):
|
||||||
|
print(f"MQTT conectado rc={rc}")
|
||||||
|
print(f"SUB {topic}")
|
||||||
|
client.subscribe(topic)
|
||||||
|
|
||||||
|
|
||||||
|
def on_message(client, userdata, msg):
|
||||||
|
machine_id = msg.topic.rsplit("/", 1)[-1]
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg.payload.decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
payload = msg.payload.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
messages[machine_id] = {
|
||||||
|
"topic": msg.topic,
|
||||||
|
"retain": bool(msg.retain),
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
client = mqtt.Client(client_id="read_order_state_retained")
|
||||||
|
|
||||||
|
if mqtt_user:
|
||||||
|
client.username_pw_set(mqtt_user, mqtt_password)
|
||||||
|
|
||||||
|
client.on_connect = on_connect
|
||||||
|
client.on_message = on_message
|
||||||
|
|
||||||
|
client.connect(mqtt_host, mqtt_port, 60)
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline and len(messages) < 3:
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
client.loop_stop()
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
|
print(json.dumps(messages, indent=2, ensure_ascii=False))
|
||||||
188
ucepsa/edge-oee-demo/tools/watch_odoo_active_workorders.py
Executable file
188
ucepsa/edge-oee-demo/tools/watch_odoo_active_workorders.py
Executable file
@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import xmlrpc.client
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
MACHINE_MAP = {
|
||||||
|
"CORT-00": "Cortadora 0",
|
||||||
|
"CORT-01": "Cortadora 01",
|
||||||
|
"CORT-02": "Cortadora 02",
|
||||||
|
}
|
||||||
|
|
||||||
|
WATCH_STATES = ["waiting", "ready", "progress"]
|
||||||
|
|
||||||
|
|
||||||
|
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 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, fields, limit=50, order="id desc"):
|
||||||
|
valid_fields = existing_fields(models, db, uid, password, model, fields)
|
||||||
|
return models.execute_kw(
|
||||||
|
db, uid, password,
|
||||||
|
model, "search_read",
|
||||||
|
[domain],
|
||||||
|
{
|
||||||
|
"fields": valid_fields,
|
||||||
|
"limit": limit,
|
||||||
|
"order": order,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def m2o_name(value):
|
||||||
|
if isinstance(value, list) and len(value) >= 2:
|
||||||
|
return value[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
env = read_env()
|
||||||
|
|
||||||
|
url = env["ODOO_URL"].rstrip("/")
|
||||||
|
db = os.getenv("ODOO_DB_OVERRIDE") or env["ODOO_DB"]
|
||||||
|
user = env["ODOO_USER"]
|
||||||
|
password = env["ODOO_PASSWORD"]
|
||||||
|
|
||||||
|
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
|
||||||
|
uid = common.authenticate(db, user, password, {})
|
||||||
|
if not uid:
|
||||||
|
raise RuntimeError("Autenticación Odoo fallida")
|
||||||
|
|
||||||
|
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
|
||||||
|
return url, db, user, password, uid, models
|
||||||
|
|
||||||
|
|
||||||
|
def print_state(models, db, uid, password):
|
||||||
|
print()
|
||||||
|
print("=" * 100)
|
||||||
|
print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
for machine_id, wc_name in MACHINE_MAP.items():
|
||||||
|
workcenters = search_read(
|
||||||
|
models, db, uid, password,
|
||||||
|
"mrp.workcenter",
|
||||||
|
[("name", "=", wc_name)],
|
||||||
|
["id", "name", "active", "display_name"],
|
||||||
|
limit=5,
|
||||||
|
order="id asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not workcenters:
|
||||||
|
print(f"{machine_id} | {wc_name} | ERROR: no existe centro de trabajo")
|
||||||
|
continue
|
||||||
|
|
||||||
|
wc = workcenters[0]
|
||||||
|
wc_id = wc["id"]
|
||||||
|
|
||||||
|
workorders = search_read(
|
||||||
|
models, db, uid, password,
|
||||||
|
"mrp.workorder",
|
||||||
|
[
|
||||||
|
("workcenter_id", "=", wc_id),
|
||||||
|
("state", "in", WATCH_STATES),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"state",
|
||||||
|
"workcenter_id",
|
||||||
|
"production_id",
|
||||||
|
"product_id",
|
||||||
|
"date_start",
|
||||||
|
"date_finished",
|
||||||
|
"qty_production",
|
||||||
|
],
|
||||||
|
limit=100,
|
||||||
|
order="id desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
by_state = {s: [] for s in WATCH_STATES}
|
||||||
|
for wo in workorders:
|
||||||
|
by_state.setdefault(wo.get("state"), []).append(wo)
|
||||||
|
|
||||||
|
progress = by_state.get("progress", [])
|
||||||
|
ready = by_state.get("ready", [])
|
||||||
|
waiting = by_state.get("waiting", [])
|
||||||
|
|
||||||
|
if len(progress) == 1:
|
||||||
|
beacon_state = "AZUL_FIJO_CANDIDATO"
|
||||||
|
elif len(progress) > 1:
|
||||||
|
beacon_state = "INCOHERENTE_MULTIPLES_PROGRESS"
|
||||||
|
else:
|
||||||
|
beacon_state = "SIN_ORDEN_CARGADA"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{machine_id} | {wc_name} | "
|
||||||
|
f"waiting={len(waiting)} ready={len(ready)} progress={len(progress)} | "
|
||||||
|
f"{beacon_state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for wo in progress:
|
||||||
|
print(
|
||||||
|
f" ACTIVE progress: "
|
||||||
|
f"wo_id={wo.get('id')} "
|
||||||
|
f"mo={m2o_name(wo.get('production_id'))} "
|
||||||
|
f"product={m2o_name(wo.get('product_id'))} "
|
||||||
|
f"date_start={wo.get('date_start')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not progress and ready:
|
||||||
|
first = ready[0]
|
||||||
|
print(
|
||||||
|
f" primera ready: "
|
||||||
|
f"wo_id={first.get('id')} "
|
||||||
|
f"mo={m2o_name(first.get('production_id'))} "
|
||||||
|
f"product={m2o_name(first.get('product_id'))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
watch = False
|
||||||
|
interval = 2.0
|
||||||
|
|
||||||
|
if "--watch" in sys.argv:
|
||||||
|
watch = True
|
||||||
|
idx = sys.argv.index("--watch")
|
||||||
|
if len(sys.argv) > idx + 1:
|
||||||
|
interval = float(sys.argv[idx + 1])
|
||||||
|
|
||||||
|
url, db, user, password, uid, models = connect()
|
||||||
|
|
||||||
|
print(f"ODOO_URL={url}")
|
||||||
|
print(f"ODOO_DB={db}")
|
||||||
|
print(f"ODOO_USER={user}")
|
||||||
|
print(f"uid={uid}")
|
||||||
|
|
||||||
|
if watch:
|
||||||
|
while True:
|
||||||
|
print_state(models, db, uid, password)
|
||||||
|
time.sleep(interval)
|
||||||
|
else:
|
||||||
|
print_state(models, db, uid, password)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user