From 36b432839a1857fda1b28af92ac8efe6fa352c60 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 8 Dec 2021 14:13:18 +0100 Subject: [PATCH] =?UTF-8?q?Calcul=20moyennes=20modules=20avec=20inscriptio?= =?UTF-8?q?ns=20partielles,=20ATT,=20EXC,=20ABS,=20mode=20imm=C3=A9diat.?= =?UTF-8?q?=20(+=20tests=20unit.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/bulletin_but.py | 6 +-- app/comp/moy_mod.py | 91 ++++++++++++++++++++++++---------- app/comp/moy_ue.py | 6 ++- app/scodoc/sco_edit_module.py | 25 ++++++---- app/scodoc/sco_liste_notes.py | 9 +++- app/scodoc/sco_utils.py | 2 +- tests/unit/test_but_modules.py | 66 ++++++++++++++++-------- tests/unit/test_but_ues.py | 2 +- 8 files changed, 139 insertions(+), 68 deletions(-) diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 0e725bc25..009a6b1b9 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -166,19 +166,19 @@ class ResultatsSemestreBUT: def etud_eval_results(self, etud, e) -> dict: "dict resultats d'un étudiant à une évaluation" - eval_notes = self.modimpls_evals_notes[e.moduleimpl_id][str(e.id)] # pd.Series + eval_notes = self.modimpls_evals_notes[e.moduleimpl_id][e.id] # pd.Series notes_ok = eval_notes.where(eval_notes > -1000).dropna() d = { "id": e.id, "description": e.description, - "date": e.jour.isoformat(), + "date": e.jour.isoformat() if e.jour else None, "heure_debut": e.heure_debut.strftime("%H:%M") if e.heure_debut else None, "heure_fin": e.heure_fin.strftime("%H:%M") if e.heure_debut else None, "coef": e.coefficient, "poids": {p.ue.acronyme: p.poids for p in e.ue_poids}, "note": { "value": fmt_note( - self.modimpls_evals_notes[e.moduleimpl_id][str(e.id)][etud.id] + self.modimpls_evals_notes[e.moduleimpl_id][e.id][etud.id] ), "min": fmt_note(notes_ok.min()), "max": fmt_note(notes_ok.max()), diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 1c5fa4d78..b59ad298a 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -81,63 +81,92 @@ def check_moduleimpl_conformity( return check -def df_load_modimpl_notes(moduleimpl_id: int) -> pd.DataFrame: +def df_load_modimpl_notes(moduleimpl_id: int) -> tuple: """Construit un dataframe avec toutes les notes des évaluations du module. - colonnes: evaluation_id (le nom de la colonne est l'evaluation_id en str) - index (lignes): etudid + colonnes: le nom de la colonne est l'evaluation_id (int) + index (lignes): etudid (int) - Résultat: (evals_notes, liste de évaluations du moduleimpl) + Résultat: (evals_notes, liste de évaluations du moduleimpl, + liste de booleens indiquant si l'évaluation est "complete") L'ensemble des étudiants est celui des inscrits au SEMESTRE. 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 + pas de note: NaN (rien en bd, ou étudiant non inscrit au module) + absent: NOTES_ABSENCE (NULL en bd) excusé: NOTES_NEUTRALISE (voir sco_utils) attente: NOTES_ATTENTE + L'évaluation "complete" (prise en compte dans les calculs) si: + - soit tous les étudiants inscrits au module ont des notes + - soit elle a été déclarée "à prise ne compte immédiate" (publish_incomplete) + N'utilise pas de cache ScoDoc. """ # L'index du dataframe est la liste des étudiants inscrits au semestre: etudids = [ e.etudid for e in ModuleImpl.query.get(moduleimpl_id).formsemestre.inscriptions ] - evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id) + evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() + if evaluations: + nb_inscrits_module = len(evaluations[0].moduleimpl.inscriptions) + else: + nb_inscrits_module = 0 evals_notes = pd.DataFrame(index=etudids, dtype=float) # empty df with all students - + evaluations_completes = [] for evaluation in evaluations: eval_df = pd.read_sql_query( - """SELECT etudid, value AS "%(evaluation_id)s" - FROM notes_notes - WHERE evaluation_id=%(evaluation_id)s""", + """SELECT n.etudid, n.value AS "%(evaluation_id)s" + FROM notes_notes n, notes_moduleimpl_inscription i + WHERE evaluation_id=%(evaluation_id)s + AND n.etudid = i.etudid + AND i.moduleimpl_id = %(moduleimpl_id)s + """, db.engine, - params={"evaluation_id": evaluation.id}, + params={ + "evaluation_id": evaluation.id, + "moduleimpl_id": evaluation.moduleimpl.id, + }, index_col="etudid", dtype=np.float64, ) + evaluations_completes.append( + len(eval_df) == nb_inscrits_module or evaluation.publish_incomplete + ) + # NULL en base => ABS + eval_df.fillna(scu.NOTES_ABSENCE, inplace=True) + # Ce merge met à NULL les élements non présents + # (notes non saisies ou etuds non inscrits au module): evals_notes = evals_notes.merge( eval_df, how="outer", left_index=True, right_index=True ) - - return evals_notes, evaluations + # Force columns names to integers (evaluation ids) + evals_notes.columns = pd.Int64Index( + [int(x) for x in evals_notes.columns], dtype="int64" + ) + return evals_notes, evaluations, evaluations_completes def compute_module_moy( evals_notes_df: pd.DataFrame, evals_poids_df: pd.DataFrame, evaluations: list, + evaluations_completes: list, ) -> pd.DataFrame: """Calcule les moyennes des étudiants dans ce module - evals_notes : DataFrame, colonnes: EVALS, Lignes: etudid - valeur: notes brutes, float ou NOTES_ATTENTE ou NOTES_NEUTRALISE - Les NaN désignent les ABS. + valeur: notes brutes, float ou NOTES_ATTENTE, NOTES_NEUTRALISE, NOTES_ABSENCE + Les NaN désignent les notes manquantes (non saisies). - evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs - evaluations: séquence d'évaluations (utilisées pour le coef et le barème) + - evaluations_completes: séquence de booléens indiquaant les évals à prendre + en compte. + Résultat: DataFrame, colonnes UE, lignes etud = la note de l'étudiant dans chaque UE pour ce module. ou NaN si les évaluations (dans lesquelles l'étudiant à des notes) @@ -146,26 +175,34 @@ def compute_module_moy( nb_etuds, nb_evals = evals_notes_df.shape nb_ues = evals_poids_df.shape[1] assert evals_poids_df.shape[0] == nb_evals # compat notes/poids - evals_coefs = np.array([e.coefficient for e in evaluations], dtype=float).reshape( - -1, 1 - ) + # Coefficients des évaluations, met à zéro ceux des évals incomplètes: + evals_coefs = ( + np.array( + [e.coefficient for e in evaluations], + dtype=float, + ) + * evaluations_completes + ).reshape(-1, 1) evals_poids = evals_poids_df.values * evals_coefs - # -> evals_poids_arr shape : (nb_evals, nb_ues) + # -> evals_poids shape : (nb_evals, nb_ues) assert evals_poids.shape == (nb_evals, nb_ues) - # Remet les notes sur 20 (sauf notes spéciales <= -1000): + # Remplace les notes ATT, EXC, ABS, NaN par zéro et mets les notes sur 20: evals_notes = np.where( - evals_notes_df.values > -1000, evals_notes_df.values, 0.0 + evals_notes_df.values > scu.NOTES_ABSENCE, evals_notes_df.values, 0.0 ) / [e.note_max / 20.0 for e in evaluations] # Les poids des évals pour les é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_etuds_arr - # (la comparaison est toujours false face à un NaN) + # (ABS n'est pas neutralisée, mais ATTENTE et NEUTRALISE oui) + # Note: les NaN sont remplacés par des 0 dans evals_notes + # et dans dans evals_poids_etuds + # (rappel: la comparaison est toujours false face à un NaN) # shape: (nb_etuds, nb_evals, nb_ues) poids_stacked = np.stack([evals_poids] * nb_etuds) evals_poids_etuds = np.where( - np.stack([evals_notes_df.values] * nb_ues, axis=2) <= -1000.0, 0, poids_stacked + np.stack([evals_notes_df.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE, + poids_stacked, + 0, ) - # Calcule la moyenne pondérée sur les notes disponibles + # Calcule la moyenne pondérée sur les notes disponibles: evals_notes_stacked = np.stack([evals_notes] * nb_ues, axis=2) with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) etuds_moy_module = np.sum( diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 292047450..f609060ea 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -130,10 +130,12 @@ def notes_sem_load_cube(formsemestre): modimpls_evaluations = {} # modimpl.id : liste des évaluations modimpls_notes = [] for modimpl in formsemestre.modimpls: - evals_notes, evaluations = moy_mod.df_load_modimpl_notes(modimpl.id) + evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( + modimpl.id + ) evals_poids, ues = moy_mod.df_load_evaluations_poids(modimpl.id) etuds_moy_module = moy_mod.compute_module_moy( - evals_notes, evals_poids, evaluations + evals_notes, evals_poids, evaluations, evaluations_completes ) modimpls_evals_poids[modimpl.id] = evals_poids modimpls_evals_notes[modimpl.id] = evals_notes diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index c717c1dd7..72932b45e 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -528,18 +528,21 @@ def module_edit(module_id=None): ("formation_id", {"input_type": "hidden"}), ("ue_id", {"input_type": "hidden"}), ("module_id", {"input_type": "hidden"}), - ( - "ue_matiere_id", - { - "input_type": "menu", - "title": "Matière", - "explanation": "un module appartient à une seule matière.", - "labels": mat_names, - "allowed_values": ue_mat_ids, - "enabled": unlocked, - }, - ), ] + if not is_apc: + descr += [ + ( + "ue_matiere_id", + { + "input_type": "menu", + "title": "Matière", + "explanation": "un module appartient à une seule matière.", + "labels": mat_names, + "allowed_values": ue_mat_ids, + "enabled": unlocked, + }, + ), + ] if is_apc: # le semestre du module est toujours celui de son UE descr += [ diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index 9c00e2315..3fbdf0505 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -810,8 +810,12 @@ def _add_apc_columns( # rows est une liste de dict avec une clé "etudid" # on va y ajouter une clé par UE du semestre - evals_notes, evaluations = moy_mod.df_load_modimpl_notes(moduleimpl_id) - etuds_moy_module = moy_mod.compute_module_moy(evals_notes, evals_poids, evaluations) + evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( + moduleimpl_id + ) + etuds_moy_module = moy_mod.compute_module_moy( + evals_notes, evals_poids, evaluations, evaluations_completes + ) for row in rows: for ue in ues: @@ -822,3 +826,4 @@ def _add_apc_columns( col_id = f"moy_ue_{ue.id}" titles[col_id] = ue.acronyme columns_ids.append(col_id) + row_coefs[f"moy_ue_{ue.id}"] = "m" diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index ab8dad994..c1aeaab00 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -66,11 +66,11 @@ import sco_version NOTES_PRECISION = 1e-4 # evite eventuelles erreurs d'arrondis NOTES_MIN = 0.0 # valeur minimale admise pour une note (sauf malus, dans [-20, 20]) NOTES_MAX = 1000.0 +NOTES_ABSENCE = -999.0 # absences dans les DataFrames, NULL en base NOTES_NEUTRALISE = -1000.0 # notes non prises en comptes dans moyennes NOTES_SUPPRESS = -1001.0 # note a supprimer NOTES_ATTENTE = -1002.0 # note "en attente" (se calcule comme une note neutralisee) - # Types de modules class ModuleType(IntEnum): """Code des types de module.""" diff --git a/tests/unit/test_but_modules.py b/tests/unit/test_but_modules.py index e89655802..a73c98bbc 100644 --- a/tests/unit/test_but_modules.py +++ b/tests/unit/test_but_modules.py @@ -12,7 +12,12 @@ from app.comp import moy_mod from app.comp import moy_ue from app.models import Evaluation from app.scodoc import sco_saisie_notes -from app.scodoc.sco_utils import NOTES_ATTENTE, NOTES_NEUTRALISE +from app.scodoc.sco_utils import ( + NOTES_ATTENTE, + NOTES_NEUTRALISE, + NOTES_SUPPRESS, + NOTES_PRECISION, +) """ mapp.set_sco_dept("RT") @@ -23,6 +28,10 @@ login_user(admin_user) """ +def same_note(x, y): + return abs(x - y) < NOTES_PRECISION + + def test_evaluation_poids(test_client): """Association de poids vers les UE""" G, formation_id, ue1_id, ue2_id, ue3_id, module_ids = setup.build_formation_test() @@ -140,27 +149,33 @@ def test_module_moy_elem(test_client): """Vérification calcul moyenne d'un module (notes entrées dans un DataFrame sans passer par ScoDoc) """ + # Création de deux évaluations: + e1 = Evaluation(note_max=20.0, coefficient=1.0) + e2 = Evaluation(note_max=20.0, coefficient=1.0) + db.session.add(e1) + db.session.add(e2) + db.session.commit() # Repris du notebook CalculNotesBUT.ipynb data = [ # Les notes de chaque étudiant dans les 2 evals: { - "EVAL1": 11.0, - "EVAL2": 16.0, + e1.id: 11.0, + e2.id: 16.0, }, { - "EVAL1": np.NaN, # une absence (NaN) - "EVAL2": 17.0, + e1.id: None, # une absence + e2.id: 17.0, }, { - "EVAL1": 13.0, - "EVAL2": NOTES_NEUTRALISE, # une abs EXC + e1.id: 13.0, + e2.id: NOTES_NEUTRALISE, # une abs EXC }, { - "EVAL1": 14.0, - "EVAL2": 19.0, + e1.id: 14.0, + e2.id: 19.0, }, { - "EVAL1": NOTES_ATTENTE, # une ATT (traitée comme EXC) - "EVAL2": np.NaN, # et une ABS + e1.id: NOTES_ATTENTE, # une ATT (traitée comme EXC) + e2.id: None, # et une ABS }, ] evals_notes_df = pd.DataFrame( @@ -171,13 +186,10 @@ def test_module_moy_elem(test_client): {"UE1": 1, "UE2": 0, "UE3": 0}, {"UE1": 2, "UE2": 5, "UE3": 0}, ] - evals_poids_df = 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), - ] + evals_poids_df = pd.DataFrame(data, index=[e1.id, e2.id], dtype=float) + evaluations = [e1, e2] etuds_moy_module_df = moy_mod.compute_module_moy( - evals_notes_df.fillna(0.0), evals_poids_df, evaluations + evals_notes_df.fillna(0.0), evals_poids_df, evaluations, [True, True] ) NAN = 666.0 # pour pouvoir comparer NaN et NaN (car NaN != NaN) r = etuds_moy_module_df.fillna(NAN) @@ -235,10 +247,14 @@ def test_module_moy(test_client): # 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 + evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( + moduleimpl_id + ) + assert evals_notes[evaluations[0].id].dtype == np.float64 + assert evaluation1.id == evaluations[0].id + assert evaluation2.id == evaluations[1].id etuds_moy_module = moy_mod.compute_module_moy( - evals_notes, evals_poids, evaluations + evals_notes, evals_poids, evaluations, evaluations_completes ) return etuds_moy_module @@ -254,7 +270,7 @@ def test_module_moy(test_client): moy_ue3 = etuds_moy_module[ue3.id][etudid] assert np.isnan(moy_ue3) # car les poids vers UE3 sont nuls - # --- Une Note ABS (comptée comme zéro) + # --- Une note ABS (comptée comme zéro) etuds_moy_module = change_notes(None, note2) assert etuds_moy_module[ue1.id][etudid] == (note2 * e2p1 * coef_e2) / sum_copo1 assert etuds_moy_module[ue2.id][etudid] == (note2 * e2p2 * coef_e2) / sum_copo2 @@ -287,3 +303,11 @@ def test_module_moy(test_client): assert moy_ue2 == ((note1 * e1p2 * coef_e1) + (note2 * e2p2 * coef_e2)) / sum_copo2 moy_ue3 = etuds_moy_module[ue3.id][etudid] assert np.isnan(moy_ue3) # car les poids vers UE3 sont nuls + # --- Note manquante à l'éval. 1 + note_2_37 = note2 / 20 * 37 + etuds_moy_module = change_notes(NOTES_SUPPRESS, note_2_37) + assert same_note(etuds_moy_module[ue2.id][etudid], note2) + # --- Prise en compte immédiate: + evaluation1.publish_incomplete = True + etuds_moy_module = change_notes(NOTES_SUPPRESS, note_2_37) + assert same_note(etuds_moy_module[ue2.id][etudid], note2) diff --git a/tests/unit/test_but_ues.py b/tests/unit/test_but_ues.py index 65275d4ae..3dcb21d2f 100644 --- a/tests/unit/test_but_ues.py +++ b/tests/unit/test_but_ues.py @@ -94,7 +94,7 @@ def test_ue_moy(test_client): # EXC à un module n1, n2 = 5.0, NOTES_NEUTRALISE etud_moy_ue = change_notes(n1, n2) - # Pour le moment, une note NEUTRALISE var entrainer le non calcul + # Pour le moment, une note NEUTRALISE va entrainer le non-calcul # des moyennes. assert np.isnan(etud_moy_ue.values).all() # Désinscrit l'étudiant du module 2: