feat(ucepsa): add power-aware LossIntelligence shadow v0.2

This commit is contained in:
Victor Fraile Garcia 2026-07-16 18:33:11 +02:00
parent 2d198e5ddb
commit 4586e29afe
5 changed files with 2698 additions and 0 deletions

View File

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

View File

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

View File

@ -0,0 +1,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;

View File

@ -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;