File size: 48,286 Bytes
46acf3b
18b9f9a
 
 
6b31050
46acf3b
b73dd92
a9c45f0
b73dd92
 
 
 
46acf3b
6b31050
36a8237
6b31050
36a8237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18b9f9a
36a8237
 
 
6b31050
 
36a8237
 
6b31050
36a8237
6b31050
46acf3b
36a8237
 
 
18b9f9a
46acf3b
6b31050
fdc47d6
 
 
 
 
6b31050
b73dd92
 
 
fdc47d6
46acf3b
079aa82
a9c45f0
 
 
 
 
 
 
 
 
 
18b9f9a
6b31050
18b9f9a
6b31050
 
 
 
18b9f9a
46acf3b
36a8237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b73dd92
 
 
6b31050
 
36a8237
 
 
 
 
 
 
 
 
 
 
 
6b31050
36a8237
 
 
 
 
6b31050
 
 
36a8237
 
 
6b31050
 
36a8237
6b31050
 
 
 
 
 
 
 
 
 
 
 
 
 
36a8237
 
 
 
 
 
 
 
 
 
a9c45f0
 
36a8237
 
 
 
6b31050
36a8237
a9c45f0
 
6b31050
 
 
 
 
2d49af7
6b31050
36a8237
6b31050
 
 
a9c45f0
6b31050
 
 
b73dd92
36a8237
b73dd92
 
36a8237
 
 
b73dd92
6b31050
 
fdc47d6
6b31050
 
 
 
 
 
 
 
 
 
a9c45f0
 
6b31050
 
36a8237
6b31050
 
36a8237
 
6b31050
 
 
 
 
 
 
 
18b9f9a
 
46acf3b
18b9f9a
 
 
 
6b31050
36a8237
 
6b31050
 
 
 
 
18b9f9a
 
 
b73dd92
18b9f9a
fdc47d6
6b31050
 
46acf3b
6b31050
 
46acf3b
6b31050
 
 
46acf3b
6b31050
 
fdc47d6
6b31050
 
b73dd92
 
6b31050
46acf3b
6b31050
 
 
b73dd92
6b31050
18b9f9a
b73dd92
 
 
6b31050
36a8237
 
 
6b31050
fdc47d6
6b31050
36a8237
 
 
 
 
 
6b31050
 
 
 
b73dd92
 
6b31050
 
36a8237
6b31050
36a8237
6b31050
36a8237
6b31050
36a8237
18b9f9a
a9c45f0
 
36a8237
6b31050
a9c45f0
36a8237
6b31050
18b9f9a
 
6b31050
18b9f9a
6b31050
 
36a8237
 
 
 
 
6b31050
 
36a8237
 
6b31050
 
 
36a8237
6b31050
 
a9c45f0
 
 
 
36a8237
 
18b9f9a
 
b73dd92
 
 
 
a9c45f0
 
 
 
 
 
 
b73dd92
 
a9c45f0
 
 
fdc47d6
b73dd92
6b31050
36a8237
 
 
 
 
18b9f9a
36a8237
 
 
 
 
 
 
 
 
 
 
18b9f9a
fdc47d6
18b9f9a
b73dd92
 
36a8237
 
 
 
 
 
 
 
 
18b9f9a
a9c45f0
 
 
 
 
 
 
 
 
 
 
36a8237
 
 
 
 
 
 
 
 
 
 
 
a9c45f0
 
 
 
 
 
 
 
 
 
 
 
36a8237
 
 
 
 
 
18b9f9a
b73dd92
 
 
 
 
 
 
 
 
 
 
6b31050
4bdedde
b73dd92
fdc47d6
4bdedde
 
b73dd92
4bdedde
a9c45f0
 
4bdedde
fdc47d6
4bdedde
36a8237
b73dd92
4bdedde
36a8237
4bdedde
fdc47d6
4bdedde
b73dd92
4bdedde
 
 
 
b73dd92
 
36a8237
b73dd92
 
 
36a8237
fdc47d6
36a8237
b73dd92
36a8237
b73dd92
 
 
4bdedde
b73dd92
4bdedde
 
b73dd92
36a8237
b73dd92
36a8237
b73dd92
4bdedde
36a8237
4bdedde
b73dd92
4bdedde
 
b73dd92
4bdedde
36a8237
4bdedde
b73dd92
36a8237
b73dd92
4bdedde
 
b73dd92
 
 
 
4bdedde
 
b73dd92
 
 
 
 
4bdedde
36a8237
b73dd92
4bdedde
36a8237
4bdedde
b73dd92
4bdedde
 
b73dd92
4bdedde
36a8237
 
 
 
 
 
b73dd92
 
 
4bdedde
36a8237
 
b73dd92
36a8237
b73dd92
4bdedde
b73dd92
4bdedde
36a8237
 
 
 
b73dd92
4bdedde
fdc47d6
36a8237
4bdedde
36a8237
4bdedde
b73dd92
 
 
36a8237
 
 
 
 
b73dd92
 
36a8237
 
 
 
 
b73dd92
 
 
 
 
 
 
 
 
 
 
 
36a8237
95981ba
 
079aa82
36a8237
 
 
 
 
 
 
 
 
a9c45f0
 
36a8237
18b9f9a
 
fdc47d6
6b31050
 
 
18b9f9a
 
6b31050
 
 
fdc47d6
b73dd92
6b31050
 
 
 
b73dd92
6b31050
fdc47d6
b73dd92
6b31050
b73dd92
 
18b9f9a
 
6b31050
 
 
18b9f9a
6b31050
b73dd92
36a8237
6b31050
18b9f9a
 
 
 
6b31050
18b9f9a
6b31050
 
 
 
 
 
fdc47d6
6b31050
 
36a8237
6b31050
 
 
 
 
 
 
 
 
 
b73dd92
 
a9c45f0
 
 
36a8237
 
 
 
 
 
 
 
 
6b31050
36a8237
 
 
 
 
6b31050
36a8237
 
 
6b31050
 
 
 
18b9f9a
 
6b31050
36a8237
 
 
6b31050
18b9f9a
 
fdc47d6
18b9f9a
36a8237
 
 
 
 
18b9f9a
 
 
6b31050
18b9f9a
b73dd92
 
 
 
a9c45f0
 
 
6b31050
 
 
 
 
36a8237
 
 
 
18b9f9a
36a8237
 
 
6b31050
36a8237
 
 
 
 
 
 
b73dd92
36a8237
b73dd92
36a8237
 
18b9f9a
36a8237
 
 
 
 
 
 
 
 
 
18b9f9a
fdc47d6
 
6b31050
18b9f9a
 
6b31050
 
18b9f9a
 
 
 
 
fdc47d6
 
46acf3b
fdc47d6
4bdedde
 
 
b73dd92
 
4bdedde
fdc47d6
b73dd92
4bdedde
36a8237
 
 
fdc47d6
 
 
 
 
a9c45f0
 
fdc47d6
 
 
 
 
 
 
 
 
 
 
46acf3b
4bdedde
 
 
36a8237
 
 
 
 
4bdedde
 
b73dd92
 
 
 
 
 
 
fdc47d6
 
 
 
 
 
b73dd92
 
 
 
 
 
18b9f9a
 
a9c45f0
36a8237
 
18b9f9a
46acf3b
 
a9c45f0
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
import gradio as gr
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score
import os
import tempfile
import threading
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import io

