Evaluations de session 2: moyenne sur plusieurs, en prennant en compte les poids en BUT. Modif vérification conformite (bug #811). WIP: reste à vérifier ratrapages.

This commit is contained in:
Emmanuel Viennet 2024-05-20 23:28:39 +02:00
parent fbff151be0
commit 69780b3f24
6 changed files with 138 additions and 55 deletions

View File

@ -113,6 +113,8 @@ class ModuleImplResults:
"""
self.evals_etudids_sans_note = {}
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
self.evals_type = {}
"""Type de chaque eval { evaluation.id : evaluation.evaluation_type }"""
self.load_notes(etudids, etudids_actifs)
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module vient de la session2"""
@ -164,7 +166,10 @@ class ModuleImplResults:
self.evaluations_completes = []
self.evaluations_completes_dict = {}
self.etudids_attente = set() # empty
self.evals_type = {}
evaluation: Evaluation
for evaluation in moduleimpl.evaluations:
self.evals_type[evaluation.id] = evaluation.evaluation_type
eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi
# tous les inscrits (non dem) au module ont une note
@ -270,6 +275,26 @@ class ModuleImplResults:
* self.evaluations_completes
).reshape(-1, 1)
def get_evaluations_session2_coefs(self, modimpl: ModuleImpl) -> np.array:
"""Coefficients des évaluations de session 2.
Les évals de session 2 sont réputées "complètes": elles sont toujours
prises en compte mais seules les notes numériques et ABS sont utilisées.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[
(
e.coefficient
if e.evaluation_type == Evaluation.EVALUATION_SESSION2
else 0.0
)
for e in modimpl.evaluations
],
dtype=float,
)
).reshape(-1, 1)
# was _list_notes_evals_titles
def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"Liste des évaluations complètes"
@ -310,18 +335,15 @@ class ModuleImplResults:
return eval_list[0]
return None
def get_evaluation_session2(self, moduleimpl: ModuleImpl) -> Evaluation | None:
"""L'évaluation de deuxième session de ce module, ou None s'il n'en a pas.
Session 2: remplace la note de moyenne des autres évals.
def get_evaluations_session2(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations de deuxième session de ce module, ou None s'il n'en a pas.
La moyenne des notes de Session 2 remplace la note de moyenne des autres évals.
"""
eval_list = [
return [
e
for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_SESSION2
]
if eval_list:
return eval_list[0]
return None
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations bonus de ce module, ou liste vide s'il n'en a pas."""
@ -370,6 +392,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
return pd.DataFrame(index=[], columns=evals_poids_df.columns)
if nb_ues == 0:
return pd.DataFrame(index=self.evals_notes.index, columns=[])
# coefs des évals complètes normales (pas rattr., session 2 ni bonus):
evals_coefs = self.get_evaluations_coefs(modimpl)
evals_poids = evals_poids_df.values * evals_coefs
# -> evals_poids shape : (nb_evals, nb_ues)
@ -398,26 +421,30 @@ class ModuleImplResultsAPC(ModuleImplResults):
) / np.sum(evals_poids_etuds, axis=1)
# etuds_moy_module shape: nb_etuds x nb_ues
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_poids_df,
evals_notes_stacked,
# Session2 : quand elle existe, remplace la note de module
evals_session2 = self.get_evaluations_session2(modimpl)
if evals_session2:
# Calcul moyenne notes session2 et remplace (si la note session 2 existe)
evals_coefs_s2 = self.get_evaluations_session2_coefs(modimpl)
evals_poids_s2 = evals_poids_df.values * evals_coefs_s2
poids_stacked_s2 = np.stack(
[evals_poids_s2] * nb_etuds
) # nb_etuds, nb_evals, nb_ues
evals_poids_etuds_s2 = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2)
> scu.NOTES_NEUTRALISE,
poids_stacked_s2,
0,
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
notes_session2 = self.evals_notes[eval_session2.id].values
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
etuds_moy_module_s2 = np.sum(
evals_poids_etuds_s2 * evals_notes_stacked, axis=1
) / np.sum(evals_poids_etuds_s2, axis=1)
# Vrai si toutes les UEs ont bien une note de session 2 calculée:
etuds_use_session2 = np.all(np.isfinite(etuds_moy_module_s2), axis=1)
etuds_moy_module = np.where(
etuds_use_session2[:, np.newaxis],
np.tile(
(notes_session2 / (eval_session2.note_max / 20.0))[:, np.newaxis],
nb_ues,
),
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
@ -435,7 +462,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
np.nan,
)
# "Étend" le rattrapage sur les UE: la note de rattrapage est la même
# pour toutes les UE mais ne remplace que là où elle est supérieure
# pour toutes les UE mais ne remplace que là où elle est supérieure XXX TODO
notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1)
# prend le max
etuds_use_rattrapage = notes_rat_ues > etuds_moy_module
@ -446,6 +473,13 @@ class ModuleImplResultsAPC(ModuleImplResults):
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_poids_df,
evals_notes_stacked,
)
self.etuds_moy_module = pd.DataFrame(
etuds_moy_module,
index=self.evals_notes.index,
@ -525,6 +559,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
return evals_poids, ues
# appelé par ModuleImpl.check_apc_conformity()
def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool:
@ -546,12 +581,12 @@ def moduleimpl_is_conforme(
if len(modimpl_coefs_df) != nb_ues:
# il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
return app.critical_error("moduleimpl_is_conforme: err 1")
if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
return app.critical_error("moduleimpl_is_conforme: err 2")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
@ -593,22 +628,28 @@ class ModuleImplResultsClassic(ModuleImplResults):
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_notes_20,
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
notes_session2 = self.evals_notes[eval_session2.id].values
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
evals_session2 = self.get_evaluations_session2(modimpl)
if evals_session2:
# Calculer la moyenne des évaluations de session2
# n'utilise que les notes valides et ABS (0).
# Même calcul que pour les évals normales, mais avec seulement les
# coefs des évals de session 2:
evals_coefs_s2 = self.get_evaluations_session2_coefs(modimpl).reshape(-1)
coefs_stacked_s2 = np.stack([evals_coefs_s2] * nb_etuds)
# zéro partout sauf si une note ou ABS:
evals_coefs_etuds_s2 = np.where(
self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked_s2, 0
)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module_s2 = np.sum(
evals_coefs_etuds_s2 * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds_s2, axis=1)
etuds_use_session2 = np.isfinite(etuds_moy_module_s2)
etuds_moy_module = np.where(
etuds_use_session2,
notes_session2 / (eval_session2.note_max / 20.0),
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
@ -633,6 +674,13 @@ class ModuleImplResultsClassic(ModuleImplResults):
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage, index=self.evals_notes.index
)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_notes_20,
)
self.etuds_moy_module = pd.Series(
etuds_moy_module,
index=self.evals_notes.index,

View File

@ -207,7 +207,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
evals_poids = modimpl.get_evaluations_poids()
etuds_moy_module = mod_results.compute_module_moy(evals_poids)
modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids

View File

@ -6,6 +6,7 @@ from flask import abort, g
from flask_login import current_user
from flask_sqlalchemy.query import Query
import app
from app import db
from app.auth.models import User
from app.comp import df_cache
@ -78,7 +79,9 @@ class ModuleImpl(ScoDocModel):
] or self.module.get_edt_ids()
def get_evaluations_poids(self) -> pd.DataFrame:
"""Les poids des évaluations vers les UE (accès via cache)"""
"""Les poids des évaluations vers les UEs (accès via cache redis).
Toutes les évaluations sont considérées (normales, bonus, rattr., etc.)
"""
evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id)
if evaluations_poids is None:
from app.comp import moy_mod
@ -108,20 +111,37 @@ class ModuleImpl(ScoDocModel):
"""Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id)
def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
"""true si les poids des évaluations du module permettent de satisfaire
les coefficients du PN.
def check_apc_conformity(
self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE
) -> bool:
"""true si les poids des évaluations du type indiqué (normales par défaut)
du module permettent de satisfaire les coefficients du PN.
"""
# appelé par formsemestre_status, liste notes, et moduleimpl_status
if not self.module.formation.get_cursus().APC_SAE or (
self.module.module_type != scu.ModuleType.RESSOURCE
and self.module.module_type != scu.ModuleType.SAE
self.module.module_type
not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE}
):
return True # Non BUT, toujours conforme
from app.comp import moy_mod
mod_results = res.modimpls_results.get(self.id)
if mod_results is None:
app.critical_error("check_apc_conformity: err 1")
selected_evaluations_ids = [
eval_id
for eval_id, eval_type in mod_results.evals_type.items()
if eval_type == evaluation_type
]
if not selected_evaluations_ids:
return True # conforme si pas d'évaluations
selected_evaluations_poids = self.get_evaluations_poids().loc[
selected_evaluations_ids
]
return moy_mod.moduleimpl_is_conforme(
self,
self.get_evaluations_poids(),
selected_evaluations_poids,
res.modimpl_coefs_df,
)

