diff --git a/app/but/jury_but.py b/app/but/jury_but.py
index fa956c8668ad86136c9ff8277d7b15c501a2d6cf..4fd87833706f0e75ce2118a1996865ee0f3bc34f 100644
--- a/app/but/jury_but.py
+++ b/app/but/jury_but.py
@@ -5,163 +5,463 @@
 ##############################################################################
 
 """Jury BUT: logique de gestion
+
+Utilisation:
+    1) chargement page jury, pour un étudiant et un formsemestre BUT quelconque
+    - DecisionsProposeesAnnee(formsemestre)
+      cherche l'autre formsemestre de la même année scolaire (peut ne pas exister)
+      cherche les RCUEs de l'année (BUT1, 2, 3)
+        pour un redoublant, le RCUE peut considérer un formsemestre d'une année antérieure.
+
+    on instancie des DecisionsProposees pour les 
+        différents éléments (UEs, RCUEs, Année, Diplôme)
+        Cela donne 
+            - les codes possibles (dans .codes)
+            - le code actuel si une décision existe déjà (dans code_valide)
+            - pour les UEs, le rcue s'il y en a un)
+    
+    2) Validation pour l'utilisateur (form)) => enregistrement code
+            - on vérifie que le code soumis est bien dans les codes possibles
+            - on enregistre la décision (dans ScolarFormSemestreValidation pour les UE,
+                ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années)
+            - Si RCUE validé, on déclenche d'éventuelles validations: 
+            ("La validation des deux UE du niveau d’une compétence emporte la validation 
+            de l’ensemble des UE du niveau inférieur de cette même compétence.")
+        
+Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une 
+autorisation d'inscription dans le semestre pair suivant: `ScolarAutorisationInscription`.
+Les jurys de semestres pairs non (S2, S4, S6): il y a une décision sur l'année (ETP)
+    - autorisation en S_2n+1 (S3 ou S5) si: ADM, ADJ, PASD, PAS1CN
+    - autorisation en S2n-1 (S1, S3 ou S5) si: RED
+    - rien si pour les autres codes d'année.
+
+Le formulaire permet de choisir des codes d'UE, RCUE et Année (ETP).
+Mais normalement, les codes d'UE sont à choisir: les RCUE et l'année s'en déduisent.
+Si l'utilisateur coche "décision manuelle", il peut alors choisir les codes RCUE et années.
+
+La soumission du formulaire: 
+    - etud, formation 
+    - UEs: [(formsemestre, ue, code), ...]
+    - RCUE: [(formsemestre, ue, code), ...] le formsemestre est celui d'indice pair du niveau
+        (S2, S4 ou S6), il sera regoupé avec celui impair de la même année ou de la suivante.
+    - Année: [(formsemestre, code)]
+
+DecisionsProposeesAnnee:
+    si 1/2 des rcue et aucun < 8 + pour S5 condition sur les UE de BUT1 et BUT2
+    => charger les DecisionsProposeesRCUE
+
+DecisionsProposeesRCUE: les RCUEs pour cette année
+    validable, compensable, ajourné. Utilise classe RegroupementCoherentUE
+
+DecisionsProposeesUE: décisions de jury sur une UE du BUT
+    initialisation sans compensation (ue isolée), mais
+    DecisionsProposeesRCUE appelera .set_compensable()
+    si on a la possibilité de la compenser dans le RCUE.
 """
 from operator import attrgetter
+from typing import Union
 
+from app import log
 from app.comp.res_but import ResultatsSemestreBUT
 from app.comp import res_sem
-from app.models import but_validations
+
 from app.models.but_refcomp import (
     ApcAnneeParcours,
     ApcCompetence,
     ApcNiveau,
+    ApcParcours,
     ApcParcoursNiveauCompetence,
 )
-from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
+from app.models import but_validations
+from app.models.but_validations import (
+    ApcValidationAnnee,
+    ApcValidationRCUE,
+    RegroupementCoherentUE,
+)
 from app.models.etudiants import Identite
 from app.models.formations import Formation
-from app.models.formsemestre import FormSemestre
+from app.models.formsemestre import FormSemestre, FormSemestreInscription
 from app.models.ues import UniteEns
-from app.scodoc import sco_codes_parcours as codes
+from app.models.validations import ScolarFormSemestreValidation
+from app.scodoc import sco_codes_parcours as sco_codes
 from app.scodoc import sco_utils as scu
-from app.scodoc.sco_exceptions import ScoException
+from app.scodoc.sco_exceptions import ScoException, ScoValueError
 
 
-class RegroupementCoherentUE:
-    def __init__(
-        self,
-        etud: Identite,
-        formsemestre_1: FormSemestre,
-        ue_1: UniteEns,
-        formsemestre_2: FormSemestre,
-        ue_2: UniteEns,
-    ):
-        self.formsemestre_1 = formsemestre_1
-        self.ue_1 = ue_1
-        self.formsemestre_2 = formsemestre_2
-        self.ue_2 = ue_2
-        # stocke les moyennes d'UE
-        res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_1)
-        if ue_1.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_1.id]:
-            self.moy_ue_1 = res.etud_moy_ue[ue_1.id][etud.id]
-        else:
-            self.moy_ue_1 = None
-        res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_2)
-        if ue_2.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_2.id]:
-            self.moy_ue_2 = res.etud_moy_ue[ue_1.id][etud.id]
-        else:
-            self.moy_ue_2 = None
-        # Calcul de la moyenne au RCUE
-        if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
-            # Moyenne RCUE non pondérée (pour le moment)
-            self.moy_rcue = (self.moy_ue_1 + self.moy_ue_2) / 2
-        else:
-            self.moy_rcue = None
+class DecisionsProposees:
+    """Une décision de jury proposé, constituée d'une liste de codes et d'une explication.
+    Super-classe, spécialisée pour les UE, les RCUE, les années et le diplôme.
 
+    validation : None ou une instance de d'une classe avec un champ code
+                ApcValidationRCUE, ApcValidationAnnee ou ScolarFormSemestreValidation
+    """
 