# ─────────────────────────────────────────────
# CONSTANTES FÍSICAS (capacidad pulmonar vía globo)
# ─────────────────────────────────────────────
# El globo se modela como un elipsoide de revolución (esferoide), a partir de
# dos circunferencias medidas con cinta métrica:
#   - Circunferencia ECUATORIAL: la parte más ancha del globo.
#   - Circunferencia POLAR: de polo a polo, pasando por la boquilla.
#
# El volumen medido en campo (a la temperatura y presión ambiente del momento)
# se normaliza a una condición estándar de referencia -similar al criterio
# BTPS usado en espirometría real (Body Temperature, Pressure, Saturated)-
# para que las mediciones tomadas en el Chimborazo (frío, muy baja presión)
# sean comparables con las tomadas en otras condiciones (por ejemplo, en el
# salón de clases antes de la expedición).
#
# Simplificación pedagógica: se usa la ley combinada de los gases ideales
# (P·V/T = constante) sin corregir por presión de vapor de agua, ya que esa
# corrección requiere datos de humedad que esta práctica de campo no captura.
STANDARD_PRESSURE_HPA = 1013.25   # presión estándar a nivel del mar (atmósfera estándar internacional)
STANDARD_TEMP_K = 310.15          # 37 °C — temperatura corporal, referencia tipo BTPS

def presion_estandar_por_altitud(altitud_m):
    """Fórmula barométrica de la Atmósfera Estándar Internacional (ISA).
    Estima la presión atmosférica esperada a una altitud dada, para no
    depender de que el equipo lleve un barómetro: basta con conocer la
    altitud (GPS, altímetro o dato de referencia del lugar)."""
    altitud_m = max(0.0, float(altitud_m))
    return STANDARD_PRESSURE_HPA * (1 - 2.25577e-5 * altitud_m) ** 5.25588

def calcular_capacidad_pulmonar_globo(circ_ecuatorial_cm, circ_polar_cm,
                                       temperatura_c, altitud_m=0.0):
    """
    Devuelve (volumen_L_medido, volumen_L_estandar, presion_ambiente_hPa).

    volumen_L_medido:   volumen del globo en las condiciones reales de campo.
    volumen_L_estandar: mismo volumen, normalizado a 37°C y a la presión de
                         referencia a nivel del mar, para comparar mediciones
                         tomadas en distintas condiciones de temperatura/altitud.
    """
    r_ecuatorial = circ_ecuatorial_cm / (2 * np.pi)
    r_polar = circ_polar_cm / (2 * np.pi)

    volumen_cm3 = (4 / 3) * np.pi * (r_ecuatorial ** 2) * r_polar
    volumen_L_medido = volumen_cm3 / 1000.0

    presion_ambiente_hpa = presion_estandar_por_altitud(altitud_m)
    temp_ambiente_k = temperatura_c + 273.15

    # Ley combinada de los gases: P1V1/T1 = P2V2/T2  →  V2 = V1·(P1/P2)·(T2/T1)
    volumen_L_estandar = volumen_L_medido * (presion_ambiente_hpa / STANDARD_PRESSURE_HPA) * (STANDARD_TEMP_K / temp_ambiente_k)

    return volumen_L_medido, volumen_L_estandar, presion_ambiente_hpa

def _circunferencias_desde_volumen_estandar(volumen_L_estandar, temperatura_c, altitud_m, k=1.3):
    """Función inversa, usada SOLO para construir el set de datos sintético
    base a partir de las capacidades pulmonares originales de la demo.
    Dado un volumen estándar deseado, calcula qué circunferencias ecuatorial
    y polar debió tener el globo en esas condiciones, asumiendo una razón fija
    k = radio_polar / radio_ecuatorial (forma típica de un globo de fiesta)."""
    presion_amb = presion_estandar_por_altitud(altitud_m)
    temp_amb_k = temperatura_c + 273.15
    volumen_L_medido = volumen_L_estandar / ((presion_amb / STANDARD_PRESSURE_HPA) * (STANDARD_TEMP_K / temp_amb_k))
    volumen_cm3 = volumen_L_medido * 1000.0
    r_ecuatorial = (3 * volumen_cm3 / (4 * np.pi * k)) ** (1 / 3)
    r_polar = k * r_ecuatorial
    return round(2 * np.pi * r_ecuatorial, 1), round(2 * np.pi * r_polar, 1)

# ─────────────────────────────────────────────
# ESTADO DE ÁNIMO — Modelo Circunflejo del Afecto (Russell, 1980)
# ─────────────────────────────────────────────
# El estado de ánimo se captura en dos dimensiones independientes:
#   - Valencia:   1 (muy negativa) a 5 (muy positiva) — agrado/desagrado.
#   - Activación:  1 (muy baja) a 5 (muy alta) — intensidad fisiológica.
# La combinación de ambas clasifica el perfil afectivo en 6 categorías,
# siguiendo el criterio: si la valencia es neutra (3) domina el criterio de
# "neutro"; si no, y la activación es moderada (3), domina "activación
# moderada"; en cualquier otro caso se usa el cuadrante correspondiente.
VALENCIA_LABELS = {
    "1 - Muy negativa": 1, "2 - Negativa": 2, "3 - Neutral": 3,
    "4 - Positiva": 4, "5 - Muy positiva": 5,
}
ACTIVACION_LABELS = {
    "1 - Muy baja": 1, "2 - Baja": 2, "3 - Moderada": 3,
    "4 - Alta": 4, "5 - Muy alta": 5,
}
VALENCIA_NOMBRE = {1: "Muy negativa", 2: "Negativa", 3: "Neutral", 4: "Positiva", 5: "Muy positiva"}

COLOR_CATEGORIA = {
    "Positivo alta activación":  "#27ae60",
    "Positivo baja activación":  "#3498db",
    "Neutro":                    "#95a5a6",
    "Activación moderada":       "#9b59b6",
    "Negativo alta activación":  "#e74c3c",
    "Negativo baja activación":  "#7f8c8d",
}

def categorizar_estado_animo(valencia, activacion):
    """Devuelve (categoria, descripcion) según el Modelo Circunflejo del Afecto."""
    valencia = int(round(valencia))
    activacion = int(round(activacion))
    if valencia == 3:
        return "Neutro", "No hay predominio claro de emociones positivas o negativas."
    if activacion == 3:
        return "Activación moderada", "La intensidad emocional permanece en un nivel intermedio."
    if valencia >= 4 and activacion >= 4:
        return "Positivo alta activación", "Entusiasmo, motivación y energía elevada."
    if valencia >= 4 and activacion <= 2:
        return "Positivo baja activación", "Tranquilidad, relajación, satisfacción y bienestar."
    if valencia <= 2 and activacion >= 4:
        return "Negativo alta activación", "Ansiedad, estrés, irritabilidad o enojo."
    return "Negativo baja activación", "Tristeza, apatía, desmotivación o fatiga emocional."

# ─────────────────────────────────────────────
# ESQUEMA DE DATOS
# ─────────────────────────────────────────────
COLUMNS = [
    "ID", "Edad", "Talla_m", "Sexo", "Ejercicio",
    "Circ_Ecu_Antes_cm", "Circ_Pol_Antes_cm", "Circ_Ecu_Despues_cm", "Circ_Pol_Despues_cm",
    "Temp_Antes_C", "Temp_Despues_C", "Altitud_m",
    "SpO2_Antes", "SpO2_Despues",
    "Valencia_Antes", "Activacion_Antes", "Valencia_Despues", "Activacion_Despues",
]

COLUMNAS_CAPTURA = COLUMNS + [
    "CapPulm_Antes", "CapPulm_Despues", "Delta_CapPulm", "Delta_SpO2",
    "Delta_Valencia", "Delta_Activacion", "Categoria_Antes", "Categoria_Despues",
]