View File

@ -64,7 +64,7 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str:
can_edit_notes_ens = modimpl.can_edit_notes(current_user)
if can_edit_notes and nbnotes != 0:
sup_label = "Supprimer évaluation impossible (il y a des notes)"
sup_label = "Suppression évaluation impossible (il y a des notes)"
else:
sup_label = "Supprimer évaluation"
@ -344,9 +344,23 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
#
if not modimpl.check_apc_conformity(nt):
H.append(
"""<div class="warning conformite">Les poids des évaluations de ce module ne sont
pas encore conformes au PN.
Ses notes ne peuvent pas être prises en compte dans les moyennes d'UE.
"""<div class="warning conformite">Les poids des évaluations de ce
module ne permettent pas d'évaluer toutes les UEs (compétences)
prévues par les coefficients du programme.
<b>Ses notes ne peuvent pas être prises en compte dans les moyennes d'UE.</b>
Vérifiez les poids des évaluations.
</div>"""
)
if not modimpl.check_apc_conformity(
nt, evaluation_type=Evaluation.EVALUATION_SESSION2
):
H.append(
"""<div class="warning conformite">
Il y a des évaluations de <b>deuxième session</b>
mais leurs poids ne permettent pas d'évaluer toutes les UEs (compétences)
prévues par les coefficients du programme.
La deuxième session en sera donc <b>pas prise en compte</b>.
Vérifiez les poids de ces évaluations.
</div>"""
)

View File

@ -247,7 +247,7 @@ def test_module_moy(test_client):
_ = sco_saisie_notes.notes_add(G.default_user, evaluation1.id, [(etudid, n1)])
_ = sco_saisie_notes.notes_add(G.default_user, evaluation2.id, [(etudid, n2)])
# Calcul de la moyenne du module
evals_poids, _ = moy_mod.load_evaluations_poids(moduleimpl_id)
evals_poids = modimpl.get_evaluations_poids()
assert evals_poids.shape == (nb_evals, nb_ues)
etudids, etudids_actifs = formsemestre.etudids_actifs()
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)

View File

@ -151,8 +151,9 @@ def test_notes_rattrapage(test_client):
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
mod_res = res.modimpls_results[moduleimpl_id]
# retrouve l'éval. de session 2:
eval_session2 = mod_res.get_evaluation_session2(moduleimpl)
assert eval_session2.id == e_session2["id"]
evals_session2 = mod_res.get_evaluations_session2(moduleimpl)
assert len(evals_session2) == 1
assert evals_session2[0].id == e_session2["id"]
# Les deux évaluations sont considérées comme complètes:
assert len(mod_res.get_evaluations_completes(moduleimpl)) == 2