diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index d819beda5f..5aa0f25365 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -33,25 +33,28 @@ moyenne générale d'une UE. """ import numpy as np import pandas as pd +from pandas.core.frame import DataFrame from app import db from app import models +from app.models import ModuleImpl, Evaluation, EvaluationUEPoids +from app.scodoc import sco_utils as scu -def df_load_evaluations_poids(moduleimpl_id: int, default_poids=1.0) -> pd.DataFrame: +def df_load_evaluations_poids(moduleimpl_id: int, default_poids=0.0) -> pd.DataFrame: """Charge poids des évaluations d'un module et retourne un dataframe rows = evaluations, columns = UE, value = poids (float). Les valeurs manquantes (évaluations sans coef vers des UE) sont remplies par default_poids. """ - modimpl = models.ModuleImpl.query.get(moduleimpl_id) - evaluations = models.Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() + modimpl = ModuleImpl.query.get(moduleimpl_id) + evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() ues = modimpl.formsemestre.query_ues().all() ue_ids = [ue.id for ue in ues] evaluation_ids = [evaluation.id for evaluation in evaluations] df = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float) - for eval_poids in models.EvaluationUEPoids.query.join( - models.EvaluationUEPoids.evaluation + for eval_poids in EvaluationUEPoids.query.join( + EvaluationUEPoids.evaluation ).filter_by(moduleimpl_id=moduleimpl_id): df[eval_poids.ue_id][eval_poids.evaluation_id] = eval_poids.poids if default_poids is not None: @@ -73,3 +76,79 @@ def check_moduleimpl_conformity( == module_evals_poids ) return check + + +def df_load_modimpl_notes(moduleimpl_id: int) -> pd.DataFrame: + """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 + + L'ensemble des étudiants est celui des inscrits au module. + + Valeurs des notes: + note : float (valeur enregistrée brute, pas normalisée sur 20) + pas de note: NaN + absent: 0. + excusé: NOTES_NEUTRALISE (voir sco_utils) + attente: NOTES_ATTENTE + + N'utilise pas de cache ScoDoc. + """ + etudids = [e.etudid for e in ModuleImpl.query.get(moduleimpl_id).inscriptions] + evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id) + df = pd.DataFrame(index=etudids, dtype=float) # empty df with all students + + for evaluation in evaluations: + eval_df = pd.read_sql( + """SELECT etudid, value AS "%(evaluation_id)s" + FROM notes_notes + WHERE evaluation_id=%(evaluation_id)s""", + db.engine, + params={"evaluation_id": evaluation.evaluation_id}, + index_col="etudid", + ) + # Remplace les ABS (NULL en BD, donc NaN ici) par des zéros. + eval_df.fillna(value=0.0, inplace=True) + df = df.merge(eval_df, how="outer", left_index=True, right_index=True) + + return df + + +def compute_module_moy(evals_notes: pd.DataFrame, evals_poids: 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. + + - evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs + + 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) + ne donnent pas de coef vers cette UE. + """ + 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_notes_arr = evals_notes.values # .to_numpy() + val_neutres = np.array((scu.NOTES_NEUTRALISE, scu.NOTES_ATTENTE)) + for i in range(nb_etuds): + note_vect = evals_notes_arr[ + i + ] # array [note_ue1, note_ue2, ...] de l'étudiant i + # Les poids des évals pour cet étudiant: là où il a des notes non neutralisées + evals_poids_etud_arr = np.where( + np.isin(note_vect, val_neutres, invert=True), evals_poids_arr, 0.0 + ) + # Calcule la moyenne pondérée sur les notes disponibles + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etud_moy_module_arr[i] = (note_vect * evals_poids_etud_arr).sum( + axis=1 + ) / evals_poids_etud_arr.sum(axis=1) + + etud_moy_module_df = pd.DataFrame( + etud_moy_module_arr, index=evals_notes.index, columns=evals_poids.columns + ) + return etud_moy_module_df diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 90733b857b..570b8e702f 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -46,6 +46,9 @@ class Identite(db.Model): # billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic") + def __repr__(self): + return f"" + class Adresse(db.Model): """Adresse d'un étudiant diff --git a/app/models/evaluations.py b/app/models/evaluations.py index c5085ae663..df9cc812bd 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -45,6 +45,9 @@ class Evaluation(db.Model): numero = db.Column(db.Integer) ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True) + def __repr__(self): + return f"UE vs poids des évaluations""" +def _setup_module_evaluation(ue_coefs=(1.0, 2.0, 3.0)): + """Utilisé dans plusieurs tests: + - création formation 3 UE, 1 module + - 1 semestre, 1 moduleimpl, 1 eval + """ G, formation_id, ue1_id, ue2_id, ue3_id, module_id = setup_formation_test() ue1 = models.UniteEns.query.get(ue1_id) ue2 = models.UniteEns.query.get(ue2_id) @@ -142,7 +146,7 @@ def test_modules_conformity(test_client): nb_ues = 3 # 3 UEs dans ce test nb_mods = 1 # 1 seul module # Coef du module vers les UE - c1, c2, c3 = 1.0, 2.0, 3.0 + c1, c2, c3 = ue_coefs coefs_mod = {ue1.id: c1, ue2.id: c2, ue3.id: c3} mod.set_ue_coef_dict(coefs_mod) assert mod.get_ue_coef_dict() == coefs_mod @@ -171,19 +175,77 @@ def test_modules_conformity(test_client): coefficient=0, ) evaluation_id = _e1["evaluation_id"] + return formation_id, evaluation_id, ue1, ue2, ue3 + + +def test_module_conformity(test_client): + """Vérification coefficients module<->UE vs poids des évaluations""" + formation_id, evaluation_id, ue1, ue2, ue3 = _setup_module_evaluation() + semestre_idx = 2 + nb_ues = 3 # 3 UEs dans ce test + nb_mods = 1 # 1 seul module nb_evals = 1 # 1 seule evaluation pour l'instant p1, p2, p3 = 1.0, 2.0, 0.0 # poids de l'éval vers les UE 1, 2 et 3 evaluation = models.Evaluation.query.get(evaluation_id) evaluation.set_ue_poids_dict({ue1.id: p1, ue2.id: p2}) assert evaluation.get_ue_poids_dict() == {ue1.id: p1, ue2.id: p2} # On n'est pas conforme car p3 est nul alors que c3 est non nul - modules_coefficients, _ues, _modules = moy_ue.df_load_ue_coefs(formation_id) + modules_coefficients, _ues, _modules = moy_ue.df_load_ue_coefs( + formation_id, semestre_idx + ) assert isinstance(modules_coefficients, pd.DataFrame) assert modules_coefficients.shape == (nb_ues, nb_mods) - evals_poids = moy_mod.df_load_evaluations_poids(moduleimpl_id) + evals_poids = moy_mod.df_load_evaluations_poids(evaluation.moduleimpl_id) assert isinstance(evals_poids, pd.DataFrame) assert all(evals_poids.dtypes == np.float64) assert evals_poids.shape == (nb_evals, nb_ues) assert not moy_mod.check_moduleimpl_conformity( - modimpl, evals_poids, modules_coefficients + evaluation.moduleimpl, evals_poids, modules_coefficients ) + + +def test_module_moy(): + """Vérification calcul moyenne d'un module + (calcul bas niveau) + """ + # Repris du notebook CalculNotesBUT.ipynb + data = [ # Les notes de chaque étudiant dans les 2 evals: + { + "EVAL1": 11.0, + "EVAL2": 16.0, + }, + { + "EVAL1": np.NaN, # une absence (NaN) + "EVAL2": 17.0, + }, + { + "EVAL1": 13.0, + "EVAL2": NOTES_NEUTRALISE, # une abs EXC + }, + { + "EVAL1": 14.0, + "EVAL2": 19.0, + }, + { + "EVAL1": NOTES_ATTENTE, # une ATT (traitée comme EXC) + "EVAL2": np.NaN, # et une ABS + }, + ] + evals_notes = pd.DataFrame( + data, index=["etud1", "etud2", "etud3", "etud4", "etud5"] + ) + # Poids des évaluations (1 ligne / évaluation) + data = [ + {"UE1": 1, "UE2": 0, "UE3": 0}, + {"UE1": 2, "UE2": 5, "UE3": 0}, + ] + evals_poids = pd.DataFrame(data, index=["EVAL1", "EVAL2"], dtype=float) + etud_moy_module_df = moy_mod.compute_module_moy(evals_notes, evals_poids) + NAN = 666.0 # pour pouvoir comparer NaN et NaN (car NaN != NaN) + r = etud_moy_module_df.fillna(NAN) + tuple(r.loc["etud1"]) == (14 + 1 / 3, 16.0, NAN) + tuple(r.loc["etud2"]) == (11 + 1 / 3, 17.0, NAN) + tuple(r.loc["etud3"]) == (13, NAN, NAN) + tuple(r.loc["etud4"]) == (17 + 1 / 3, 19, NAN) + tuple(r.loc["etud5"]) == (0.0, 0.0, NAN) + # note: les notes UE3 sont toutes NAN car les poids vers l'UE3 sont nuls