EJERCICIO_LABELS = {
    "1 - Sedentario (no hace ejercicio)": 1,
    "2 - Ocasional (1 vez/semana)": 2,
    "3 - Moderado (2-3 veces/semana)": 3,
    "4 - Activo (4-5 veces/semana)": 4,
    "5 - Muy activo (diario)": 5,
}
EJER_NOMBRE = {1: "Sedentario", 2: "Ocasional", 3: "Moderado", 4: "Activo", 5: "Muy activo"}
COLORES_EJER = {1: "#e74c3c", 2: "#e67e22", 3: "#f1c40f", 4: "#2ecc71", 5: "#27ae60"}

CSV_PATH = "datos_animo_ejercicio.csv"

# Nota metodológica (variable Sexo):
# El modelo usa "Sexo" como variable binaria (Mujer=0, Hombre=1) por simplicidad
# estadística en esta demostración pedagógica de regresión lineal múltiple con un
# grupo pequeño de participantes. No representa una postura sobre identidad de
# género; es una simplificación técnica típica de un ejercicio introductorio de
# regresión con muestras pequeñas.

# Candado de escritura para evitar condiciones de carrera cuando varios
# participantes guardan datos casi simultáneamente desde sus celulares (FIX #1).
csv_lock = threading.Lock()

estado = {
    "modelo": None,
    "df": None,
    "entrenado": False,
    "r2": None,
    "coefs": None,
    "intercept": None,
}

# ─────────────────────────────────────────────
# DATOS BASE SINTÉTICOS (calibración en el salón, antes de la expedición)
# ─────────────────────────────────────────────
# Se asume que estos 24 registros de calibración se tomaron en Quito
# (altitud ≈ 2,850 m, ≈15 °C), ANTES de subir al Chimborazo. Los datos reales
# de la expedición (con la altitud y temperatura real del volcán) se capturan
# en la Sección 3 de la app y se combinan con estos en el reentrenamiento.
_SEED_ROWS = [
    # ID, Edad, Talla, Sexo, Ejercicio, CapPulm_Antes(L), CapPulm_Despues(L), SpO2_A, SpO2_D, Animo_A, Animo_D
    [1,  17, 1.62, 0, 3, 2.8, 3.1, 97, 98, 3, 5],
    [2,  16, 1.75, 1, 5, 3.5, 3.9, 96, 99, 2, 5],
    [3,  18, 1.58, 0, 1, 2.2, 2.3, 95, 96, 3, 3],
    [4,  17, 1.70, 1, 4, 3.2, 3.6, 97, 98, 3, 5],
    [5,  16, 1.55, 0, 2, 2.5, 2.7, 96, 97, 2, 4],
    [6,  18, 1.80, 1, 5, 4.0, 4.5, 98, 99, 3, 5],
    [7,  17, 1.60, 0, 1, 2.1, 2.2, 94, 95, 3, 3],
    [8,  16, 1.65, 0, 3, 2.7, 3.0, 97, 98, 2, 4],
    [9,  18, 1.78, 1, 4, 3.6, 4.0, 96, 98, 3, 5],
    [10, 17, 1.72, 1, 3, 3.3, 3.5, 97, 97, 3, 4],
    [11, 16, 1.50, 0, 1, 2.0, 2.1, 95, 95, 2, 2],
    [12, 18, 1.68, 0, 2, 2.6, 2.8, 96, 97, 3, 4],
    [13, 17, 1.74, 1, 5, 3.8, 4.3, 97, 99, 2, 5],
    [14, 16, 1.57, 0, 2, 2.3, 2.5, 96, 96, 3, 4],
    [15, 18, 1.82, 1, 4, 3.9, 4.2, 98, 99, 3, 5],
    [16, 17, 1.61, 0, 1, 2.1, 2.1, 95, 95, 2, 2],
    [17, 16, 1.69, 0, 3, 2.8, 3.1, 97, 98, 3, 4],
    [18, 18, 1.76, 1, 4, 3.5, 3.8, 96, 98, 2, 5],
    [19, 17, 1.63, 0, 2, 2.4, 2.6, 96, 97, 3, 4],
    [20, 16, 1.71, 1, 3, 3.1, 3.3, 97, 98, 3, 4],
    [21, 18, 1.59, 0, 1, 2.0, 2.0, 94, 94, 2, 2],
    [22, 17, 1.77, 1, 5, 4.1, 4.6, 98, 99, 3, 5],
    [23, 16, 1.64, 0, 2, 2.5, 2.7, 96, 97, 2, 3],
    [24, 18, 1.73, 1, 4, 3.4, 3.7, 97, 98, 3, 5],
]
_ALTITUD_BASE = 2850.0   # Quito, m s.n.m.
_TEMP_BASE = 15.0        # °C promedio de Quito

def _construir_base_data():
    filas = []
    for (id_, edad, talla, sexo, ejercicio, cap_a, cap_d, spo2_a, spo2_d, animo_a, animo_d) in _SEED_ROWS:
        ce_a, cp_a = _circunferencias_desde_volumen_estandar(cap_a, _TEMP_BASE, _ALTITUD_BASE)
        ce_d, cp_d = _circunferencias_desde_volumen_estandar(cap_d, _TEMP_BASE, _ALTITUD_BASE)
        activ_a = min(5, max(1, ejercicio))
        activ_d = min(5, max(1, ejercicio + 1))
        filas.append([
            id_, edad, talla, sexo, ejercicio,
            ce_a, cp_a, ce_d, cp_d,
            _TEMP_BASE, _TEMP_BASE, _ALTITUD_BASE,
            spo2_a, spo2_d,
            animo_a, activ_a, animo_d, activ_d,
        ])
    return filas

BASE_DATA = _construir_base_data()

# ─────────────────────────────────────────────
# LÓGICA DEL MODELO
# ─────────────────────────────────────────────
def calcular_deltas(df):
    df = df.copy()

    cap_a_list, cap_d_list = [], []
    for _, row in df.iterrows():
        _, v_a, _ = calcular_capacidad_pulmonar_globo(
            row["Circ_Ecu_Antes_cm"], row["Circ_Pol_Antes_cm"], row["Temp_Antes_C"], row["Altitud_m"])
        _, v_d, _ = calcular_capacidad_pulmonar_globo(
            row["Circ_Ecu_Despues_cm"], row["Circ_Pol_Despues_cm"], row["Temp_Despues_C"], row["Altitud_m"])
        cap_a_list.append(v_a)
        cap_d_list.append(v_d)

    df["CapPulm_Antes"] = cap_a_list
    df["CapPulm_Despues"] = cap_d_list
    df["Delta_CapPulm"] = df["CapPulm_Despues"] - df["CapPulm_Antes"]
    df["Delta_SpO2"] = df["SpO2_Despues"] - df["SpO2_Antes"]
    df["Delta_Valencia"] = df["Valencia_Despues"] - df["Valencia_Antes"]
    df["Delta_Activacion"] = df["Activacion_Despues"] - df["Activacion_Antes"]
    df["Categoria_Antes"] = df.apply(lambda r: categorizar_estado_animo(r["Valencia_Antes"], r["Activacion_Antes"])[0], axis=1)
    df["Categoria_Despues"] = df.apply(lambda r: categorizar_estado_animo(r["Valencia_Despues"], r["Activacion_Despues"])[0], axis=1)
    return df

def entrenar_modelo(df):
    df_delta = calcular_deltas(df)
    X = df_delta[["Ejercicio", "Delta_CapPulm", "Delta_SpO2", "Edad", "Talla_m", "Sexo"]].values
    y = df_delta["Delta_Valencia"].values
    modelo = LinearRegression().fit(X, y)
    r2 = r2_score(y, modelo.predict(X))
    return modelo, r2, df_delta

