EHRley commited on
Commit
b73dd92
·
verified ·
1 Parent(s): 4bdedde

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +264 -240
app.py CHANGED
@@ -4,13 +4,18 @@ import pandas as pd
4
  from sklearn.linear_model import LinearRegression
5
  from sklearn.metrics import r2_score
6
  import os
 
 
 
 
 
7
 
8
  # ─────────────────────────────────────────────
9
- # DATOS BASE DE EJEMPLO (24 participantes hipotéticos)
10
- # Columnas: ID, Edad, Talla(m), Sexo(0=M,1=H), Ejercicio(1-5),
11
- # CapPulm_Antes(L), CapPulm_Despues(L),
12
- # SpO2_Antes(%), SpO2_Despues(%),
13
- # Animo_Antes(1-5), Animo_Despues(1-5)
14
  # ─────────────────────────────────────────────
15
  BASE_DATA = [
16
  [1, 17, 1.62, 0, 3, 2.8, 3.1, 97, 98, 3, 5],
@@ -69,6 +74,10 @@ ANIMO_LABELS = {
69
  "5 – Muy alto": 5,
70
  }
71
 
 
 
 
 
72
  CSV_PATH = "datos_animo_ejercicio.csv"
73
 
74
  estado = {
@@ -81,6 +90,10 @@ estado = {
81
  }
82
 
83
 
 
 
 
 
84
  def calcular_deltas(df):
85
  df = df.copy()
86
  df["Delta_Animo"] = df["Animo_Despues"] - df["Animo_Antes"]
@@ -99,7 +112,6 @@ def entrenar_modelo(df):
99
 
100
 
101
  def _construir_salidas_modelo(df, df_delta, modelo, r2, fuente):
102
- """Genera los textos de salida del modelo a partir de un df entrenado."""
103
  estado["modelo"] = modelo
104
  estado["df"] = df_delta
105
  estado["entrenado"] = True
@@ -122,7 +134,7 @@ def _construir_salidas_modelo(df, df_delta, modelo, r2, fuente):
122
  f"**R² = {r2:.4f}** — El modelo explica el **{r2*100:.1f}%** de la varianza en el cambio de ánimo.\n\n"
123
  f"**Interpretación de β₁ (Ejercicio) = {c[0]:.4f}:**\n"
124
  f"Por cada nivel adicional de ejercicio, el ánimo cambia en promedio "
125
- f"{'↑' if c[0]>0 else '↓'} {abs(c[0]):.4f} puntos Likert, controlando las demás variables."
126
  )
127
 
128
  coef_md = (
@@ -138,13 +150,15 @@ def _construir_salidas_modelo(df, df_delta, modelo, r2, fuente):
138
  f"| **R²** | **{r2:.4f}** |"
139
  )
140
 
141
- tabla = df_delta[["ID","Edad","Talla_m","Sexo","Ejercicio",
142
- "CapPulm_Antes","CapPulm_Despues","Delta_CapPulm",
143
- "SpO2_Antes","SpO2_Despues","Delta_SpO2",
144
- "Animo_Antes","Animo_Despues","Delta_Animo"]]
 
 
145
 
146
  return (
147
- f" Modelo entrenado con {len(df)} participantes. Fuente: {fuente}",
148
  len(df),
149
  tabla,
150
  ecuacion,
@@ -153,18 +167,16 @@ def _construir_salidas_modelo(df, df_delta, modelo, r2, fuente):
153
 
154
 
155
  def _csv_capturado_a_df():
156
- """Lee el CSV capturado y lo convierte al formato COLUMNS para entrenar."""
157
  if not os.path.exists(CSV_PATH):
158
  return None, "No hay datos capturados en el CSV todavía."
159
  try:
160
  df_csv = pd.read_csv(CSV_PATH)
161
  if df_csv.empty:
162
  return None, "El CSV está vacío."
163
- # El CSV capturado ya tiene los deltas; reconstruimos Animo_Despues desde Delta_Animo
164
- needed = ["ID","Edad","Talla_m","Sexo","Ejercicio",
165
- "CapPulm_Antes","CapPulm_Despues",
166
- "SpO2_Antes","SpO2_Despues",
167
- "Animo_Antes","Animo_Despues"]
168
  missing = [c for c in needed if c not in df_csv.columns]
169
  if missing:
170
  return None, f"Al CSV le faltan columnas: {missing}"
@@ -180,7 +192,6 @@ def _csv_capturado_a_df():
180
 
181
 
182
  def cargar_y_entrenar(file_obj=None):
183
- """Entrena con datos base + archivo externo opcional (CSV/Excel ajeno)."""
184
  df = pd.DataFrame(BASE_DATA, columns=COLUMNS)
185
 
186
  if file_obj is not None:
@@ -188,12 +199,11 @@ def cargar_y_entrenar(file_obj=None):
188
  ext = os.path.splitext(file_obj.name)[1].lower()
189
  df_new = pd.read_csv(file_obj.name) if ext == ".csv" else pd.read_excel(file_obj.name)
190
 
191
- # Detectar si es el CSV capturado por la app (tiene Delta_Animo)
192
  if "Delta_Animo" in df_new.columns:
193
- needed = ["ID","Edad","Talla_m","Sexo","Ejercicio",
194
- "CapPulm_Antes","CapPulm_Despues",
195
- "SpO2_Antes","SpO2_Despues",
196
- "Animo_Antes","Animo_Despues"]
197
  df_new = df_new[[c for c in needed if c in df_new.columns]].copy()
198
  else:
199
  df_new = df_new.iloc[:, :len(COLUMNS)]
@@ -203,7 +213,7 @@ def cargar_y_entrenar(file_obj=None):
203
  df_new[col] = pd.to_numeric(df_new[col], errors="coerce")
204
  df_new = df_new.dropna()
205
  df = pd.concat([df, df_new], ignore_index=True)
206
- fuente = f"datos base ({len(pd.DataFrame(BASE_DATA))} reg.) + archivo subido ({len(df_new)} reg.)"
207
  except Exception as e:
208
  return f"❌ Error al procesar el archivo: {e}", 0, pd.DataFrame(), "", ""
209
  else:
@@ -214,7 +224,6 @@ def cargar_y_entrenar(file_obj=None):
214
 
215
 
216
  def reentrenar_con_csv():
217
- """Entrena con datos base + CSV capturado por la app."""
218
  df_base = pd.DataFrame(BASE_DATA, columns=COLUMNS)
219
  df_csv, err = _csv_capturado_a_df()
220
 
@@ -223,22 +232,20 @@ def reentrenar_con_csv():
223
  f"⚠️ {err}",
224
  len(df_base),
225
  pd.DataFrame(),
226
- estado.get("ecuacion_md", "— Entrena primero el modelo —"),
227
- estado.get("coef_md", ""),
228
  )
229
 
230
- df_csv.columns = [c for c in COLUMNS if c in df_csv.columns] + \
231
- [c for c in df_csv.columns if c not in COLUMNS]
232
  df_csv = df_csv.reindex(columns=COLUMNS)
233
-
234
  df_combined = pd.concat([df_base, df_csv], ignore_index=True)
235
  modelo, r2, df_delta = entrenar_modelo(df_combined)
236
- fuente = f"datos base ({len(df_base)} reg.) + CSV capturado ({len(df_csv)} reg.)"
237
  return _construir_salidas_modelo(df_combined, df_delta, modelo, r2, fuente)
238
 
239
 
240
- init_msg, init_count, init_df, init_ec, init_coef = cargar_y_entrenar()
241
-
 
242
 
243
  def predecir(ejercicio_label, edad, talla, sexo,
244
  cap_antes, cap_despues, spo2_antes, spo2_despues):
@@ -252,25 +259,22 @@ def predecir(ejercicio_label, edad, talla, sexo,
252
 
253
  X_new = np.array([[ejer_val, delta_cap, delta_spo2, edad, talla, sexo_val]])
254
  pred = estado["modelo"].predict(X_new)[0]
 
 
255
 
256
- c = estado["coefs"]
257
- b0 = estado["intercept"]
258
-
259
- # Interpretación cualitativa
260
  if pred >= 1.5:
261
- interp = " **Mejora notable del ánimo** — se espera un aumento significativo."
262
  elif pred >= 0.5:
263
- interp = " **Ligera mejora del ánimo** — cambio positivo moderado."
264
  elif pred >= -0.5:
265
- interp = " **Sin cambio relevante** — el ánimo se mantiene similar."
266
  else:
267
- interp = " **Posible descenso del ánimo** — revisar variables de contexto."
268
 
269
- # Hipótesis
270
  hip = (
271
- " **H₁ apoyada:** el ejercicio predice positivamente el ánimo."
272
  if c[0] > 0 else
273
- " **H₁ no apoyada:** el coeficiente de ejercicio es negativo en este modelo."
274
  )
275
 
276
  return (
@@ -293,6 +297,10 @@ def predecir(ejercicio_label, edad, talla, sexo,
293
  )
294
 
295
 
 
 
 
 
296
  def obtener_siguiente_id():
297
  if os.path.exists(CSV_PATH):
298
  try:
@@ -304,37 +312,61 @@ def obtener_siguiente_id():
304
  return 1
305
 
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  def guardar_participante(edad, talla, sexo, ejercicio_label,
308
  cap_antes, cap_despues,
309
  spo2_antes, spo2_despues,
310
  animo_antes_label, animo_despues_label):
311
  errores = []
312
- if not (10 <= edad <= 100): errores.append("Edad fuera de rango (10-100).")
313
- if not (1.0 <= talla <= 2.5): errores.append("Talla fuera de rango (1.0-2.5 m).")
314
- if not (50 <= spo2_antes <= 100): errores.append("SpO2 Antes fuera de rango.")
315
- if not (50 <= spo2_despues <= 100): errores.append("SpO2 Después fuera de rango.")
316
- if not (0 < cap_antes <= 10): errores.append("Cap. Pulmonar Antes fuera de rango (0-10 L).")
317
- if not (0 < cap_despues <= 10): errores.append("Cap. Pulmonar Después fuera de rango (0-10 L).")
318
  if errores:
319
  return "⚠️ " + " | ".join(errores), obtener_tabla_csv()
320
 
321
- sexo_val = 1 if sexo == "Hombre" else 0
322
- ejer_val = EJERCICIO_LABELS.get(ejercicio_label, 1)
323
- animo_a_val = ANIMO_LABELS.get(animo_antes_label, 3)
324
- animo_d_val = ANIMO_LABELS.get(animo_despues_label, 3)
325
- delta_animo = animo_d_val - animo_a_val
326
- delta_cap = cap_despues - cap_antes
327
- delta_spo2 = spo2_despues - spo2_antes
328
- nuevo_id = obtener_siguiente_id()
329
 
330
  nueva_fila = {
331
- "ID": nuevo_id, "Edad": int(edad), "Talla_m": talla, "Sexo": sexo_val,
332
- "Ejercicio": ejer_val,
333
- "CapPulm_Antes": cap_antes, "CapPulm_Despues": cap_despues,
334
- "SpO2_Antes": spo2_antes, "SpO2_Despues": spo2_despues,
335
- "Animo_Antes": animo_a_val, "Animo_Despues": animo_d_val,
336
- "Delta_Animo": delta_animo, "Delta_CapPulm": round(delta_cap, 3),
337
- "Delta_SpO2": round(delta_spo2, 1),
 
 
 
 
 
 
 
338
  }
339
 
340
  df_nuevo = pd.DataFrame([nueva_fila], columns=COLUMNAS_CAPTURA)
@@ -343,163 +375,182 @@ def guardar_participante(edad, talla, sexo, ejercicio_label,
343
  else:
344
  df_nuevo.to_csv(CSV_PATH, mode="w", header=True, index=False)
345
 
346
- return f" Participante {nuevo_id} guardado.", obtener_tabla_csv()
347
-
348
 
349
- def obtener_tabla_csv():
350
- if os.path.exists(CSV_PATH):
351
- try:
352
- return pd.read_csv(CSV_PATH)
353
- except Exception:
354
- pass
355
- return pd.DataFrame(columns=COLUMNAS_CAPTURA)
356
-
357
-
358
- def obtener_csv_path():
359
- if not os.path.exists(CSV_PATH):
360
- pd.DataFrame(columns=COLUMNAS_CAPTURA).to_csv(CSV_PATH, index=False)
361
- return CSV_PATH
362
 
 
 
 
363
 
364
- def limpiar_csv():
365
- if os.path.exists(CSV_PATH):
366
- os.remove(CSV_PATH)
367
- return " Registros eliminados.", pd.DataFrame(columns=COLUMNAS_CAPTURA)
 
 
 
 
 
368
 
369
 
370
- # ─────────────────────────────────────────────
371
- # INTERFAZ
372
- # ─────────────────────────────────────────────
373
  def generar_graficas():
374
- """Genera gráficas de mejora del estado de ánimo desde el CSV capturado."""
375
- import matplotlib
376
- matplotlib.use("Agg")
377
- import matplotlib.pyplot as plt
378
- import matplotlib.patches as mpatches
379
- import io, base64
380
 
381
  if not os.path.exists(CSV_PATH):
382
- return None, None, None, "⚠️ No hay datos capturados todavía. Registra participantes primero."
383
-
384
  try:
385
  df = pd.read_csv(CSV_PATH)
386
  except Exception as e:
387
  return None, None, None, f"❌ Error leyendo CSV: {e}"
388
 
389
  if df.empty or "Animo_Antes" not in df.columns:
390
- return None, None, None, "⚠️ El CSV no tiene datos de ánimo."
391
 
392
- df = df.dropna(subset=["Animo_Antes","Animo_Despues","Delta_Animo","Ejercicio"])
393
  if len(df) < 2:
394
  return None, None, None, "⚠️ Se necesitan al menos 2 participantes para graficar."
395
 
396
- EJER_NOMBRE = {1:"Sedentario", 2:"Ocasional", 3:"Moderado", 4:"Activo", 5:"Muy activo"}
397
- ANIMO_NOMBRE = {1:"Muy bajo", 2:"Bajo", 3:"Neutral", 4:"Alto", 5:"Muy alto"}
398
- COLORES = {1:"#e74c3c", 2:"#e67e22", 3:"#f1c40f", 4:"#2ecc71", 5:"#27ae60"}
399
-
400
- def fig_to_img(fig):
401
  buf = io.BytesIO()
402
  fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
403
  buf.seek(0)
404
  plt.close(fig)
405
- return buf
406
-
407
- # ── GRÁFICA 1: Antes vs Después por participante (barras agrupadas) ──
408
- fig1, ax1 = plt.subplots(figsize=(max(8, len(df)*0.6), 5))
409
- ids = [f"P{int(i)}" for i in df["ID"]]
410
- x = range(len(df))
411
- w = 0.35
412
- bars_a = ax1.bar([i - w/2 for i in x], df["Animo_Antes"], w, label="Antes", color="#5b8dd9", alpha=0.85)
413
- bars_d = ax1.bar([i + w/2 for i in x], df["Animo_Despues"], w, label="Después", color="#27ae60", alpha=0.85)
 
414
  for bar, delta in zip(bars_d, df["Delta_Animo"]):
415
- color = "#27ae60" if delta > 0 else ("#e74c3c" if delta < 0 else "#888")
416
- ax1.text(bar.get_x() + bar.get_width()/2,
417
- bar.get_height() + 0.05,
418
  f"{delta:+.0f}", ha="center", va="bottom",
419
- fontsize=8, color=color, fontweight="bold")
420
  ax1.set_xticks(list(x))
421
  ax1.set_xticklabels(ids, rotation=45, ha="right", fontsize=8)
422
- ax1.set_yticks([1,2,3,4,5])
423
- ax1.set_yticklabels([f"{v} – {ANIMO_NOMBRE[v]}" for v in [1,2,3,4,5]], fontsize=8)
424
- ax1.set_ylim(0, 6)
425
- ax1.set_title("Estado de Ánimo: Antes vs Después por Participante", fontsize=13, fontweight="bold", pad=12)
 
426
  ax1.set_xlabel("Participante")
427
  ax1.set_ylabel("Nivel de Ánimo (Likert 1–5)")
428
  ax1.legend(loc="upper left")
429
- ax1.spines[["top","right"]].set_visible(False)
430
  ax1.grid(axis="y", alpha=0.3)
431
  fig1.tight_layout()
 
432
 
433
- # ── GRÁFICA 2: ΔÁnimo por nivel de ejercicio (boxplot + puntos) ──
434
  fig2, ax2 = plt.subplots(figsize=(7, 5))
435
- niveles_presentes = sorted(df["Ejercicio"].dropna().unique().astype(int))
436
- data_box = [df[df["Ejercicio"] == n]["Delta_Animo"].values for n in niveles_presentes]
437
- etiquetas = [f"{n}\n{EJER_NOMBRE.get(n,'')}" for n in niveles_presentes]
438
  bp = ax2.boxplot(data_box, patch_artist=True, widths=0.45,
439
  medianprops=dict(color="black", linewidth=2))
440
- for patch, niv in zip(bp["boxes"], niveles_presentes):
441
- patch.set_facecolor(COLORES.get(niv, "#aaa"))
442
- patch.set_alpha(0.7)
443
- for i, (niv, datos) in enumerate(zip(niveles_presentes, data_box), 1):
444
  jitter = np.random.uniform(-0.15, 0.15, size=len(datos))
445
  ax2.scatter([i + j for j in jitter], datos,
446
- color=COLORES.get(niv, "#aaa"), s=50, zorder=5, edgecolors="white", linewidths=0.5)
447
- ax2.axhline(0, color="#e74c3c", linestyle="--", linewidth=1.2, alpha=0.7, label="Sin cambio (Δ=0)")
448
- ax2.set_xticks(range(1, len(niveles_presentes)+1))
 
 
449
  ax2.set_xticklabels(etiquetas, fontsize=9)
450
- ax2.set_title("Cambio en Ánimo (ΔÁnimo) según Nivel de Ejercicio", fontsize=13, fontweight="bold", pad=12)
 
451
  ax2.set_xlabel("Nivel de Ejercitación")
452
  ax2.set_ylabel("ΔÁnimo (puntos Likert)")
453
  ax2.legend(fontsize=9)
454
- ax2.spines[["top","right"]].set_visible(False)
455
  ax2.grid(axis="y", alpha=0.3)
456
  fig2.tight_layout()
 
457
 
458
- # ── GRÁFICA 3: Proporción de mejora / sin cambio / descenso ──
459
  fig3, ax3 = plt.subplots(figsize=(6, 5))
460
- mejora = (df["Delta_Animo"] > 0).sum()
461
- igual = (df["Delta_Animo"] == 0).sum()
462
- descenso = (df["Delta_Animo"] < 0).sum()
463
  total = len(df)
464
- cats = []
465
- vals = []
466
- colors = []
467
- labels_pie = []
468
- for label, val, col in [("Mejora ", mejora, "#27ae60"),
469
- ("Sin cambio ", igual, "#f1c40f"),
470
- ("Descenso ", descenso, "#e74c3c")]:
 
471
  if val > 0:
472
- cats.append(label); vals.append(val)
473
  colors.append(col)
474
- labels_pie.append(f"{label}\n{val} participantes\n({val/total*100:.0f}%)")
475
- wedges, texts = ax3.pie(vals, colors=colors, startangle=90,
476
- wedgeprops=dict(width=0.55, edgecolor="white", linewidth=2))
 
477
  ax3.legend(wedges, labels_pie, loc="lower center",
478
- bbox_to_anchor=(0.5, -0.18), ncol=len(vals), fontsize=9)
479
- ax3.set_title("Distribución del Cambio en Estado de Ánimo", fontsize=13, fontweight="bold", pad=12)
480
- centro = dict(ha="center", va="center", fontsize=14, fontweight="bold", color="#333")
481
- ax3.text(0, 0, f"n={total}", **centro)
 
482
  fig3.tight_layout()
 
483
 
484
- pct_mejora = mejora / total * 100 if total > 0 else 0
485
  resumen = (
486
- f" **Resumen:** {total} participantes analizados — "
487
- f"**{mejora} mejoraron** ({pct_mejora:.0f}%), "
488
  f"{igual} sin cambio, {descenso} con descenso. "
489
  f"ΔÁnimo promedio: **{df['Delta_Animo'].mean():+.2f} puntos**."
490
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
 
492
- return fig_to_img(fig1), fig_to_img(fig2), fig_to_img(fig3), resumen
 
 
 
 
 
 
 
 
493
  with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
494
 
495
- gr.Markdown("# Experimento: ¿Cómo afecta el ejercicio físico al estado de ánimo?")
496
  gr.Markdown(
497
- "**Hipótesis:** El nivel de ejercicio físico predice significativamente el cambio en el estado de ánimo, "
498
- "controlando capacidad pulmonar, oximetría, edad, talla y sexo.\n\n"
499
  "**Variable dependiente:** ΔÁnimo = Ánimo_después − Ánimo_antes (escala Likert 1–5)"
500
  )
501
 
502
- # ── SECCIÓN 1: MODELO ─────────────────────────────────────────
503
  gr.Markdown("---")
504
  gr.Markdown("## 1. Modelo de Regresión Lineal Múltiple")
505
 
@@ -508,16 +559,19 @@ with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
508
  gr.Markdown("### Carga de datos")
509
  file_input = gr.File(label="Subir CSV/Excel externo (opcional)",
510
  file_types=[".csv", ".xlsx"])
511
- load_btn = gr.Button(" Entrenar con datos base (+ archivo si se subió)", variant="primary")
 
512
  gr.Markdown("---")
513
  gr.Markdown(
514
  "**¿Ya capturaste participantes?**\n\n"
515
  "Usa el botón de abajo para reentrenar el modelo con los datos base "
516
- "**más** todos los participantes que registraste en la Sección 3."
517
  )
518
- retrain_btn = gr.Button(" Reentrenar con datos base + CSV capturado", variant="secondary")
 
519
  status_out = gr.Textbox(label="Estado del modelo", value=init_msg, interactive=False)
520
- n_part_out = gr.Number(label="Total de participantes usados", value=init_count, interactive=False)
 
521
 
522
  with gr.Column(scale=2):
523
  gr.Markdown("### Ecuación y coeficientes")
@@ -525,7 +579,8 @@ with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
525
  coef_out = gr.Markdown(value=init_coef)
526
 
527
  gr.Markdown("### Datos de entrenamiento")
528
- data_table = gr.DataFrame(value=init_df, label="Conjunto de datos (con deltas calculados)",
 
529
  interactive=False, wrap=True)
530
 
531
  load_btn.click(
@@ -553,9 +608,9 @@ with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
553
  value=list(EJERCICIO_LABELS.keys())[2]
554
  )
555
  with gr.Row():
556
- edad_pred = gr.Number(label="Edad (años)", value=17, step=1)
557
- talla_pred = gr.Number(label="Talla (m)", value=1.65, step=0.01)
558
- sexo_pred = gr.Radio(["Mujer", "Hombre"], label="Sexo", value="Mujer")
559
  gr.Markdown("#### Mediciones fisiológicas")
560
  with gr.Row():
561
  cap_a_pred = gr.Number(label="Cap. Pulmonar ANTES (L)", value=2.8, step=0.1)
@@ -563,7 +618,7 @@ with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
563
  with gr.Row():
564
  spo2_a_pred = gr.Number(label="Oximetría ANTES (%)", value=97.0, step=0.5)
565
  spo2_d_pred = gr.Number(label="Oximetría DESPUÉS (%)", value=98.0, step=0.5)
566
- btn_pred = gr.Button(" Calcular ΔÁnimo Predicho", variant="primary")
567
 
568
  with gr.Column():
569
  salida_pred = gr.Markdown(label="Resultado")
@@ -584,23 +639,27 @@ with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
584
  with gr.Column():
585
  gr.Markdown("#### Datos generales")
586
  with gr.Row():
587
- edad_cap = gr.Number(label="Edad (años)", value=17, step=1, minimum=10, maximum=100)
588
- talla_cap = gr.Number(label="Talla (m)", value=1.65, step=0.01, minimum=1.0, maximum=2.5)
 
 
589
  sexo_cap = gr.Radio(["Mujer", "Hombre"], label="Sexo", value="Mujer")
590
  ejercicio_cap = gr.Dropdown(
591
  choices=list(EJERCICIO_LABELS.keys()),
592
  label="Nivel de ejercitación habitual",
593
  value=list(EJERCICIO_LABELS.keys())[0]
594
  )
595
-
596
  gr.Markdown("#### Mediciones fisiológicas (ANTES y DESPUÉS)")
597
  with gr.Row():
598
- cap_a_cap = gr.Number(label="Cap. Pulmonar ANTES (L)", value=2.5, step=0.1, minimum=0.1, maximum=10)
599
- cap_d_cap = gr.Number(label="Cap. Pulmonar DESPUÉS (L)", value=2.7, step=0.1, minimum=0.1, maximum=10)
 
 
600
  with gr.Row():
601
- spo2_a_cap = gr.Number(label="SpO2 ANTES (%)", value=97.0, step=0.5, minimum=50, maximum=100)
602
- spo2_d_cap = gr.Number(label="SpO2 DESPUÉS (%)", value=98.0, step=0.5, minimum=50, maximum=100)
603
-
 
604
  gr.Markdown("#### Estado de ánimo (ANTES y DESPUÉS)")
605
  with gr.Row():
606
  animo_a_cap = gr.Dropdown(
@@ -613,10 +672,9 @@ with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
613
  label="Ánimo DESPUÉS del ejercicio",
614
  value=list(ANIMO_LABELS.keys())[3]
615
  )
616
-
617
  with gr.Row():
618
- btn_guardar = gr.Button(" Guardar Participante", variant="primary")
619
- btn_limpiar = gr.Button(" Borrar Todos los Registros")
620
  msg_cap = gr.Textbox(label="Estado del registro", interactive=False)
621
 
622
  with gr.Column():
@@ -627,74 +685,25 @@ with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
627
  interactive=False,
628
  wrap=True
629
  )
630
- btn_dl = gr.DownloadButton(label=" Descargar CSV", value=obtener_csv_path)
631
 
632
- btn_guardar.click(
633
- guardar_participante,
634
- inputs=[edad_cap, talla_cap, sexo_cap, ejercicio_cap,
635
- cap_a_cap, cap_d_cap, spo2_a_cap, spo2_d_cap,
636
- animo_a_cap, animo_d_cap],
637
- outputs=[msg_cap, tabla_cap]
638
- )
639
- btn_limpiar.click(
640
- limpiar_csv,
641
- inputs=None,
642
- outputs=[msg_cap, tabla_cap]
643
- )
644
- # ── SECCIÓN 4: GRÁFICAS ────────────────────────────────────────
645
  gr.Markdown("---")
646
  gr.Markdown("## 4. Gráficas de Mejora en el Estado de Ánimo")
647
  gr.Markdown(
648
- "Genera las gráficas con los participantes ya capturados. "
649
- "Puedes actualizar en cualquier momento después de agregar más registros."
650
  )
651
- btn_graficas = gr.Button(" Generar Gráficas", variant="primary")
652
- resumen_graf = gr.Markdown()
653
  with gr.Row():
654
  graf1 = gr.Image(label="Antes vs Después por participante", type="filepath")
655
  graf2 = gr.Image(label="ΔÁnimo por nivel de ejercicio", type="filepath")
656
- graf3 = gr.Image(label="Distribución del cambio (dona)", type="filepath")
657
- def guardar_y_graficar(*args):
658
- """Guarda participante y actualiza gráficas automáticamente."""
659
- msg, tabla = guardar_participante(*args)
660
- f1, f2, f3, res = generar_graficas()
661
- imgs = []
662
- for buf in [f1, f2, f3]:
663
- if buf:
664
- path = f"/tmp/graf_{len(imgs)}.png"
665
- with open(path, "wb") as fout:
666
- fout.write(buf.read())
667
- imgs.append(path)
668
- else:
669
- imgs.append(None)
670
- return msg, tabla, res, imgs[0], imgs[1], imgs[2]
671
 
672
- btn_graficas.click(
673
- lambda: _run_graficas(),
674
- inputs=None,
675
- outputs=[resumen_graf, graf1, graf2, graf3]
676
- )
677
 
678
- def _run_graficas():
679
- f1, f2, f3, res = generar_graficas()
680
- imgs = []
681
- for buf in [f1, f2, f3]:
682
- if buf:
683
- import tempfile
684
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
685
- tmp.write(buf.read()); tmp.flush()
686
- imgs.append(tmp.name)
687
- else:
688
- imgs.append(None)
689
- return res, imgs[0], imgs[1], imgs[2]
690
-
691
- btn_graficas.click(
692
- _run_graficas,
693
- inputs=None,
694
- outputs=[resumen_graf, graf1, graf2, graf3]
695
- )
696
-
697
- # Reemplaza también el btn_guardar.click existente con este:
698
  btn_guardar.click(
699
  guardar_y_graficar,
700
  inputs=[edad_cap, talla_cap, sexo_cap, ejercicio_cap,
@@ -702,6 +711,21 @@ with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
702
  animo_a_cap, animo_d_cap],
703
  outputs=[msg_cap, tabla_cap, resumen_graf, graf1, graf2, graf3]
704
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
705
  gr.Markdown("---")
706
  gr.Markdown(
707
  "*Basado en: Ruiz Cruz, EH (2026). Pensamiento científico auténtico mediante STEAM, "
@@ -709,4 +733,4 @@ with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
709
  )
710
 
711
  if __name__ == "__main__":
712
- app.launch(server_name="0.0.0.0", server_port=7860)
 
4
  from sklearn.linear_model import LinearRegression
5
  from sklearn.metrics import r2_score
6
  import os
7
+ import tempfile
8
+ import matplotlib
9
+ matplotlib.use("Agg")
10
+ import matplotlib.pyplot as plt
11
+ import io
12
 
13
  # ─────────────────────────────────────────────
14
+ # DATOS BASE (24 participantes hipotéticos)
15
+ # ID, Edad, Talla(m), Sexo(0=M,1=H), Ejercicio(1-5),
16
+ # CapPulm_Antes(L), CapPulm_Despues(L),
17
+ # SpO2_Antes(%), SpO2_Despues(%),
18
+ # Animo_Antes(1-5), Animo_Despues(1-5)
19
  # ─────────────────────────────────────────────
20
  BASE_DATA = [
21
  [1, 17, 1.62, 0, 3, 2.8, 3.1, 97, 98, 3, 5],
 
74
  "5 – Muy alto": 5,
75
  }
76
 
77
+ EJER_NOMBRE = {1: "Sedentario", 2: "Ocasional", 3: "Moderado", 4: "Activo", 5: "Muy activo"}
78
+ ANIMO_NOMBRE = {1: "Muy bajo", 2: "Bajo", 3: "Neutral", 4: "Alto", 5: "Muy alto"}
79
+ COLORES_EJER = {1: "#e74c3c", 2: "#e67e22", 3: "#f1c40f", 4: "#2ecc71", 5: "#27ae60"}
80
+
81
  CSV_PATH = "datos_animo_ejercicio.csv"
82
 
83
  estado = {
 
90
  }
91
 
92
 
93
+ # ─────────────────────────────────────────────
94
+ # LÓGICA DEL MODELO
95
+ # ─────────────────────────────────────────────
96
+
97
  def calcular_deltas(df):
98
  df = df.copy()
99
  df["Delta_Animo"] = df["Animo_Despues"] - df["Animo_Antes"]
 
112
 
113
 
114
  def _construir_salidas_modelo(df, df_delta, modelo, r2, fuente):
 
115
  estado["modelo"] = modelo
116
  estado["df"] = df_delta
117
  estado["entrenado"] = True
 
134
  f"**R² = {r2:.4f}** — El modelo explica el **{r2*100:.1f}%** de la varianza en el cambio de ánimo.\n\n"
135
  f"**Interpretación de β₁ (Ejercicio) = {c[0]:.4f}:**\n"
136
  f"Por cada nivel adicional de ejercicio, el ánimo cambia en promedio "
137
+ f"{'↑' if c[0] > 0 else '↓'} {abs(c[0]):.4f} puntos Likert, controlando las demás variables."
138
  )
139
 
140
  coef_md = (
 
150
  f"| **R²** | **{r2:.4f}** |"
151
  )
152
 
153
+ tabla = df_delta[[
154
+ "ID", "Edad", "Talla_m", "Sexo", "Ejercicio",
155
+ "CapPulm_Antes", "CapPulm_Despues", "Delta_CapPulm",
156
+ "SpO2_Antes", "SpO2_Despues", "Delta_SpO2",
157
+ "Animo_Antes", "Animo_Despues", "Delta_Animo"
158
+ ]]
159
 
160
  return (
161
+ f" Modelo entrenado con {len(df)} participantes. Fuente: {fuente}",
162
  len(df),
163
  tabla,
164
  ecuacion,
 
167
 
168
 
169
  def _csv_capturado_a_df():
 
170
  if not os.path.exists(CSV_PATH):
171
  return None, "No hay datos capturados en el CSV todavía."
172
  try:
173
  df_csv = pd.read_csv(CSV_PATH)
174
  if df_csv.empty:
175
  return None, "El CSV está vacío."
176
+ needed = ["ID", "Edad", "Talla_m", "Sexo", "Ejercicio",
177
+ "CapPulm_Antes", "CapPulm_Despues",
178
+ "SpO2_Antes", "SpO2_Despues",
179
+ "Animo_Antes", "Animo_Despues"]
 
180
  missing = [c for c in needed if c not in df_csv.columns]
181
  if missing:
182
  return None, f"Al CSV le faltan columnas: {missing}"
 
192
 
193
 
194
  def cargar_y_entrenar(file_obj=None):
 
195
  df = pd.DataFrame(BASE_DATA, columns=COLUMNS)
196
 
197
  if file_obj is not None:
 
199
  ext = os.path.splitext(file_obj.name)[1].lower()
200
  df_new = pd.read_csv(file_obj.name) if ext == ".csv" else pd.read_excel(file_obj.name)
201
 
 
202
  if "Delta_Animo" in df_new.columns:
203
+ needed = ["ID", "Edad", "Talla_m", "Sexo", "Ejercicio",
204
+ "CapPulm_Antes", "CapPulm_Despues",
205
+ "SpO2_Antes", "SpO2_Despues",
206
+ "Animo_Antes", "Animo_Despues"]
207
  df_new = df_new[[c for c in needed if c in df_new.columns]].copy()
208
  else:
209
  df_new = df_new.iloc[:, :len(COLUMNS)]
 
213
  df_new[col] = pd.to_numeric(df_new[col], errors="coerce")
214
  df_new = df_new.dropna()
215
  df = pd.concat([df, df_new], ignore_index=True)
216
+ fuente = f"datos base (24 reg.) + archivo subido ({len(df_new)} reg.)"
217
  except Exception as e:
218
  return f"❌ Error al procesar el archivo: {e}", 0, pd.DataFrame(), "", ""
219
  else:
 
224
 
225
 
226
  def reentrenar_con_csv():
 
227
  df_base = pd.DataFrame(BASE_DATA, columns=COLUMNS)
228
  df_csv, err = _csv_capturado_a_df()
229
 
 
232
  f"⚠️ {err}",
233
  len(df_base),
234
  pd.DataFrame(),
235
+ "— Entrena primero el modelo —",
236
+ "",
237
  )
238
 
 
 
239
  df_csv = df_csv.reindex(columns=COLUMNS)
 
240
  df_combined = pd.concat([df_base, df_csv], ignore_index=True)
241
  modelo, r2, df_delta = entrenar_modelo(df_combined)
242
+ fuente = f"datos base (24 reg.) + CSV capturado ({len(df_csv)} reg.)"
243
  return _construir_salidas_modelo(df_combined, df_delta, modelo, r2, fuente)
244
 
245
 
246
+ # ─────────────────────────────────────────────
247
+ # PREDICCIÓN
248
+ # ─────────────────────────────────────────────
249
 
250
  def predecir(ejercicio_label, edad, talla, sexo,
251
  cap_antes, cap_despues, spo2_antes, spo2_despues):
 
259
 
260
  X_new = np.array([[ejer_val, delta_cap, delta_spo2, edad, talla, sexo_val]])
261
  pred = estado["modelo"].predict(X_new)[0]
262
+ c = estado["coefs"]
263
+ b0 = estado["intercept"]
264
 
 
 
 
 
265
  if pred >= 1.5:
266
+ interp = "📈 **Mejora notable del ánimo** — se espera un aumento significativo."
267
  elif pred >= 0.5:
268
+ interp = "📊 **Ligera mejora del ánimo** — cambio positivo moderado."
269
  elif pred >= -0.5:
270
+ interp = "➡️ **Sin cambio relevante** — el ánimo se mantiene similar."
271
  else:
272
+ interp = "📉 **Posible descenso del ánimo** — revisar variables de contexto."
273
 
 
274
  hip = (
275
+ " **H₁ apoyada:** el ejercicio predice positivamente el ánimo."
276
  if c[0] > 0 else
277
+ " **H₁ no apoyada:** el coeficiente de ejercicio es negativo en este modelo."
278
  )
279
 
280
  return (
 
297
  )
298
 
299
 
300
+ # ─────────────────────────────────────────────
301
+ # CAPTURA DE PARTICIPANTES
302
+ # ─────────────────────────────────────────────
303
+
304
  def obtener_siguiente_id():
305
  if os.path.exists(CSV_PATH):
306
  try:
 
312
  return 1
313
 
314
 
315
+ def obtener_tabla_csv():
316
+ if os.path.exists(CSV_PATH):
317
+ try:
318
+ return pd.read_csv(CSV_PATH)
319
+ except Exception:
320
+ pass
321
+ return pd.DataFrame(columns=COLUMNAS_CAPTURA)
322
+
323
+
324
+ def obtener_csv_path():
325
+ if not os.path.exists(CSV_PATH):
326
+ pd.DataFrame(columns=COLUMNAS_CAPTURA).to_csv(CSV_PATH, index=False)
327
+ return CSV_PATH
328
+
329
+
330
+ def limpiar_csv():
331
+ if os.path.exists(CSV_PATH):
332
+ os.remove(CSV_PATH)
333
+ return "🗑️ Registros eliminados.", pd.DataFrame(columns=COLUMNAS_CAPTURA)
334
+
335
+
336
  def guardar_participante(edad, talla, sexo, ejercicio_label,
337
  cap_antes, cap_despues,
338
  spo2_antes, spo2_despues,
339
  animo_antes_label, animo_despues_label):
340
  errores = []
341
+ if not (10 <= edad <= 100): errores.append("Edad fuera de rango (10-100).")
342
+ if not (1.0 <= talla <= 2.5): errores.append("Talla fuera de rango (1.0-2.5 m).")
343
+ if not (50 <= spo2_antes <= 100): errores.append("SpO2 Antes fuera de rango (50-100).")
344
+ if not (50 <= spo2_despues <= 100): errores.append("SpO2 Después fuera de rango (50-100).")
345
+ if not (0 < cap_antes <= 10): errores.append("Cap. Pulmonar Antes fuera de rango (0-10 L).")
346
+ if not (0 < cap_despues <= 10): errores.append("Cap. Pulmonar Después fuera de rango (0-10 L).")
347
  if errores:
348
  return "⚠️ " + " | ".join(errores), obtener_tabla_csv()
349
 
350
+ sexo_val = 1 if sexo == "Hombre" else 0
351
+ ejer_val = EJERCICIO_LABELS.get(ejercicio_label, 1)
352
+ animo_a_val = ANIMO_LABELS.get(animo_antes_label, 3)
353
+ animo_d_val = ANIMO_LABELS.get(animo_despues_label, 3)
 
 
 
 
354
 
355
  nueva_fila = {
356
+ "ID": obtener_siguiente_id(),
357
+ "Edad": int(edad),
358
+ "Talla_m": talla,
359
+ "Sexo": sexo_val,
360
+ "Ejercicio": ejer_val,
361
+ "CapPulm_Antes": cap_antes,
362
+ "CapPulm_Despues":cap_despues,
363
+ "SpO2_Antes": spo2_antes,
364
+ "SpO2_Despues": spo2_despues,
365
+ "Animo_Antes": animo_a_val,
366
+ "Animo_Despues": animo_d_val,
367
+ "Delta_Animo": animo_d_val - animo_a_val,
368
+ "Delta_CapPulm": round(cap_despues - cap_antes, 3),
369
+ "Delta_SpO2": round(spo2_despues - spo2_antes, 1),
370
  }
371
 
372
  df_nuevo = pd.DataFrame([nueva_fila], columns=COLUMNAS_CAPTURA)
 
375
  else:
376
  df_nuevo.to_csv(CSV_PATH, mode="w", header=True, index=False)
377
 
378
+ return f" Participante {nueva_fila['ID']} guardado.", obtener_tabla_csv()
 
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
 
381
+ # ─────────────────────────────────────────────
382
+ # GRÁFICAS
383
+ # ─────────────────────────────────────────────
384
 
385
+ def _buf_a_tempfile(buf):
386
+ """Guarda un BytesIO en un archivo temporal y devuelve la ruta."""
387
+ if buf is None:
388
+ return None
389
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
390
+ tmp.write(buf.read())
391
+ tmp.flush()
392
+ tmp.close()
393
+ return tmp.name
394
 
395
 
 
 
 
396
  def generar_graficas():
397
+ """Devuelve (path1, path2, path3, resumen_md) con las 3 gráficas."""
398
+ VACIO = (None, None, None,
399
+ "⚠️ No hay datos capturados todavía. Registra participantes en la Sección 3.")
 
 
 
400
 
401
  if not os.path.exists(CSV_PATH):
402
+ return VACIO
 
403
  try:
404
  df = pd.read_csv(CSV_PATH)
405
  except Exception as e:
406
  return None, None, None, f"❌ Error leyendo CSV: {e}"
407
 
408
  if df.empty or "Animo_Antes" not in df.columns:
409
+ return VACIO
410
 
411
+ df = df.dropna(subset=["Animo_Antes", "Animo_Despues", "Delta_Animo", "Ejercicio"])
412
  if len(df) < 2:
413
  return None, None, None, "⚠️ Se necesitan al menos 2 participantes para graficar."
414
 
415
+ def save_fig(fig):
 
 
 
 
416
  buf = io.BytesIO()
417
  fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
418
  buf.seek(0)
419
  plt.close(fig)
420
+ return _buf_a_tempfile(buf)
421
+
422
+ # ── Gráfica 1: Barras Antes vs Después por participante ──────────
423
+ fig1, ax1 = plt.subplots(figsize=(max(8, len(df) * 0.65), 5))
424
+ ids = [f"P{int(i)}" for i in df["ID"]]
425
+ x, w = range(len(df)), 0.35
426
+ ax1.bar([i - w / 2 for i in x], df["Animo_Antes"], w,
427
+ label="Antes", color="#5b8dd9", alpha=0.85)
428
+ bars_d = ax1.bar([i + w / 2 for i in x], df["Animo_Despues"], w,
429
+ label="Después", color="#27ae60", alpha=0.85)
430
  for bar, delta in zip(bars_d, df["Delta_Animo"]):
431
+ col = "#27ae60" if delta > 0 else ("#e74c3c" if delta < 0 else "#888888")
432
+ ax1.text(bar.get_x() + bar.get_width() / 2,
433
+ bar.get_height() + 0.06,
434
  f"{delta:+.0f}", ha="center", va="bottom",
435
+ fontsize=8, color=col, fontweight="bold")
436
  ax1.set_xticks(list(x))
437
  ax1.set_xticklabels(ids, rotation=45, ha="right", fontsize=8)
438
+ ax1.set_yticks([1, 2, 3, 4, 5])
439
+ ax1.set_yticklabels([f"{v} – {ANIMO_NOMBRE[v]}" for v in [1, 2, 3, 4, 5]], fontsize=8)
440
+ ax1.set_ylim(0, 6.2)
441
+ ax1.set_title("Estado de Ánimo: Antes vs Después por Participante",
442
+ fontsize=13, fontweight="bold", pad=12)
443
  ax1.set_xlabel("Participante")
444
  ax1.set_ylabel("Nivel de Ánimo (Likert 1–5)")
445
  ax1.legend(loc="upper left")
446
+ ax1.spines[["top", "right"]].set_visible(False)
447
  ax1.grid(axis="y", alpha=0.3)
448
  fig1.tight_layout()
449
+ path1 = save_fig(fig1)
450
 
451
+ # ── Gráfica 2: Boxplot ΔÁnimo por nivel de ejercicio ─────────────
452
  fig2, ax2 = plt.subplots(figsize=(7, 5))
453
+ niveles = sorted(df["Ejercicio"].dropna().unique().astype(int))
454
+ data_box = [df[df["Ejercicio"] == n]["Delta_Animo"].values for n in niveles]
455
+ etiquetas = [f"{n}\n{EJER_NOMBRE.get(n, '')}" for n in niveles]
456
  bp = ax2.boxplot(data_box, patch_artist=True, widths=0.45,
457
  medianprops=dict(color="black", linewidth=2))
458
+ for patch, niv in zip(bp["boxes"], niveles):
459
+ patch.set_facecolor(COLORES_EJER.get(niv, "#aaaaaa"))
460
+ patch.set_alpha(0.72)
461
+ for i, (niv, datos) in enumerate(zip(niveles, data_box), 1):
462
  jitter = np.random.uniform(-0.15, 0.15, size=len(datos))
463
  ax2.scatter([i + j for j in jitter], datos,
464
+ color=COLORES_EJER.get(niv, "#aaaaaa"),
465
+ s=55, zorder=5, edgecolors="white", linewidths=0.6)
466
+ ax2.axhline(0, color="#e74c3c", linestyle="--", linewidth=1.2,
467
+ alpha=0.7, label="Sin cambio (Δ = 0)")
468
+ ax2.set_xticks(range(1, len(niveles) + 1))
469
  ax2.set_xticklabels(etiquetas, fontsize=9)
470
+ ax2.set_title("ΔÁnimo según Nivel de Ejercicio",
471
+ fontsize=13, fontweight="bold", pad=12)
472
  ax2.set_xlabel("Nivel de Ejercitación")
473
  ax2.set_ylabel("ΔÁnimo (puntos Likert)")
474
  ax2.legend(fontsize=9)
475
+ ax2.spines[["top", "right"]].set_visible(False)
476
  ax2.grid(axis="y", alpha=0.3)
477
  fig2.tight_layout()
478
+ path2 = save_fig(fig2)
479
 
480
+ # ── Gráfica 3: Dona con proporción mejora / igual / descenso ─────
481
  fig3, ax3 = plt.subplots(figsize=(6, 5))
 
 
 
482
  total = len(df)
483
+ mejora = int((df["Delta_Animo"] > 0).sum())
484
+ igual = int((df["Delta_Animo"] == 0).sum())
485
+ descenso = int((df["Delta_Animo"] < 0).sum())
486
+
487
+ vals, colors, labels_pie = [], [], []
488
+ for lbl, val, col in [("Mejora 📈", mejora, "#27ae60"),
489
+ ("Sin cambio ➡️", igual, "#f1c40f"),
490
+ ("Descenso 📉", descenso, "#e74c3c")]:
491
  if val > 0:
492
+ vals.append(val)
493
  colors.append(col)
494
+ labels_pie.append(f"{lbl}\n{val} ({val / total * 100:.0f}%)")
495
+
496
+ wedges, _ = ax3.pie(vals, colors=colors, startangle=90,
497
+ wedgeprops=dict(width=0.55, edgecolor="white", linewidth=2))
498
  ax3.legend(wedges, labels_pie, loc="lower center",
499
+ bbox_to_anchor=(0.5, -0.22), ncol=len(vals), fontsize=9)
500
+ ax3.set_title("Distribución del Cambio en Estado de Ánimo",
501
+ fontsize=13, fontweight="bold", pad=12)
502
+ ax3.text(0, 0, f"n={total}", ha="center", va="center",
503
+ fontsize=14, fontweight="bold", color="#333333")
504
  fig3.tight_layout()
505
+ path3 = save_fig(fig3)
506
 
507
+ pct = mejora / total * 100 if total > 0 else 0
508
  resumen = (
509
+ f"📊 **Resumen:** {total} participantes analizados — "
510
+ f"**{mejora} mejoraron** ({pct:.0f}%), "
511
  f"{igual} sin cambio, {descenso} con descenso. "
512
  f"ΔÁnimo promedio: **{df['Delta_Animo'].mean():+.2f} puntos**."
513
  )
514
+ return path1, path2, path3, resumen
515
+
516
+
517
+ # ─────────────────────────────────────────────
518
+ # FUNCIONES COMBINADAS (guardar + graficar)
519
+ # ─────────────────────────────────────────────
520
+
521
+ def guardar_y_graficar(edad, talla, sexo, ejercicio_label,
522
+ cap_antes, cap_despues,
523
+ spo2_antes, spo2_despues,
524
+ animo_antes_label, animo_despues_label):
525
+ msg, tabla = guardar_participante(
526
+ edad, talla, sexo, ejercicio_label,
527
+ cap_antes, cap_despues,
528
+ spo2_antes, spo2_despues,
529
+ animo_antes_label, animo_despues_label
530
+ )
531
+ p1, p2, p3, resumen = generar_graficas()
532
+ return msg, tabla, resumen, p1, p2, p3
533
+
534
 
535
+ # ─────────────────────────────────────────────
536
+ # INICIALIZACIÓN
537
+ # ─────────────────────────────────────────────
538
+ init_msg, init_count, init_df, init_ec, init_coef = cargar_y_entrenar()
539
+
540
+
541
+ # ─────────────────────────────────────────────
542
+ # INTERFAZ
543
+ # ─────────────────────────────────────────────
544
  with gr.Blocks(title="Ejercicio Físico y Estado de Ánimo | CONALEP") as app:
545
 
546
+ gr.Markdown("# 🏃 Experimento: ¿Cómo afecta el ejercicio físico al estado de ánimo?")
547
  gr.Markdown(
548
+ "**Hipótesis:** El nivel de ejercicio físico predice significativamente el cambio "
549
+ "en el estado de ánimo, controlando capacidad pulmonar, oximetría, edad, talla y sexo.\n\n"
550
  "**Variable dependiente:** ΔÁnimo = Ánimo_después − Ánimo_antes (escala Likert 1–5)"
551
  )
552
 
553
+ # ── SECCIÓN 1: MODELO ─────────────────────────────────────────
554
  gr.Markdown("---")
555
  gr.Markdown("## 1. Modelo de Regresión Lineal Múltiple")
556
 
 
559
  gr.Markdown("### Carga de datos")
560
  file_input = gr.File(label="Subir CSV/Excel externo (opcional)",
561
  file_types=[".csv", ".xlsx"])
562
+ load_btn = gr.Button("🔄 Entrenar con datos base (+ archivo si se subió)",
563
+ variant="primary")
564
  gr.Markdown("---")
565
  gr.Markdown(
566
  "**¿Ya capturaste participantes?**\n\n"
567
  "Usa el botón de abajo para reentrenar el modelo con los datos base "
568
+ "**más** todos los participantes registrados en la Sección 3."
569
  )
570
+ retrain_btn = gr.Button("🧠 Reentrenar con datos base + CSV capturado",
571
+ variant="secondary")
572
  status_out = gr.Textbox(label="Estado del modelo", value=init_msg, interactive=False)
573
+ n_part_out = gr.Number(label="Total de participantes usados",
574
+ value=init_count, interactive=False)
575
 
576
  with gr.Column(scale=2):
577
  gr.Markdown("### Ecuación y coeficientes")
 
579
  coef_out = gr.Markdown(value=init_coef)
580
 
581
  gr.Markdown("### Datos de entrenamiento")
582
+ data_table = gr.DataFrame(value=init_df,
583
+ label="Conjunto de datos (con deltas calculados)",
584
  interactive=False, wrap=True)
585
 
586
  load_btn.click(
 
608
  value=list(EJERCICIO_LABELS.keys())[2]
609
  )
610
  with gr.Row():
611
+ edad_pred = gr.Number(label="Edad (años)", value=17, step=1)
612
+ talla_pred = gr.Number(label="Talla (m)", value=1.65, step=0.01)
613
+ sexo_pred = gr.Radio(["Mujer", "Hombre"], label="Sexo", value="Mujer")
614
  gr.Markdown("#### Mediciones fisiológicas")
615
  with gr.Row():
616
  cap_a_pred = gr.Number(label="Cap. Pulmonar ANTES (L)", value=2.8, step=0.1)
 
618
  with gr.Row():
619
  spo2_a_pred = gr.Number(label="Oximetría ANTES (%)", value=97.0, step=0.5)
620
  spo2_d_pred = gr.Number(label="Oximetría DESPUÉS (%)", value=98.0, step=0.5)
621
+ btn_pred = gr.Button("🔮 Calcular ΔÁnimo Predicho", variant="primary")
622
 
623
  with gr.Column():
624
  salida_pred = gr.Markdown(label="Resultado")
 
639
  with gr.Column():
640
  gr.Markdown("#### Datos generales")
641
  with gr.Row():
642
+ edad_cap = gr.Number(label="Edad (años)", value=17, step=1,
643
+ minimum=10, maximum=100)
644
+ talla_cap = gr.Number(label="Talla (m)", value=1.65, step=0.01,
645
+ minimum=1.0, maximum=2.5)
646
  sexo_cap = gr.Radio(["Mujer", "Hombre"], label="Sexo", value="Mujer")
647
  ejercicio_cap = gr.Dropdown(
648
  choices=list(EJERCICIO_LABELS.keys()),
649
  label="Nivel de ejercitación habitual",
650
  value=list(EJERCICIO_LABELS.keys())[0]
651
  )
 
652
  gr.Markdown("#### Mediciones fisiológicas (ANTES y DESPUÉS)")
653
  with gr.Row():
654
+ cap_a_cap = gr.Number(label="Cap. Pulmonar ANTES (L)", value=2.5,
655
+ step=0.1, minimum=0.1, maximum=10)
656
+ cap_d_cap = gr.Number(label="Cap. Pulmonar DESPUÉS (L)", value=2.7,
657
+ step=0.1, minimum=0.1, maximum=10)
658
  with gr.Row():
659
+ spo2_a_cap = gr.Number(label="SpO2 ANTES (%)", value=97.0,
660
+ step=0.5, minimum=50, maximum=100)
661
+ spo2_d_cap = gr.Number(label="SpO2 DESPUÉS (%)", value=98.0,
662
+ step=0.5, minimum=50, maximum=100)
663
  gr.Markdown("#### Estado de ánimo (ANTES y DESPUÉS)")
664
  with gr.Row():
665
  animo_a_cap = gr.Dropdown(
 
672
  label="Ánimo DESPUÉS del ejercicio",
673
  value=list(ANIMO_LABELS.keys())[3]
674
  )
 
675
  with gr.Row():
676
+ btn_guardar = gr.Button("💾 Guardar Participante", variant="primary")
677
+ btn_limpiar = gr.Button("🗑️ Borrar Todos los Registros")
678
  msg_cap = gr.Textbox(label="Estado del registro", interactive=False)
679
 
680
  with gr.Column():
 
685
  interactive=False,
686
  wrap=True
687
  )
688
+ btn_dl = gr.DownloadButton(label="⬇️ Descargar CSV", value=obtener_csv_path)
689
 
690
+ # ── SECCIÓN 4: GRÁFICAS ────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
691
  gr.Markdown("---")
692
  gr.Markdown("## 4. Gráficas de Mejora en el Estado de Ánimo")
693
  gr.Markdown(
694
+ "Las gráficas se actualizan automáticamente al guardar cada participante. "
695
+ "También puedes generarlas manualmente con el botón."
696
  )
697
+ btn_graficas = gr.Button("📊 Generar / Actualizar Gráficas", variant="secondary")
698
+ resumen_graf = gr.Markdown()
699
  with gr.Row():
700
  graf1 = gr.Image(label="Antes vs Después por participante", type="filepath")
701
  graf2 = gr.Image(label="ΔÁnimo por nivel de ejercicio", type="filepath")
702
+ graf3 = gr.Image(label="Distribución del cambio (dona)", type="filepath")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703
 
704
+ # ── EVENTOS ────────────────────────────────────────────────────
 
 
 
 
705
 
706
+ # Guardar → actualiza tabla + gráficas automáticamente
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
707
  btn_guardar.click(
708
  guardar_y_graficar,
709
  inputs=[edad_cap, talla_cap, sexo_cap, ejercicio_cap,
 
711
  animo_a_cap, animo_d_cap],
712
  outputs=[msg_cap, tabla_cap, resumen_graf, graf1, graf2, graf3]
713
  )
714
+
715
+ # Borrar registros
716
+ btn_limpiar.click(
717
+ limpiar_csv,
718
+ inputs=None,
719
+ outputs=[msg_cap, tabla_cap]
720
+ )
721
+
722
+ # Generar gráficas manualmente
723
+ btn_graficas.click(
724
+ generar_graficas,
725
+ inputs=None,
726
+ outputs=[graf1, graf2, graf3, resumen_graf]
727
+ )
728
+
729
  gr.Markdown("---")
730
  gr.Markdown(
731
  "*Basado en: Ruiz Cruz, EH (2026). Pensamiento científico auténtico mediante STEAM, "
 
733
  )
734
 
735
  if __name__ == "__main__":
736
+ app.launch(server_name="0.0.0.0", server_port=7860)