diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 4b186938dd..358f9cfd40 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -90,7 +90,7 @@ def df_load_modimpl_notes(moduleimpl_id: int) -> pd.DataFrame: L'ensemble des étudiants est celui des inscrits au module. - Les notes renvoyées sont "brutes" et peuvent prendre els valeurs: + Les notes renvoyées sont "brutes" (séries de floats) et peuvent prendre les valeurs: note : float (valeur enregistrée brute, non normalisée sur 20) pas de note: NaN absent: NaN @@ -104,13 +104,14 @@ def df_load_modimpl_notes(moduleimpl_id: int) -> pd.DataFrame: evals_notes = pd.DataFrame(index=etudids, dtype=float) # empty df with all students for evaluation in evaluations: - eval_df = pd.read_sql( + eval_df = pd.read_sql_query( """SELECT etudid, value AS "%(evaluation_id)s" FROM notes_notes WHERE evaluation_id=%(evaluation_id)s""", db.engine, - params={"evaluation_id": evaluation.evaluation_id}, + params={"evaluation_id": evaluation.id}, index_col="etudid", + dtype=np.float64, ) evals_notes = evals_notes.merge( eval_df, how="outer", left_index=True, right_index=True @@ -119,32 +120,20 @@ def df_load_modimpl_notes(moduleimpl_id: int) -> pd.DataFrame: return evals_notes, evaluations -def normalize_evals_notes(evals_notes: pd.DataFrame, evaluations: list) -> pd.DataFrame: - """Transforme les notes brutes (en base) en valeurs entre 0 et 20: - les notes manquantes, ABS, EXC ATT sont mises à zéro, et les valeurs - normalisées entre 0 et 20. - Return: notes sur 20""" - # Le fillna (pour traiter les ABS) est inutile car le where matche le NaN - # eval_df.fillna(value=0.0, inplace=True) - return evals_notes.where(evals_notes > -1000, 0) / [ - e.note_max / 20.0 for e in evaluations - ] - - def compute_module_moy( evals_notes: pd.DataFrame, evals_poids: pd.DataFrame, - evals_coefs=1.0, + evaluations: list, ) -> pd.DataFrame: """Calcule les moyennes des étudiants dans ce module - evals_notes : DataFrame, colonnes: EVALS, Lignes: etudid - valeur: float, ou NOTES_ATTENTE ou NOTES_NEUTRALISE - Les NaN (ABS) doivent avoir déjà été remplacés par des zéros. + valeur: notes brutes, float ou NOTES_ATTENTE ou NOTES_NEUTRALISE + Les NaN désignent les ABS. - evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs - - evals_coefs: sequence, 1 coef par UE + - evaluations: séquence d'évaluations (utilisées pour le coef et le barème) Résultat: DataFrame, colonnes UE, lignes etud = la note de l'étudiant dans chaque UE pour ce module. @@ -154,16 +143,23 @@ def compute_module_moy( nb_etuds = len(evals_notes) nb_ues = evals_poids.shape[1] etud_moy_module_arr = np.zeros((nb_etuds, nb_ues)) - evals_poids_arr = evals_poids.to_numpy().transpose() * evals_coefs - evals_notes_arr = evals_notes.values # .to_numpy() - val_neutres = np.array((scu.NOTES_NEUTRALISE, scu.NOTES_ATTENTE)) + evals_poids_arr = evals_poids.to_numpy().transpose() * [ + e.coefficient for e in evaluations + ] + # -> evals_poids_arr shape : (nb_ues, nb_evals) + # Remet les notes sur 20 (sauf notes spéciales <= -1000): + evals_notes_arr = np.where(evals_notes.values > -1000, evals_notes.values, 0.0) / [ + e.note_max / 20.0 for e in evaluations + ] for i in range(nb_etuds): - note_vect = evals_notes_arr[ - i - ] # array [note_ue1, note_ue2, ...] de l'étudiant i + # note_vect: array [note_ue1, note_ue2, ...] de l'étudiant i + note_vect = evals_notes_arr[i] # Les poids des évals pour cet étudiant: là où il a des notes non neutralisées + # Attention: les NaN (codant les absents) sont remplacés par des 0 dans + # evals_notes_arr mais pas dans evals_poids_etud_arr + # (la comparaison est toujours false face à un NaN) evals_poids_etud_arr = np.where( - np.isin(note_vect, val_neutres, invert=True), evals_poids_arr, 0.0 + evals_notes.values[i] <= -1000, 0, evals_poids_arr ) # Calcule la moyenne pondérée sur les notes disponibles with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index bb3a62bcc8..9b0f93c132 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -772,10 +772,7 @@ def _add_apc_columns( # on va y ajouter une clé par UE du semestre evals_notes, evaluations = moy_mod.df_load_modimpl_notes(moduleimpl_id) - evals_notes_sur_20 = moy_mod.normalize_evals_notes(evals_notes, evaluations) - etud_moy_module = moy_mod.compute_module_moy( - evals_notes_sur_20, evals_poids, [e.coefficient for e in evaluations] - ) + etud_moy_module = moy_mod.compute_module_moy(evals_notes, evals_poids, evaluations) for row in rows: for ue in ues: diff --git a/tests/unit/test_but_modules.py b/tests/unit/test_but_modules.py index 847c4a65a5..db9febcd26 100644 --- a/tests/unit/test_but_modules.py +++ b/tests/unit/test_but_modules.py @@ -10,6 +10,7 @@ from app import db from app import models from app.comp import moy_mod from app.comp import moy_ue +from app.models import Evaluation from app.scodoc import sco_codes_parcours, sco_saisie_notes from app.scodoc.sco_utils import NOTES_ATTENTE, NOTES_NEUTRALISE @@ -242,8 +243,12 @@ def test_module_moy_elem(test_client): {"UE1": 2, "UE2": 5, "UE3": 0}, ] evals_poids = pd.DataFrame(data, index=["EVAL1", "EVAL2"], dtype=float) + evaluations = [ + Evaluation(note_max=20.0, coefficient=1.0), + Evaluation(note_max=20.0, coefficient=1.0), + ] etud_moy_module_df = moy_mod.compute_module_moy( - evals_notes.fillna(0.0), evals_poids + evals_notes.fillna(0.0), evals_poids, evaluations ) NAN = 666.0 # pour pouvoir comparer NaN et NaN (car NaN != NaN) r = etud_moy_module_df.fillna(NAN) @@ -278,35 +283,68 @@ def test_module_moy(test_client): e2p1, e2p2, e2p3 = 0.0, 1.0, 0.0 # poids de l'éval 2 vers les UE evaluation1.set_ue_poids_dict({ue1.id: e1p1, ue2.id: e1p2, ue3.id: e1p3}) evaluation2.set_ue_poids_dict({ue1.id: e2p1, ue2.id: e2p2, ue3.id: e2p3}) - # Saisie d'une note dans chaque éval - note1, note2 = 11.0, 12.0 - t = sco_saisie_notes.notes_add(G.default_user, evaluation1.id, [(etudid, note1)]) - assert t == (1, 0, []) - _ = sco_saisie_notes.notes_add(G.default_user, evaluation2.id, [(etudid, note2)]) - # # Vérifications moduleimpl_id = evaluation1.moduleimpl_id nb_evals = models.Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).count() assert nb_evals == 2 nb_ues = 3 - # Calcul de la moyenne du module - evals_poids, ues = moy_mod.df_load_evaluations_poids(moduleimpl_id) - assert evals_poids.shape == (nb_evals, nb_ues) - evals_notes, evaluations = moy_mod.df_load_modimpl_notes(moduleimpl_id) - evals_notes_sur_20 = moy_mod.normalize_evals_notes(evals_notes, evaluations) - etud_moy_module = moy_mod.compute_module_moy( - evals_notes_sur_20, evals_poids, [coef_e1, coef_e2] - ) - # Moyenne dans les UE 1, 2, 3: + + # --- Change les notes et recalcule les moyennes du module + # (rappel: on a deux évaluations: evaluation1, evaluation2, et un seul étudiant) + def change_notes(n1, n2): + # Saisie d'une note dans chaque éval + _ = sco_saisie_notes.notes_add(G.default_user, evaluation1.id, [(etudid, n1)]) + _ = sco_saisie_notes.notes_add(G.default_user, evaluation2.id, [(etudid, n2)]) + # Calcul de la moyenne du module + evals_poids, ues = moy_mod.df_load_evaluations_poids(moduleimpl_id) + assert evals_poids.shape == (nb_evals, nb_ues) + evals_notes, evaluations = moy_mod.df_load_modimpl_notes(moduleimpl_id) + assert evals_notes[str(evaluations[0].id)].dtype == np.float64 + etud_moy_module = moy_mod.compute_module_moy( + evals_notes, evals_poids, evaluations + ) + return etud_moy_module + + # --- Notes ordinaires: + note1, note2 = 11.0, 12.0 + sum_copo1 = e1p1 * coef_e1 + e2p1 * coef_e2 # coefs vers UE1 + sum_copo2 = e1p2 * coef_e1 + e2p2 * coef_e2 # + etud_moy_module = change_notes(note1, note2) moy_ue1 = etud_moy_module[ue1.id][etudid] - assert moy_ue1 == ((note1 * e1p1 * coef_e1) + (note2 * e2p1 * coef_e2)) / ( - e1p1 * coef_e1 + e2p1 * coef_e2 - ) + assert moy_ue1 == ((note1 * e1p1 * coef_e1) + (note2 * e2p1 * coef_e2)) / sum_copo1 moy_ue2 = etud_moy_module[ue2.id][etudid] - assert moy_ue2 == ((note1 * e1p2 * coef_e1) + (note2 * e2p2 * coef_e2)) / ( - e1p2 * coef_e1 + e2p2 * coef_e2 - ) + assert moy_ue2 == ((note1 * e1p2 * coef_e1) + (note2 * e2p2 * coef_e2)) / sum_copo2 moy_ue3 = etud_moy_module[ue3.id][etudid] - assert np.isnan(moy_ue3) - # moy_ue3 == ((note1 * e1p3 * coef_e1) + (note2 * e2p3 * coef_e2)) / ( - # e1p3 * coef_e1 + e2p3 * coef_e2) + assert np.isnan(moy_ue3) # car les poids vers UE3 sont nuls + + # --- Une Note ABS (comptée comme zéro) + etud_moy_module = change_notes(None, note2) + assert etud_moy_module[ue1.id][etudid] == (note2 * e2p1 * coef_e2) / sum_copo1 + assert etud_moy_module[ue2.id][etudid] == (note2 * e2p2 * coef_e2) / sum_copo2 + assert np.isnan(etud_moy_module[ue3.id][etudid]) + # --- Deux notes ABS + etud_moy_module = change_notes(None, None) + assert etud_moy_module[ue1.id][etudid] == 0.0 + assert etud_moy_module[ue2.id][etudid] == 0.0 + assert np.isnan(etud_moy_module[ue3.id][etudid]) + # --- Note EXC + etud_moy_module = change_notes(NOTES_ATTENTE, note2) + assert np.isnan(etud_moy_module[ue1.id][etudid]) # car l'eval 2 ne touche que l'UE2 + assert etud_moy_module[ue2.id][etudid] == note2 + assert np.isnan(etud_moy_module[ue3.id][etudid]) + # --- Toutes notes ATT (ATT se traite comme EXC) + etud_moy_module = change_notes(NOTES_NEUTRALISE, NOTES_NEUTRALISE) + assert np.isnan(etud_moy_module[ue1.id][etudid]) + assert np.isnan(etud_moy_module[ue2.id][etudid]) + assert np.isnan(etud_moy_module[ue3.id][etudid]) + # --- Barème sur 37 + evaluation2.note_max = 37.0 + note1, note2 = 11.0, 12.0 + note_2_37 = note2 / 20 * 37 + etud_moy_module = change_notes(note1, note_2_37) + moy_ue1 = etud_moy_module[ue1.id][etudid] + assert moy_ue1 == ((note1 * e1p1 * coef_e1) + (note2 * e2p1 * coef_e2)) / sum_copo1 + moy_ue2 = etud_moy_module[ue2.id][etudid] + assert moy_ue2 == ((note1 * e1p2 * coef_e1) + (note2 * e2p2 * coef_e2)) / sum_copo2 + moy_ue3 = etud_moy_module[ue3.id][etudid] + assert np.isnan(moy_ue3) # car les poids vers UE3 sont nuls