-class DecisionsProposees:
     # Codes toujours proposés sauf si include_communs est faux:
-    codes_communs = [codes.RAT, codes.DEF, codes.ABAN, codes.DEM, codes.UEBSL]
+    codes_communs = [
+        sco_codes.RAT,
+        sco_codes.DEF,
+        sco_codes.ABAN,
+        sco_codes.DEM,
+        sco_codes.UEBSL,
+    ]
 
-    def __init__(self, code: str = None, explanation="", include_communs=True):
+    def __init__(
+        self,
+        etud: Identite = None,
+        code: Union[str, list[str]] = None,
+        explanation="",
+        code_valide=None,
+        include_communs=True,
+    ):
+        self.etud = etud
+        self.codes = []
+        "Les codes attribuables par ce jury"
         if include_communs:
             self.codes = self.codes_communs
-        else:
-            self.codes = []
         if isinstance(code, list):
             self.codes = code + self.codes_communs
         elif code is not None:
             self.codes = [code] + self.codes_communs
-        self.explanation = explanation
+        self.code_valide: str = code_valide
+        "La décision actuelle enregistrée"
+        self.explanation: str = explanation
+        "Explication en à afficher à côté de la décision"
 
     def __repr__(self) -> str:
-        return f"""<{self.__class__.__name__} codes={self.codes} explanation={self.explanation}"""
-
+        return f"""<{self.__class__.__name__} valid={self.code_valide
+        } codes={self.codes} explanation={self.explanation}"""
 
-def decisions_ue_proposees(
-    etud: Identite, formsemestre: FormSemestre, ue: UniteEns
-) -> DecisionsProposees:
-    """Liste des codes de décisions que l'on peut proposer pour
-    cette UE de cet étudiant dans ce semestre.
 
-    si DEF ou DEM ou ABAN ou ABL sur année BUT: seulement DEF, DEM, ABAN, ABL
+class DecisionsProposeesAnnee(DecisionsProposees):
+    """Décisions de jury sur une année (ETP) du BUT
 
