mannnon commited on
Commit
e4fa939
·
verified ·
1 Parent(s): 14fd027

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +697 -2
app.py CHANGED
@@ -418,6 +418,692 @@ def match_pdf_to_athlete(pdfs_data, athlete_name):
418
  return pdf
419
  return None
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
 
422
  # Charge CSV
423
  dfs = []
@@ -489,6 +1175,12 @@ attaque_finale = metrics["attaque_csv"]
489
  if matched_pdf and matched_pdf.get("attaque_pdf") and matched_pdf["attaque_pdf"] != "indéterminée":
490
  attaque_finale = matched_pdf["attaque_pdf"]
491
 
 
 
 
 
 
 
492
 
493
  def build_summary(row, metrics, attaque_finale):
494
  contraintes_txt = (
@@ -516,8 +1208,8 @@ def build_summary(row, metrics, attaque_finale):
516
 
517
  summary = build_summary(row, metrics, attaque_finale)
518
 
519
- tab_profil, tab_seuils, tab_pdf = st.tabs(
520
- ["Profil biomécanique", "Seuils individualisés", "Apports du PDF Zebris"]
521
  )
522
 
523
  with tab_profil:
@@ -666,6 +1358,9 @@ with tab_seuils:
666
  with s3:
667
  st.dataframe(symetrie_df, hide_index=True, use_container_width=True)
668
 
 
 
 
669
  with tab_pdf:
670
  if not matched_pdf:
671
  st.info("Aucun PDF Zebris associé à cet athlète n’a été trouvé.")
 
418
  return pdf
419
  return None
420
 
421
+ # =========================================================
422
+ # ANALYSE V3
423
+ # =========================================================
424
+
425
+ ANALYSIS_CONFIG = {
426
+ "heel_force_N": {
427
+ "label": "Force talon",
428
+ "unit": "N",
429
+ "description_low": "Force talon plutôt faible par rapport à la zone attendue.",
430
+ "description_normal": "Force talon dans la zone attendue.",
431
+ "description_high": "Force talon élevée, pouvant refléter une contrainte d'impact majorée."
432
+ },
433
+ "heel_pressure_N_cm2": {
434
+ "label": "Pression talon",
435
+ "unit": "N/cm²",
436
+ "description_low": "Pression talon plutôt faible.",
437
+ "description_normal": "Pression talon dans la zone attendue.",
438
+ "description_high": "Pression talon élevée, pouvant indiquer une concentration de charge accrue."
439
+ },
440
+ "cadence_spm": {
441
+ "label": "Cadence",
442
+ "unit": "pas/min",
443
+ "description_low": "Cadence basse par rapport à la zone attendue.",
444
+ "description_normal": "Cadence dans la zone attendue.",
445
+ "description_high": "Cadence élevée par rapport à la zone attendue."
446
+ },
447
+ "contact_pct": {
448
+ "label": "Temps de contact",
449
+ "unit": "%",
450
+ "description_low": "Temps de contact plutôt faible.",
451
+ "description_normal": "Temps de contact dans la zone attendue.",
452
+ "description_high": "Temps de contact élevé, pouvant traduire une dynamique de course réduite."
453
+ },
454
+ "flight_pct": {
455
+ "label": "Temps de vol",
456
+ "unit": "%",
457
+ "description_low": "Temps de vol plutôt faible.",
458
+ "description_normal": "Temps de vol dans la zone attendue.",
459
+ "description_high": "Temps de vol élevé par rapport à la zone attendue."
460
+ },
461
+ "asymmetry_pct": {
462
+ "label": "Asymétrie talon",
463
+ "unit": "%",
464
+ "description_low": "Asymétrie faible.",
465
+ "description_normal": "Asymétrie dans la zone acceptable.",
466
+ "description_high": "Asymétrie élevée, à surveiller."
467
+ },
468
+ "foot_rotation_deg": {
469
+ "label": "Différence rotation G/D",
470
+ "unit": "°",
471
+ "description_low": "Différence de rotation plutôt faible.",
472
+ "description_normal": "Différence de rotation dans la zone attendue.",
473
+ "description_high": "Différence de rotation élevée, pouvant majorer certaines contraintes mécaniques."
474
+ }
475
+ }
476
+
477
+
478
+ def clamp(value, min_value=0, max_value=100):
479
+ return max(min_value, min(max_value, value))
480
+
481
+
482
+ def classify_value(value, low, high):
483
+ if value is None or pd.isna(value):
484
+ return "non disponible"
485
+ if value < low:
486
+ return "basse"
487
+ if value > high:
488
+ return "élevée"
489
+ return "normale"
490
+
491
+
492
+ def compute_deviation_score(value, low, high):
493
+ if value is None or pd.isna(value):
494
+ return 0.0
495
+
496
+ if low <= value <= high:
497
+ return 0.0
498
+
499
+ if value < low:
500
+ if low == 0:
501
+ return 0.0
502
+ return round((low - value) / low, 3)
503
+
504
+ if value > high:
505
+ if high == 0:
506
+ return 0.0
507
+ return round((value - high) / high, 3)
508
+
509
+ return 0.0
510
+
511
+
512
+ def get_priority(status, deviation_score, variable_key=None):
513
+ if status == "normale":
514
+ return "RAS"
515
+
516
+ if status == "basse":
517
+ if variable_key in ["cadence_spm", "flight_pct"]:
518
+ return "modérée"
519
+ return "faible"
520
+
521
+ if status == "élevée":
522
+ if deviation_score >= 0.20:
523
+ return "élevée"
524
+ return "modérée"
525
+
526
+ return "RAS"
527
+
528
+
529
+ def priority_to_points(priority):
530
+ mapping = {"RAS": 0, "faible": 1, "modérée": 2, "élevée": 3}
531
+ return mapping.get(priority, 0)
532
+
533
+
534
+ def pattern_priority_to_points(priority):
535
+ mapping = {"modérée": 2, "élevée": 3}
536
+ return mapping.get(priority, 0)
537
+
538
+
539
+ def build_analysis_inputs(row, metrics, thresholds, attaque_finale):
540
+ merged_data = {
541
+ "heel_force_N": metrics["force_talon_moy"],
542
+ "heel_pressure_N_cm2": metrics["pression_talon_moy"],
543
+ "cadence_spm": row["Cadence (pas/min)"],
544
+ "contact_pct": row["Contact (%)"],
545
+ "flight_pct": row["Flight (%)"],
546
+ "asymmetry_pct": metrics["asym_talon"],
547
+ "foot_rotation_deg": metrics["diff_rotation"],
548
+ "impact_score": metrics["contraintes"],
549
+ "dynamic_score": metrics["dynamique"],
550
+ "symmetry_score": metrics["symetrie"],
551
+ "rollover_score": metrics["deroule"],
552
+ "attack_type": attaque_finale,
553
+ }
554
+
555
+ thresholds_analysis = {
556
+ "heel_force_N": {
557
+ "low": thresholds["force_n_low"],
558
+ "high": thresholds["force_n_high"],
559
+ },
560
+ "heel_pressure_N_cm2": {
561
+ "low": thresholds["pression_low"],
562
+ "high": thresholds["pression_high"],
563
+ },
564
+ "cadence_spm": {
565
+ "low": thresholds["cadence_low"],
566
+ "high": thresholds["cadence_high"],
567
+ },
568
+ "contact_pct": {
569
+ "low": thresholds["contact_low"],
570
+ "high": thresholds["contact_high"],
571
+ },
572
+ "flight_pct": {
573
+ "low": thresholds["flight_low"],
574
+ "high": thresholds["flight_high"],
575
+ },
576
+ "asymmetry_pct": {
577
+ "low": thresholds["asym_low"],
578
+ "high": thresholds["asym_high"],
579
+ },
580
+ "foot_rotation_deg": {
581
+ "low": thresholds["rotation_low"],
582
+ "high": thresholds["rotation_high"],
583
+ },
584
+ }
585
+
586
+ return merged_data, thresholds_analysis
587
+
588
+
589
+ def analyze_variable(key, value, thresholds):
590
+ if key not in ANALYSIS_CONFIG:
591
+ return None
592
+ if key not in thresholds:
593
+ return None
594
+
595
+ config = ANALYSIS_CONFIG[key]
596
+ low = thresholds[key]["low"]
597
+ high = thresholds[key]["high"]
598
+
599
+ status = classify_value(value, low, high)
600
+ deviation_score = compute_deviation_score(value, low, high)
601
+ priority = get_priority(status, deviation_score, variable_key=key)
602
+
603
+ if status == "basse":
604
+ interpretation = config["description_low"]
605
+ elif status == "élevée":
606
+ interpretation = config["description_high"]
607
+ elif status == "normale":
608
+ interpretation = config["description_normal"]
609
+ else:
610
+ interpretation = "Donnée non disponible."
611
+
612
+ return {
613
+ "variable": key,
614
+ "label": config["label"],
615
+ "value": value,
616
+ "unit": config["unit"],
617
+ "low": low,
618
+ "high": high,
619
+ "status": status,
620
+ "priority": priority,
621
+ "deviation_score": deviation_score,
622
+ "interpretation": interpretation,
623
+ }
624
+
625
+
626
+ def run_biomech_analysis(merged_data, thresholds_analysis):
627
+ results = []
628
+ for key in ANALYSIS_CONFIG.keys():
629
+ result = analyze_variable(key, merged_data.get(key), thresholds_analysis)
630
+ if result is not None:
631
+ results.append(result)
632
+ return pd.DataFrame(results)
633
+
634
+
635
+ def get_status_map(df_analysis):
636
+ if df_analysis.empty:
637
+ return {}
638
+ return dict(zip(df_analysis["variable"], df_analysis["status"]))
639
+
640
+
641
+ def is_high(status_map, key):
642
+ return status_map.get(key) == "élevée"
643
+
644
+
645
+ def is_low(status_map, key):
646
+ return status_map.get(key) == "basse"
647
+
648
+
649
+ def detect_combined_patterns(merged_data, df_analysis):
650
+ patterns = []
651
+ status_map = get_status_map(df_analysis)
652
+
653
+ impact_score = merged_data.get("impact_score")
654
+ dynamic_score = merged_data.get("dynamic_score")
655
+ symmetry_score = merged_data.get("symmetry_score")
656
+ rollover_score = merged_data.get("rollover_score")
657
+ attack_type = merged_data.get("attack_type", "indéterminée")
658
+
659
+ if is_high(status_map, "heel_force_N") and is_high(status_map, "heel_pressure_N_cm2"):
660
+ patterns.append({
661
+ "name": "impact_load_flag",
662
+ "title": "Contrainte d'impact majorée",
663
+ "priority": "élevée",
664
+ "category": "impact",
665
+ "message": "La combinaison d'une force talon élevée et d'une pression talon élevée suggère une contrainte d'impact majorée."
666
+ })
667
+ elif is_high(status_map, "heel_force_N") or is_high(status_map, "heel_pressure_N_cm2"):
668
+ patterns.append({
669
+ "name": "impact_signal_flag",
670
+ "title": "Signal d'impact à surveiller",
671
+ "priority": "modérée",
672
+ "category": "impact",
673
+ "message": "Un marqueur d'impact talonnier est au-dessus de la zone attendue."
674
+ })
675
+
676
+ if is_low(status_map, "cadence_spm") and is_high(status_map, "contact_pct"):
677
+ patterns.append({
678
+ "name": "low_dynamics_flag",
679
+ "title": "Dynamique de course possiblement réduite",
680
+ "priority": "élevée",
681
+ "category": "dynamics",
682
+ "message": "La combinaison d'une cadence basse et d'un temps de contact élevé évoque une dynamique de course potentiellement réduite."
683
+ })
684
+
685
+ if is_low(status_map, "flight_pct") and is_high(status_map, "contact_pct"):
686
+ patterns.append({
687
+ "name": "reactivity_flag",
688
+ "title": "Réactivité mécanique possiblement diminuée",
689
+ "priority": "modérée",
690
+ "category": "dynamics",
691
+ "message": "Le temps de vol bas associé à un temps de contact élevé évoque une moindre réactivité mécanique."
692
+ })
693
+
694
+ if pd.notna(dynamic_score) and dynamic_score < 50:
695
+ if is_high(status_map, "contact_pct") or is_low(status_map, "flight_pct"):
696
+ patterns.append({
697
+ "name": "global_dynamic_flag",
698
+ "title": "Déficit dynamique renforcé",
699
+ "priority": "élevée",
700
+ "category": "dynamics",
701
+ "message": "Le score de dynamique bas renforce l'hypothèse d'une dynamique de course altérée."
702
+ })
703
+
704
+ if is_high(status_map, "asymmetry_pct"):
705
+ if pd.notna(symmetry_score) and symmetry_score < 60:
706
+ patterns.append({
707
+ "name": "asymmetry_flag",
708
+ "title": "Asymétrie renforcée",
709
+ "priority": "élevée",
710
+ "category": "symmetry",
711
+ "message": "L'asymétrie mesurée est élevée et cohérente avec un score de symétrie faible."
712
+ })
713
+ else:
714
+ patterns.append({
715
+ "name": "asymmetry_watch_flag",
716
+ "title": "Asymétrie à surveiller",
717
+ "priority": "modérée",
718
+ "category": "symmetry",
719
+ "message": "Une asymétrie au-dessus de la zone attendue est observée."
720
+ })
721
+
722
+ if is_high(status_map, "foot_rotation_deg"):
723
+ if pd.notna(rollover_score) and rollover_score < 60:
724
+ patterns.append({
725
+ "name": "mechanical_pattern_flag",
726
+ "title": "Pattern mécanique distal à surveiller",
727
+ "priority": "modérée",
728
+ "category": "mechanics",
729
+ "message": "La différence de rotation élevée associée à un déroulé peu efficient suggère un pattern mécanique distal à surveiller."
730
+ })
731
+ else:
732
+ patterns.append({
733
+ "name": "rotation_flag",
734
+ "title": "Différence de rotation élevée",
735
+ "priority": "modérée",
736
+ "category": "mechanics",
737
+ "message": "La différence de rotation est au-dessus de la zone attendue."
738
+ })
739
+
740
+ if attack_type == "attaque talon":
741
+ if is_high(status_map, "heel_force_N") or is_high(status_map, "heel_pressure_N_cm2"):
742
+ patterns.append({
743
+ "name": "rearfoot_impact_context",
744
+ "title": "Attaque talon avec charge d'impact marquée",
745
+ "priority": "modérée",
746
+ "category": "attack",
747
+ "message": "Le profil d'attaque talon est associé à des marqueurs d'impact élevés."
748
+ })
749
+
750
+ if pd.notna(dynamic_score) and pd.notna(rollover_score):
751
+ if dynamic_score < 50 and rollover_score < 55:
752
+ patterns.append({
753
+ "name": "global_efficiency_flag",
754
+ "title": "Efficience mécanique possiblement réduite",
755
+ "priority": "modérée",
756
+ "category": "global",
757
+ "message": "La combinaison d'un score de dynamique bas et d'un déroulé faible évoque une efficience mécanique possiblement réduite."
758
+ })
759
+
760
+ return deduplicate_patterns(patterns)
761
+
762
+
763
+ def deduplicate_patterns(patterns):
764
+ seen = set()
765
+ unique_patterns = []
766
+ for pattern in patterns:
767
+ key = (pattern["name"], pattern["title"])
768
+ if key not in seen:
769
+ seen.add(key)
770
+ unique_patterns.append(pattern)
771
+ return unique_patterns
772
+
773
+
774
+ def compute_domain_scores(merged_data, df_analysis, patterns):
775
+ if df_analysis.empty:
776
+ return {
777
+ "impact": 0,
778
+ "dynamics": 0,
779
+ "symmetry": 0,
780
+ "mechanics": 0,
781
+ "attack": 0,
782
+ "global": 0,
783
+ }
784
+
785
+ row_map = {row["variable"]: row for _, row in df_analysis.iterrows()}
786
+
787
+ def var_points(var_name, weight=1.0):
788
+ row = row_map.get(var_name)
789
+ if not row:
790
+ return 0.0
791
+ base = priority_to_points(row["priority"]) * 10
792
+ bonus = row["deviation_score"] * 20
793
+ return (base + bonus) * weight
794
+
795
+ def pattern_points(category):
796
+ total = 0
797
+ for p in patterns:
798
+ if p["category"] == category:
799
+ total += pattern_priority_to_points(p["priority"]) * 10
800
+ return total
801
+
802
+ impact_score_profile = merged_data.get("impact_score")
803
+ dynamic_score_profile = merged_data.get("dynamic_score")
804
+ symmetry_score_profile = merged_data.get("symmetry_score")
805
+ rollover_score_profile = merged_data.get("rollover_score")
806
+
807
+ impact = 0
808
+ impact += var_points("heel_force_N", 1.2)
809
+ impact += var_points("heel_pressure_N_cm2", 1.2)
810
+ impact += pattern_points("impact")
811
+ impact += pattern_points("attack")
812
+ if pd.notna(impact_score_profile) and impact_score_profile >= 70:
813
+ impact += (impact_score_profile - 70) * 0.2
814
+
815
+ dynamics = 0
816
+ dynamics += var_points("cadence_spm", 1.0)
817
+ dynamics += var_points("contact_pct", 1.2)
818
+ dynamics += var_points("flight_pct", 1.0)
819
+ dynamics += pattern_points("dynamics")
820
+ if pd.notna(dynamic_score_profile) and dynamic_score_profile < 60:
821
+ dynamics += (60 - dynamic_score_profile) * 0.5
822
+
823
+ symmetry = 0
824
+ symmetry += var_points("asymmetry_pct", 1.5)
825
+ symmetry += pattern_points("symmetry")
826
+ if pd.notna(symmetry_score_profile) and symmetry_score_profile < 70:
827
+ symmetry += (70 - symmetry_score_profile) * 0.5
828
+
829
+ mechanics = 0
830
+ mechanics += var_points("foot_rotation_deg", 1.4)
831
+ mechanics += pattern_points("mechanics")
832
+ if pd.notna(rollover_score_profile) and rollover_score_profile < 65:
833
+ mechanics += (65 - rollover_score_profile) * 0.35
834
+
835
+ attack = 0
836
+ attack += pattern_points("attack")
837
+ attack += 0.5 * var_points("heel_force_N", 1.0)
838
+ attack += 0.5 * var_points("heel_pressure_N_cm2", 1.0)
839
+
840
+ global_score = (
841
+ impact * 0.30 +
842
+ dynamics * 0.30 +
843
+ symmetry * 0.20 +
844
+ mechanics * 0.20
845
+ )
846
+
847
+ return {
848
+ "impact": int(clamp(round(impact))),
849
+ "dynamics": int(clamp(round(dynamics))),
850
+ "symmetry": int(clamp(round(symmetry))),
851
+ "mechanics": int(clamp(round(mechanics))),
852
+ "attack": int(clamp(round(attack))),
853
+ "global": int(clamp(round(global_score))),
854
+ }
855
+
856
+
857
+ def get_domain_label(score):
858
+ if score >= 75:
859
+ return "élevé"
860
+ if score >= 45:
861
+ return "modéré"
862
+ if score >= 20:
863
+ return "léger"
864
+ return "faible"
865
+
866
+
867
+ def get_primary_domains(domain_scores, top_n=3):
868
+ filtered = {k: v for k, v in domain_scores.items() if k != "global"}
869
+ return sorted(filtered.items(), key=lambda x: x[1], reverse=True)[:top_n]
870
+
871
+
872
+ def compute_global_summary_v3(df_analysis, patterns, domain_scores):
873
+ if df_analysis.empty:
874
+ return {
875
+ "normal_count": 0,
876
+ "attention_count": 0,
877
+ "high_priority_count": 0,
878
+ "moderate_priority_count": 0,
879
+ "pattern_count": 0,
880
+ "global_level": "indéterminé",
881
+ }
882
+
883
+ normal_count = int((df_analysis["status"] == "normale").sum())
884
+ attention_count = int((df_analysis["status"] != "normale").sum())
885
+ high_priority_count = int((df_analysis["priority"] == "élevée").sum())
886
+ moderate_priority_count = int((df_analysis["priority"] == "modérée").sum())
887
+
888
+ pattern_high = sum(1 for p in patterns if p["priority"] == "élevée")
889
+ pattern_moderate = sum(1 for p in patterns if p["priority"] == "modérée")
890
+ global_domain_score = domain_scores.get("global", 0)
891
+
892
+ total_high = high_priority_count + pattern_high
893
+ total_moderate = moderate_priority_count + pattern_moderate
894
+
895
+ if total_high >= 2 or global_domain_score >= 75:
896
+ global_level = "élevé"
897
+ elif total_high == 1 or total_moderate >= 3 or global_domain_score >= 45:
898
+ global_level = "modéré"
899
+ elif attention_count >= 1 or len(patterns) >= 1 or global_domain_score >= 20:
900
+ global_level = "léger"
901
+ else:
902
+ global_level = "faible"
903
+
904
+ return {
905
+ "normal_count": normal_count,
906
+ "attention_count": attention_count,
907
+ "high_priority_count": total_high,
908
+ "moderate_priority_count": total_moderate,
909
+ "pattern_count": len(patterns),
910
+ "global_level": global_level,
911
+ }
912
+
913
+
914
+ def generate_global_narrative(summary, domain_scores):
915
+ if summary["global_level"] == "faible":
916
+ return (
917
+ "Le profil est globalement cohérent par rapport aux seuils individualisés, "
918
+ "sans signal biomécanique majeur détecté à ce stade."
919
+ )
920
+
921
+ label_map = {
922
+ "impact": "contrainte d'impact",
923
+ "dynamics": "dynamique de course",
924
+ "symmetry": "symétrie",
925
+ "mechanics": "mécanique distale",
926
+ "attack": "organisation de l'attaque",
927
+ }
928
+
929
+ top_domains = get_primary_domains(domain_scores, top_n=3)
930
+ top_labels = [label_map.get(name, name) for name, score in top_domains if score >= 20]
931
+ domains_text = ", ".join(top_labels) if top_labels else "plusieurs dimensions biomécaniques"
932
+
933
+ if summary["global_level"] == "léger":
934
+ return f"Le profil met en évidence quelques signaux isolés, principalement autour de : {domains_text}."
935
+ if summary["global_level"] == "modéré":
936
+ return f"Le profil présente plusieurs points d'attention cohérents, notamment sur : {domains_text}."
937
+ return (
938
+ f"Le profil présente plusieurs signaux convergents, en particulier sur : {domains_text}. "
939
+ "Une interprétation approfondie est justifiée avant la phase de recommandations."
940
+ )
941
+
942
+
943
+ def prepare_analysis_table(df_analysis):
944
+ if df_analysis.empty:
945
+ return df_analysis
946
+
947
+ priority_order = {"élevée": 3, "modérée": 2, "faible": 1, "RAS": 0}
948
+ status_order = {"élevée": 2, "basse": 1, "normale": 0, "non disponible": -1}
949
+
950
+ df = df_analysis.copy()
951
+ df["priority_rank"] = df["priority"].map(priority_order).fillna(0)
952
+ df["status_rank"] = df["status"].map(status_order).fillna(-1)
953
+
954
+ df = df.sort_values(
955
+ by=["priority_rank", "status_rank", "deviation_score"],
956
+ ascending=[False, False, False]
957
+ )
958
+
959
+ return df.drop(columns=["priority_rank", "status_rank"])
960
+
961
+
962
+ def display_status_badge(status):
963
+ if status == "normale":
964
+ st.success("Normale")
965
+ elif status == "basse":
966
+ st.warning("Basse")
967
+ elif status == "élevée":
968
+ st.error("Élevée")
969
+ else:
970
+ st.info("Non disponible")
971
+
972
+
973
+ def display_priority_badge(priority):
974
+ if priority == "RAS":
975
+ st.success("RAS")
976
+ elif priority == "faible":
977
+ st.info("Faible")
978
+ elif priority == "modérée":
979
+ st.warning("Modérée")
980
+ elif priority == "élevée":
981
+ st.error("Élevée")
982
+ else:
983
+ st.info(priority)
984
+
985
+
986
+ def display_pattern_badge(priority):
987
+ if priority == "élevée":
988
+ st.error("Pattern prioritaire")
989
+ elif priority == "modérée":
990
+ st.warning("Pattern à surveiller")
991
+ else:
992
+ st.info(priority)
993
+
994
+
995
+ def render_analysis_tab_v3(merged_data, thresholds_analysis):
996
+ st.subheader("📈 Analyse des données")
997
+
998
+ df_analysis = run_biomech_analysis(merged_data, thresholds_analysis)
999
+ patterns = detect_combined_patterns(merged_data, df_analysis)
1000
+ domain_scores = compute_domain_scores(merged_data, df_analysis, patterns)
1001
+ summary = compute_global_summary_v3(df_analysis, patterns, domain_scores)
1002
+ narrative = generate_global_narrative(summary, domain_scores)
1003
+
1004
+ c1, c2, c3, c4, c5 = st.columns(5)
1005
+ c1.metric("Variables normales", summary["normal_count"])
1006
+ c2.metric("Points d'attention", summary["attention_count"])
1007
+ c3.metric("Priorités hautes", summary["high_priority_count"])
1008
+ c4.metric("Patterns détectés", summary["pattern_count"])
1009
+ c5.metric("Niveau global", summary["global_level"].capitalize())
1010
+
1011
+ st.markdown("---")
1012
+ st.markdown("### Conclusion synthétique")
1013
+
1014
+ if summary["global_level"] == "faible":
1015
+ st.success(narrative)
1016
+ elif summary["global_level"] == "léger":
1017
+ st.info(narrative)
1018
+ elif summary["global_level"] == "modéré":
1019
+ st.warning(narrative)
1020
+ else:
1021
+ st.error(narrative)
1022
+
1023
+ st.markdown("---")
1024
+ st.markdown("### Scores par domaine")
1025
+
1026
+ d1, d2, d3, d4, d5 = st.columns(5)
1027
+ d1.metric("Impact", f"{domain_scores['impact']}/100")
1028
+ d2.metric("Dynamique", f"{domain_scores['dynamics']}/100")
1029
+ d3.metric("Symétrie", f"{domain_scores['symmetry']}/100")
1030
+ d4.metric("Mécanique", f"{domain_scores['mechanics']}/100")
1031
+ d5.metric("Attaque", f"{domain_scores['attack']}/100")
1032
+
1033
+ st.markdown("---")
1034
+ st.markdown("### Synthèse par variable")
1035
+
1036
+ if df_analysis.empty:
1037
+ st.info("Aucune donnée disponible pour l'analyse.")
1038
+ else:
1039
+ df_display = prepare_analysis_table(df_analysis)[[
1040
+ "label", "value", "unit", "low", "high", "status", "priority"
1041
+ ]].copy()
1042
+
1043
+ df_display.columns = [
1044
+ "Variable", "Valeur", "Unité", "Seuil bas", "Seuil haut", "Statut", "Priorité"
1045
+ ]
1046
+ st.dataframe(df_display, hide_index=True, use_container_width=True)
1047
+
1048
+ st.markdown("---")
1049
+ st.markdown("### Patterns biomécaniques détectés")
1050
+
1051
+ if not patterns:
1052
+ st.success("Aucun pattern combiné majeur détecté.")
1053
+ else:
1054
+ for pattern in patterns:
1055
+ col1, col2 = st.columns([4, 1])
1056
+ with col1:
1057
+ st.markdown(f"**{pattern['title']}**")
1058
+ st.write(pattern["message"])
1059
+ with col2:
1060
+ display_pattern_badge(pattern["priority"])
1061
+ st.markdown("---")
1062
+
1063
+ st.markdown("### Détail par variable")
1064
+ if not df_analysis.empty:
1065
+ df_sorted = prepare_analysis_table(df_analysis)
1066
+ for _, row in df_sorted.iterrows():
1067
+ col1, col2, col3 = st.columns([2.5, 1, 1])
1068
+
1069
+ with col1:
1070
+ st.markdown(f"**{row['label']}**")
1071
+ st.write(
1072
+ f"Valeur mesurée : **{row['value']:.2f} {row['unit']}** \n"
1073
+ f"Zone attendue : **{row['low']:.2f} à {row['high']:.2f} {row['unit']}**"
1074
+ if pd.notna(row["value"]) else
1075
+ f"Valeur mesurée : **N/A** \nZone attendue : **{row['low']:.2f} à {row['high']:.2f} {row['unit']}**"
1076
+ )
1077
+ st.write(row["interpretation"])
1078
+
1079
+ with col2:
1080
+ st.markdown("**Statut**")
1081
+ display_status_badge(row["status"])
1082
+
1083
+ with col3:
1084
+ st.markdown("**Priorité**")
1085
+ display_priority_badge(row["priority"])
1086
+
1087
+ st.markdown("---")
1088
+
1089
+ st.markdown("### Axes dominants à prioriser")
1090
+ top_domains = get_primary_domains(domain_scores, top_n=3)
1091
+ domain_name_map = {
1092
+ "impact": "Impact",
1093
+ "dynamics": "Dynamique",
1094
+ "symmetry": "Symétrie",
1095
+ "mechanics": "Mécanique distale",
1096
+ "attack": "Attaque",
1097
+ }
1098
+
1099
+ shown = False
1100
+ for domain_key, score in top_domains:
1101
+ if score >= 20:
1102
+ shown = True
1103
+ st.markdown(f"- **{domain_name_map.get(domain_key, domain_key)}** : {score}/100 (**{get_domain_label(score)}**)")
1104
+
1105
+ if not shown:
1106
+ st.success("Aucun axe dominant majeur ne se dégage à ce stade.")
1107
 
1108
  # Charge CSV
1109
  dfs = []
 
1175
  if matched_pdf and matched_pdf.get("attaque_pdf") and matched_pdf["attaque_pdf"] != "indéterminée":
1176
  attaque_finale = matched_pdf["attaque_pdf"]
1177
 
1178
+ merged_data, thresholds_analysis = build_analysis_inputs(
1179
+ row=row,
1180
+ metrics=metrics,
1181
+ thresholds=thresholds,
1182
+ attaque_finale=attaque_finale,
1183
+ )
1184
 
1185
  def build_summary(row, metrics, attaque_finale):
1186
  contraintes_txt = (
 
1208
 
1209
  summary = build_summary(row, metrics, attaque_finale)
1210
 
1211
+ tab_profil, tab_seuils, tab_analyse, tab_pdf = st.tabs(
1212
+ ["Profil biomécanique", "Seuils individualisés", "Analyse", "Apports du PDF Zebris"]
1213
  )
1214
 
1215
  with tab_profil:
 
1358
  with s3:
1359
  st.dataframe(symetrie_df, hide_index=True, use_container_width=True)
1360
 
1361
+ with tab_analyse:
1362
+ render_analysis_tab_v3(merged_data, thresholds_analysis)
1363
+
1364
  with tab_pdf:
1365
  if not matched_pdf:
1366
  st.info("Aucun PDF Zebris associé à cet athlète n’a été trouvé.")