diff --git a/ucepsa/edge-oee-demo/docs/runbooks/091_lossintelligence_shadow_v0.2_power_aware.md b/ucepsa/edge-oee-demo/docs/runbooks/091_lossintelligence_shadow_v0.2_power_aware.md new file mode 100644 index 0000000..59d3c06 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/091_lossintelligence_shadow_v0.2_power_aware.md @@ -0,0 +1,56 @@ +# UCEPSA LossIntelligence SHADOW v0.2 — elegibilidad power-aware + +## Problema corregido + +En UCEPSA, cuando una cortadora no tiene trabajo se corta su alimentación. El último evento puede ser `machine_stopped` y el paro raw permanece abierto o se cierra varios días después. Esa duración física no equivale a pérdida. + +La v0.2 calcula tiempo elegible mediante la intersección: + +```text +paro raw +∩ intervalo de orden Odoo válida +∩ isla de telemetría real += tiempo candidato a pérdida +``` + +Cuando desaparece la alimentación, dejan de llegar muestras y la isla de telemetría termina. El tiempo posterior no entra en LossIntelligence. + +## Umbrales iniciales + +- Frecuencia nominal de captura observada: alrededor de 5 s. +- Un hueco mayor de 90 s abre una nueva isla de telemetría. +- Se toleran 30 s después de la última muestra válida. +- Menos de 5 s sigue siendo ruido. +- Entre 5 s y 5 min se agrupa. +- A partir de 5 min se muestra individualmente. + +Los valores son de modo sombra y deben revisarse antes del kickoff de octubre. + +## Vistas nuevas + +- `li_telemetry_coverage_v1` +- `li_stop_order_overlaps_v2` +- `li_stop_eligible_slices_v2` +- `li_stop_candidates_v2` +- `li_stop_exclusion_diagnostics_v2` +- `li_stop_groups_v2` +- `li_shadow_review_queue_v2` +- `li_operator_stop_queue_v2` +- `li_shadow_summary_v2` +- `li_eligibility_comparison_v1` +- `machine_operational_state_v1` +- `li_data_quality_v2` + +## Regla de interfaz + +Una máquina sin telemetría y sin orden debe mostrarse como `SIN PRODUCCIÓN`, no como `NO OK`. + +Estados que sí requieren atención: + +- máquina en marcha sin orden válida; +- orden válida sin telemetría; +- telemetría inválida o comunicación fallida con orden activa. + +## Activación + +La v0.2 no cambia `deployment_config.mode`. Permanece `SHADOW` y la cola oficial v2 debe devolver cero filas. diff --git a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-edge-oee-machine.shadow-v0.2.dashboard.json b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-edge-oee-machine.shadow-v0.2.dashboard.json new file mode 100644 index 0000000..40a01ac --- /dev/null +++ b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-edge-oee-machine.shadow-v0.2.dashboard.json @@ -0,0 +1,1678 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "SIN PRODUCCIÓN", + "color": "gray" + }, + "1": { + "text": "OK", + "color": "green" + }, + "2": { + "text": "REVISAR", + "color": "red" + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "none" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\nSELECT communication_code AS value\nFROM mv_reports_ucepsa_prod.machine_operational_state_v1\nWHERE machine_id = '$machine_id'\n", + "refId": "A" + } + ], + "title": "Comunicación / contexto", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\nSELECT age_s AS value\nFROM mv_reports_ucepsa_prod.machine_operational_state_v1\nWHERE machine_id = '$machine_id'\n", + "refId": "A" + } + ], + "title": "Tiempo desde último dato", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "SIN PRODUCCIÓN", + "color": "gray" + }, + "1": { + "text": "PARADA CON ORDEN", + "color": "yellow" + }, + "2": { + "text": "EN MARCHA", + "color": "green" + }, + "3": { + "text": "REVISAR", + "color": "red" + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "none" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\nSELECT machine_state_code AS value\nFROM mv_reports_ucepsa_prod.machine_operational_state_v1\nWHERE machine_id = '$machine_id'\n", + "refId": "A" + } + ], + "title": "Situación operativa", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "cpm" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\nSELECT CASE WHEN telemetry_fresh THEN cycle_rate_ppm END AS value\nFROM mv_reports_ucepsa_prod.machine_operational_state_v1\nWHERE machine_id = '$machine_id'\n", + "refId": "A" + } + ], + "title": "Ciclos/min", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "kwatt" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 4 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\nSELECT CASE WHEN telemetry_fresh THEN power_total_kw END AS value\nFROM mv_reports_ucepsa_prod.machine_operational_state_v1\nWHERE machine_id = '$machine_id'\n", + "refId": "A" + } + ], + "title": "Potencia total", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "kwatth" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 4 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT import_kwh AS value\n FROM mv_reports_ucepsa_demo.machine_state_latest_multi\n WHERE machine_id = '$machine_id'\n ", + "refId": "A" + } + ], + "title": "Energía importada", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "hertz" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 4 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT frequency_hz AS value\n FROM mv_reports_ucepsa_demo.machine_state_latest_multi\n WHERE machine_id = '$machine_id'\n ", + "refId": "A" + } + ], + "title": "Frecuencia", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "none" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 4 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT cycle_pulse_count AS value\n FROM mv_reports_ucepsa_demo.machine_state_latest_multi\n WHERE machine_id = '$machine_id'\n ", + "refId": "A" + } + ], + "title": "Pulsos acumulados", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "watt" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 9, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "\n SELECT\n ts AS \"time\",\n active_power_l1_w AS \"L1 W\",\n active_power_l2_w AS \"L2 W\",\n active_power_l3_w AS \"L3 W\",\n active_power_w AS \"Total W\"\n FROM mv_reports_ucepsa_demo.energy_3phase_timeseries\n WHERE $__timeFilter(ts)\n AND site = 'ucepsa_onpremise'\n AND machine_id = '$machine_id'\n ORDER BY ts\n ", + "refId": "A" + } + ], + "title": "Potencia por fase", + "type": "timeseries" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "volt" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 10, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "\n SELECT\n ts AS \"time\",\n voltage_l1_v AS \"L1 V\",\n voltage_l2_v AS \"L2 V\",\n voltage_l3_v AS \"L3 V\"\n FROM mv_reports_ucepsa_demo.energy_3phase_timeseries\n WHERE $__timeFilter(ts)\n AND site = 'ucepsa_onpremise'\n AND machine_id = '$machine_id'\n ORDER BY ts\n ", + "refId": "A" + } + ], + "title": "Tensiones L1/L2/L3", + "type": "timeseries" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "amp" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 11, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "\n SELECT\n ts AS \"time\",\n current_l1_a AS \"L1 A\",\n current_l2_a AS \"L2 A\",\n current_l3_a AS \"L3 A\"\n FROM mv_reports_ucepsa_demo.energy_3phase_timeseries\n WHERE $__timeFilter(ts)\n AND site = 'ucepsa_onpremise'\n AND machine_id = '$machine_id'\n ORDER BY ts\n ", + "refId": "A" + } + ], + "title": "Corrientes L1/L2/L3", + "type": "timeseries" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "footer": { + "reducers": [] + }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 12, + "options": { + "cellHeight": "sm", + "showHeader": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT\n machine_id,\n round(age_s::numeric, 1) AS age_s,\n comm_ok,\n sample_valid,\n energy_map,\n machine_running,\n cycle_input_state,\n cycle_pulse_count,\n wise_cycle_counter_raw,\n round(voltage_l1_v::numeric, 2) AS v_l1,\n round(voltage_l2_v::numeric, 2) AS v_l2,\n round(voltage_l3_v::numeric, 2) AS v_l3,\n round(current_l1_a::numeric, 3) AS i_l1,\n round(current_l2_a::numeric, 3) AS i_l2,\n round(current_l3_a::numeric, 3) AS i_l3,\n round(power_total_kw::numeric, 4) AS kw_total,\n digital_inputs\n FROM mv_reports_ucepsa_demo.machine_state_latest_multi\n WHERE machine_id = '$machine_id'\n ", + "refId": "A" + } + ], + "title": "Última lectura trifásica", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "footer": { + "reducers": [] + }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 13, + "options": { + "cellHeight": "sm", + "showHeader": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n machine_id AS \"Máquina\",\n order_ref AS \"Orden\",\n product_name AS \"Producto\",\n odoo_state AS \"Estado Odoo\",\n order_present AS \"Orden presente\",\n order_coherent AS \"Coherente\",\n waiting_count AS \"Waiting\",\n ready_count AS \"Ready\",\n progress_count AS \"Progress\",\n machine_running AS \"Máquina en marcha\",\n cycle_rate_ppm AS \"Ciclos/min\",\n round(order_age_s::numeric, 1) AS \"Edad Odoo s\",\n round(telemetry_age_s::numeric, 1) AS \"Edad telemetría s\",\n edge_odoo_status AS \"Diagnóstico\",\n reason AS \"Motivo\"\nFROM mv_reports_ucepsa_demo.odoo_order_state_machine_grafana\nWHERE machine_id = '$machine_id';", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "Odoo vs máquina", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "footer": { + "reducers": [] + }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 14, + "options": { + "cellHeight": "sm", + "showHeader": true + }, + "pluginVersion": "13.0.1+security-01", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\nSELECT\n shadow_rank,\n machine_id,\n order_ref,\n product_name,\n cause_name,\n event_count,\n started_at,\n ended_at,\n duration_min,\n capacity_loss_eur,\n order_clipped,\n telemetry_clipped,\n crosses_order_boundary,\n queue_status\nFROM mv_reports_ucepsa_prod.li_shadow_review_queue_v2\nWHERE machine_id = '$machine_id'\nORDER BY shadow_rank\nLIMIT 25\n", + "refId": "A" + } + ], + "title": "LossIntelligence — SHADOW v0.2", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 32 + }, + "id": 300, + "panels": [], + "title": "Informe de paros", + "type": "row" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 33 + }, + "id": 301, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT COUNT(*) AS value\n FROM mv_reports_ucepsa_demo.machine_stops_grafana\n WHERE machine_id = '$machine_id'\n AND status = 'OPEN'\n ", + "refId": "A" + } + ], + "title": "Paros abiertos", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 33 + }, + "id": 302, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT COUNT(*) AS value\n FROM mv_reports_ucepsa_demo.machine_stops_grafana\n WHERE machine_id = '$machine_id'\n AND classification_status = 'PENDING'\n ", + "refId": "A" + } + ], + "title": "Pendientes clasificar", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 33 + }, + "id": 303, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT COUNT(*) AS value\n FROM mv_reports_ucepsa_demo.machine_stops_grafana\n WHERE machine_id = '$machine_id'\n AND $__timeFilter(started_at)\n ", + "refId": "A" + } + ], + "title": "Nº paros en rango", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "min" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 33 + }, + "id": 304, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT COALESCE(SUM(duration_min), 0) AS value\n FROM mv_reports_ucepsa_demo.machine_stops_grafana\n WHERE machine_id = '$machine_id'\n AND $__timeFilter(started_at)\n ", + "refId": "A" + } + ], + "title": "Minutos paro en rango", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "currencyEUR" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 33 + }, + "id": 305, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT COALESCE(SUM(cost_eur), 0) AS value\n FROM mv_reports_ucepsa_demo.machine_stops_grafana\n WHERE machine_id = '$machine_id'\n AND $__timeFilter(started_at)\n ", + "refId": "A" + } + ], + "title": "Coste paros en rango", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "min" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 33 + }, + "id": 306, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT COALESCE(AVG(duration_min), 0) AS value\n FROM mv_reports_ucepsa_demo.machine_stops_grafana\n WHERE machine_id = '$machine_id'\n AND $__timeFilter(started_at)\n ", + "refId": "A" + } + ], + "title": "Duración media en rango", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "unit": "min" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 37 + }, + "id": 307, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "always", + "xField": "Causa" + }, + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT\n cause_name AS \"Causa\",\n ROUND(SUM(duration_min)::numeric, 2) AS \"Minutos\"\n FROM mv_reports_ucepsa_demo.machine_stops_grafana\n WHERE machine_id = '$machine_id'\n AND classification_status = 'CLASSIFIED'\n AND status = 'CLOSED'\n GROUP BY cause_name\n ORDER BY SUM(duration_min) DESC\n LIMIT 10\n ", + "refId": "A" + } + ], + "title": "Pareto causas clasificadas por minutos", + "type": "barchart" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 5, + "lineInterpolation": "linear", + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "min" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 37 + }, + "id": 308, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "\n SELECT\n date_trunc('hour', started_at) AS \"time\",\n SUM(duration_min) AS \"Minutos de paro\"\n FROM mv_reports_ucepsa_demo.machine_stops_grafana\n WHERE machine_id = '$machine_id'\n AND $__timeFilter(started_at)\n GROUP BY 1\n ORDER BY 1\n ", + "refId": "A" + } + ], + "title": "Minutos de paro por hora", + "type": "timeseries" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 45 + }, + "id": 309, + "options": { + "cellHeight": "sm", + "showHeader": true + }, + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT\n id AS \"ID\",\n started_at AS \"Inicio\",\n ROUND(duration_min::numeric, 2) AS \"Min\",\n cost_eur AS \"Coste €\",\n status AS \"Estado\",\n classification_status AS \"Clasificación\",\n cause_name AS \"Causa\",\n operator_note AS \"Nota operario\"\n FROM mv_reports_ucepsa_demo.machine_stops_grafana\n WHERE machine_id = '$machine_id'\n AND classification_status = 'PENDING'\n ORDER BY started_at DESC\n ", + "refId": "A" + } + ], + "title": "Paros pendientes de clasificación", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 51 + }, + "id": 310, + "options": { + "cellHeight": "sm", + "showHeader": true + }, + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "\n SELECT\n id AS \"ID\",\n machine_id AS \"Máquina\",\n started_at AS \"Inicio\",\n ended_at AS \"Fin\",\n duration_min AS \"Min\",\n cost_eur AS \"Coste €\",\n stop_status_label AS \"Estado\",\n classification_status AS \"Clasificación\",\n cause_code AS \"Código causa\",\n cause_name AS \"Causa\",\n start_event_name AS \"Evento inicio\",\n end_event_name AS \"Evento fin\",\n operator_note AS \"Nota operario\"\n FROM mv_reports_ucepsa_demo.machine_stops_grafana\n WHERE machine_id = '$machine_id'\n AND $__timeFilter(started_at)\n ORDER BY started_at DESC\n LIMIT 100\n ", + "refId": "A" + } + ], + "title": "Detalle completo de paros", + "type": "table" + } + ], + "preload": false, + "refresh": "5s", + "schemaVersion": 42, + "tags": [ + "mesavault", + "ucepsa", + "edge-oee", + "lossintelligence", + "shadow-v0.2" + ], + "templating": { + "list": [ + { + "allowCustomValue": true, + "current": { + "text": "CORT-00", + "value": "CORT-00" + }, + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "hide": 0, + "includeAll": false, + "label": "Máquina", + "multi": false, + "name": "machine_id", + "options": [], + "query": "SELECT machine_id FROM mv_reports_ucepsa_demo.machine_state_latest_multi ORDER BY machine_id", + "refresh": 1, + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "browser", + "title": "MESAVAULT Edge-OEE — Máquina — SHADOW v0.2", + "uid": "ucepsa-edge-oee-machine-shadow-v02", + "version": 6 +} diff --git a/ucepsa/edge-oee-demo/ops/validate_lossintelligence_shadow_v0.2.sql b/ucepsa/edge-oee-demo/ops/validate_lossintelligence_shadow_v0.2.sql new file mode 100644 index 0000000..6992bd5 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_lossintelligence_shadow_v0.2.sql @@ -0,0 +1,73 @@ +\pset pager off +\echo '=== 1. Política telemetry-aware ===' +SELECT policy_code, require_telemetry_coverage, telemetry_gap_s, telemetry_tail_s, + noise_below_s, operator_individual_from_s +FROM mv_loss_intelligence.loss_policies +WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise' AND policy_code = 'CUTTER_STOPS_V1'; + +\echo '=== 2. Estado operativo power-aware ===' +SELECT machine_id, operational_state, machine_state_label, telemetry_fresh, + round(age_s::numeric, 1) AS age_s, machine_running, + order_present, order_coherent, production_order +FROM mv_reports_ucepsa_prod.machine_operational_state_v1 +ORDER BY machine_id; + +\echo '=== 3. Islas de telemetría más recientes ===' +SELECT machine_id, coverage_from, coverage_to, sample_count, sampled_span_s +FROM mv_reports_ucepsa_prod.li_telemetry_coverage_v1 +ORDER BY coverage_to DESC +LIMIT 15; + +\echo '=== 4. Invariantes temporales: debe devolver 0 ===' +SELECT COUNT(*) AS invalid_slice_rows +FROM mv_reports_ucepsa_prod.li_stop_eligible_slices_v2 +WHERE eligible_from < order_overlap_from + OR eligible_to > order_overlap_to + OR eligible_from < telemetry_coverage_from + OR eligible_to > telemetry_coverage_to + OR eligible_to <= eligible_from + OR eligible_duration_s <= 0; + +\echo '=== 5. Duración elegible nunca supera la raw: debe devolver 0 ===' +SELECT COUNT(*) AS duration_overflow_rows +FROM mv_reports_ucepsa_prod.li_stop_candidates_v2 +WHERE eligible_duration_s > raw_duration_s + 0.01 + OR eligible_duration_s > order_overlap_duration_s + 0.01; + +\echo '=== 6. Comparación v0.1 frente a v0.2 ===' +SELECT * FROM mv_reports_ucepsa_prod.li_eligibility_comparison_v1; + +\echo '=== 7. Resumen v0.2 ===' +SELECT * +FROM mv_reports_ucepsa_prod.li_shadow_summary_v2 +ORDER BY machine_id, eligibility_status; + +\echo '=== 8. Primeros candidatos power-aware ===' +SELECT shadow_rank, machine_id, order_ref, product_name, event_count, + started_at, ended_at, duration_min, capacity_loss_eur, + order_clipped, telemetry_clipped, crosses_order_boundary, queue_status +FROM mv_reports_ucepsa_prod.li_shadow_review_queue_v2 +ORDER BY shadow_rank +LIMIT 25; + +\echo '=== 9. Diagnóstico de exclusiones ===' +SELECT diagnostic_status, machine_id, COUNT(*) AS stop_count, + ROUND(SUM(raw_duration_min)::numeric, 2) AS raw_minutes +FROM mv_reports_ucepsa_prod.li_stop_exclusion_diagnostics_v2 +GROUP BY diagnostic_status, machine_id +ORDER BY machine_id, diagnostic_status; + +\echo '=== 10. Cola oficial v2: debe estar vacía en SHADOW ===' +SELECT COUNT(*) AS official_operator_queue_v2_rows +FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v2; + +\echo '=== 11. Calidad de dato v2 ===' +SELECT * FROM mv_reports_ucepsa_prod.li_data_quality_v2 +ORDER BY check_code; + +\echo '=== 12. Candidatos v2 de más de 12 h para revisión manual ===' +SELECT shadow_rank, machine_id, order_ref, started_at, ended_at, + duration_min, telemetry_clipped, order_clipped +FROM mv_reports_ucepsa_prod.li_shadow_review_queue_v2 +WHERE duration_min >= 720 +ORDER BY duration_min DESC; diff --git a/ucepsa/edge-oee-demo/sql/versions/097_lossintelligence_telemetry_aware_eligibility_v2.sql b/ucepsa/edge-oee-demo/sql/versions/097_lossintelligence_telemetry_aware_eligibility_v2.sql new file mode 100644 index 0000000..4f5541d --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/097_lossintelligence_telemetry_aware_eligibility_v2.sql @@ -0,0 +1,687 @@ +BEGIN; + +ALTER TABLE mv_loss_intelligence.loss_policies + ADD COLUMN IF NOT EXISTS require_telemetry_coverage BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS telemetry_gap_s NUMERIC(12,2) NOT NULL DEFAULT 90 + CHECK (telemetry_gap_s > 0), + ADD COLUMN IF NOT EXISTS telemetry_tail_s NUMERIC(12,2) NOT NULL DEFAULT 30 + CHECK (telemetry_tail_s >= 0); + +UPDATE mv_loss_intelligence.loss_policies +SET require_telemetry_coverage = true, + telemetry_gap_s = 90, + telemetry_tail_s = 30, + notes = concat_ws(' ', notes, + 'v0.2: la pérdida elegible se limita a la intersección entre paro raw, orden Odoo válida y cobertura real de telemetría. Un hueco >90 s abre una nueva isla; se toleran 30 s tras la última muestra.'), + updated_at = now() +WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = 'CUTTER_STOPS_V1'; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_telemetry_coverage_v1 AS +WITH policy AS ( + SELECT + COALESCE(telemetry_gap_s, 90)::double precision AS telemetry_gap_s, + COALESCE(telemetry_tail_s, 30)::double precision AS telemetry_tail_s + FROM mv_loss_intelligence.loss_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = 'CUTTER_STOPS_V1' + AND is_active = true + ORDER BY id DESC + LIMIT 1 +), samples AS ( + SELECT + r.tenant, + r.site, + r.machine_id, + r.ingest_ts, + r.comm_ok, + CASE + WHEN lower(COALESCE(r.payload_json ->> 'sample_valid', 'true')) + IN ('false', 'f', '0', 'no') THEN false + ELSE true + END AS sample_valid, + p.telemetry_gap_s, + p.telemetry_tail_s, + lag(r.ingest_ts) OVER ( + PARTITION BY r.tenant, r.site, r.machine_id + ORDER BY r.ingest_ts + ) AS previous_ingest_ts + FROM mv_hot.edge_oee_eastron_readings r + CROSS JOIN policy p + WHERE r.tenant = 'ucepsa' + AND r.site = 'ucepsa_onpremise' + AND r.machine_id IS NOT NULL + AND r.comm_ok = true + AND CASE + WHEN lower(COALESCE(r.payload_json ->> 'sample_valid', 'true')) + IN ('false', 'f', '0', 'no') THEN false + ELSE true + END = true +), marked AS ( + SELECT + s.*, + CASE + WHEN previous_ingest_ts IS NULL THEN 1 + WHEN ingest_ts - previous_ingest_ts + > make_interval(secs => telemetry_gap_s) THEN 1 + ELSE 0 + END AS starts_new_island + FROM samples s +), islanded AS ( + SELECT + m.*, + sum(starts_new_island) OVER ( + PARTITION BY tenant, site, machine_id + ORDER BY ingest_ts + ROWS UNBOUNDED PRECEDING + ) AS telemetry_island_id + FROM marked m +) +SELECT + tenant, + site, + machine_id, + telemetry_island_id, + min(ingest_ts) AS coverage_from, + max(ingest_ts) + make_interval(secs => max(telemetry_tail_s)) AS coverage_to, + count(*) AS sample_count, + round(EXTRACT(EPOCH FROM (max(ingest_ts) - min(ingest_ts)))::numeric, 2) AS sampled_span_s, + max(telemetry_gap_s)::numeric(12,2) AS telemetry_gap_s, + max(telemetry_tail_s)::numeric(12,2) AS telemetry_tail_s +FROM islanded +GROUP BY tenant, site, machine_id, telemetry_island_id; + +COMMENT ON VIEW mv_reports_ucepsa_prod.li_telemetry_coverage_v1 IS +'Islas de cobertura real de telemetría válida. Cuando una cortadora pierde alimentación, la isla termina y el tiempo posterior no puede computarse como pérdida.'; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_stop_order_overlaps_v2 AS +WITH raw_stops AS ( + SELECT + s.*, + COALESCE(s.ended_at, now()) AS raw_effective_end, + EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))::numeric AS raw_duration_s + FROM mv_hot.edge_oee_machine_stops s + WHERE s.tenant = 'ucepsa' + AND s.site = 'ucepsa_onpremise' + AND COALESCE(s.ended_at, now()) > s.started_at +) +SELECT + s.id AS source_stop_id, + s.tenant, + s.site, + s.vertical, + s.asset, + s.machine_id, + s.order_id AS source_order_id, + s.started_at AS raw_started_at, + s.ended_at AS raw_ended_at, + s.raw_effective_end, + s.raw_duration_s, + s.status AS source_status, + s.classification_status, + s.cause_code, + s.operator_note, + s.value_hour_eur, + s.start_event_name, + s.end_event_name, + s.start_payload_json, + s.end_payload_json, + o.id AS order_state_event_id, + o.effective_from AS order_context_from, + o.effective_to AS order_context_to, + o.odoo_state, + o.order_present, + o.order_coherent, + o.workorder_id AS odoo_workorder_id, + o.workorder_name, + o.workorder_state, + o.production_id AS odoo_production_id, + o.production_order AS order_ref, + o.product_id, + o.product AS product_name, + o.qty_production, + o.date_start AS odoo_date_start, + o.date_finished AS odoo_date_finished, + GREATEST(s.started_at, o.effective_from) AS order_overlap_from, + LEAST( + s.raw_effective_end, + COALESCE(o.effective_to, s.raw_effective_end) + ) AS order_overlap_to, + EXTRACT(EPOCH FROM ( + LEAST(s.raw_effective_end, COALESCE(o.effective_to, s.raw_effective_end)) + - GREATEST(s.started_at, o.effective_from) + ))::numeric AS order_overlap_duration_s +FROM raw_stops s +JOIN mv_reports_ucepsa_prod.li_order_state_intervals_v1 o + ON o.tenant = s.tenant + AND o.site = s.site + AND o.machine_id = s.machine_id + AND o.order_present = true + AND o.order_coherent = true + AND s.raw_effective_end > o.effective_from + AND s.started_at < COALESCE(o.effective_to, s.raw_effective_end) +WHERE LEAST(s.raw_effective_end, COALESCE(o.effective_to, s.raw_effective_end)) + > GREATEST(s.started_at, o.effective_from); + +COMMENT ON VIEW mv_reports_ucepsa_prod.li_stop_order_overlaps_v2 IS +'Segmenta un paro raw por cada intervalo de orden Odoo válida que solapa. Un paro que atraviesa cambios de orden deja de atribuirse íntegramente a la orden inicial.'; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_stop_eligible_slices_v2 AS +SELECT + o.*, + t.telemetry_island_id, + t.coverage_from AS telemetry_coverage_from, + t.coverage_to AS telemetry_coverage_to, + GREATEST(o.order_overlap_from, t.coverage_from) AS eligible_from, + LEAST(o.order_overlap_to, t.coverage_to) AS eligible_to, + EXTRACT(EPOCH FROM ( + LEAST(o.order_overlap_to, t.coverage_to) + - GREATEST(o.order_overlap_from, t.coverage_from) + ))::numeric AS eligible_duration_s +FROM mv_reports_ucepsa_prod.li_stop_order_overlaps_v2 o +JOIN mv_reports_ucepsa_prod.li_telemetry_coverage_v1 t + ON t.tenant = o.tenant + AND t.site = o.site + AND t.machine_id = o.machine_id + AND o.order_overlap_to > t.coverage_from + AND o.order_overlap_from < t.coverage_to +WHERE LEAST(o.order_overlap_to, t.coverage_to) + > GREATEST(o.order_overlap_from, t.coverage_from); + +COMMENT ON VIEW mv_reports_ucepsa_prod.li_stop_eligible_slices_v2 IS +'Intersección exacta entre paro raw, orden Odoo válida y telemetría disponible. Es la unidad temporal elegible de LossIntelligence v0.2.'; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_stop_candidates_v2 AS +WITH slice_summary AS ( + SELECT + source_stop_id, + tenant, + site, + vertical, + asset, + machine_id, + raw_started_at, + raw_ended_at, + raw_effective_end, + raw_duration_s, + source_status, + classification_status, + cause_code, + operator_note, + value_hour_eur, + start_event_name, + end_event_name, + start_payload_json, + end_payload_json, + order_state_event_id, + order_context_from, + order_context_to, + odoo_state, + order_present, + order_coherent, + odoo_workorder_id, + workorder_name, + workorder_state, + odoo_production_id, + order_ref, + product_id, + product_name, + qty_production, + odoo_date_start, + odoo_date_finished, + order_overlap_from, + order_overlap_to, + order_overlap_duration_s, + count(*) AS telemetry_slice_count, + min(eligible_from) AS eligible_from, + max(eligible_to) AS eligible_to, + sum(eligible_duration_s)::numeric AS eligible_duration_s, + min(telemetry_coverage_from) AS first_telemetry_coverage_from, + max(telemetry_coverage_to) AS last_telemetry_coverage_to + FROM mv_reports_ucepsa_prod.li_stop_eligible_slices_v2 + GROUP BY + source_stop_id, tenant, site, vertical, asset, machine_id, + raw_started_at, raw_ended_at, raw_effective_end, raw_duration_s, + source_status, classification_status, cause_code, operator_note, + value_hour_eur, start_event_name, end_event_name, + start_payload_json, end_payload_json, + order_state_event_id, order_context_from, order_context_to, + odoo_state, order_present, order_coherent, odoo_workorder_id, + workorder_name, workorder_state, odoo_production_id, order_ref, + product_id, product_name, qty_production, odoo_date_start, + odoo_date_finished, order_overlap_from, order_overlap_to, + order_overlap_duration_s +), order_counts AS ( + SELECT source_stop_id, count(*) AS valid_order_overlap_count + FROM mv_reports_ucepsa_prod.li_stop_order_overlaps_v2 + GROUP BY source_stop_id +) +SELECT + s.source_stop_id, + s.tenant, + s.site, + d.id AS deployment_id, + d.scope_code, + d.mode AS deployment_mode, + d.planned_go_live_date, + d.actual_go_live_at, + CASE + WHEN d.mode = 'ACTIVE' + AND d.actual_go_live_at IS NOT NULL + AND s.eligible_from >= d.actual_go_live_at THEN 'ACTIVE' + ELSE 'SHADOW' + END AS ledger_phase, + s.machine_id, + s.asset, + s.raw_started_at, + s.raw_ended_at, + s.raw_duration_s, + s.order_overlap_from, + s.order_overlap_to, + s.order_overlap_duration_s, + s.eligible_from AS started_at, + s.eligible_to AS ended_at, + s.eligible_duration_s, + ROUND((s.eligible_duration_s / 60.0)::numeric, 2) AS eligible_duration_min, + GREATEST(s.raw_duration_s - s.eligible_duration_s, 0)::numeric AS excluded_duration_s, + ROUND((GREATEST(s.raw_duration_s - s.eligible_duration_s, 0) / 60.0)::numeric, 2) AS excluded_duration_min, + ROUND((s.eligible_duration_s / NULLIF(s.raw_duration_s, 0))::numeric, 6) AS raw_coverage_ratio, + ROUND((s.eligible_duration_s / NULLIF(s.order_overlap_duration_s, 0))::numeric, 6) AS order_telemetry_coverage_ratio, + (s.order_overlap_duration_s < s.raw_duration_s - 1) AS order_clipped, + (s.eligible_duration_s < s.order_overlap_duration_s - 1) AS telemetry_clipped, + (COALESCE(oc.valid_order_overlap_count, 0) > 1) AS crosses_order_boundary, + s.telemetry_slice_count, + s.first_telemetry_coverage_from, + s.last_telemetry_coverage_to, + s.source_status, + s.classification_status, + s.cause_code, + c.cause_name, + c.is_planned, + s.operator_note, + s.start_event_name, + s.end_event_name, + s.order_state_event_id, + s.order_context_from, + s.order_context_to, + s.odoo_state, + s.order_present, + s.order_coherent, + s.odoo_workorder_id, + s.workorder_name, + s.workorder_state, + s.odoo_production_id, + s.order_ref, + s.product_id, + s.product_name, + s.qty_production, + s.odoo_date_start, + s.odoo_date_finished, + pc.units_per_cycle, + pc.target_cycle_rate_ppm, + COALESCE(ep.parameter_value, s.value_hour_eur) AS applied_capacity_eur_h, + ep.confidence AS capacity_cost_confidence, + ROUND(( + s.eligible_duration_s / 3600.0 + * COALESCE(ep.parameter_value, s.value_hour_eur) + )::numeric, 2) AS eligible_capacity_cost_eur, + CASE + WHEN s.source_status = 'OPEN' THEN 'OPEN_MONITORING' + WHEN s.eligible_duration_s < p.noise_below_s THEN 'EXCLUDED_NOISE' + WHEN s.eligible_duration_s < p.operator_individual_from_s THEN 'CANDIDATE_GROUP' + ELSE 'CANDIDATE_INDIVIDUAL' + END AS eligibility_status, + CASE + WHEN s.source_status = 'CLOSED' + AND s.eligible_duration_s >= p.noise_below_s THEN true + ELSE false + END AS technically_eligible, + CASE + WHEN d.mode = 'ACTIVE' + AND d.actual_go_live_at IS NOT NULL + AND s.eligible_from >= d.actual_go_live_at + AND s.source_status = 'CLOSED' + AND s.eligible_duration_s >= p.noise_below_s THEN true + ELSE false + END AS official_ledger_eligible, + jsonb_build_object( + 'source_stop_id', s.source_stop_id, + 'raw_started_at', s.raw_started_at, + 'raw_ended_at', s.raw_ended_at, + 'raw_duration_s', s.raw_duration_s, + 'order_overlap_from', s.order_overlap_from, + 'order_overlap_to', s.order_overlap_to, + 'order_overlap_duration_s', s.order_overlap_duration_s, + 'eligible_duration_s', s.eligible_duration_s, + 'telemetry_slice_count', s.telemetry_slice_count, + 'order_clipped', (s.order_overlap_duration_s < s.raw_duration_s - 1), + 'telemetry_clipped', (s.eligible_duration_s < s.order_overlap_duration_s - 1), + 'crosses_order_boundary', (COALESCE(oc.valid_order_overlap_count, 0) > 1), + 'source_start_payload', s.start_payload_json, + 'source_end_payload', s.end_payload_json + ) AS evidence_json +FROM slice_summary s +LEFT JOIN order_counts oc ON oc.source_stop_id = s.source_stop_id +LEFT JOIN mv_hot.edge_oee_stop_causes c ON c.cause_code = s.cause_code +LEFT JOIN LATERAL ( + SELECT x.* + FROM mv_loss_intelligence.deployment_config x + WHERE x.tenant = s.tenant + AND x.site = s.site + AND x.scope_code = 'CUTTERS' + ORDER BY x.id DESC + LIMIT 1 +) d ON true +LEFT JOIN LATERAL ( + SELECT x.* + FROM mv_loss_intelligence.loss_policies x + WHERE x.tenant = s.tenant + AND x.site = s.site + AND x.policy_code = 'CUTTER_STOPS_V1' + AND x.is_active = true + ORDER BY x.id DESC + LIMIT 1 +) p ON true +LEFT JOIN LATERAL ( + SELECT x.* + FROM mv_loss_intelligence.product_cycle_parameters x + WHERE x.tenant = s.tenant + AND x.site = s.site + AND x.is_active = true + AND x.product_id = s.product_id + AND (x.machine_id IS NULL OR x.machine_id = s.machine_id) + AND s.eligible_from >= x.valid_from + AND (x.valid_to IS NULL OR s.eligible_from < x.valid_to) + ORDER BY (x.machine_id IS NOT NULL) DESC, x.valid_from DESC, x.id DESC + LIMIT 1 +) pc ON true +LEFT JOIN LATERAL ( + SELECT x.* + FROM mv_loss_intelligence.economic_parameters x + WHERE x.tenant = s.tenant + AND x.site = s.site + AND x.parameter_code = 'CAPACITY_HOUR_EUR' + AND x.is_active = true + AND (x.machine_id IS NULL OR x.machine_id = s.machine_id) + AND s.eligible_from >= x.valid_from + AND (x.valid_to IS NULL OR s.eligible_from < x.valid_to) + ORDER BY (x.machine_id IS NOT NULL) DESC, x.valid_from DESC, x.id DESC + LIMIT 1 +) ep ON true; + +COMMENT ON VIEW mv_reports_ucepsa_prod.li_stop_candidates_v2 IS +'Candidatos power-aware: la duración económica solo incluye tiempo con orden válida y telemetría real. Las noches y periodos sin alimentación dejan de inflar el Loss Ledger.'; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_stop_exclusion_diagnostics_v2 AS +WITH raw AS ( + SELECT + s.*, + COALESCE(s.ended_at, now()) AS raw_effective_end, + EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))::numeric AS raw_duration_s + FROM mv_hot.edge_oee_machine_stops s + WHERE s.tenant = 'ucepsa' + AND s.site = 'ucepsa_onpremise' + AND COALESCE(s.ended_at, now()) > s.started_at +), order_counts AS ( + SELECT source_stop_id, count(*) AS n + FROM mv_reports_ucepsa_prod.li_stop_order_overlaps_v2 + GROUP BY source_stop_id +), slice_counts AS ( + SELECT source_stop_id, count(*) AS n + FROM mv_reports_ucepsa_prod.li_stop_eligible_slices_v2 + GROUP BY source_stop_id +) +SELECT + r.id AS source_stop_id, + r.machine_id, + r.started_at, + r.ended_at, + r.status AS source_status, + ROUND((r.raw_duration_s / 60.0)::numeric, 2) AS raw_duration_min, + CASE + WHEN r.status = 'OPEN' THEN 'OPEN_MONITORING' + WHEN COALESCE(o.n, 0) = 0 THEN 'EXCLUDED_NO_VALID_ORDER_WINDOW' + WHEN COALESCE(t.n, 0) = 0 THEN 'EXCLUDED_NO_TELEMETRY_COVERAGE' + ELSE 'SEGMENTED_IN_V2' + END AS diagnostic_status, + COALESCE(o.n, 0) AS valid_order_overlap_count, + COALESCE(t.n, 0) AS telemetry_slice_count +FROM raw r +LEFT JOIN order_counts o ON o.source_stop_id = r.id +LEFT JOIN slice_counts t ON t.source_stop_id = r.id; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_stop_groups_v2 AS +WITH candidates AS ( + SELECT + c.*, + COALESCE(p.aggregation_window_min, 15) AS aggregation_window_min + FROM mv_reports_ucepsa_prod.li_stop_candidates_v2 c + LEFT JOIN LATERAL ( + SELECT x.* + FROM mv_loss_intelligence.loss_policies x + WHERE x.tenant = c.tenant + AND x.site = c.site + AND x.policy_code = 'CUTTER_STOPS_V1' + AND x.is_active = true + ORDER BY x.id DESC + LIMIT 1 + ) p ON true + WHERE c.eligibility_status IN ('CANDIDATE_GROUP', 'CANDIDATE_INDIVIDUAL') +), bucketed AS ( + SELECT + c.*, + date_bin( + make_interval(mins => c.aggregation_window_min), + c.started_at, + TIMESTAMPTZ '2000-01-01 00:00:00+00' + ) AS bucket_start + FROM candidates c +), grouped_micro AS ( + SELECT + tenant, + site, + deployment_id, + ledger_phase, + machine_id, + asset, + order_ref, + odoo_production_id, + NULLIF(odoo_workorder_id, '')::bigint AS odoo_workorder_id, + product_id, + product_name, + units_per_cycle, + target_cycle_rate_ppm, + COALESCE(cause_code, 'PENDING') AS cause_code, + COALESCE(cause_name, 'Pendiente de clasificar') AS cause_name, + classification_status, + bucket_start AS group_window_start, + COUNT(DISTINCT source_stop_id)::int AS event_count, + MIN(started_at) AS started_at, + MAX(ended_at) AS ended_at, + SUM(eligible_duration_s)::numeric AS duration_s, + ROUND(SUM(eligible_capacity_cost_eur)::numeric, 2) AS capacity_loss_eur, + bool_or(order_clipped) AS order_clipped, + bool_or(telemetry_clipped) AS telemetry_clipped, + bool_or(crosses_order_boundary) AS crosses_order_boundary, + array_agg(DISTINCT source_stop_id ORDER BY source_stop_id) AS source_stop_ids, + bool_and(official_ledger_eligible) AS official_ledger_eligible + FROM bucketed + WHERE eligibility_status = 'CANDIDATE_GROUP' + GROUP BY + tenant, site, deployment_id, ledger_phase, machine_id, asset, + order_ref, odoo_production_id, odoo_workorder_id, product_id, + product_name, units_per_cycle, target_cycle_rate_ppm, + COALESCE(cause_code, 'PENDING'), + COALESCE(cause_name, 'Pendiente de clasificar'), + classification_status, bucket_start +), individual AS ( + SELECT + tenant, + site, + deployment_id, + ledger_phase, + machine_id, + asset, + order_ref, + odoo_production_id, + NULLIF(odoo_workorder_id, '')::bigint AS odoo_workorder_id, + product_id, + product_name, + units_per_cycle, + target_cycle_rate_ppm, + COALESCE(cause_code, 'PENDING') AS cause_code, + COALESCE(cause_name, 'Pendiente de clasificar') AS cause_name, + classification_status, + started_at AS group_window_start, + 1::int AS event_count, + started_at, + ended_at, + eligible_duration_s::numeric AS duration_s, + eligible_capacity_cost_eur::numeric AS capacity_loss_eur, + order_clipped, + telemetry_clipped, + crosses_order_boundary, + ARRAY[source_stop_id]::bigint[] AS source_stop_ids, + official_ledger_eligible + FROM bucketed + WHERE eligibility_status = 'CANDIDATE_INDIVIDUAL' +), combined AS ( + SELECT * FROM grouped_micro + UNION ALL + SELECT * FROM individual +) +SELECT + CASE + WHEN event_count = 1 THEN 'STOPV2:' || source_stop_ids[1]::text || ':' + || COALESCE(odoo_production_id::text, COALESCE(order_ref, 'NO_ORDER')) + ELSE 'STOPGROUPV2:' || machine_id || ':' + || COALESCE(odoo_production_id::text, COALESCE(order_ref, 'NO_ORDER')) || ':' + || to_char(group_window_start AT TIME ZONE 'Europe/Madrid', 'YYYYMMDDHH24MI') || ':' + || cause_code + END AS source_key, + *, + ROUND((duration_s / 60.0)::numeric, 2) AS duration_min, + CASE + WHEN duration_s >= 900 THEN 'HIGH' + WHEN duration_s >= 300 THEN 'MEDIUM' + ELSE 'LOW' + END AS preliminary_priority +FROM combined; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_shadow_review_queue_v2 AS +SELECT + row_number() OVER ( + ORDER BY capacity_loss_eur DESC, duration_s DESC, started_at DESC + ) AS shadow_rank, + source_key, + machine_id, + order_ref, + product_name, + cause_code, + cause_name, + event_count, + started_at, + ended_at, + duration_min, + capacity_loss_eur, + preliminary_priority, + order_clipped, + telemetry_clipped, + crosses_order_boundary, + 'SHADOW_ONLY_NOT_OFFICIAL'::text AS queue_status, + official_ledger_eligible, + source_stop_ids +FROM mv_reports_ucepsa_prod.li_stop_groups_v2 +WHERE ledger_phase = 'SHADOW' +ORDER BY shadow_rank; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_operator_stop_queue_v2 AS +SELECT + source_key, + machine_id, + order_ref, + product_name, + cause_code, + cause_name, + classification_status, + event_count, + started_at, + ended_at, + duration_min, + capacity_loss_eur, + order_clipped, + telemetry_clipped, + crosses_order_boundary, + source_stop_ids +FROM mv_reports_ucepsa_prod.li_stop_groups_v2 +WHERE ledger_phase = 'ACTIVE' + AND official_ledger_eligible = true + AND classification_status = 'PENDING' +ORDER BY capacity_loss_eur DESC, duration_s DESC, started_at DESC; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_shadow_summary_v2 AS +SELECT + ledger_phase, + eligibility_status, + machine_id, + COUNT(*) AS candidate_segment_count, + COUNT(DISTINCT source_stop_id) AS source_stop_count, + ROUND(SUM(eligible_duration_s / 60.0)::numeric, 2) AS eligible_minutes, + ROUND(SUM(eligible_capacity_cost_eur)::numeric, 2) AS capacity_cost_eur +FROM mv_reports_ucepsa_prod.li_stop_candidates_v2 +GROUP BY ledger_phase, eligibility_status, machine_id; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_eligibility_comparison_v1 AS +WITH old_calc AS ( + SELECT + machine_id, + COUNT(*) FILTER (WHERE eligibility_status IN ('CANDIDATE_GROUP', 'CANDIDATE_INDIVIDUAL')) AS v1_candidate_rows, + SUM(effective_duration_s) FILTER (WHERE eligibility_status IN ('CANDIDATE_GROUP', 'CANDIDATE_INDIVIDUAL')) AS v1_candidate_s, + SUM(effective_capacity_cost_eur) FILTER (WHERE eligibility_status IN ('CANDIDATE_GROUP', 'CANDIDATE_INDIVIDUAL')) AS v1_candidate_eur + FROM mv_reports_ucepsa_prod.li_stop_candidates_v1 + GROUP BY machine_id +), new_calc AS ( + SELECT + machine_id, + COUNT(*) FILTER (WHERE eligibility_status IN ('CANDIDATE_GROUP', 'CANDIDATE_INDIVIDUAL')) AS v2_candidate_rows, + COUNT(DISTINCT source_stop_id) FILTER (WHERE eligibility_status IN ('CANDIDATE_GROUP', 'CANDIDATE_INDIVIDUAL')) AS v2_source_stops, + SUM(eligible_duration_s) FILTER (WHERE eligibility_status IN ('CANDIDATE_GROUP', 'CANDIDATE_INDIVIDUAL')) AS v2_candidate_s, + SUM(eligible_capacity_cost_eur) FILTER (WHERE eligibility_status IN ('CANDIDATE_GROUP', 'CANDIDATE_INDIVIDUAL')) AS v2_candidate_eur + FROM mv_reports_ucepsa_prod.li_stop_candidates_v2 + GROUP BY machine_id +) +SELECT + COALESCE(o.machine_id, n.machine_id) AS machine_id, + COALESCE(o.v1_candidate_rows, 0) AS v1_candidate_rows, + ROUND((COALESCE(o.v1_candidate_s, 0) / 60.0)::numeric, 2) AS v1_candidate_minutes, + ROUND(COALESCE(o.v1_candidate_eur, 0)::numeric, 2) AS v1_candidate_eur, + COALESCE(n.v2_candidate_rows, 0) AS v2_candidate_segments, + COALESCE(n.v2_source_stops, 0) AS v2_source_stops, + ROUND((COALESCE(n.v2_candidate_s, 0) / 60.0)::numeric, 2) AS v2_candidate_minutes, + ROUND(COALESCE(n.v2_candidate_eur, 0)::numeric, 2) AS v2_candidate_eur, + ROUND(( + 100.0 * (1 - COALESCE(n.v2_candidate_s, 0) / NULLIF(o.v1_candidate_s, 0)) + )::numeric, 2) AS removed_time_pct +FROM old_calc o +FULL OUTER JOIN new_calc n ON n.machine_id = o.machine_id +ORDER BY machine_id; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro') THEN + GRANT SELECT ON mv_reports_ucepsa_prod.li_telemetry_coverage_v1 TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_stop_order_overlaps_v2 TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_stop_eligible_slices_v2 TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_stop_candidates_v2 TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_stop_exclusion_diagnostics_v2 TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_stop_groups_v2 TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_shadow_review_queue_v2 TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_operator_stop_queue_v2 TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_shadow_summary_v2 TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_eligibility_comparison_v1 TO grafana_ucepsa_ro; + END IF; +END $$; + +COMMIT; diff --git a/ucepsa/edge-oee-demo/sql/versions/098_ucepsa_power_aware_machine_state_v1.sql b/ucepsa/edge-oee-demo/sql/versions/098_ucepsa_power_aware_machine_state_v1.sql new file mode 100644 index 0000000..de19c3d --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/098_ucepsa_power_aware_machine_state_v1.sql @@ -0,0 +1,204 @@ +BEGIN; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.machine_operational_state_v1 AS +WITH latest_telemetry AS ( + SELECT DISTINCT ON (r.machine_id) + r.tenant, + r.site, + r.machine_id, + r.asset, + r.ingest_ts, + EXTRACT(EPOCH FROM (now() - r.ingest_ts))::numeric AS age_s, + r.comm_ok, + CASE + WHEN lower(COALESCE(r.payload_json ->> 'sample_valid', 'true')) + IN ('false', 'f', '0', 'no') THEN false + ELSE true + END AS sample_valid, + CASE + WHEN lower(COALESCE(r.payload_json ->> 'machine_running', 'false')) + IN ('true', 't', '1', 'yes') THEN true + ELSE false + END AS machine_running, + COALESCE(NULLIF(r.payload_json ->> 'cycle_rate_ppm', '')::numeric, 0) AS cycle_rate_ppm, + NULLIF(r.payload_json ->> 'cycle_pulse_count', '')::bigint AS cycle_pulse_count, + r.power_total_kw, + r.import_kwh, + r.frequency_hz, + r.payload_json + FROM mv_hot.edge_oee_eastron_readings r + WHERE r.tenant = 'ucepsa' + AND r.site = 'ucepsa_onpremise' + AND r.machine_id IS NOT NULL + ORDER BY r.machine_id, r.ingest_ts DESC +), machines AS ( + SELECT machine_id FROM latest_telemetry + UNION + SELECT machine_id + FROM mv_hot.odoo_order_state_current + WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise' +) +SELECT + m.machine_id, + t.asset, + t.ingest_ts AS last_telemetry_at, + t.age_s, + t.comm_ok, + t.sample_valid, + (COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false)) AS telemetry_fresh, + t.machine_running, + t.cycle_rate_ppm, + t.cycle_pulse_count, + t.power_total_kw, + t.import_kwh, + t.frequency_hz, + o.received_at AS order_state_received_at, + o.order_present, + o.order_coherent, + o.odoo_state, + o.production_order, + o.product, + o.production_id, + o.product_id, + o.qty_production, + CASE + WHEN COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false) + AND COALESCE(t.machine_running, false) + AND COALESCE(o.order_present, false) + AND COALESCE(o.order_coherent, false) + THEN 'RUNNING_WITH_ORDER' + WHEN COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false) + AND COALESCE(t.machine_running, false) + AND NOT (COALESCE(o.order_present, false) AND COALESCE(o.order_coherent, false)) + THEN 'RUNNING_WITHOUT_VALID_ORDER' + WHEN COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false) + AND NOT COALESCE(t.machine_running, false) + AND COALESCE(o.order_present, false) + AND COALESCE(o.order_coherent, false) + THEN 'STOPPED_WITH_ORDER' + WHEN COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false) + AND NOT COALESCE(t.machine_running, false) + AND NOT (COALESCE(o.order_present, false) AND COALESCE(o.order_coherent, false)) + THEN 'POWERED_ON_NO_ORDER' + WHEN NOT (COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false)) + AND COALESCE(o.order_present, false) + AND COALESCE(o.order_coherent, false) + THEN 'ORDER_WITHOUT_TELEMETRY' + ELSE 'POWERED_OFF_NO_ORDER' + END AS operational_state, + CASE + WHEN COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false) THEN 1 + WHEN NOT (COALESCE(o.order_present, false) AND COALESCE(o.order_coherent, false)) THEN 0 + ELSE 2 + END AS communication_code, + CASE + WHEN COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false) + AND COALESCE(t.machine_running, false) + AND COALESCE(o.order_present, false) + AND COALESCE(o.order_coherent, false) THEN 2 + WHEN COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false) + AND NOT COALESCE(t.machine_running, false) + AND COALESCE(o.order_present, false) + AND COALESCE(o.order_coherent, false) THEN 1 + WHEN NOT (COALESCE(o.order_present, false) AND COALESCE(o.order_coherent, false)) + AND NOT COALESCE(t.machine_running, false) THEN 0 + ELSE 3 + END AS machine_state_code, + CASE + WHEN COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false) + AND COALESCE(t.machine_running, false) + AND COALESCE(o.order_present, false) + AND COALESCE(o.order_coherent, false) THEN 'EN MARCHA' + WHEN COALESCE(t.age_s, 999999999) < 30 + AND COALESCE(t.comm_ok, false) + AND COALESCE(t.sample_valid, false) + AND NOT COALESCE(t.machine_running, false) + AND COALESCE(o.order_present, false) + AND COALESCE(o.order_coherent, false) THEN 'PARADA CON ORDEN' + WHEN NOT (COALESCE(o.order_present, false) AND COALESCE(o.order_coherent, false)) + AND NOT COALESCE(t.machine_running, false) THEN 'SIN PRODUCCIÓN' + ELSE 'REVISAR' + END AS machine_state_label +FROM machines m +LEFT JOIN latest_telemetry t ON t.machine_id = m.machine_id +LEFT JOIN mv_hot.odoo_order_state_current o + ON o.tenant = 'ucepsa' + AND o.site = 'ucepsa_onpremise' + AND o.machine_id = m.machine_id; + +COMMENT ON VIEW mv_reports_ucepsa_prod.machine_operational_state_v1 IS +'Estado power-aware para planta: una máquina sin telemetría y sin orden no se muestra como fallo, sino como SIN PRODUCCIÓN. Una orden sin telemetría o una máquina en marcha sin orden sí exige revisión.'; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_data_quality_v2 AS +WITH base AS ( + SELECT * FROM mv_reports_ucepsa_prod.li_data_quality_v1 +), historical_cycle_missing AS ( + SELECT COUNT(*) AS n + FROM mv_reports_ucepsa_prod.li_product_cycle_coverage_v1 + WHERE cycle_parameter_status <> 'READY' +), power_off_expected AS ( + SELECT COUNT(*) AS n + FROM mv_reports_ucepsa_prod.machine_operational_state_v1 + WHERE operational_state = 'POWERED_OFF_NO_ORDER' +), running_without_order AS ( + SELECT COUNT(*) AS n + FROM mv_reports_ucepsa_prod.machine_operational_state_v1 + WHERE operational_state = 'RUNNING_WITHOUT_VALID_ORDER' +), order_without_telemetry AS ( + SELECT COUNT(*) AS n + FROM mv_reports_ucepsa_prod.machine_operational_state_v1 + WHERE operational_state = 'ORDER_WITHOUT_TELEMETRY' +) +SELECT * FROM base +UNION ALL +SELECT 'HISTORICAL_CYCLE_PARAMETERS', + CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END, + n, + 'Productos observados en sombra sin bolsas por golpe o cadencia configurada.' +FROM historical_cycle_missing +UNION ALL +SELECT 'POWERED_OFF_NO_ORDER', 'INFO', n, + 'Máquinas sin telemetría y sin orden: situación esperada cuando no tienen producción.' +FROM power_off_expected +UNION ALL +SELECT 'RUNNING_WITHOUT_VALID_ORDER', + CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END, + n, + 'Máquinas produciendo sin orden Odoo válida.' +FROM running_without_order +UNION ALL +SELECT 'ORDER_WITHOUT_TELEMETRY', + CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END, + n, + 'Orden cargada con máquina o WISE sin telemetría reciente.' +FROM order_without_telemetry; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro') THEN + GRANT SELECT ON mv_reports_ucepsa_prod.machine_operational_state_v1 TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_data_quality_v2 TO grafana_ucepsa_ro; + END IF; +END $$; + +COMMIT;