-    si moy_ue > 10, ADM
-    sinon si compensation dans RCUE: CMP
-    sinon: ADJ, AJ
-    et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL
+    Le texte:
+    La poursuite d'études dans un semestre pair d’une même année est de droit
+    pour tout étudiant. La poursuite d’études dans un semestre impair est
+    possible si et seulement si l’étudiant a obtenu :
+        - la moyenne à plus de la moitié des regroupements cohérents d’UE;
+        - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE.
+    La poursuite d'études dans le semestre 5 nécessite de plus la validation
+    de toutes les UE des semestres 1 et 2 dans les conditions de validation
+    des points 4.3 (moy_ue >= 10) et 4.4 (compensation rcue), ou par décision
+    de jury.
     """
-    if ue.type == codes.UE_SPORT:
-        return DecisionsProposees(
-            explanation="UE bonus, pas de décision de jury", include_communs=False
-        )
-    # Code sur année ?
-    decision_annee = ApcValidationAnnee.query.filter_by(
-        etudid=etud.id, annee_scolaire=formsemestre.annee_scolaire()
-    ).first()
-    if (
-        decision_annee is not None and decision_annee.code in codes.CODES_ANNEE_ARRET
-    ):  # DEF, DEM, ABAN, ABL
-        return DecisionsProposees(
-            code=decision_annee.code,
-            explanation=f"l'année a le code {decision_annee.code}",
-            include_communs=False,
+
+    # Codes toujours proposés sauf si include_communs est faux:
+    codes_communs = [
+        sco_codes.RAT,
+        sco_codes.ABAN,
+        sco_codes.ABL,
+        sco_codes.DEF,
+        sco_codes.DEM,
+        sco_codes.EXCLU,
+    ]
+
+    def __init__(
+        self,
+        etud: Identite,
+        formsemestre: FormSemestre,
+    ):
+        super().__init__(etud=etud)
+        formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre)
+        assert (
+            (formsemestre_pair is None)
+            or (formsemestre_impair is None)
+            or (
+                ((formsemestre_pair.semestre_id - formsemestre_impair.semestre_id) == 1)
+                and (
+                    formsemestre_pair.formation.referentiel_competence_id
+                    == formsemestre_impair.formation.referentiel_competence_id
+                )
+            )
         )
-    # Moyenne de l'UE ?
-    res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
-    if not ue.id in res.etud_moy_ue:
-        return DecisionsProposees(explanation="UE sans résultat")
-    if not etud.id in res.etud_moy_ue[ue.id]:
-        return DecisionsProposees(explanation="Étudiant sans résultat dans cette UE")
-    moy_ue = res.etud_moy_ue[ue.id][etud.id]
-    if moy_ue > (codes.ParcoursBUT.BARRE_MOY - codes.NOTES_TOLERANCE):
-        return DecisionsProposees(
-            code=codes.ADM,
-            explanation=f"Moyenne >= {codes.ParcoursBUT.BARRE_MOY}/20",
+
+        self.formsemestre_impair = formsemestre_impair
+        "le 1er semestre de l'année scolaire considérée (S1, S3, S5)"
+        self.formsemestre_pair = formsemestre_pair
+        "le second formsemestre de la même année scolaire (S2, S4, S6)"
+        self.annee_but = formsemestre_impair.semestre_id // 2 + 1
+        "le rang de l'année dans le BUT: 1, 2, 3"
+        assert self.annee_but in (1, 2, 3)
+        self.validation = ApcValidationAnnee.query.filter_by(
+            etudid=self.etud.id,
+            formsemestre_id=formsemestre_impair.id,
+            ordre=self.annee_but,
+        ).first()
+        if self.validation is not None:
+            self.code_valide = self.validation.code
+        self.parcour = None
+        "Le parcours considéré (celui du semestre pair, ou à défaut impair)"
+        self.ues_impair, self.ues_pair = self.compute_ues_annee()  # pylint: disable=all
+        assert self.parcour is not None
+        self.rcues_annee = self.compute_rcues_annee()
+        "RCUEs de l'année"
+
+        self.nb_competences = len(
+            ApcNiveau.niveaux_annee_de_parcours(self.parcour, self.annee_but).all()
+        )  # note that .count() won't give the same res
+        self.nb_validables = len(
+            [rcue for rcue in self.rcues_annee if rcue.est_validable()]
         )
-    # Compensation dans le RCUE ?
-    other_ue, other_formsemestre = but_validations.get_other_ue_rcue(ue, etud.id)
-    if other_ue is not None:
-        # inscrit à une autre UE du même RCUE
-        other_res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
-            other_formsemestre
+        self.nb_rcues_under_8 = len(
+            [rcue for rcue in self.rcues_annee if not rcue.est_suffisant()]
         )
-        if (other_ue.id in other_res.etud_moy_ue) and (
-            etud.id in other_res.etud_moy_ue[other_ue.id]
-        ):
-            other_moy_ue = other_res.etud_moy_ue[other_ue.id][etud.id]
-            # Moyenne RCUE: non pondérée (pour le moment)
-            moy_rcue = (moy_ue + other_moy_ue) / 2
-            if moy_rcue > codes.NOTES_BARRE_GEN_COMPENSATION:  # 10-epsilon
-                return DecisionsProposees(
-                    code=codes.CMP,
-                    explanation=f"Compensée par {other_ue} (moyenne RCUE={scu.fmt_note(moy_rcue)}/20",
+        # année ADM si toutes RCUE validées (sinon PASD)
+        admis = self.nb_validables == self.nb_competences
+        valide_moitie_rcue = self.nb_validables > self.nb_competences // 2
+        # Peut passer si plus de la moitié validables et tous > 8
+        passage_de_droit = valide_moitie_rcue and (self.nb_rcues_under_8 == 0)
+        # XXX TODO ajouter condition pour passage en S5
+
+        # Reste à attribuer ADM, ADJ, PASD, PAS1NCI, RED, NAR
+        expl_rcues = f"{self.nb_validables} validables sur {self.nb_competences}"
+        if admis:
+            self.codes = [sco_codes.ADM] + self.codes
+            self.explanation = expl_rcues
+        elif passage_de_droit:
+            self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes
+            self.explanation = expl_rcues
+        elif valide_moitie_rcue:  # mais au moins 1 rcue insuffisante
+            self.codes = [sco_codes.PAS1NCI, sco_codes.ADJ] + self.codes
+            self.explanation = expl_rcues + f" et {self.nb_rcues_under_8} < 8"
+        else:
+            self.codes = [sco_codes.RED, sco_codes.NAR, sco_codes.ADJ] + self.codes
+            self.explanation = expl_rcues + f" et {self.nb_rcues_under_8} < 8"
+        #
+
+    def infos(self) -> str:
+        "informations, for debugging purpose"
+        return f"""DecisionsProposeesAnnee
+        etud: {self.etud}
+        formsemestre_pair: {self.formsemestre_pair}
+        formsemestre_impair: {self.formsemestre_impair}
+        RCUEs: {self.rcues_annee}
+        nb_competences: {self.nb_competences}
+        nb_nb_validables: {self.nb_validables}
+        codes: {self.codes}
+        explanation: {self.explanation}
+        """
+
+    def comp_formsemestres(
+        self, formsemestre: FormSemestre
+    ) -> tuple[FormSemestre, FormSemestre]:
+        "les deux formsemestres de l'année scolaire à laquelle appartient formsemestre"
+        if formsemestre.semestre_id % 2 == 0:
+            other_semestre_id = formsemestre.semestre_id - 1
+        else:
+            other_semestre_id = formsemestre.semestre_id + 1
+        annee_scolaire = formsemestre.annee_scolaire()
+        other_formsemestre = None
+        for inscr in self.etud.formsemestre_inscriptions:
+            if (
+                # Même spécialité BUT (tolère ainsi des variantes de formation)
+                (
+                    inscr.formsemestre.formation.referentiel_competence
+                    == formsemestre.formation.referentiel_competence
+                )
+                # L'autre semestre
+                and (inscr.formsemestre.semestre_id == other_semestre_id)
+                # de la même année scolaire:
+                and (inscr.formsemestre.annee_scolaire() == annee_scolaire)
+            ):
+                other_formsemestre = inscr.formsemestre
+        if formsemestre.semestre_id % 2 == 0:
+            return other_formsemestre, formsemestre
+        return formsemestre, other_formsemestre
+
+    def compute_ues_annee(self) -> list[list[UniteEns], list[UniteEns]]:
+        """UEs à valider cette année pour cet étudiant, selon son parcours.
+        Ramène [ listes des UE du semestre impair, liste des UE du semestre pair ].
+        """
+        etudid = self.etud.id
+        ues_sems = []
+        for formsemestre in self.formsemestre_impair, self.formsemestre_pair:
+            if formsemestre is None:
+                ues = []
+            else:
+                formation: Formation = formsemestre.formation
+                res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
+                    formsemestre
+                )
+                # Parcour dans lequel l'étudiant est inscrit, et liste des UEs
+                if res.etuds_parcour_id[etudid] is None:
+                    # pas de parcour: prend toutes les UEs (non bonus)
+                    ues = res.etud_ues(etudid)
+                else:
+                    parcour = ApcParcours.query.get(res.etuds_parcour_id[etudid])
+                    if parcour is not None:
+                        self.parcour = parcour
+                    ues = (
+                        formation.query_ues_parcour(parcour)
+                        .filter_by(semestre_idx=formsemestre.semestre_id)
+                        .all()
+                    )
+            ues_sems.append(ues)
+        return ues_sems
+
+    def check_ues_ready_jury(self) -> list[str]:
+        """Vérifie que les toutes les UEs (hors bonus) de l'année sont
+        bien associées à des niveaux de compétences.
+        Renvoie liste vide si ok, sinon liste de message explicatifs
+        """
+        messages = []
+        for ue in self.ues_impair + self.ues_pair:
+            if ue.niveau_competence is None:
+                messages.append(
+                    f"UE {ue.acronyme} non associée à un niveau de compétence"
+                )
+            if ue.semestre_idx is None:
+                messages.append(
+                    f"UE {ue.acronyme} n'a pas d'indice de semestre dans la formation"
                 )
-    return DecisionsProposees(
-        code=[codes.AJ, codes.ADJ],
-        explanation="notes insuffisantes",
-    )
-
-
-def decisions_rcue_proposees(
-    etud: Identite,
-    formsemestre_1: FormSemestre,
-    ue_1: UniteEns,
-    formsemestre_2: FormSemestre,
-    ue_2: UniteEns,
-) -> DecisionsProposees:
+        return messages
+
+    def compute_rcues_annee(self) -> list[RegroupementCoherentUE]:
+        """Liste des regroupements d'UE à considérer cette année.
+        Pour le moment on ne considère pas de RCUE à cheval sur plusieurs années (redoublants).
+        Si on n'a pas les deux semestres, aucun RCUE.
+        Raises ScoValueError s'il y a des UE sans RCUE.
+        """
+        if self.formsemestre_pair is None or self.formsemestre_impair is None:
+            return []
+        rcues_annee = []
+        ues_impair_sans_rcue = {ue.id for ue in self.ues_impair}
+        for ue_pair in self.ues_pair:
+            rcue = None
+            for ue_impair in self.ues_impair:
+                if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id:
+                    rcue = RegroupementCoherentUE(
+                        self.etud,
+                        self.formsemestre_impair,
+                        ue_impair,
+                        self.formsemestre_pair,
+                        ue_pair,
+                    )
+                    ues_impair_sans_rcue.remove(ue_impair.id)
+                    break
+            if rcue is None:
+                raise ScoValueError(f"pas de RCUE pour l'UE {ue_pair.acronyme}")
+            rcues_annee.append(rcue)
+        if len(ues_impair_sans_rcue) > 0:
+            ue = ues_impair_sans_rcue.pop()
+            raise ScoValueError(f"pas de RCUE pour l'UE {ue.acronyme}")
+        return rcues_annee
+
+
+class DecisionsProposeesRCUE(DecisionsProposees):
     """Liste des codes de décisions que l'on peut proposer pour
