forked from ScoDoc/ScoDoc
Calcul moyennes modules avec inscriptions partielles, ATT, EXC, ABS, mode immédiat. (+ tests unit.)
This commit is contained in:
parent
9927368680
commit
36b432839a
@ -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()),
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -528,6 +528,9 @@ def module_edit(module_id=None):
|
||||
("formation_id", {"input_type": "hidden"}),
|
||||
("ue_id", {"input_type": "hidden"}),
|
||||
("module_id", {"input_type": "hidden"}),
|
||||
]
|
||||
if not is_apc:
|
||||
descr += [
|
||||
(
|
||||
"ue_matiere_id",
|
||||
{
|
||||
|
@ -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"
|
||||
|
@ -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."""
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user