def _construir_salidas_modelo(df, df_delta, modelo, r2, fuente):
    estado["modelo"]    = modelo
    estado["df"]        = df_delta
    estado["entrenado"] = True
    estado["r2"]        = r2
    estado["coefs"]     = modelo.coef_
    estado["intercept"] = modelo.intercept_

    c  = modelo.coef_
    b0 = modelo.intercept_

    ecuacion = (
        f"**Ecuación del modelo ({fuente}):**\n\n"
        f"`ΔValencia = {b0:.4f}`\n"
        f"`         + {c[0]:.4f} × Ejercicio`\n"
        f"`         + {c[1]:.4f} × ΔCapacidadPulmonar (L, estandarizada)`\n"
        f"`         + {c[2]:.4f} × ΔOximetría`\n"
        f"`         + {c[3]:.4f} × Edad`\n"
        f"`         + {c[4]:.4f} × Talla`\n"
        f"`         + {c[5]:.4f} × Sexo`\n\n"
        f"**R² = {r2:.4f}** — El modelo explica el **{r2*100:.1f}%** de la varianza en el cambio de "
        f"valencia (agrado/desagrado) *dentro de esta muestra* (demostración pedagógica, no un "
        f"resultado inferencial generalizable).\n\n"
        f"**Nota sobre 'Sexo':** se codifica como variable binaria (Mujer=0, Hombre=1) por simplicidad "
        f"estadística de esta demo introductoria; no busca representar la diversidad de identidad de género.\n\n"
        f"**Nota sobre ΔCapacidadPulmonar:** proviene del volumen del globo (elipsoide, a partir de las "
        f"circunferencias ecuatorial y polar), normalizado por temperatura y presión atmosférica "
        f"(estimada por altitud) a una condición estándar de referencia — así una medición en el "
        f"Chimborazo es comparable con una tomada a nivel del mar.\n\n"
        f"**Interpretación de β₁ (Ejercicio) = {c[0]:.4f}:**\n"
        f"Por cada nivel adicional de ejercicio, la valencia cambia en promedio "
        f"{'↑' if c[0] > 0 else '↓'} {abs(c[0]):.4f} puntos Likert dentro de esta muestra, "
        f"manteniendo fijas las demás variables del modelo."
    )

    coef_md = (
        "| Variable | Coeficiente (β) |\n"
        "|---|---|\n"
        f"| Constante (β₀) | {b0:.4f} |\n"
        f"| Ejercicio (β₁) | {c[0]:.4f} |\n"
        f"| ΔCap. Pulmonar estandarizada (β₂) | {c[1]:.4f} |\n"
        f"| ΔOximetría (β₃) | {c[2]:.4f} |\n"
        f"| Edad (β₄) | {c[3]:.4f} |\n"
        f"| Talla (β₅) | {c[4]:.4f} |\n"
        f"| Sexo (β₆, binaria: M=0/H=1) | {c[5]:.4f} |\n"
        f"| **R²** | **{r2:.4f}** |"
    )

    tabla = df_delta[[
        "ID", "Edad", "Talla_m", "Sexo", "Ejercicio", "Altitud_m",
        "CapPulm_Antes", "CapPulm_Despues", "Delta_CapPulm",
        "SpO2_Antes", "SpO2_Despues", "Delta_SpO2",
        "Valencia_Antes", "Activacion_Antes", "Categoria_Antes",
        "Valencia_Despues", "Activacion_Despues", "Categoria_Despues",
        "Delta_Valencia", "Delta_Activacion",
    ]]

    return (
        f"Modelo entrenado con {len(df)} participantes. Fuente: {fuente}",
        len(df),
        tabla,
        ecuacion,
        coef_md,
    )

def _csv_capturado_a_df():
    if not os.path.exists(CSV_PATH):
        return None, "No hay datos capturados en el CSV todavía."
    try:
        with csv_lock:
            df_csv = pd.read_csv(CSV_PATH)
        if df_csv.empty:
            return None, "El CSV está vacío."
        missing = [c for c in COLUMNS if c not in df_csv.columns]
        if missing:
            return None, f"Al CSV le faltan columnas: {missing}"
        df_out = df_csv[COLUMNS].copy()
        for col in COLUMNS[1:]:
            df_out[col] = pd.to_numeric(df_out[col], errors="coerce")
        df_out = df_out.dropna()
        if len(df_out) < 3:
            return None, f"Se necesitan al menos 3 participantes para entrenar. Hay {len(df_out)}."
        return df_out, None
    except Exception as e:
        return None, f"Error leyendo el CSV: {e}"

def cargar_y_entrenar(file_obj=None):
    df = pd.DataFrame(BASE_DATA, columns=COLUMNS)

    if file_obj is not None:
        try:
            ext = os.path.splitext(file_obj.name)[1].lower()
            df_new = pd.read_csv(file_obj.name) if ext == ".csv" else pd.read_excel(file_obj.name)

            if all(c in df_new.columns for c in COLUMNS):
                df_new = df_new[COLUMNS].copy()
            else:
                df_new = df_new.iloc[:, :len(COLUMNS)]
                df_new.columns = COLUMNS[:df_new.shape[1]]

            for col in df_new.columns:
                df_new[col] = pd.to_numeric(df_new[col], errors="coerce")
            df_new = df_new.dropna()
            df = pd.concat([df, df_new], ignore_index=True)
            fuente = f"datos base (24 reg.) + archivo subido ({len(df_new)} reg.)"
        except Exception as e:
            return f"Error al procesar el archivo: {e}", 0, pd.DataFrame(), "", ""
    else:
        fuente = "datos base únicamente"

    modelo, r2, df_delta = entrenar_modelo(df)
    return _construir_salidas_modelo(df, df_delta, modelo, r2, fuente)

def reentrenar_con_csv():
    df_base = pd.DataFrame(BASE_DATA, columns=COLUMNS)
    df_csv, err = _csv_capturado_a_df()

    if err:
        return (
            f"Error: {err}",
            len(df_base),
            pd.DataFrame(),
            "— Entrena primero el modelo —",
            "",
        )

    df_csv = df_csv.reindex(columns=COLUMNS)
    df_combined = pd.concat([df_base, df_csv], ignore_index=True)
    modelo, r2, df_delta = entrenar_modelo(df_combined)
    fuente = f"datos base (24 reg.) + CSV capturado ({len(df_csv)} reg.)"
    return _construir_salidas_modelo(df_combined, df_delta, modelo, r2, fuente)