-    le RCUE de cet étudiant dans ces semestres.
+    le RCUE de cet étudiant dans cette année.
 
     ADM, CMP, ADJ, AJ, RAT, DEF, ABAN
+    """
 
-    La validation des deux UE du niveau d’une compétence emporte la validation de
-    l’ensemble des UE du niveau inférieur de cette même compétence.
+    codes_communs = [
+        sco_codes.ADJ,
+        sco_codes.RAT,
+        sco_codes.DEF,
+        sco_codes.ABAN,
+    ]
+
+    def __init__(
+        self, dec_prop_annee: DecisionsProposeesAnnee, rcue: RegroupementCoherentUE
+    ):
+        super().__init__(etud=dec_prop_annee.etud)
+        self.rcue = rcue
+
+        validation = rcue.query_validations().first()
+        if validation is not None:
+            self.code_valide = validation.code
+        if rcue.est_compense():
+            self.codes.insert(0, sco_codes.CMP)
+        elif rcue.est_validable():
+            self.codes.insert(0, sco_codes.ADM)
+        else:
+            self.codes.insert(0, sco_codes.AJ)
+
+
+class DecisionsProposeesUE(DecisionsProposees):
+    """Décisions de jury sur une UE du BUT
+
+    Liste des codes de décisions que l'on peut proposer pour
+    cette UE d'un étudiant dans un semestre.
+
+    Si DEF ou DEM ou ABAN ou ABL sur année BUT: seulement DEF, DEM, ABAN, ABL
+
+    si moy_ue > 10, ADM
+    sinon si compensation dans RCUE: CMP
+    sinon: ADJ, AJ
+
+    et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL (codes_communs)
     """
-    #
+
+    # Codes toujours proposés sauf si include_communs est faux:
+    codes_communs = [
+        sco_codes.RAT,
+        sco_codes.DEF,
+        sco_codes.ABAN,
+        sco_codes.DEM,
+        sco_codes.UEBSL,
+    ]
+
+    def __init__(
+        self,
+        etud: Identite,
+        formsemestre: FormSemestre,
+        ue: UniteEns,
+    ):
+        super().__init__(etud=etud)
+        self.ue: UniteEns = ue
+        self.rcue: RegroupementCoherentUE = None
+        # Une UE peut être validée plusieurs fois en cas de redoublement (qu'elle soit capitalisée ou non)
+        # mais ici on a restreint au formsemestre donc une seule (prend la première)
+        self.validation = ScolarFormSemestreValidation.query.filter_by(
+            etudid=self.etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id
+        ).first()
+        if self.validation is not None:
+            self.code_valide = self.validation.code
+        if ue.type == sco_codes.UE_SPORT:
+            self.explanation = "UE bonus, pas de décision de jury"
+            self.codes = []  # aucun code proposé
+            return
+        # Code sur année ?
+        decision_annee = ApcValidationAnnee.query.filter_by(
+            etudid=etud.id, annee_scolaire=formsemestre.annee_scolaire()
+        ).first()
+        if (
+            decision_annee is not None
+            and decision_annee.code in sco_codes.CODES_ANNEE_ARRET
+        ):  # DEF, DEM, ABAN, ABL
+            self.explanation = f"l'année a le code {decision_annee.code}"
+            self.codes = [decision_annee.code]  # sans les codes communs
+            return
+        # Moyenne de l'UE ?
+        res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
+        if not ue.id in res.etud_moy_ue:
+            self.explanation = "UE sans résultat"
+            return
+        if not etud.id in res.etud_moy_ue[ue.id]:
+            self.explanation = "Étudiant sans résultat dans cette UE"
+            return
+        moy_ue = res.etud_moy_ue[ue.id][etud.id]
+        if moy_ue > (sco_codes.ParcoursBUT.BARRE_MOY - sco_codes.NOTES_TOLERANCE):
+            self.codes.insert(0, sco_codes.ADM)
+            self.explanation = (f"Moyenne >= {sco_codes.ParcoursBUT.BARRE_MOY}/20",)
+
+        # Compensation dans un RCUE ?
+        rcues = but_validations.find_rcues(formsemestre, ue, etud)
+        for rcue in rcues:
+            if rcue.est_validable():
+                self.codes.insert(0, sco_codes.CMP)
+                self.explanation = f"Compensée par {rcue.other_ue(ue)} (moyenne RCUE={scu.fmt_note(rcue.moy_rcue)}/20"
+                self.rcue = rcue
+                return  # s'arrête au 1er RCU validable
+
+        # Échec à valider cette UE
+        self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
+        self.explanation = "notes insuffisantes"
 
 
-class BUTCursusEtud:
+class BUTCursusEtud:  # WIP TODO
     """Validation du cursus d'un étudiant"""
 
     def __init__(self, formsemestre: FormSemestre, etud: Identite):
