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