# ─────────────────────────────────────────────
# PREDICCIÓN
# ─────────────────────────────────────────────
def predecir(ejercicio_label, edad, talla, sexo,
             circ_ec_antes, circ_pol_antes, temp_antes,
             circ_ec_despues, circ_pol_despues, temp_despues,
             altitud_m, spo2_antes, spo2_despues):
    if not estado["entrenado"]:
        return "El modelo no ha sido entrenado. Haz clic en 'Entrenar'."

    ejer_val = EJERCICIO_LABELS.get(ejercicio_label, 1)
    sexo_val = 1 if sexo == "Hombre" else 0

    v_med_a, v_std_a, p_amb = calcular_capacidad_pulmonar_globo(circ_ec_antes, circ_pol_antes, temp_antes, altitud_m)
    v_med_d, v_std_d, _     = calcular_capacidad_pulmonar_globo(circ_ec_despues, circ_pol_despues, temp_despues, altitud_m)
    delta_cap  = v_std_d - v_std_a
    delta_spo2 = spo2_despues - spo2_antes

    X_new = np.array([[ejer_val, delta_cap, delta_spo2, edad, talla, sexo_val]])
    pred  = estado["modelo"].predict(X_new)[0]
    c     = estado["coefs"]
    b0    = estado["intercept"]

    if pred >= 1.5:
        interp = "Mejora notable en la valencia (agrado) — se espera un cambio hacia un estado más positivo."
    elif pred >= 0.5:
        interp = "Ligera mejora en la valencia — cambio positivo moderado."
    elif pred >= -0.5:
        interp = "Sin cambio relevante en la valencia."
    else:
        interp = "Posible descenso en la valencia — revisar variables de contexto."

    direccion = (
        "El coeficiente de Ejercicio es positivo en este modelo: dentro de esta "
        "muestra, mayor nivel de ejercicio se asocia con mayor ΔValencia."
        if c[0] > 0 else
        "El coeficiente de Ejercicio es negativo en este modelo: dentro de esta "
        "muestra, mayor nivel de ejercicio se asocia con menor ΔValencia."
    )

    return (
        f"## Resultado de la Predicción\n\n"
        f"**Perfil ingresado:**\n"
        f"- Ejercicio: {ejercicio_label} → valor numérico = {ejer_val}\n"
        f"- Edad: {edad} años | Talla: {talla} m | Sexo: {sexo}\n"
        f"- Altitud: {altitud_m:.0f} m → presión atmosférica estimada: **{p_amb:.1f} hPa**\n\n"
        f"**Capacidad pulmonar (globo, elipsoide):**\n"
        f"- Antes: medida en campo = {v_med_a:.2f} L → estandarizada (37°C, nivel del mar) = **{v_std_a:.2f} L**\n"
        f"- Después: medida en campo = {v_med_d:.2f} L → estandarizada = **{v_std_d:.2f} L**\n"
        f"- ΔCapacidad pulmonar estandarizada: **{delta_cap:+.2f} L**\n"
        f"- Oximetría: {spo2_antes}% → {spo2_despues}% (Δ = {delta_spo2:+.1f}%)\n\n"
        f"---\n\n"
        f"**ΔValencia predicho: `{pred:+.3f}` puntos Likert** (dimensión de agrado/desagrado del Modelo "
        f"Circunflejo del Afecto)\n\n"
        f"{interp}\n\n"
        f"---\n\n"
        f"**Ecuación aplicada:**\n"
        f"`ΔValencia = {b0:.4f} + {c[0]:.4f}×{ejer_val} + {c[1]:.4f}×{delta_cap:.2f} "
        f"+ {c[2]:.4f}×{delta_spo2:.1f} + {c[3]:.4f}×{edad} "
        f"+ {c[4]:.4f}×{talla} + {c[5]:.4f}×{sexo_val}`\n\n"
        f"**R² del modelo = {estado['r2']:.4f}** (ajuste dentro de esta muestra, no una medida "
        f"de significancia estadística)\n\n"
        f"**Dirección observada del efecto:** {direccion}\n\n"
        f"*Esta es una demostración pedagógica del funcionamiento de un modelo de regresión, "
        f"no una prueba de hipótesis formal (no incluye p-valores ni pruebas de significancia). "
        f"Predice el cambio en la dimensión de VALENCIA; no predice la activación.*"
    )

# ─────────────────────────────────────────────
# CAPTURA DE PARTICIPANTES
# ─────────────────────────────────────────────
def obtener_tabla_csv():
    with csv_lock:
        if os.path.exists(CSV_PATH):
            try:
                return pd.read_csv(CSV_PATH)
            except Exception:
                pass
        return pd.DataFrame(columns=COLUMNAS_CAPTURA)

def limpiar_csv():
    with csv_lock:
        if os.path.exists(CSV_PATH):
            os.remove(CSV_PATH)
    return "Registros eliminados.", pd.DataFrame(columns=COLUMNAS_CAPTURA)

def guardar_participante(edad, talla, sexo, ejercicio_label,
                          circ_ec_antes, circ_pol_antes, temp_antes,
                          circ_ec_despues, circ_pol_despues, temp_despues,
                          altitud_m, spo2_antes, spo2_despues,
                          valencia_antes_label, activacion_antes_label,
                          valencia_despues_label, activacion_despues_label):
    errores = []
    if not (10 <= edad <= 100):          errores.append("Edad fuera de rango (10-100).")
    if not (1.0 <= talla <= 2.5):        errores.append("Talla fuera de rango (1.0-2.5 m).")
    if not (50 <= spo2_antes <= 100):    errores.append("SpO2 Antes fuera de rango (50-100).")
    if not (50 <= spo2_despues <= 100):  errores.append("SpO2 Después fuera de rango (50-100).")
    if not (10 <= circ_ec_antes <= 150): errores.append("Circunferencia ecuatorial Antes fuera de rango (10-150 cm).")
    if not (10 <= circ_pol_antes <= 150):errores.append("Circunferencia polar Antes fuera de rango (10-150 cm).")
    if not (10 <= circ_ec_despues <= 150): errores.append("Circunferencia ecuatorial Después fuera de rango (10-150 cm).")
    if not (10 <= circ_pol_despues <= 150): errores.append("Circunferencia polar Después fuera de rango (10-150 cm).")
    if not (-40 <= temp_antes <= 50):    errores.append("Temperatura Antes fuera de rango (-40 a 50°C).")
    if not (-40 <= temp_despues <= 50):  errores.append("Temperatura Después fuera de rango (-40 a 50°C).")
    if not (0 <= altitud_m <= 9000):     errores.append("Altitud fuera de rango (0-9000 m).")
    if errores:
        return "Error: " + " | ".join(errores), obtener_tabla_csv()

    sexo_val    = 1 if sexo == "Hombre" else 0
    ejer_val    = EJERCICIO_LABELS.get(ejercicio_label, 1)
    val_a       = VALENCIA_LABELS.get(valencia_antes_label, 3)
    act_a       = ACTIVACION_LABELS.get(activacion_antes_label, 3)
    val_d       = VALENCIA_LABELS.get(valencia_despues_label, 3)
    act_d       = ACTIVACION_LABELS.get(activacion_despues_label, 3)

    v_med_a, v_std_a, _ = calcular_capacidad_pulmonar_globo(circ_ec_antes, circ_pol_antes, temp_antes, altitud_m)
    v_med_d, v_std_d, _ = calcular_capacidad_pulmonar_globo(circ_ec_despues, circ_pol_despues, temp_despues, altitud_m)
    cat_a, _ = categorizar_estado_animo(val_a, act_a)
    cat_d, _ = categorizar_estado_animo(val_d, act_d)

    with csv_lock:
        siguiente_id = 1
        if os.path.exists(CSV_PATH):
            try:
                df_ex = pd.read_csv(CSV_PATH)
                if not df_ex.empty and "ID" in df_ex.columns:
                    siguiente_id = int(df_ex["ID"].max()) + 1
            except Exception:
                pass

        nueva_fila = {
            "ID": siguiente_id, "Edad": int(edad), "Talla_m": talla, "Sexo": sexo_val, "Ejercicio": ejer_val,
            "Circ_Ecu_Antes_cm": circ_ec_antes, "Circ_Pol_Antes_cm": circ_pol_antes,
            "Circ_Ecu_Despues_cm": circ_ec_despues, "Circ_Pol_Despues_cm": circ_pol_despues,
            "Temp_Antes_C": temp_antes, "Temp_Despues_C": temp_despues, "Altitud_m": altitud_m,
            "SpO2_Antes": spo2_antes, "SpO2_Despues": spo2_despues,
            "Valencia_Antes": val_a, "Activacion_Antes": act_a,
            "Valencia_Despues": val_d, "Activacion_Despues": act_d,
            "CapPulm_Antes": round(v_std_a, 3), "CapPulm_Despues": round(v_std_d, 3),
            "Delta_CapPulm": round(v_std_d - v_std_a, 3),
            "Delta_SpO2": round(spo2_despues - spo2_antes, 1),
            "Delta_Valencia": val_d - val_a, "Delta_Activacion": act_d - act_a,
            "Categoria_Antes": cat_a, "Categoria_Despues": cat_d,
        }

        df_nuevo = pd.DataFrame([nueva_fila], columns=COLUMNAS_CAPTURA)
        try:
            modo = "a" if os.path.exists(CSV_PATH) else "w"
            with open(CSV_PATH, mode=modo, newline="", encoding="utf-8") as f:
                df_nuevo.to_csv(f, header=(modo == "w"), index=False)
            tabla_actual = pd.read_csv(CSV_PATH)
        except Exception as e:
            print(f"Error guardando CSV: {e}")
            return f"Error al guardar: {e}", pd.read_csv(CSV_PATH) if os.path.exists(CSV_PATH) else pd.DataFrame(columns=COLUMNAS_CAPTURA)

    return (
        f"Participante {nueva_fila['ID']} guardado. "
        f"Cap. pulmonar estandarizada: {v_std_a:.2f} L → {v_std_d:.2f} L. "
        f"Ánimo: {cat_a}{cat_d}.",
        tabla_actual
    )