@@ -215,6 +515,7 @@ class BUTCursusEtud:
         """Vrai si la compétence est validée, c'est à dire que tous ses
         niveaux sont validés (ApcValidationRCUE).
         """
+        # XXX A REVOIR
         validations = (
             ApcValidationRCUE.query.filter_by(etudid=self.etud.id)
             .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
diff --git a/app/comp/res_but.py b/app/comp/res_but.py
index 0ea2e2eaf86a04eddcf0dbc2e13ef5d0dd9afc52..b139a6ab0a449ef79b4001c3042102fce60ab9e4 100644
--- a/app/comp/res_but.py
+++ b/app/comp/res_but.py
@@ -29,6 +29,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
         "modimpl_coefs_df",
         "modimpls_evals_poids",
         "sem_cube",
+        "etuds_parcour_id",  # parcours de chaque étudiant
         "ues_inscr_parcours_df",  # inscriptions aux UE / parcours
     )
 
@@ -37,7 +38,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
 
         self.sem_cube = None
         """ndarray (etuds x modimpl x ue)"""
-
+        self.etuds_parcour_id = None
+        """Parcours de chaque étudiant { etudid : parcour_id }"""
         if not self.load_cached():
             t0 = time.time()
             self.compute()
@@ -190,13 +192,14 @@ class ResultatsSemestreBUT(NotesTableCompat):
         La matrice avec ue ne comprend que les UE non bonus.
         1.0 si étudiant inscrit à l'UE, NaN sinon.
         """
-        etuds_parcours = {
+        etuds_parcour_id = {
             inscr.etudid: inscr.parcour_id for inscr in self.formsemestre.inscriptions
         }
+        self.etuds_parcour_id = etuds_parcour_id
         ue_ids = [ue.id for ue in self.ues]
         # matrice de 1, inscrits par défaut à toutes les UE:
         ues_inscr_parcours_df = pd.DataFrame(
-            1.0, index=etuds_parcours.keys(), columns=ue_ids, dtype=float
+            1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
         )
         if self.formsemestre.formation.referentiel_competence is None:
             return ues_inscr_parcours_df
@@ -209,11 +212,11 @@ class ResultatsSemestreBUT(NotesTableCompat):
                     parcour
                 ).filter_by(semestre_idx=self.formsemestre.semestre_id)
             }
-        for etudid in etuds_parcours:
-            parcour = etuds_parcours[etudid]
+        for etudid in etuds_parcour_id:
+            parcour = etuds_parcour_id[etudid]
             if parcour is not None:
                 ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[
-                    etuds_parcours[etudid]
+                    etuds_parcour_id[etudid]
                 ]
         return ues_inscr_parcours_df
 
diff --git a/app/models/but_validations.py b/app/models/but_validations.py
index 7d58ace45f640030c2f52640030e1331d33566e5..294d61a0c4b92862a21cd0f79bd0f4cb8e857a7a 100644
--- a/app/models/but_validations.py
+++ b/app/models/but_validations.py
@@ -3,13 +3,18 @@
 """Décisions de jury (validations) des RCUE et années du BUT
 """
 
+import flask_sqlalchemy
 from sqlalchemy.sql import text
+from typing import Union
 
 from app import db
+
 from app.models import CODE_STR_LEN
 from app.models.but_refcomp import ApcNiveau
+from app.models.etudiants import Identite
 from app.models.ues import UniteEns
 from app.models.formsemestre import FormSemestre
+from app.scodoc import sco_codes_parcours as sco_codes
 
 
 class ApcValidationRCUE(db.Model):
@@ -17,6 +22,7 @@ class ApcValidationRCUE(db.Model):
 
     aka "regroupements cohérents d'UE" dans le jargon BUT.
 
+    le formsemestre est celui du semestre PAIR du niveau de compétence
     """
 
     __tablename__ = "apc_validation_rcue"
