diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 4d10383c2..3ed27ffad 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -45,7 +45,6 @@ from app.models import Evaluation, EvaluationUEPoids, ModuleImpl from app.scodoc import sco_cache from app.scodoc import sco_utils as scu from app.scodoc.codes_cursus import UE_SPORT -from app.scodoc.sco_exceptions import ScoBugCatcher from app.scodoc.sco_utils import ModuleType @@ -275,20 +274,18 @@ class ModuleImplResults: * self.evaluations_completes ).reshape(-1, 1) - def get_evaluations_session2_coefs(self, modimpl: ModuleImpl) -> np.array: - """Coefficients des évaluations de session 2. - Les évals de session 2 sont réputées "complètes": elles sont toujours + def get_evaluations_special_coefs( + self, modimpl: ModuleImpl, evaluation_type=Evaluation.EVALUATION_SESSION2 + ) -> np.array: + """Coefficients des évaluations de session 2 ou rattrapage. + Les évals de session 2 et rattrapage sont réputées "complètes": elles sont toujours prises en compte mais seules les notes numériques et ABS sont utilisées. Résultat: 2d-array of floats, shape (nb_evals, 1) """ return ( np.array( [ - ( - e.coefficient - if e.evaluation_type == Evaluation.EVALUATION_SESSION2 - else 0.0 - ) + (e.coefficient if e.evaluation_type == evaluation_type else 0.0) for e in modimpl.evaluations ], dtype=float, @@ -321,19 +318,16 @@ class ModuleImplResults: for (etudid, x) in self.evals_notes[evaluation_id].items() } - def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None: - """L'évaluation de rattrapage de ce module, ou None s'il n'en a pas. + def get_evaluations_rattrapage(self, moduleimpl: ModuleImpl) -> list[Evaluation]: + """Les évaluations de rattrapage de ce module. Rattrapage: la moyenne du module est la meilleure note entre moyenne - des autres évals et la note eval rattrapage. + des autres évals et la moyenne des notes de rattrapage. """ - eval_list = [ + return [ e for e in moduleimpl.evaluations if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE ] - if eval_list: - return eval_list[0] - return None def get_evaluations_session2(self, moduleimpl: ModuleImpl) -> list[Evaluation]: """Les évaluations de deuxième session de ce module, ou None s'il n'en a pas. @@ -421,25 +415,18 @@ class ModuleImplResultsAPC(ModuleImplResults): ) / np.sum(evals_poids_etuds, axis=1) # etuds_moy_module shape: nb_etuds x nb_ues - # Session2 : quand elle existe, remplace la note de module evals_session2 = self.get_evaluations_session2(modimpl) + evals_rat = self.get_evaluations_rattrapage(modimpl) if evals_session2: + # Session2 : quand elle existe, remplace la note de module # Calcul moyenne notes session2 et remplace (si la note session 2 existe) - evals_coefs_s2 = self.get_evaluations_session2_coefs(modimpl) - evals_poids_s2 = evals_poids_df.values * evals_coefs_s2 - poids_stacked_s2 = np.stack( - [evals_poids_s2] * nb_etuds - ) # nb_etuds, nb_evals, nb_ues - evals_poids_etuds_s2 = np.where( - np.stack([self.evals_notes.values] * nb_ues, axis=2) - > scu.NOTES_NEUTRALISE, - poids_stacked_s2, - 0, + etuds_moy_module_s2 = self._compute_moy_special( + modimpl, + evals_notes_stacked, + evals_poids_df, + Evaluation.EVALUATION_SESSION2, ) - etuds_moy_module_s2 = np.sum( - evals_poids_etuds_s2 * evals_notes_stacked, axis=1 - ) / np.sum(evals_poids_etuds_s2, axis=1) # Vrai si toutes les UEs ont bien une note de session 2 calculée: etuds_use_session2 = np.all(np.isfinite(etuds_moy_module_s2), axis=1) etuds_moy_module = np.where( @@ -450,29 +437,22 @@ class ModuleImplResultsAPC(ModuleImplResults): self.etuds_use_session2 = pd.Series( etuds_use_session2, index=self.evals_notes.index ) - else: - # Rattrapage: remplace la note de module ssi elle est supérieure - eval_rat = self.get_evaluation_rattrapage(modimpl) - if eval_rat: - notes_rat = self.evals_notes[eval_rat.id].values - # remplace les notes invalides (ATT, EXC...) par des NaN - notes_rat = np.where( - notes_rat > scu.NOTES_ABSENCE, - notes_rat / (eval_rat.note_max / 20.0), - np.nan, - ) - # "Étend" le rattrapage sur les UE: la note de rattrapage est la même - # pour toutes les UE mais ne remplace que là où elle est supérieure XXX TODO - notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1) - # prend le max - etuds_use_rattrapage = notes_rat_ues > etuds_moy_module - etuds_moy_module = np.where( - etuds_use_rattrapage, notes_rat_ues, etuds_moy_module - ) - # Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE: - self.etuds_use_rattrapage = pd.Series( - etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index - ) + elif evals_rat: + etuds_moy_module_rat = self._compute_moy_special( + modimpl, + evals_notes_stacked, + evals_poids_df, + Evaluation.EVALUATION_RATTRAPAGE, + ) + etuds_ue_use_rattrapage = ( + etuds_moy_module_rat > etuds_moy_module + ) # etud x UE + etuds_moy_module = np.where( + etuds_ue_use_rattrapage, etuds_moy_module_rat, etuds_moy_module + ) + self.etuds_use_rattrapage = pd.Series( + np.any(etuds_ue_use_rattrapage, axis=1), index=self.evals_notes.index + ) # Application des évaluations bonus: etuds_moy_module = self.apply_bonus( etuds_moy_module, @@ -487,6 +467,34 @@ class ModuleImplResultsAPC(ModuleImplResults): ) return self.etuds_moy_module + def _compute_moy_special( + self, + modimpl: ModuleImpl, + evals_notes_stacked: np.array, + evals_poids_df: pd.DataFrame, + evaluation_type: int, + ) -> np.array: + """Calcul moyenne APC sur évals rattrapage ou session2""" + nb_etuds = self.evals_notes.shape[0] + nb_ues = evals_poids_df.shape[1] + evals_coefs_s2 = self.get_evaluations_special_coefs( + modimpl, evaluation_type=evaluation_type + ) + evals_poids_s2 = evals_poids_df.values * evals_coefs_s2 + poids_stacked_s2 = np.stack( + [evals_poids_s2] * nb_etuds + ) # nb_etuds, nb_evals, nb_ues + evals_poids_etuds_s2 = np.where( + np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE, + poids_stacked_s2, + 0, + ) + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etuds_moy_module_s2 = np.sum( + evals_poids_etuds_s2 * evals_notes_stacked, axis=1 + ) / np.sum(evals_poids_etuds_s2, axis=1) + return etuds_moy_module_s2 + def apply_bonus( self, etuds_moy_module: pd.DataFrame, @@ -628,24 +636,14 @@ class ModuleImplResultsClassic(ModuleImplResults): evals_coefs_etuds * evals_notes_20, axis=1 ) / np.sum(evals_coefs_etuds, axis=1) - # Session2 : quand elle existe, remplace la note de module evals_session2 = self.get_evaluations_session2(modimpl) + evals_rat = self.get_evaluations_rattrapage(modimpl) if evals_session2: - # Calculer la moyenne des évaluations de session2 - # n'utilise que les notes valides et ABS (0). - # Même calcul que pour les évals normales, mais avec seulement les - # coefs des évals de session 2: - evals_coefs_s2 = self.get_evaluations_session2_coefs(modimpl).reshape(-1) - coefs_stacked_s2 = np.stack([evals_coefs_s2] * nb_etuds) - # zéro partout sauf si une note ou ABS: - evals_coefs_etuds_s2 = np.where( - self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked_s2, 0 + # Session2 : quand elle existe, remplace la note de module + # Calcule la moyenne des évaluations de session2 + etuds_moy_module_s2 = self._compute_moy_special( + modimpl, evals_notes_20, Evaluation.EVALUATION_SESSION2 ) - with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) - etuds_moy_module_s2 = np.sum( - evals_coefs_etuds_s2 * evals_notes_20, axis=1 - ) / np.sum(evals_coefs_etuds_s2, axis=1) - etuds_use_session2 = np.isfinite(etuds_moy_module_s2) etuds_moy_module = np.where( etuds_use_session2, @@ -655,25 +653,19 @@ class ModuleImplResultsClassic(ModuleImplResults): self.etuds_use_session2 = pd.Series( etuds_use_session2, index=self.evals_notes.index ) - else: + elif evals_rat: # Rattrapage: remplace la note de module ssi elle est supérieure - eval_rat = self.get_evaluation_rattrapage(modimpl) - if eval_rat: - notes_rat = self.evals_notes[eval_rat.id].values - # remplace les notes invalides (ATT, EXC...) par des NaN - notes_rat = np.where( - notes_rat > scu.NOTES_ABSENCE, - notes_rat / (eval_rat.note_max / 20.0), - np.nan, - ) - # prend le max - etuds_use_rattrapage = notes_rat > etuds_moy_module - etuds_moy_module = np.where( - etuds_use_rattrapage, notes_rat, etuds_moy_module - ) - self.etuds_use_rattrapage = pd.Series( - etuds_use_rattrapage, index=self.evals_notes.index - ) + # Calcule la moyenne des évaluations de rattrapage + etuds_moy_module_rat = self._compute_moy_special( + modimpl, evals_notes_20, Evaluation.EVALUATION_RATTRAPAGE + ) + etuds_use_rattrapage = etuds_moy_module_rat > etuds_moy_module + etuds_moy_module = np.where( + etuds_use_rattrapage, etuds_moy_module_rat, etuds_moy_module + ) + self.etuds_use_rattrapage = pd.Series( + etuds_use_rattrapage, index=self.evals_notes.index + ) # Application des évaluations bonus: etuds_moy_module = self.apply_bonus( @@ -688,6 +680,28 @@ class ModuleImplResultsClassic(ModuleImplResults): return self.etuds_moy_module + def _compute_moy_special( + self, modimpl: ModuleImpl, evals_notes_20: np.array, evaluation_type: int + ) -> np.array: + """Calcul moyenne sur évals rattrapage ou session2""" + # n'utilise que les notes valides et ABS (0). + # Même calcul que pour les évals normales, mais avec seulement les + # coefs des évals de session 2 ou rattrapage: + nb_etuds = self.evals_notes.shape[0] + evals_coefs = self.get_evaluations_special_coefs( + modimpl, evaluation_type=evaluation_type + ).reshape(-1) + coefs_stacked = np.stack([evals_coefs] * nb_etuds) + # zéro partout sauf si une note ou ABS: + evals_coefs_etuds = np.where( + self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked, 0 + ) + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etuds_moy_module = np.sum( + evals_coefs_etuds * evals_notes_20, axis=1 + ) / np.sum(evals_coefs_etuds, axis=1) + return etuds_moy_module # array 1d (nb_etuds) + def apply_bonus( self, etuds_moy_module: np.ndarray, diff --git a/tests/unit/test_notes_rattrapage.py b/tests/unit/test_notes_rattrapage.py index d7888e980..22cd1e475 100644 --- a/tests/unit/test_notes_rattrapage.py +++ b/tests/unit/test_notes_rattrapage.py @@ -81,8 +81,9 @@ def test_notes_rattrapage(test_client): mod_res = res.modimpls_results[moduleimpl_id] moduleimpl = db.session.get(ModuleImpl, moduleimpl_id) # retrouve l'éval. de rattrapage: - eval_rat = mod_res.get_evaluation_rattrapage(moduleimpl) - assert eval_rat.id == e_rat["id"] + evals_rat = mod_res.get_evaluations_rattrapage(moduleimpl) + assert len(evals_rat) == 1 + assert evals_rat[0].id == e_rat["id"] # Les deux évaluations sont considérées comme complètes: assert len(mod_res.get_evaluations_completes(moduleimpl)) == 2 @@ -176,18 +177,25 @@ def test_notes_rattrapage(test_client): # Note moyenne: utilise session 2 même si inférieure assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(20.0) + # Met la note session2 à ABS (None) _, _, _ = G.create_note( evaluation_id=e_session2["id"], etudid=etud["etudid"], note=None ) b = sco_bulletins.formsemestre_bulletinetud_dict( sem["formsemestre_id"], etud["etudid"] ) - # Note moyenne: revient à note normale - assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(10.0) - # Supprime évaluation session 2 + # Note moyenne: zéro car ABS + assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(0.0) + # Supprime note session 2 _, _, _ = G.create_note( evaluation_id=e_session2["id"], etudid=etud["etudid"], note=scu.NOTES_SUPPRESS ) + b = sco_bulletins.formsemestre_bulletinetud_dict( + sem["formsemestre_id"], etud["etudid"] + ) + # Note moyenne: revient à sa valeur initiale, 10/20 + assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(10.0) + # Supprime évaluation session 2 evaluation = db.session.get(Evaluation, e_session2["id"]) assert evaluation evaluation.delete()