# ─────────────────────────────────────────────
# GRÁFICAS
# ─────────────────────────────────────────────
def _buf_a_tempfile(buf):
    if buf is None:
        return None
    tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
    tmp.write(buf.read())
    tmp.flush()
    tmp.close()
    return tmp.name

def generar_graficas():
    VACIO = (None, None, None,
             "No hay datos capturados todavía. Registra participantes en la Sección 3.")

    if not os.path.exists(CSV_PATH):
        return VACIO
    try:
        with csv_lock:
            df = pd.read_csv(CSV_PATH)
    except Exception as e:
        return None, None, None, f"Error leyendo CSV: {e}"

    if df.empty or "Valencia_Antes" not in df.columns:
        return VACIO

    df = df.dropna(subset=["Valencia_Antes", "Valencia_Despues", "Delta_Valencia", "Ejercicio"])
    if len(df) < 2:
        return None, None, None, "Se necesitan al menos 2 participantes para graficar."

    def save_fig(fig):
        buf = io.BytesIO()
        fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
        buf.seek(0)
        plt.close(fig)
        return _buf_a_tempfile(buf)

    # Gráfica 1: Barras Antes vs Después (Valencia) por participante
    fig1, ax1 = plt.subplots(figsize=(max(8, len(df) * 0.65), 5))
    ids = [f"P{int(i)}" for i in df["ID"]]
    x, w = range(len(df)), 0.35
    ax1.bar([i - w / 2 for i in x], df["Valencia_Antes"], w,
            label="Antes", color="#5b8dd9", alpha=0.85)
    bars_d = ax1.bar([i + w / 2 for i in x], df["Valencia_Despues"], w,
                     label="Después", color="#27ae60", alpha=0.85)
    for bar, delta in zip(bars_d, df["Delta_Valencia"]):
        col = "#27ae60" if delta > 0 else ("#e74c3c" if delta < 0 else "#888888")
        ax1.text(bar.get_x() + bar.get_width() / 2,
                 bar.get_height() + 0.06,
                 f"{delta:+.0f}", ha="center", va="bottom",
                 fontsize=8, color=col, fontweight="bold")
    ax1.set_xticks(list(x))
    ax1.set_xticklabels(ids, rotation=45, ha="right", fontsize=8)
    ax1.set_yticks([1, 2, 3, 4, 5])
    ax1.set_yticklabels([f"{v} - {VALENCIA_NOMBRE[v]}" for v in [1, 2, 3, 4, 5]], fontsize=8)
    ax1.set_ylim(0, 6.2)
    ax1.set_title("Valencia (agrado): Antes vs Después por Participante",
                  fontsize=13, fontweight="bold", pad=12)
    ax1.set_xlabel("Participante")
    ax1.set_ylabel("Valencia (Likert 1-5)")
    ax1.legend(loc="upper left")
    ax1.spines[["top", "right"]].set_visible(False)
    ax1.grid(axis="y", alpha=0.3)
    fig1.tight_layout()
    path1 = save_fig(fig1)

    # Gráfica 2: Boxplot ΔValencia por nivel de ejercicio
    fig2, ax2 = plt.subplots(figsize=(7, 5))
    niveles = sorted(df["Ejercicio"].dropna().unique().astype(int))
    data_box = [df[df["Ejercicio"] == n]["Delta_Valencia"].values for n in niveles]
    etiquetas = [f"{n}\n{EJER_NOMBRE.get(n, '')}" for n in niveles]
    bp = ax2.boxplot(data_box, patch_artist=True, widths=0.45,
                     medianprops=dict(color="black", linewidth=2))
    for patch, niv in zip(bp["boxes"], niveles):
        patch.set_facecolor(COLORES_EJER.get(niv, "#aaaaaa"))
        patch.set_alpha(0.72)
    for i, (niv, datos) in enumerate(zip(niveles, data_box), 1):
        jitter = np.random.uniform(-0.15, 0.15, size=len(datos))
        ax2.scatter([i + j for j in jitter], datos,
                    color=COLORES_EJER.get(niv, "#aaaaaa"),
                    s=55, zorder=5, edgecolors="white", linewidths=0.6)
    ax2.axhline(0, color="#e74c3c", linestyle="--", linewidth=1.2,
                alpha=0.7, label="Sin cambio (Δ = 0)")
    ax2.set_xticks(range(1, len(niveles) + 1))
    ax2.set_xticklabels(etiquetas, fontsize=9)
    ax2.set_title("ΔValencia según Nivel de Ejercicio",
                  fontsize=13, fontweight="bold", pad=12)
    ax2.set_xlabel("Nivel de Ejercitación")
    ax2.set_ylabel("ΔValencia (puntos Likert)")
    ax2.legend(fontsize=9)
    ax2.spines[["top", "right"]].set_visible(False)
    ax2.grid(axis="y", alpha=0.3)
    fig2.tight_layout()
    path2 = save_fig(fig2)

    # Gráfica 3: Dona con la distribución de categorías de ánimo (Después)
    fig3, ax3 = plt.subplots(figsize=(6.5, 5))
    conteo = df["Categoria_Despues"].value_counts()
    vals   = conteo.values.tolist()
    labels_pie = [f"{cat}\n{n} ({n/conteo.sum()*100:.0f}%)" for cat, n in conteo.items()]
    colors = [COLOR_CATEGORIA.get(cat, "#aaaaaa") for cat in conteo.index]

    wedges, _ = ax3.pie(vals, colors=colors, startangle=90,
                        wedgeprops=dict(width=0.55, edgecolor="white", linewidth=2))
    ax3.legend(wedges, labels_pie, loc="lower center",
               bbox_to_anchor=(0.5, -0.35), ncol=min(2, len(vals)), fontsize=8)
    ax3.set_title("Categorías de Ánimo Después (Modelo Circunflejo)",
                  fontsize=13, fontweight="bold", pad=12)
    ax3.text(0, 0, f"n={conteo.sum()}", ha="center", va="center",
             fontsize=14, fontweight="bold", color="#333333")
    fig3.tight_layout()
    path3 = save_fig(fig3)

    total    = len(df)
    mejora   = int((df["Delta_Valencia"] > 0).sum())
    igual    = int((df["Delta_Valencia"] == 0).sum())
    descenso = int((df["Delta_Valencia"] < 0).sum())
    pct = mejora / total * 100 if total > 0 else 0
    resumen = (
        f"Resumen: {total} participantes analizados — "
        f"**{mejora} mejoraron su valencia** ({pct:.0f}%), "
        f"{igual} sin cambio, {descenso} con descenso. "
        f"ΔValencia promedio: **{df['Delta_Valencia'].mean():+.2f} puntos**."
    )
    return path1, path2, path3, resumen

def guardar_y_graficar(edad, talla, sexo, ejercicio_label,
                        circ_ec_antes, circ_pol_antes, temp_antes,
                        circ_ec_despues, circ_pol_despues, temp_despues,
                        altitud_m, spo2_antes, spo2_despues,
                        valencia_antes_label, activacion_antes_label,
                        valencia_despues_label, activacion_despues_label):
    msg, tabla = guardar_participante(
        edad, talla, sexo, ejercicio_label,
        circ_ec_antes, circ_pol_antes, temp_antes,
        circ_ec_despues, circ_pol_despues, temp_despues,
        altitud_m, spo2_antes, spo2_despues,
        valencia_antes_label, activacion_antes_label,
        valencia_despues_label, activacion_despues_label
    )
    p1, p2, p3, resumen = generar_graficas()
    return msg, tabla, resumen, p1, p2, p3

# ─────────────────────────────────────────────
# INICIALIZACIÓN
# ─────────────────────────────────────────────
init_msg, init_count, init_df, init_ec, init_coef = cargar_y_entrenar()

# ─────────────────────────────────────────────
# INTERFAZ
# ─────────────────────────────────────────────
with gr.Blocks(title="Signos Vitales de Misión | SEEC 2027 | HEJAU") as app:
    gr.Markdown("# Experimento: ¿Cómo afecta el ejercicio físico al estado de ánimo de un astronauta?")
    gr.Markdown(
        "**¿Cómo sabes si un ser humano está listo para una misión extrema?**\n\n"
        "**Pregunta exploratoria:** ¿El nivel de ejercicio físico se asocia con el cambio en la "
        "*valencia* del estado de ánimo (Modelo Circunflejo del Afecto, Russell, 1980), dentro de una "
        "misión de altura extrema, al controlar capacidad pulmonar (estandarizada por temperatura y "
        "presión atmosférica), oximetría, edad, talla y sexo?\n\n"
        "**Variable dependiente:** ΔValencia = Valencia_después − Valencia_antes (escala Likert 1-5)\n\n"
        "**Práctica en campo:** Volcán Chimborazo (Ecuador). La capacidad pulmonar se estima inflando "
        "un globo y midiendo su circunferencia ecuatorial y polar (se modela como un elipsoide), y el "
        "volumen se normaliza por la temperatura ambiente y la presión atmosférica de la altitud del "
        "punto de medición.\n\n"
        "*Esta app es una demostración pedagógica de un modelo de regresión lineal múltiple, "
        "pensada para que docentes vean el flujo completo de datos → modelo → predicción. "
        "No sustituye un análisis estadístico formal con muestras grandes ni una espirometría clínica.*"
    )

    # SECCIÓN 1: MODELO
    gr.Markdown("---")
    gr.Markdown("## 1. Modelo de Regresión Lineal Múltiple")

    with gr.Row():
        with gr.Column(scale=1):
            gr.Markdown("### Carga de datos")
            file_input  = gr.File(label="Subir CSV/Excel externo (opcional)",
                                  file_types=[".csv", ".xlsx"])
            load_btn    = gr.Button("Entrenar con datos base (+ archivo si se subió)",
                                    variant="primary")
            gr.Markdown("---")
            gr.Markdown(
                "**¿Ya capturaste participantes?**\n\n"
                "Usa el botón de abajo para reentrenar el modelo con los datos base "
                "**más** todos los participantes registrados en la Sección 3."
            )
            retrain_btn = gr.Button("Reentrenar con datos base + CSV capturado",
                                    variant="secondary")
            status_out  = gr.Textbox(label="Estado del modelo", value=init_msg, interactive=False)
            n_part_out  = gr.Number(label="Total de participantes usados",
                                    value=init_count, interactive=False)

        with gr.Column(scale=2):
            gr.Markdown("### Ecuación y coeficientes")
            ecuacion_out = gr.Markdown(value=init_ec)
            coef_out     = gr.Markdown(value=init_coef)

    gr.Markdown("### Datos de entrenamiento")
    data_table = gr.DataFrame(value=init_df,
                              label="Conjunto de datos (con deltas y categorías calculadas)",
                              interactive=False, wrap=True)

    load_btn.click(
        cargar_y_entrenar,
        inputs=file_input,
        outputs=[status_out, n_part_out, data_table, ecuacion_out, coef_out]
    )
    retrain_btn.click(
        reentrenar_con_csv,
        inputs=None,
        outputs=[status_out, n_part_out, data_table, ecuacion_out, coef_out]
    )

    # SECCIÓN 2: PREDICCIÓN
    gr.Markdown("---")
    gr.Markdown("## 2. Realizar una Predicción")
    gr.Markdown("Ingresa el perfil de un participante para predecir su cambio esperado en la valencia del ánimo.")

    with gr.Row():
        with gr.Column():
            gr.Markdown("#### Variables del perfil")
            ejercicio_pred = gr.Dropdown(
                choices=list(EJERCICIO_LABELS.keys()),
                label="Nivel de ejercitación habitual",
                value=list(EJERCICIO_LABELS.keys())[2]
            )
            with gr.Row():
                edad_pred  = gr.Number(label="Edad (años)", value=17, step=1)
                talla_pred = gr.Number(label="Talla (m)", value=1.65, step=0.01)
                sexo_pred  = gr.Radio(["Mujer", "Hombre"], label="Sexo",
                                      info="Variable binaria simplificada para esta demo estadística.",
                                      value="Mujer")
            altitud_pred = gr.Number(label="Altitud del punto de medición (m s.n.m.)", value=5000, step=50,
                                     info="Ej. Chimborazo (refugio/zona de práctica) ≈ 5000 m")

            gr.Markdown("#### Globo — Capacidad pulmonar (elipsoide) ANTES")
            with gr.Row():
                ce_a_pred = gr.Number(label="Circunf. ecuatorial ANTES (cm)", value=55.0, step=0.5)
                cp_a_pred = gr.Number(label="Circunf. polar ANTES (cm)", value=70.0, step=0.5)
                temp_a_pred = gr.Number(label="Temperatura ambiente ANTES (°C)", value=5.0, step=0.5)
            gr.Markdown("#### Globo — Capacidad pulmonar (elipsoide) DESPUÉS")
            with gr.Row():
                ce_d_pred = gr.Number(label="Circunf. ecuatorial DESPUÉS (cm)", value=60.0, step=0.5)
                cp_d_pred = gr.Number(label="Circunf. polar DESPUÉS (cm)", value=76.0, step=0.5)
                temp_d_pred = gr.Number(label="Temperatura ambiente DESPUÉS (°C)", value=5.0, step=0.5)

            gr.Markdown("#### Oximetría")
            with gr.Row():
                spo2_a_pred = gr.Number(label="Oximetría ANTES (%)", value=90.0, step=0.5)
                spo2_d_pred = gr.Number(label="Oximetría DESPUÉS (%)", value=92.0, step=0.5)
            btn_pred = gr.Button("Calcular ΔValencia Predicho", variant="primary")

        with gr.Column():
            salida_pred = gr.Markdown(label="Resultado")

    btn_pred.click(
        predecir,
        inputs=[ejercicio_pred, edad_pred, talla_pred, sexo_pred,
                ce_a_pred, cp_a_pred, temp_a_pred,
                ce_d_pred, cp_d_pred, temp_d_pred,
                altitud_pred, spo2_a_pred, spo2_d_pred],
        outputs=salida_pred
    )

    # SECCIÓN 3: CAPTURA
    gr.Markdown("---")
    gr.Markdown("## 3. Registro de Participantes en Campo")
    gr.Markdown(
        "Captura los datos de cada participante antes y después de la actividad física, "
        "midiendo el globo con cinta métrica y anotando la temperatura ambiente y la altitud."
    )

    with gr.Row():
        with gr.Column():
            gr.Markdown("#### Datos generales")
            with gr.Row():
                edad_cap  = gr.Number(label="Edad (años)", value=17, step=1,
                                      minimum=10, maximum=100)
                talla_cap = gr.Number(label="Talla (m)", value=1.65, step=0.01,
                                      minimum=1.0, maximum=2.5)
                sexo_cap  = gr.Radio(["Mujer", "Hombre"], label="Sexo",
                                     info="Variable binaria simplificada para esta demo estadística.",
                                     value="Mujer")
            ejercicio_cap = gr.Dropdown(
                choices=list(EJERCICIO_LABELS.keys()),
                label="Nivel de ejercitación habitual",
                value=list(EJERCICIO_LABELS.keys())[0]
            )
            altitud_cap = gr.Number(label="Altitud del punto de medición (m s.n.m.)", value=5000, step=50,
                                    info="Misma altitud para Antes y Después (mismo punto de la ruta).")

            gr.Markdown("#### Globo — Capacidad pulmonar (elipsoide): mide circunferencia ecuatorial y polar")
            with gr.Row():
                ce_a_cap = gr.Number(label="Circunf. ecuatorial ANTES (cm)", value=50.0, step=0.5, minimum=10, maximum=150)
                cp_a_cap = gr.Number(label="Circunf. polar ANTES (cm)", value=65.0, step=0.5, minimum=10, maximum=150)
                temp_a_cap = gr.Number(label="Temperatura ANTES (°C)", value=5.0, step=0.5, minimum=-40, maximum=50)
            with gr.Row():
                ce_d_cap = gr.Number(label="Circunf. ecuatorial DESPUÉS (cm)", value=55.0, step=0.5, minimum=10, maximum=150)
                cp_d_cap = gr.Number(label="Circunf. polar DESPUÉS (cm)", value=70.0, step=0.5, minimum=10, maximum=150)
                temp_d_cap = gr.Number(label="Temperatura DESPUÉS (°C)", value=5.0, step=0.5, minimum=-40, maximum=50)

            gr.Markdown("#### Oximetría (ANTES y DESPUÉS)")
            with gr.Row():
                spo2_a_cap = gr.Number(label="SpO2 ANTES (%)", value=92.0,
                                       step=0.5, minimum=50, maximum=100)
                spo2_d_cap = gr.Number(label="SpO2 DESPUÉS (%)", value=90.0,
                                       step=0.5, minimum=50, maximum=100)

            gr.Markdown("#### Estado de ánimo — Valencia y Activación (Modelo Circunflejo, Russell 1980)")
            with gr.Row():
                valencia_a_cap = gr.Dropdown(choices=list(VALENCIA_LABELS.keys()),
                                             label="Valencia ANTES", value=list(VALENCIA_LABELS.keys())[2])
                activacion_a_cap = gr.Dropdown(choices=list(ACTIVACION_LABELS.keys()),
                                               label="Activación ANTES", value=list(ACTIVACION_LABELS.keys())[2])
            with gr.Row():
                valencia_d_cap = gr.Dropdown(choices=list(VALENCIA_LABELS.keys()),
                                             label="Valencia DESPUÉS", value=list(VALENCIA_LABELS.keys())[3])
                activacion_d_cap = gr.Dropdown(choices=list(ACTIVACION_LABELS.keys()),
                                               label="Activación DESPUÉS", value=list(ACTIVACION_LABELS.keys())[3])

            with gr.Row():
                btn_guardar = gr.Button("Guardar Participante", variant="primary")
                btn_limpiar = gr.Button("Borrar Todos los Registros")
            msg_cap = gr.Textbox(label="Estado del registro", interactive=False)

        with gr.Column():
            gr.Markdown("#### Participantes capturados")
            tabla_cap = gr.DataFrame(
                value=obtener_tabla_csv(),
                label="Registros guardados",
                interactive=False,
                wrap=True
            )
            btn_dl = gr.Button("Descargar CSV")
            archivo_dl = gr.File(label="Archivo generado", visible=False)

    # SECCIÓN 4: GRÁFICAS
    gr.Markdown("---")
    gr.Markdown("## 4. Gráficas de Mejora en el Estado de Ánimo")
    gr.Markdown(
        "Las gráficas se actualizan automáticamente al guardar cada participante. "
        "También puedes generarlas manualmente con el botón."
    )
    btn_graficas  = gr.Button("Generar / Actualizar Gráficas", variant="secondary")
    resumen_graf  = gr.Markdown()
    with gr.Row():
        graf1 = gr.Image(label="Valencia: Antes vs Después por participante", type="filepath")
        graf2 = gr.Image(label="ΔValencia por nivel de ejercicio", type="filepath")
    graf3 = gr.Image(label="Categorías de ánimo (Después) — Modelo Circunflejo", type="filepath")

    # EVENTOS
    def descargar_csv():
        if os.path.exists(CSV_PATH):
            try:
                with csv_lock:
                    df = pd.read_csv(CSV_PATH)
                if not df.empty:
                    tmp = tempfile.NamedTemporaryFile(
                        delete=False, suffix=".csv",
                        prefix="datos_animo_ejercicio_"
                    )
                    df.to_csv(tmp.name, index=False)
                    tmp.close()
                    return gr.File(value=tmp.name, visible=True)
            except Exception as e:
                print(f"Error descarga: {e}")
        return gr.File(visible=False)

    btn_guardar.click(
        guardar_y_graficar,
        inputs=[edad_cap, talla_cap, sexo_cap, ejercicio_cap,
                ce_a_cap, cp_a_cap, temp_a_cap,
                ce_d_cap, cp_d_cap, temp_d_cap,
                altitud_cap, spo2_a_cap, spo2_d_cap,
                valencia_a_cap, activacion_a_cap,
                valencia_d_cap, activacion_d_cap],
        outputs=[msg_cap, tabla_cap, resumen_graf, graf1, graf2, graf3]
    )

    btn_limpiar.click(
        limpiar_csv,
        inputs=None,
        outputs=[msg_cap, tabla_cap]
    )

    btn_dl.click(
        descargar_csv,
        inputs=None,
        outputs=archivo_dl
    )

    btn_graficas.click(
        generar_graficas,
        inputs=None,
        outputs=[graf1, graf2, graf3, resumen_graf]
    )

    gr.Markdown("---")
    gr.Markdown(
        "*Basado en: Ruiz Cruz, E. H. (2026). Pensamiento científico auténtico mediante STEAM, "
        "u-learning y modelos predictivos en educación técnica. CONALEP. Clasificación del estado "
        "de ánimo según el Modelo Circunflejo del Afecto (Russell, 1980).*"
    )

if __name__ == "__main__":
    # concurrency_count limita cuántos eventos corren en paralelo dentro de la
    # cola de Gradio; junto con csv_lock, evita que 25-30 celulares guardando
    # casi al mismo tiempo corrompan el archivo CSV compartido.
    app.queue(default_concurrency_limit=4)
    app.launch(server_name="0.0.0.0", server_port=7860)