@@ -58,16 +64,151 @@ class ApcValidationRCUE(db.Model):
         return self.ue2.niveau_competence
 
 
-def get_other_ue_rcue(ue: UniteEns, etudid: int) -> tuple[UniteEns, FormSemestre]:
-    """L'autre UE du RCUE (niveau de compétence) pour cet étudiant.
+# Attention: ce n'est pas un modèle mais une classe ordinaire:
+class RegroupementCoherentUE:
+    """Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
+    de la même année (BUT1,2,3) liées au même niveau de compétence.
 
-    Cherche une UE du même niveau de compétence, à laquelle l'étudiant soit inscrit.
-    Résultat: le couple (UE, FormSemestre), ou (None, None) si pas trouvée.
+    La moyenne (10/20) au RCU déclenche la compensation des UE.
+    """
+
+    def __init__(
+        self,
+        etud: Identite,
+        formsemestre_1: FormSemestre,
+        ue_1: UniteEns,
+        formsemestre_2: FormSemestre,
+        ue_2: UniteEns,
+    ):
+        from app.comp import res_sem
+        from app.comp.res_but import ResultatsSemestreBUT
+
+        # Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)...
+        if formsemestre_1.semestre_id > formsemestre_2.semestre_id:
+            (ue_1, formsemestre_1), (ue_2, formsemestre_2) = (
+                (
+                    ue_2,
+                    formsemestre_2,
+                ),
+                (ue_1, formsemestre_1),
+            )
+        assert formsemestre_1.semestre_id % 2 == 1
+        assert formsemestre_2.semestre_id % 2 == 0
+        assert abs(formsemestre_1.semestre_id - formsemestre_2.semestre_id) == 1
+        assert ue_1.niveau_competence_id == ue_2.niveau_competence_id
+        self.etud = etud
+        self.formsemestre_1 = formsemestre_1
+        "semestre impair"
+        self.ue_1 = ue_1
+        self.formsemestre_2 = formsemestre_2
+        "semestre pair"
+        self.ue_2 = ue_2
+        # Stocke les moyennes d'UE
+        res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_1)
+        if ue_1.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_1.id]:
+            self.moy_ue_1 = res.etud_moy_ue[ue_1.id][etud.id]
+            self.moy_ue_1_val = self.moy_ue_1  # toujours float, peut être NaN
+        else:
+            self.moy_ue_1 = None
+            self.moy_ue_1_val = 0.0
+        res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_2)
+        if ue_2.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_2.id]:
+            self.moy_ue_2 = res.etud_moy_ue[ue_2.id][etud.id]
+            self.moy_ue_2_val = self.moy_ue_2
+        else:
+            self.moy_ue_2 = None
+            self.moy_ue_2_val = 0.0
+        # Calcul de la moyenne au RCUE
+        if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
+            # Moyenne RCUE non pondérée (pour le moment -- TODO)
+            self.moy_rcue = (self.moy_ue_1 + self.moy_ue_2) / 2
+        else:
+            self.moy_rcue = None
+
+    def __repr__(self) -> str:
+        return f"<{self.__class__.__name__} {self.ue_1.acronyme}({self.moy_ue_1}) {self.ue_2.acronyme}({self.moy_ue_2})>"
+
+    def query_validations(
+        self,
+    ) -> flask_sqlalchemy.BaseQuery:  # list[ApcValidationRCUE]
+        """Les validations de jury enregistrées pour ce RCUE"""
+        niveau = self.ue_2.niveau_competence
+
+        return (
+            ApcValidationRCUE.query.filter_by(
+                etudid=self.etud.id,
+            )
+            .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
+            .join(ApcNiveau, UniteEns.niveau_id == ApcNiveau.id)
+            .filter(ApcNiveau.id == niveau.id)
+        )
+
+    def other_ue(self, ue: UniteEns) -> UniteEns:
+        """L'autre UE du regroupement. Si ue ne fait pas partie du regroupement, ValueError"""
+        if ue.id == self.ue_1.id:
+            return self.ue_2
+        elif ue.id == self.ue_2.id:
+            return self.ue_1
+        raise ValueError(f"ue {ue} hors RCUE {self}")
+
+    def est_enregistre(self) -> bool:
+        """Vrai si ce RCUE, donc le niveau de compétences correspondant
+        a une décision jury enregistrée
+        """
+        return self.query_validations().count() > 0
+
+    def est_compense(self):
+        """Vrai si ce RCUE est validable par compensation
+        c'est à dire que sa moyenne est > 10 avec une UE < 10
+        """
+        return (
+            (self.moy_rcue is not None)
+            and (self.moy_rcue > sco_codes.BUT_BARRE_RCUE)
+            and (
+                (self.moy_ue_1_val < sco_codes.NOTES_BARRE_GEN)
+                or (self.moy_ue_2_val < sco_codes.NOTES_BARRE_GEN)
+            )
+        )
+
+    def est_suffisant(self) -> bool:
+        """Vrai si ce RCUE est > 8"""
+        return (self.moy_rcue is not None) and (
+            self.moy_rcue > sco_codes.BUT_RCUE_SUFFISANT
+        )
+
+    def est_validable(self) -> bool:
+        """Vrai si ce RCU satisfait les conditions pour être validé
+        Pour cela, il suffit que la moyenne des UE qui le constitue soit > 10
+        """
+        return (self.moy_rcue is not None) and (
+            self.moy_rcue > sco_codes.BUT_BARRE_RCUE
+        )
+
+    def code_valide(self) -> Union[ApcValidationRCUE, None]:
+        "Si ce RCUE est ADM, CMP ou ADJ, la validation. Sinon, None"
+        validation = self.query_validations().first()
+        if (validation is not None) and (
+            validation.code in {sco_codes.ADM, sco_codes.ADJ, sco_codes.CMP}
+        ):
+            return validation
+        return None
+
+
+def find_rcues(
+    formsemestre: FormSemestre, ue: UniteEns, etud: Identite
+) -> list[RegroupementCoherentUE]:
+    """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
+    ce semestre pour cette UE.
+
+    Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
+    En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
+
+    Résultat: la liste peut être vide.
     """
     if (ue.niveau_competence is None) or (ue.semestre_idx is None):
-        return None, None
+        return []
 
-    if ue.semestre_idx % 2:
+    if ue.semestre_idx % 2:  # S1, S3, S5
         other_semestre_idx = ue.semestre_idx + 1
     else:
         other_semestre_idx = ue.semestre_idx - 1
@@ -75,45 +216,38 @@ def get_other_ue_rcue(ue: UniteEns, etudid: int) -> tuple[UniteEns, FormSemestre
     cursor = db.session.execute(
         text(
             """SELECT
-            ue.id, sem.id
+            ue.id, formsemestre.id
             FROM
                 notes_ue ue,
                 notes_formsemestre_inscription inscr,
-                notes_formsemestre sem
+                notes_formsemestre formsemestre
 
-            WHERE 
+            WHERE
                 inscr.etudid = :etudid
-            AND inscr.formsemestre_id = sem.id
+            AND inscr.formsemestre_id = formsemestre.id
             
-            AND sem.semestre_id = :other_semestre_idx
-            AND ue.formation_id = sem.formation_id
+            AND formsemestre.semestre_id = :other_semestre_idx
+            AND ue.formation_id = formsemestre.formation_id
             AND ue.niveau_competence_id = :ue_niveau_competence_id
             AND ue.semestre_idx = :other_semestre_idx
             """
         ),
         {
-            "etudid": etudid,
+            "etudid": etud.id,
             "other_semestre_idx": other_semestre_idx,
             "ue_niveau_competence_id": ue.niveau_competence_id,
         },
     )
-    r = cursor.fetchone()
-    if r is None:
-        return None, None
-
-    return UniteEns.query.get(r[0]), FormSemestre.query.get(r[1])
-
-    # q = UniteEns.query.filter(
-    #     FormSemestreInscription.etudid == etudid,
-    #     FormSemestreInscription.formsemestre_id == FormSemestre.id,
-    #     FormSemestre.formation_id == UniteEns.formation_id,
-    #     FormSemestre.semestre_id == UniteEns.semestre_idx,
-    #     UniteEns.niveau_competence_id == ue.niveau_competence_id,
-    #     UniteEns.semestre_idx != ue.semestre_idx,
-    # )
-    # if q.count() > 1:
-    #     log("Warning: get_other_ue_rcue: {q.count()} candidates UE")
-    # return q.first()
+    rcues = []
+    for ue_id, formsemestre_id in cursor:
+        other_ue = UniteEns.query.get(ue_id)
+        other_formsemestre = FormSemestre.query.get(formsemestre_id)
+        rcues.append(
+            RegroupementCoherentUE(etud, formsemestre, ue, other_formsemestre, other_ue)
+        )
+    # safety check: 1 seul niveau de comp. concerné:
+    assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
+    return rcues
 
 
 class ApcValidationAnnee(db.Model):
@@ -134,6 +268,7 @@ class ApcValidationAnnee(db.Model):
     formsemestre_id = db.Column(
         db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
     )
+    "le semestre IMPAIR (le 1er) de l'année"
     annee_scolaire = db.Column(db.Integer, nullable=False)  # 2021
     date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
     code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
diff --git a/app/models/validations.py b/app/models/validations.py
index 64bdaef808aa856f2ddab31289d2f308d5c98b18..976e35f9f9b80ba87989028b06e034347eb416b4 100644
--- a/app/models/validations.py
+++ b/app/models/validations.py
@@ -54,7 +54,7 @@ class ScolarFormSemestreValidation(db.Model):
     ue = db.relationship("UniteEns", lazy="select", uselist=False)
 
     def __repr__(self):
-        return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue_id}, moy_ue={self.moy_ue})"
+        return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
 
 
 class ScolarAutorisationInscription(db.Model):
diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py
index e7ebe7bdce0b824d14ac9a3588040cd8f3afdc6c..b9e4943e54afa120b1e285799e56f769fd04bf0f 100644
--- a/app/scodoc/sco_codes_parcours.py
+++ b/app/scodoc/sco_codes_parcours.py
@@ -68,7 +68,8 @@ NOTES_TOLERANCE = 0.00499999999999  # si note >= (BARRE-TOLERANCE), considere ok
 # (permet d'eviter d'afficher 10.00 sous barre alors que la moyenne vaut 9.999)
 
 # Barre sur moyenne générale utilisée pour compensations semestres:
-NOTES_BARRE_GEN_COMPENSATION = 10.0 - NOTES_TOLERANCE
+NOTES_BARRE_GEN = 10.0
+NOTES_BARRE_GEN_COMPENSATION = NOTES_BARRE_GEN - NOTES_TOLERANCE
 
 # ----------------------------------------------------------------
 #  Types d'UE:
@@ -192,6 +193,8 @@ CODES_UE_VALIDES = {ADM: True, CMP: True}  # UE validée
 # Pour le BUT:
 CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
 CODES_RCUE = {ADM, AJ, CMP}
+BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE
+BUT_RCUE_SUFFISANT = 8.0 - NOTES_TOLERANCE
 
 
 def code_semestre_validant(code: str) -> bool:
diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py
index 436e1a916a4576056e643d3c3167bc45167a4de5..a0a4e1d42353143979628f5eaedbdeddecdb3057 100644
--- a/app/scodoc/sco_edit_ue.py
+++ b/app/scodoc/sco_edit_ue.py
@@ -927,7 +927,7 @@ def _html_select_semestre_idx(formation_id, semestre_ids, semestre_idx):
 
 def _ue_table_ues(
     parcours,
-    ues,
+    ues: list[dict],
     editable,
     tag_editable,
     has_perm_change,
@@ -936,7 +936,7 @@ def _ue_table_ues(
     arrow_none,
     delete_icon,
     delete_disabled_icon,
-):
+) -> str:
     """Édition de programme: liste des UEs (avec leurs matières et modules).
     Pour les formations classiques (non APC/BUT)
     """
@@ -964,9 +964,9 @@ def _ue_table_ues(
             if ue["semestre_id"] == sco_codes_parcours.UE_SEM_DEFAULT:
                 lab = "Pas d'indication de semestre:"
             else:
-                lab = "Semestre %s:" % ue["semestre_id"]
+                lab = f"""Semestre {ue["semestre_id"]}:"""
             H.append(
-                '<div class="ue_list_div"><div class="ue_list_tit_sem">%s</div>' % lab
+                f'<div class="ue_list_div"><div class="ue_list_tit_sem">{lab}</div>'
             )
             H.append('<ul class="notes_ue_list">')
         H.append('<li class="notes_ue_list">')
diff --git a/app/templates/pn/form_ues.html b/app/templates/pn/form_ues.html
index c603c6c522bce4ca0aeeb561368fd6c6d1a0d6c8..83648854944a700d61483aeae0e3fc33706f13f7 100644
--- a/app/templates/pn/form_ues.html
+++ b/app/templates/pn/form_ues.html
@@ -32,6 +32,8 @@
                     ue.color if ue.color is not none else 'blue'}}"></span>
                 <b>{{ue.acronyme}}</b> <a class="discretelink" href="{{
                     url_for('notes.ue_infos', scodoc_dept=g.scodoc_dept, ue_id=ue.id)}}"
+                    title="{{ue.acronyme}}: {{'pas de compétence associée' if ue.niveau_competence is none 
+                    else 'compétence ' + ue.niveau_competence.annee + ' ' + ue.niveau_competence.competence.titre_long}}"
                     >{{ue.titre}}</a>
                     {% set virg = joiner(", ") %}
                     <span class="ue_code">(
diff --git a/app/views/notes.py b/app/views/notes.py
index bc5da600d132314758c15be2e7d97dc3dbcda796..8696614821c3c7faf080ed4d5df6bf45167256e4 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -31,6 +31,7 @@ Module notes: issu de ScoDoc7 / ZNotes.py
 Emmanuel Viennet, 2021
 """
 
+import html
 from operator import itemgetter
 import time
 from xml.etree import ElementTree
@@ -41,8 +42,11 @@ from flask import current_app, g, request
 from flask_login import current_user
 from werkzeug.utils import redirect
 
+from app.but import jury_but
 from app.comp import res_sem
+from app.comp.res_but import ResultatsSemestreBUT
 from app.comp.res_compat import NotesTableCompat
+from app.models.etudiants import Identite
 from app.models.formsemestre import FormSemestre
 from app.models.formsemestre import FormSemestreUEComputationExpr
 from app.models.modules import Module
@@ -2209,6 +2213,54 @@ def formsemestre_validation_etud_manu(
     )
 
 
+# --- Jurys BUT
+@bp.route(
+    "/formsemestre_validation_but/<int:formsemestre_id>/<int:etudid>",
+    methods=["GET", "POST"],
+)
+@scodoc
+@permission_required(Permission.ScoView)
+def formsemestre_validation_but(formsemestre_id: int, etudid: int):
+    "Form. saisie décision jury semestre BUT"
+    if not sco_permissions_check.can_validate_sem(formsemestre_id):
+        return scu.confirm_dialog(
+            message="<p>Opération non autorisée pour %s</h2>" % current_user,
+            dest_url=url_for(
+                "notes.formsemestre_status",
+                scodoc_dept=g.scodoc_dept,
+                formsemestre_id=formsemestre_id,
+            ),
+        )
+    # XXX TODO  Page expérimentale pour les devs
+    H = [
+        html_sco_header.sco_header(
+            page_title="Validation BUT", formsemestre_id=formsemestre_id, etudid=etudid
+        ),
+        f"""
+        <h2>XXX Experimental XXX</h2>
+        """,
+    ]
+    formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
+    etud = Identite.query.get_or_404(etudid)
+    res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
+    # ---- UEs
+    H.append(f"<ul>")
+    for ue in formsemestre.query_ues():  # volontairement toutes les UE
+        dec_proposee = jury_but.DecisionsProposeesUE(etud, formsemestre, ue)
+        H.append("<li>" + html.escape(f"""{ue} : {dec_proposee}""") + "</li>")
+    H.append(f"</ul>")
+
+    if formsemestre.semestre_id % 2 == 0:
+        # ---- RCUES
+        H.append(f"<ul>")
+        for ue in formsemestre.query_ues():  # volontairement toutes les UE
+            dec_proposee = jury_but.decisions_ue_proposees(etud, formsemestre, ue)
+            H.append("<li>" + html.escape(f"""{ue} : {dec_proposee}""") + "</li>")
+        H.append(f"</ul>")
+
+    return "\n".join(H) + html_sco_header.sco_footer()
+
+
 @bp.route("/formsemestre_validate_previous_ue", methods=["GET", "POST"])
 @scodoc
 @permission_required(Permission.ScoView)