chronoflux / web /main.py
xcenic's picture
Upload 19 files
f6330ad verified
Raw
History Blame Contribute Delete
23.5 kB
"""
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)
@dataclass
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
@ui.page('/')
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'),
)