Calcul moyennes modules avec inscriptions partielles, ATT, EXC, ABS, mode immédiat. (+ tests unit.)

This commit is contained in:
Emmanuel Viennet 2021-12-08 14:13:18 +01:00
parent 9927368680
commit 36b432839a
8 changed files with 139 additions and 68 deletions

View File

@ -166,19 +166,19 @@ class ResultatsSemestreBUT:
def etud_eval_results(self, etud, e) -> dict: def etud_eval_results(self, etud, e) -> dict:
"dict resultats d'un étudiant à une évaluation" "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() notes_ok = eval_notes.where(eval_notes > -1000).dropna()
d = { d = {
"id": e.id, "id": e.id,
"description": e.description, "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_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, "heure_fin": e.heure_fin.strftime("%H:%M") if e.heure_debut else None,
"coef": e.coefficient, "coef": e.coefficient,
"poids": {p.ue.acronyme: p.poids for p in e.ue_poids}, "poids": {p.ue.acronyme: p.poids for p in e.ue_poids},
"note": { "note": {
"value": fmt_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()), "min": fmt_note(notes_ok.min()),
"max": fmt_note(notes_ok.max()), "max": fmt_note(notes_ok.max()),

View File

@ -81,63 +81,92 @@ def check_moduleimpl_conformity(
return check 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. """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) colonnes: le nom de la colonne est l'evaluation_id (int)
index (lignes): etudid 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. 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: 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) note : float (valeur enregistrée brute, non normalisée sur 20)
pas de note: NaN pas de note: NaN (rien en bd, ou étudiant non inscrit au module)
absent: NaN absent: NOTES_ABSENCE (NULL en bd)
excusé: NOTES_NEUTRALISE (voir sco_utils) excusé: NOTES_NEUTRALISE (voir sco_utils)
attente: NOTES_ATTENTE 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. N'utilise pas de cache ScoDoc.
""" """
# L'index du dataframe est la liste des étudiants inscrits au semestre: # L'index du dataframe est la liste des étudiants inscrits au semestre:
etudids = [ etudids = [
e.etudid for e in ModuleImpl.query.get(moduleimpl_id).formsemestre.inscriptions 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 evals_notes = pd.DataFrame(index=etudids, dtype=float) # empty df with all students
evaluations_completes = []
for evaluation in evaluations: for evaluation in evaluations:
eval_df = pd.read_sql_query( eval_df = pd.read_sql_query(
"""SELECT etudid, value AS "%(evaluation_id)s" """SELECT n.etudid, n.value AS "%(evaluation_id)s"
FROM notes_notes FROM notes_notes n, notes_moduleimpl_inscription i
WHERE evaluation_id=%(evaluation_id)s""", WHERE evaluation_id=%(evaluation_id)s
AND n.etudid = i.etudid
AND i.moduleimpl_id = %(moduleimpl_id)s
""",
db.engine, db.engine,
params={"evaluation_id": evaluation.id}, params={
"evaluation_id": evaluation.id,
"moduleimpl_id": evaluation.moduleimpl.id,
},
index_col="etudid", index_col="etudid",
dtype=np.float64, 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( evals_notes = evals_notes.merge(
eval_df, how="outer", left_index=True, right_index=True eval_df, how="outer", left_index=True, right_index=True
) )
# Force columns names to integers (evaluation ids)
return evals_notes, evaluations 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( def compute_module_moy(
evals_notes_df: pd.DataFrame, evals_notes_df: pd.DataFrame,
evals_poids_df: pd.DataFrame, evals_poids_df: pd.DataFrame,
evaluations: list, evaluations: list,
evaluations_completes: list,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Calcule les moyennes des étudiants dans ce module """Calcule les moyennes des étudiants dans ce module
- evals_notes : DataFrame, colonnes: EVALS, Lignes: etudid - evals_notes : DataFrame, colonnes: EVALS, Lignes: etudid
valeur: notes brutes, float ou NOTES_ATTENTE ou NOTES_NEUTRALISE valeur: notes brutes, float ou NOTES_ATTENTE, NOTES_NEUTRALISE, NOTES_ABSENCE
Les NaN désignent les ABS. Les NaN désignent les notes manquantes (non saisies).
- evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs - evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs
- evaluations: séquence d'évaluations (utilisées pour le coef et le barème) - 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 Résultat: DataFrame, colonnes UE, lignes etud
= la note de l'étudiant dans chaque UE pour ce module. = la note de l'étudiant dans chaque UE pour ce module.
ou NaN si les évaluations (dans lesquelles l'étudiant à des notes) 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_etuds, nb_evals = evals_notes_df.shape
nb_ues = evals_poids_df.shape[1] nb_ues = evals_poids_df.shape[1]
assert evals_poids_df.shape[0] == nb_evals # compat notes/poids assert evals_poids_df.shape[0] == nb_evals # compat notes/poids
evals_coefs = np.array([e.coefficient for e in evaluations], dtype=float).reshape( # Coefficients des évaluations, met à zéro ceux des évals incomplètes:
-1, 1 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 = 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) 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 = 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] ) / [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 # 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 # (ABS n'est pas neutralisée, mais ATTENTE et NEUTRALISE oui)
# evals_notes_arr mais pas dans evals_poids_etuds_arr # Note: les NaN sont remplacés par des 0 dans evals_notes
# (la comparaison est toujours false face à un NaN) # et dans dans evals_poids_etuds
# (rappel: la comparaison est toujours false face à un NaN)
# shape: (nb_etuds, nb_evals, nb_ues) # shape: (nb_etuds, nb_evals, nb_ues)
poids_stacked = np.stack([evals_poids] * nb_etuds) poids_stacked = np.stack([evals_poids] * nb_etuds)
evals_poids_etuds = np.where( 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) evals_notes_stacked = np.stack([evals_notes] * nb_ues, axis=2)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum( etuds_moy_module = np.sum(

View File

@ -130,10 +130,12 @@ def notes_sem_load_cube(formsemestre):
modimpls_evaluations = {} # modimpl.id : liste des évaluations modimpls_evaluations = {} # modimpl.id : liste des évaluations
modimpls_notes = [] modimpls_notes = []
for modimpl in formsemestre.modimpls: 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) evals_poids, ues = moy_mod.df_load_evaluations_poids(modimpl.id)
etuds_moy_module = moy_mod.compute_module_moy( 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_poids[modimpl.id] = evals_poids
modimpls_evals_notes[modimpl.id] = evals_notes modimpls_evals_notes[modimpl.id] = evals_notes

View File

@ -528,6 +528,9 @@ def module_edit(module_id=None):
("formation_id", {"input_type": "hidden"}), ("formation_id", {"input_type": "hidden"}),
("ue_id", {"input_type": "hidden"}), ("ue_id", {"input_type": "hidden"}),
("module_id", {"input_type": "hidden"}), ("module_id", {"input_type": "hidden"}),
]
if not is_apc:
descr += [
( (
"ue_matiere_id", "ue_matiere_id",
{ {

View File

@ -810,8 +810,12 @@ def _add_apc_columns(
# rows est une liste de dict avec une clé "etudid" # rows est une liste de dict avec une clé "etudid"
# on va y ajouter une clé par UE du semestre # on va y ajouter une clé par UE du semestre
evals_notes, evaluations = moy_mod.df_load_modimpl_notes(moduleimpl_id) evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes(
etuds_moy_module = moy_mod.compute_module_moy(evals_notes, evals_poids, evaluations) moduleimpl_id
)
etuds_moy_module = moy_mod.compute_module_moy(
evals_notes, evals_poids, evaluations, evaluations_completes
)
for row in rows: for row in rows:
for ue in ues: for ue in ues:
@ -822,3 +826,4 @@ def _add_apc_columns(
col_id = f"moy_ue_{ue.id}" col_id = f"moy_ue_{ue.id}"
titles[col_id] = ue.acronyme titles[col_id] = ue.acronyme
columns_ids.append(col_id) columns_ids.append(col_id)
row_coefs[f"moy_ue_{ue.id}"] = "m"

View File

@ -66,11 +66,11 @@ import sco_version
NOTES_PRECISION = 1e-4 # evite eventuelles erreurs d'arrondis 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_MIN = 0.0 # valeur minimale admise pour une note (sauf malus, dans [-20, 20])
NOTES_MAX = 1000.0 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_NEUTRALISE = -1000.0 # notes non prises en comptes dans moyennes
NOTES_SUPPRESS = -1001.0 # note a supprimer NOTES_SUPPRESS = -1001.0 # note a supprimer
NOTES_ATTENTE = -1002.0 # note "en attente" (se calcule comme une note neutralisee) NOTES_ATTENTE = -1002.0 # note "en attente" (se calcule comme une note neutralisee)
# Types de modules # Types de modules
class ModuleType(IntEnum): class ModuleType(IntEnum):
"""Code des types de module.""" """Code des types de module."""

View File

@ -12,7 +12,12 @@ from app.comp import moy_mod
from app.comp import moy_ue from app.comp import moy_ue
from app.models import Evaluation from app.models import Evaluation
from app.scodoc import sco_saisie_notes 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") 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): def test_evaluation_poids(test_client):
"""Association de poids vers les UE""" """Association de poids vers les UE"""
G, formation_id, ue1_id, ue2_id, ue3_id, module_ids = setup.build_formation_test() 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 """Vérification calcul moyenne d'un module
(notes entrées dans un DataFrame sans passer par ScoDoc) (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 # Repris du notebook CalculNotesBUT.ipynb
data = [ # Les notes de chaque étudiant dans les 2 evals: data = [ # Les notes de chaque étudiant dans les 2 evals:
{ {
"EVAL1": 11.0, e1.id: 11.0,
"EVAL2": 16.0, e2.id: 16.0,
}, },
{ {
"EVAL1": np.NaN, # une absence (NaN) e1.id: None, # une absence
"EVAL2": 17.0, e2.id: 17.0,
}, },
{ {
"EVAL1": 13.0, e1.id: 13.0,
"EVAL2": NOTES_NEUTRALISE, # une abs EXC e2.id: NOTES_NEUTRALISE, # une abs EXC
}, },
{ {
"EVAL1": 14.0, e1.id: 14.0,
"EVAL2": 19.0, e2.id: 19.0,
}, },
{ {
"EVAL1": NOTES_ATTENTE, # une ATT (traitée comme EXC) e1.id: NOTES_ATTENTE, # une ATT (traitée comme EXC)
"EVAL2": np.NaN, # et une ABS e2.id: None, # et une ABS
}, },
] ]
evals_notes_df = pd.DataFrame( evals_notes_df = pd.DataFrame(
@ -171,13 +186,10 @@ def test_module_moy_elem(test_client):
{"UE1": 1, "UE2": 0, "UE3": 0}, {"UE1": 1, "UE2": 0, "UE3": 0},
{"UE1": 2, "UE2": 5, "UE3": 0}, {"UE1": 2, "UE2": 5, "UE3": 0},
] ]
evals_poids_df = pd.DataFrame(data, index=["EVAL1", "EVAL2"], dtype=float) evals_poids_df = pd.DataFrame(data, index=[e1.id, e2.id], dtype=float)
evaluations = [ evaluations = [e1, e2]
Evaluation(note_max=20.0, coefficient=1.0),
Evaluation(note_max=20.0, coefficient=1.0),
]
etuds_moy_module_df = moy_mod.compute_module_moy( 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) NAN = 666.0 # pour pouvoir comparer NaN et NaN (car NaN != NaN)
r = etuds_moy_module_df.fillna(NAN) r = etuds_moy_module_df.fillna(NAN)
@ -235,10 +247,14 @@ def test_module_moy(test_client):
# Calcul de la moyenne du module # Calcul de la moyenne du module
evals_poids, ues = moy_mod.df_load_evaluations_poids(moduleimpl_id) evals_poids, ues = moy_mod.df_load_evaluations_poids(moduleimpl_id)
assert evals_poids.shape == (nb_evals, nb_ues) assert evals_poids.shape == (nb_evals, nb_ues)
evals_notes, evaluations = moy_mod.df_load_modimpl_notes(moduleimpl_id) evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes(
assert evals_notes[str(evaluations[0].id)].dtype == np.float64 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( etuds_moy_module = moy_mod.compute_module_moy(
evals_notes, evals_poids, evaluations evals_notes, evals_poids, evaluations, evaluations_completes
) )
return etuds_moy_module return etuds_moy_module
@ -254,7 +270,7 @@ def test_module_moy(test_client):
moy_ue3 = etuds_moy_module[ue3.id][etudid] moy_ue3 = etuds_moy_module[ue3.id][etudid]
assert np.isnan(moy_ue3) # car les poids vers UE3 sont nuls 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) etuds_moy_module = change_notes(None, note2)
assert etuds_moy_module[ue1.id][etudid] == (note2 * e2p1 * coef_e2) / sum_copo1 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 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 assert moy_ue2 == ((note1 * e1p2 * coef_e1) + (note2 * e2p2 * coef_e2)) / sum_copo2
moy_ue3 = etuds_moy_module[ue3.id][etudid] moy_ue3 = etuds_moy_module[ue3.id][etudid]
assert np.isnan(moy_ue3) # car les poids vers UE3 sont nuls 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)

View File

@ -94,7 +94,7 @@ def test_ue_moy(test_client):
# EXC à un module # EXC à un module
n1, n2 = 5.0, NOTES_NEUTRALISE n1, n2 = 5.0, NOTES_NEUTRALISE
etud_moy_ue = change_notes(n1, n2) 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. # des moyennes.
assert np.isnan(etud_moy_ue.values).all() assert np.isnan(etud_moy_ue.values).all()
# Désinscrit l'étudiant du module 2: # Désinscrit l'étudiant du module 2: