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:
"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()),

View File

@ -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(

View File

@ -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

View File

@ -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 += [

View File

@ -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"

View File

@ -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."""

View File

@ -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)

View File

@ -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: