Spaces:
Runtime error
Runtime error
| """ | |
| main.py — Interfaz CHRONOFLUX en NiceGUI (reemplaza la capa Streamlit). | |
| Esta capa SOLO presenta: toda la matemática vive en `core`. Arrancar: | |
| python -m web.main # desde la carpeta chronoflux/ | |
| Notas de entorno: | |
| - El clima ERA5 se consulta a Open-Meteo (requiere salida a internet). | |
| - Los modelos de IA (NLP/RF) son opcionales y de carga perezosa; si transformers | |
| no está instalado, la app funciona en modo determinista (fallbacks). | |
| """ | |
| import os | |
| import re | |
| from dataclasses import dataclass, field | |
| from datetime import datetime | |
| import pandas as pd | |
| from nicegui import ui, events, run | |
| from core import ( | |
| PRESETS_MODELOS, COORDENADAS_RD, PRESET_RECOMENDADO, UBIC_NEUTRA, | |
| LAT_NEUTRA, LON_NEUTRA, IA_DISPONIBLE, | |
| auditar_xml, run_simulation, SimulationParams, generar_xml_ajustado, | |
| obtener_clima_horario_laboral, dias_idx_desde_nombres, NOMBRES_DIAS, | |
| CicloLogicoError, | |
| ) | |
| from web.theme import PALETTE | |
| from web import charts | |
| from web.exporters import generar_excel_auditoria, nombre_seguro | |
| # ------------------------------------------------------------------ | |
| # Estilos globales (Inter + acentos de marca) | |
| # ------------------------------------------------------------------ | |
| ui.add_head_html(f""" | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root {{ --brand: {PALETTE['brand']}; --ink: {PALETTE['ink']}; }} | |
| body {{ font-family: 'Inter', sans-serif; background: {PALETTE['page']}; }} | |
| .cfx-kpi {{ border-left: 4px solid var(--brand); }} | |
| .cfx-banner {{ | |
| background: linear-gradient(120deg, {PALETTE['ink']} 0%, {PALETTE['ink_soft']} 60%, {PALETTE['brand_dark']} 140%); | |
| }} | |
| </style> | |
| """, shared=True) | |
| def windy_iframe(lat: float, lon: float) -> str: | |
| return ( | |
| f'<iframe width="100%" height="360" style="border:0;border-radius:12px" ' | |
| f'src="https://embed.windy.com/embed.html?type=map&location=coordinates&metricRain=mm' | |
| f'&metricTemp=%C2%B0C&metricWind=km%2Fh&zoom=8&overlay=rain&product=ecmwf' | |
| f'&level=surface&lat={lat:.4f}&lon={lon:.4f}"></iframe>' | |
| ) | |
| def agente_html(lineas: list[str]) -> str: | |
| """Convierte los bloques del agente (con **negrita** y <br>) a HTML.""" | |
| out = [] | |
| for bloque in lineas: | |
| html = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", bloque) | |
| out.append(f'<div style="margin-bottom:10px;line-height:1.5">{html}</div>') | |
| return "".join(out) | |
| class AppState: | |
| lat: float = LAT_NEUTRA | |
| lon: float = LON_NEUTRA | |
| ubic: str = UBIC_NEUTRA | |
| audit: object = None | |
| clima: dict = None | |
| df_clima: object = None | |
| result: object = None | |
| def index(): | |
| state = AppState() | |
| # ---------------- BANNER ---------------- | |
| with ui.element('div').classes('cfx-banner w-full rounded-2xl px-8 py-5 mb-4 shadow-lg'): | |
| ui.label('CHRONOFLUX AI').classes('text-white text-3xl font-extrabold tracking-wide') | |
| ui.label('Predicción de retrasos climáticos en cronogramas de construcción · motor CPM estocástico')\ | |
| .classes('text-slate-300 text-sm') | |
| # ================= SIDEBAR DE PARÁMETROS ================= | |
| with ui.left_drawer(value=True, bordered=True).classes('bg-white').props('width=330'): | |
| ui.label('Parámetros del modelo').classes('text-lg font-bold text-slate-800') | |
| sel_preset = ui.select( | |
| options=list(PRESETS_MODELOS.keys()), value=PRESET_RECOMENDADO, | |
| label='Preset de validación', | |
| ).classes('w-full').props('outlined dense') | |
| lbl_preset_desc = ui.label('').classes('text-xs text-slate-500') | |
| ui.separator() | |
| ui.label('Jornada laboral (horas)').classes('text-sm font-medium text-slate-700') | |
| with ui.row().classes('w-full items-center gap-2'): | |
| num_h_ini = ui.number(label='Inicio', value=8, min=0, max=23).props('outlined dense').classes('flex-1') | |
| num_h_fin = ui.number(label='Fin', value=17, min=1, max=23).props('outlined dense').classes('flex-1') | |
| sel_dias = ui.select( | |
| options=NOMBRES_DIAS, value=['Lun', 'Mar', 'Mié', 'Jue', 'Vie'], | |
| multiple=True, label='Días laborables', | |
| ).classes('w-full').props('outlined dense use-chips') | |
| ui.separator() | |
| ui.label('Inteligencia artificial').classes('text-sm font-medium text-slate-700') | |
| sw_nlp = ui.switch('NLP semántico (Ic)', value=IA_DISPONIBLE) | |
| sw_ml = ui.switch('Random Forest termodinámico (Tr)', value=IA_DISPONIBLE) | |
| sw_agente = ui.switch('Agente prescriptivo', value=True) | |
| if not IA_DISPONIBLE: | |
| ui.label('IA no disponible: ejecutando en modo determinista (fallbacks).')\ | |
| .classes('text-xs text-amber-600') | |
| ui.separator() | |
| ui.label('Clima').classes('text-sm font-medium text-slate-700') | |
| sw_clima = ui.switch('Usar clima real ERA5 (Open-Meteo)', value=True) | |
| with ui.column().classes('w-full gap-1'): | |
| with ui.row().classes('w-full items-center justify-between'): | |
| ui.label('Temp. manual (°C)').classes('text-xs text-slate-500') | |
| lbl_temp = ui.label('27.0').classes('text-xs font-mono') | |
| sl_temp = ui.slider(min=10, max=45, value=27, step=0.1)\ | |
| .props('label-always').bind_enabled_from(sw_clima, 'value', backward=lambda v: not v) | |
| sl_temp.on_value_change(lambda e: lbl_temp.set_text(f'{e.value:.1f}')) | |
| with ui.row().classes('w-full items-center justify-between'): | |
| ui.label('Humedad manual (%)').classes('text-xs text-slate-500') | |
| lbl_hum = ui.label('70.0').classes('text-xs font-mono') | |
| sl_hum = ui.slider(min=30, max=100, value=70, step=0.1)\ | |
| .props('label-always').bind_enabled_from(sw_clima, 'value', backward=lambda v: not v) | |
| sl_hum.on_value_change(lambda e: lbl_hum.set_text(f'{e.value:.1f}')) | |
| # ================= CONTENIDO PRINCIPAL ================= | |
| # ----- Ubicación + mapa ----- | |
| with ui.card().classes('w-full'): | |
| ui.label('1 · Ubicación del proyecto').classes('text-base font-bold text-slate-800') | |
| with ui.row().classes('w-full items-center gap-3'): | |
| sel_ubic = ui.select( | |
| options=sorted(COORDENADAS_RD.keys()), value=UBIC_NEUTRA, | |
| label='Buscar ubicación', with_input=True, | |
| ).classes('flex-1').props('outlined dense') | |
| num_lat = ui.number(label='Lat', value=state.lat, format='%.6f').props('outlined dense').classes('w-36') | |
| num_lon = ui.number(label='Lon', value=state.lon, format='%.6f').props('outlined dense').classes('w-36') | |
| ui.button('Aplicar', on_click=lambda: aplicar_manual()).props('outline') | |
| lbl_coords = ui.label(f'Lat {state.lat:.6f}, Lon {state.lon:.6f} — {state.ubic}')\ | |
| .classes('text-xs text-slate-500 font-mono') | |
| leaflet_map = ui.leaflet(center=(state.lat, state.lon), zoom=8).classes('w-full h-80 rounded-xl') | |
| marker = leaflet_map.marker(latlng=(state.lat, state.lon)) | |
| def actualizar_ubicacion(lat, lon, nombre, recenter=True): | |
| state.lat, state.lon, state.ubic = float(lat), float(lon), nombre | |
| lbl_coords.set_text(f'Lat {state.lat:.6f}, Lon {state.lon:.6f} — {nombre}') | |
| num_lat.value = state.lat | |
| num_lon.value = state.lon | |
| try: | |
| marker.move(state.lat, state.lon) | |
| if recenter: | |
| leaflet_map.set_center((state.lat, state.lon)) | |
| except Exception: | |
| pass | |
| try: | |
| windy_html.set_content(windy_iframe(state.lat, state.lon)) | |
| except Exception: | |
| pass | |
| def on_dropdown(e): | |
| coords = COORDENADAS_RD.get(e.value, (LAT_NEUTRA, LON_NEUTRA)) | |
| zoom = 8 if e.value == UBIC_NEUTRA else 13 | |
| actualizar_ubicacion(coords[0], coords[1], e.value) | |
| try: | |
| leaflet_map.set_zoom(zoom) | |
| except Exception: | |
| pass | |
| def aplicar_manual(): | |
| actualizar_ubicacion(num_lat.value, num_lon.value, | |
| f'Coordenada manual: {float(num_lat.value):.6f}, {float(num_lon.value):.6f}') | |
| def on_map_click(e): | |
| args = getattr(e, 'args', None) or {} | |
| ll = args.get('latlng') or {} | |
| lat, lng = ll.get('lat'), ll.get('lng') | |
| if lat is not None and lng is not None: | |
| actualizar_ubicacion(lat, lng, f'Punto seleccionado: {lat:.6f}, {lng:.6f}', recenter=False) | |
| sel_ubic.on_value_change(on_dropdown) | |
| leaflet_map.on('map-click', on_map_click) | |
| # ----- Clima ----- | |
| with ui.card().classes('w-full'): | |
| with ui.row().classes('w-full items-center justify-between'): | |
| ui.label('2 · Clima histórico (ERA5)').classes('text-base font-bold text-slate-800') | |
| ui.button('Consultar clima', icon='cloud_download', on_click=lambda: consultar_clima()).props('color=primary') | |
| clima_box = ui.column().classes('w-full') | |
| with clima_box: | |
| ui.label('Pulsa "Consultar clima" para descargar la serie histórica de la ubicación seleccionada.')\ | |
| .classes('text-sm text-slate-500') | |
| ui.label('Radar (Windy)').classes('text-sm font-medium text-slate-700 mt-2') | |
| windy_html = ui.html(windy_iframe(state.lat, state.lon)).classes('w-full') | |
| async def consultar_clima(): | |
| clima_box.clear() | |
| with clima_box: | |
| spin = ui.spinner(size='lg') | |
| ui.label('Descargando ERA5 (2014–2023)…').classes('text-sm text-slate-500') | |
| try: | |
| df_g, clima_map, _ = await run.io_bound( | |
| obtener_clima_horario_laboral, state.lat, state.lon, | |
| int(num_h_ini.value), int(num_h_fin.value), | |
| ) | |
| except Exception as ex: | |
| clima_box.clear() | |
| with clima_box: | |
| ui.label(f'Error consultando el clima: {ex}').classes('text-sm text-red-600') | |
| return | |
| if clima_map is None: | |
| clima_box.clear() | |
| with clima_box: | |
| ui.label('No se pudo obtener el clima (sin conexión o coordenada sin datos).')\ | |
| .classes('text-sm text-red-600') | |
| return | |
| state.clima, state.df_clima = clima_map, df_g | |
| clima_box.clear() | |
| with clima_box: | |
| ui.label(f'Serie cargada · {len(clima_map)} días-calendario con histórico.')\ | |
| .classes('text-sm text-green-700') | |
| with ui.tabs().classes('w-full') as tabs_c: | |
| t_mm = ui.tab('Lluvia') | |
| t_temp = ui.tab('Temperatura') | |
| t_hum = ui.tab('Humedad') | |
| with ui.tab_panels(tabs_c, value=t_mm).classes('w-full'): | |
| with ui.tab_panel(t_mm): | |
| ui.plotly(charts.build_climate_fig(df_g, 'mm')).classes('w-full') | |
| with ui.tab_panel(t_temp): | |
| ui.plotly(charts.build_climate_fig(df_g, 'temp')).classes('w-full') | |
| with ui.tab_panel(t_hum): | |
| ui.plotly(charts.build_climate_fig(df_g, 'hum')).classes('w-full') | |
| # ----- Carga del cronograma ----- | |
| with ui.card().classes('w-full'): | |
| ui.label('3 · Cronograma MS Project (XML MSPDI)').classes('text-base font-bold text-slate-800') | |
| ui.upload(on_upload=lambda e: on_upload(e), auto_upload=True, label='Sube el .xml exportado de MS Project')\ | |
| .classes('w-full').props('accept=.xml') | |
| audit_box = ui.column().classes('w-full') | |
| with ui.row().classes('w-full items-center gap-4 mt-2'): | |
| sw_cal_xml = ui.switch('Usar calendario del proyecto (XML)', value=True) | |
| radio_rep = ui.radio(['Reparar Auto', 'Ignorar'], value='Reparar Auto').props('inline') | |
| async def on_upload(e: events.UploadEventArguments): | |
| try: | |
| raw = await e.file.read() | |
| audit = auditar_xml(raw) | |
| except Exception as ex: | |
| audit_box.clear() | |
| with audit_box: | |
| ui.label(f'No se pudo leer el XML: {ex}').classes('text-sm text-red-600') | |
| return | |
| state.audit = audit | |
| errores = audit.errores | |
| audit_box.clear() | |
| with audit_box: | |
| ui.label(f'Proyecto: {audit.project_name} · {len(audit.df)} tareas · ' | |
| f'{audit.hours_per_day:.0f} h/día · calendario {audit.cal_dias}')\ | |
| .classes('text-sm text-slate-700') | |
| if len(errores): | |
| ui.label(f'⚠️ {len(errores)} tarea(s) sin predecesora detectada(s):')\ | |
| .classes('text-sm text-amber-700 font-medium') | |
| disp = errores[['ID', 'Name', 'Errores']].astype(str) | |
| ui.table.from_pandas(disp).classes('w-full').props('dense flat') | |
| else: | |
| ui.label('✅ Sin errores lógicos de precedencia.').classes('text-sm text-green-700') | |
| # ----- Parámetros de corrida + ejecutar ----- | |
| with ui.card().classes('w-full'): | |
| ui.label('4 · Umbrales de sensibilidad').classes('text-base font-bold text-slate-800') | |
| with ui.row().classes('w-full gap-6'): | |
| with ui.column().classes('flex-1'): | |
| with ui.row().classes('w-full justify-between'): | |
| ui.label('Pr — Prob. de lluvia (%)').classes('text-sm text-slate-600') | |
| lbl_pr = ui.label('22').classes('text-sm font-mono') | |
| sl_pr = ui.slider(min=0, max=100, value=22, step=1).props('label-always') | |
| sl_pr.on_value_change(lambda e: lbl_pr.set_text(f'{int(e.value)}')) | |
| with ui.column().classes('flex-1'): | |
| with ui.row().classes('w-full justify-between'): | |
| ui.label('Ur — Intensidad mín. (mm)').classes('text-sm text-slate-600') | |
| lbl_ur = ui.label('2.0').classes('text-sm font-mono') | |
| sl_ur = ui.slider(min=0.0, max=50.0, value=2.0, step=0.1).props('label-always') | |
| sl_ur.on_value_change(lambda e: lbl_ur.set_text(f'{e.value:.1f}')) | |
| with ui.column().classes('flex-1'): | |
| with ui.row().classes('w-full justify-between'): | |
| ui.label('Hw — Horas mín. viables').classes('text-sm text-slate-600') | |
| lbl_hw = ui.label('5.0').classes('text-sm font-mono') | |
| sl_hw = ui.slider(min=0.0, max=10.0, value=5.0, step=0.1).props('label-always') | |
| sl_hw.on_value_change(lambda e: lbl_hw.set_text(f'{e.value:.1f}')) | |
| btn_run = ui.button('Ejecutar cálculo', icon='play_arrow', on_click=lambda: ejecutar())\ | |
| .props('color=primary size=lg').classes('mt-2') | |
| results_box = ui.column().classes('w-full') | |
| # ---------------- Aplicar preset ---------------- | |
| def aplicar_preset(nombre): | |
| cfg = PRESETS_MODELOS.get(nombre, {}) | |
| lbl_preset_desc.set_text(cfg.get('desc', '')) | |
| if 'pr' not in cfg: | |
| return # "Personalizado": no toca los controles | |
| sl_pr.value = cfg['pr']; lbl_pr.set_text(f"{int(cfg['pr'])}") | |
| sl_ur.value = cfg['ur']; lbl_ur.set_text(f"{cfg['ur']:.1f}") | |
| sl_hw.value = cfg['ut']; lbl_hw.set_text(f"{cfg['ut']:.1f}") | |
| sw_nlp.value = cfg['nlp'] and IA_DISPONIBLE | |
| sw_ml.value = cfg['ml'] and IA_DISPONIBLE | |
| num_h_ini.value, num_h_fin.value = cfg['jornada'] | |
| sel_dias.value = list(cfg['dias']) | |
| sl_temp.value = cfg['temp']; lbl_temp.set_text(f"{cfg['temp']:.1f}") | |
| sl_hum.value = cfg['hum']; lbl_hum.set_text(f"{cfg['hum']:.1f}") | |
| sel_preset.on_value_change(lambda e: aplicar_preset(e.value)) | |
| aplicar_preset(PRESET_RECOMENDADO) | |
| # ---------------- Ejecutar simulación ---------------- | |
| async def ejecutar(): | |
| if state.audit is None: | |
| ui.notify('Carga primero un XML de MS Project.', type='warning'); return | |
| if state.clima is None: | |
| ui.notify('Consulta el clima antes de ejecutar.', type='warning'); return | |
| params = SimulationParams( | |
| pr=float(sl_pr.value) / 100.0, ur=float(sl_ur.value), hw_min=float(sl_hw.value), | |
| h_inicio=int(num_h_ini.value), h_fin=int(num_h_fin.value), | |
| use_nlp=bool(sw_nlp.value) and IA_DISPONIBLE, use_ml=bool(sw_ml.value) and IA_DISPONIBLE, | |
| temp_global=float(sl_temp.value), hum_global=float(sl_hum.value), | |
| usar_clima_real=bool(sw_clima.value), ventana_dias=0, | |
| reparar='Automática' if radio_rep.value == 'Reparar Auto' else 'Ignorar', | |
| ) | |
| dias_idx = dias_idx_desde_nombres(sel_dias.value) | |
| usar_cal = bool(sw_cal_xml.value) | |
| incluir_mit = bool(sw_agente.value) | |
| btn_run.disable() | |
| results_box.clear() | |
| with results_box: | |
| ui.spinner(size='lg') | |
| ui.label('Ejecutando motor CPM estocástico… (la primera corrida con IA puede tardar ~1 min)')\ | |
| .classes('text-sm text-slate-500') | |
| def _do(): | |
| return run_simulation(state.audit, state.clima, params, | |
| usar_cal_xml=usar_cal, dias_idx_manual=dias_idx, | |
| incluir_mitigacion=incluir_mit) | |
| try: | |
| try: | |
| result = await run.io_bound(_do) | |
| except CicloLogicoError as ce: | |
| results_box.clear() | |
| with results_box: | |
| ui.label('Bucle lógico en la red de precedencias').classes('text-base font-bold text-red-700') | |
| ui.label(str(ce)).classes('text-sm text-red-600') | |
| return | |
| except Exception as ex: | |
| results_box.clear() | |
| with results_box: | |
| ui.label(f'Error durante el cálculo: {ex}').classes('text-sm text-red-600') | |
| return | |
| state.result = result | |
| render_resultados(result) | |
| finally: | |
| btn_run.enable() | |
| # ---------------- Render de resultados ---------------- | |
| def render_resultados(result): | |
| df = result.df | |
| k = result.kpis | |
| fecha_fin = k.get('fecha_final_proyectada') | |
| fecha_txt = pd.to_datetime(fecha_fin).strftime('%d/%m/%Y') if pd.notna(fecha_fin) else '—' | |
| results_box.clear() | |
| with results_box: | |
| ui.label('Resultados').classes('text-xl font-bold text-slate-800 mt-2') | |
| # KPIs | |
| with ui.row().classes('w-full gap-4'): | |
| def kpi(titulo, valor, sub): | |
| with ui.card().classes('cfx-kpi flex-1'): | |
| ui.label(titulo).classes('text-xs uppercase tracking-wide text-slate-500') | |
| ui.label(str(valor)).classes('text-3xl font-extrabold text-slate-800') | |
| ui.label(sub).classes('text-xs text-slate-500') | |
| kpi('Actividades afectadas', f"{k['actividades_afectadas']}/{k['actividades_totales']}", 'con impacto pluviométrico') | |
| kpi('Retraso del proyecto', f"{k['retraso_total_dias']} d", 'días hábiles vs. línea base') | |
| kpi('Fecha final proyectada', fecha_txt, f"{k['n_criticas']} actividades en ruta crítica") | |
| # Agente prescriptivo | |
| if result.mitigacion: | |
| with ui.card().classes('w-full'): | |
| ui.label('Agente prescriptivo').classes('text-base font-bold text-slate-800') | |
| ui.html(agente_html(result.mitigacion)).classes('text-sm text-slate-700') | |
| # Gráficas | |
| with ui.card().classes('w-full'): | |
| with ui.tabs().classes('w-full') as tabs_r: | |
| tg = ui.tab('Gantt') | |
| ts = ui.tab('Curva S') | |
| tr = ui.tab('Riesgo mensual') | |
| tt = ui.tab('Tabla de impactos') | |
| with ui.tab_panels(tabs_r, value=tg).classes('w-full'): | |
| with ui.tab_panel(tg): | |
| ui.label('Barras rojas = ruta crítica · ámbar = con impacto y holgura · ' | |
| 'rombos = fin de línea base.').classes('text-xs text-slate-500') | |
| ui.plotly(charts.build_gantt(df)).classes('w-full') | |
| with ui.tab_panel(ts): | |
| ui.label('Avance físico acumulado ponderado por duración (estilo EVM).')\ | |
| .classes('text-xs text-slate-500') | |
| ui.plotly(charts.build_scurve(df)).classes('w-full') | |
| with ui.tab_panel(tr): | |
| fig_mr = charts.build_monthly_risk(df) | |
| if fig_mr.data: | |
| ui.plotly(fig_mr).classes('w-full') | |
| else: | |
| ui.label('Sin actividades impactadas para histograma mensual.')\ | |
| .classes('text-sm text-slate-500') | |
| with ui.tab_panel(tt): | |
| cols = ['ID', 'WBS', 'Actividad', 'Días Impacto', 'Tr (Secado/Horas)', | |
| 'Holgura (Días)', 'Ruta Crítica', 'Estado'] | |
| cols = [c for c in cols if c in df.columns] | |
| df_par = df[df['IsSummary'] == False].sort_values( | |
| 'Días Impacto', key=lambda s: pd.to_numeric(s, errors='coerce'), | |
| ascending=False)[cols].astype(str) | |
| ui.table.from_pandas(df_par).classes('w-full').props('dense flat') | |
| # Descargas | |
| with ui.card().classes('w-full'): | |
| ui.label('5 · Exportar').classes('text-base font-bold text-slate-800') | |
| with ui.row().classes('gap-4'): | |
| ui.button('Descargar XML ajustado', icon='architecture', | |
| on_click=lambda: descargar_xml()).props('color=primary') | |
| ui.button('Reporte gerencial (Excel)', icon='download', | |
| on_click=lambda: descargar_excel()).props('outline') | |
| def descargar_xml(): | |
| try: | |
| xml_bytes = generar_xml_ajustado( | |
| state.audit.raw_bytes, state.audit.prefix, state.result.df, state.audit.hours_per_day) | |
| safe = nombre_seguro(state.audit.project_name) | |
| ui.download(xml_bytes, f'{safe}_AJUSTADO.xml') | |
| except Exception as ex: | |
| ui.notify(f'No se pudo generar el XML: {ex}', type='negative') | |
| def descargar_excel(): | |
| try: | |
| xls = generar_excel_auditoria(state.result.df, state.audit.project_name, state.ubic) | |
| safe = nombre_seguro(state.audit.project_name) | |
| ui.download(xls, f'Reporte_Climatico_{safe}.xlsx') | |
| except Exception as ex: | |
| ui.notify(f'No se pudo generar el Excel: {ex}', type='negative') | |
| if __name__ in {"__main__", "__mp_main__"}: | |
| # En local usa el puerto 8080; en un host (Render, Railway, Fly, etc.) se | |
| # toma el puerto inyectado por la plataforma vía la variable de entorno PORT. | |
| ui.run( | |
| title='CHRONOFLUX AI', | |
| host=os.environ.get('HOST', '0.0.0.0'), | |
| port=int(os.environ.get('PORT', 8080)), | |
| reload=False, | |
| show=False, # no abrir navegador en el servidor | |
| favicon='🌧️', | |
| storage_secret=os.environ.get('STORAGE_SECRET', 'chronoflux-local-secret'), | |
| ) | |