diff --git a/app/but/rcue.py b/app/but/rcue.py
new file mode 100644
index 000000000..26763468c
--- /dev/null
+++ b/app/but/rcue.py
@@ -0,0 +1,243 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Jury BUT: un RCUE, ou Regroupe Cohérent d'UEs
+"""
+from typing import Union
+from flask_sqlalchemy.query import Query
+
+from app.comp.res_but import ResultatsSemestreBUT
+from app.models import (
+    ApcNiveau,
+    ApcValidationRCUE,
+    Identite,
+    ScolarFormSemestreValidation,
+    UniteEns,
+)
+from app.scodoc import codes_cursus
+from app.scodoc.codes_cursus import BUT_CODES_ORDER
+
+
+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*.
+
+    La moyenne (10/20) au RCUE déclenche la compensation des UEs.
+    """
+
+    def __init__(
+        self,
+        etud: Identite,
+        niveau: ApcNiveau,
+        res_pair: ResultatsSemestreBUT,
+        res_impair: ResultatsSemestreBUT,
+        semestre_id_impair: int,
+        cur_ues_pair: list[UniteEns],
+        cur_ues_impair: list[UniteEns],
+    ):
+        """
+        res_pair, res_impair: résultats des formsemestre de l'année en cours, ou None
+        cur_ues_pair, cur_ues_impair: ues auxquelles l'étudiant est inscrit cette année
+        """
+        self.etud: Identite = etud
+        self.niveau: ApcNiveau = niveau
+        "Le niveau de compétences de ce RCUE"
+        # Chercher l'UE en cours pour pair, impair
+        # une UE à laquelle l'étudiant est inscrit (non dispensé)
+        # dans l'un des formsemestre en cours
+        ues = [ue for ue in cur_ues_pair if ue.niveau_competence_id == niveau.id]
+        self.ue_cur_pair = ues[0] if ues else None
+        "UE paire en cours"
+        ues = [ue for ue in cur_ues_impair if ue.niveau_competence_id == niveau.id]
+        self.ue_cur_impair = ues[0] if ues else None
+        "UE impaire en cours"
+
+        self.validation_ue_cur_pair = (
+            ScolarFormSemestreValidation.query.filter_by(
+                etudid=etud.id,
+                formsemestre_id=res_pair.formsemestre.id,
+                ue_id=self.ue_cur_pair,
+            ).first()
+            if self.ue_cur_pair
+            else None
+        )
+        self.validation_ue_cur_impair = (
+            ScolarFormSemestreValidation.query.filter_by(
+                etudid=etud.id,
+                formsemestre_id=res_impair.formsemestre.id,
+                ue_id=self.ue_cur_impair,
+            ).first()
+            if self.ue_cur_impair
+            else None
+        )
+
+        # Autres validations pour l'UE paire
+        self.validation_ue_best_pair = _best_autre_ue_validation(
+            etud.id,
+            niveau.id,
+            semestre_id_impair + 1,
+            self.ue_cur_pair.id if self.ue_cur_pair else None,
+        )
+        self.validation_ue_best_impair = _best_autre_ue_validation(
+            etud.id,
+            niveau.id,
+            semestre_id_impair,
+            self.ue_cur_impair.id if self.ue_cur_impair else None,
+        )
+
+        # Suis-je complet ?
+        self.complete = (self.ue_cur_pair or self.validation_ue_best_pair) and (
+            self.ue_cur_impair or self.validation_ue_best_impair
+        )
+        if not self.complete:
+            self.moy_rcue = None
+
+        # Stocke les moyennes d'UE
+        if self.ue_cur_impair:
+            ue_status = res_impair.get_etud_ue_status(etud.id, self.ue_cur_impair.id)
+            self.moy_ue_1 = ue_status["moy"] if ue_status else None  # avec capitalisée
+            self.ue_1 = self.ue_cur_impair
+        elif self.validation_ue_best_impair:
+            self.moy_ue_1 = self.validation_ue_best_pair.moy_ue
+            self.ue_1 = self.ue_cur_impair
+        else:
+            self.moy_ue_1, self.ue_1 = None, None
+        self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
+
+        if self.ue_cur_pair:
+            ue_status = res_pair.get_etud_ue_status(etud.id, self.ue_cur_pair.id)
+            self.moy_ue_2 = ue_status["moy"] if ue_status else None  # avec capitalisée
+            self.ue_2 = self.ue_cur_pair
+        elif self.validation_ue_best_pair:
+            self.moy_ue_2 = self.validation_ue_best_pair.moy_ue
+            self.ue_2 = self.validation_ue_best_pair.ue
+        else:
+            self.moy_ue_2, self.ue_2 = None, None
+        self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0
+
+        # Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées ou antérieures)
+        if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
+            # Moyenne RCUE (les pondérations par défaut sont 1.)
+            self.moy_rcue = (
+                self.moy_ue_1 * self.ue_1.coef_rcue
+                + self.moy_ue_2 * self.ue_2.coef_rcue
+            ) / (self.ue_1.coef_rcue + self.ue_2.coef_rcue)
+        else:
+            self.moy_rcue = None
+
+    def __repr__(self) -> str:
+        return f"""<{self.__class__.__name__} {
+            self.ue_1.acronyme if self.ue_1 else "?"}({self.moy_ue_1}) {
+            self.ue_2.acronyme if self.ue_2 else "?"}({self.moy_ue_2})>"""
+
+    def __str__(self) -> str:
+        return f"""RCUE {
+            self.ue_1.acronyme if self.ue_1 else "?"}({self.moy_ue_1}) + {
+            self.ue_2.acronyme if self.ue_2 else "?"}({self.moy_ue_2})"""
+
+    def query_validations(
+        self,
+    ) -> Query:  # 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_competence_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_compensable(self):
+        """Vrai si ce RCUE est validable (uniquement) par compensation
+        c'est à dire que sa moyenne est > 10 avec une UE < 10.
+        Note: si ADM, est_compensable est faux.
+        """
+        return (
+            (self.moy_rcue is not None)
+            and (self.moy_rcue > codes_cursus.BUT_BARRE_RCUE)
+            and (
+                (self.moy_ue_1_val < codes_cursus.NOTES_BARRE_GEN)
+                or (self.moy_ue_2_val < codes_cursus.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 > codes_cursus.BUT_RCUE_SUFFISANT
+        )
+
+    def est_validable(self) -> bool:
+        """Vrai si ce RCUE satisfait les conditions pour être validé,
+        c'est à dire que la moyenne des UE qui le constituent soit > 10
+        """
+        return (self.moy_rcue is not None) and (
+            self.moy_rcue > codes_cursus.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 codes_cursus.CODES_RCUE_VALIDES
+        ):
+            return validation
+        return None
+
+
+def _best_autre_ue_validation(
+    etudid: int, niveau_id: int, semestre_id: int, ue_id: int
+) -> ScolarFormSemestreValidation:
+    """La "meilleure" validation validante d'UE pour ce niveau/semestre"""
+    validations = (
+        ScolarFormSemestreValidation.query.filter_by(etudid=etudid)
+        .join(UniteEns)
+        .filter_by(semestre_idx=semestre_id + 1)
+        .join(ApcNiveau)
+        .filter(ApcNiveau.id == niveau_id)
+    )
+    validations = [v for v in validations if codes_cursus.code_ue_validant(v.code)]
+    # Elimine l'UE en cours si elle existe
+    if ue_id is not None:
+        validations = [v for v in validations if v.ue_id != ue_id]
+    validations = sorted(validations, key=lambda v: BUT_CODES_ORDER.get(v.code, 0))
+    return validations[-1] if validations else None
+
+
+# def compute_ues_by_niveau(
+#     niveaux: list[ApcNiveau],
+# ) -> dict[int, tuple[list[UniteEns], list[UniteEns]]]:
+#     """UEs à valider cette année pour cet étudiant, selon son parcours.
+#     Considérer les UEs associées aux niveaux et non celles des formsemestres
+#     en cours. Notez que même si l'étudiant n'est pas inscrit ("dispensé") à une UE
+#     dans le formsemestre origine, elle doit apparaitre sur la page jury.
+#     Return: { niveau_id : ( [ues impair], [ues pair]) }
+#     """
+#     # Les UEs associées à ce niveau, toutes formations confondues
+#     return {
+#         niveau.id: (
+#             [ue for ue in niveau.ues if ue.semestre_idx % 2],
+#             [ue for ue in niveau.ues if not (ue.semestre_idx % 2)],
+#         )
+#         for niveau in niveaux
+#     }