1
0
forked from ScoDoc/ScoDoc

WIP: unification calculs

This commit is contained in:
Emmanuel Viennet 2021-12-30 23:58:38 +01:00
parent dc9bba3f04
commit 1a472bd19d
12 changed files with 503 additions and 113 deletions

View File

@ -10,52 +10,11 @@
import datetime import datetime
from flask import url_for, g from flask import url_for, g
from app.comp import moy_ue, moy_sem, inscr_mod
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc import sco_bulletins_json from app.scodoc import sco_bulletins_json
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc.sco_utils import fmt_note from app.scodoc.sco_utils import fmt_note
from app.comp.res_sem import ResultatsSemestre, NotesTableCompat from app.comp.res_but import ResultatsSemestreBUT
class ResultatsSemestreBUT(NotesTableCompat):
"""Résultats BUT: organisation des calculs"""
_cached_attrs = NotesTableCompat._cached_attrs + ()
def __init__(self, formsemestre):
super().__init__(formsemestre)
if not self.load_cached():
self.compute()
self.store()
def compute(self):
"Charge les notes et inscriptions et calcule toutes les moyennes"
(
self.sem_cube,
self.modimpls_evals_poids,
self.modimpls_results,
) = moy_ue.notes_sem_load_cube(self.formsemestre)
self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre)
self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
self.formsemestre, ues=self.ues, modimpls=self.modimpls
)
# l'idx de la colonne du mod modimpl.id est
# modimpl_coefs_df.columns.get_loc(modimpl.id)
# idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id)
self.etud_moy_ue = moy_ue.compute_ue_moys(
self.sem_cube,
self.etuds,
self.modimpls,
self.ues,
self.modimpl_inscr_df,
self.modimpl_coefs_df,
)
self.etud_moy_gen = moy_sem.compute_sem_moys(
self.etud_moy_ue, self.modimpl_coefs_df
)
self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen)
class BulletinBUT(ResultatsSemestreBUT): class BulletinBUT(ResultatsSemestreBUT):

View File

@ -27,6 +27,8 @@
"""Fonctions de calcul des moyennes de modules (modules, ressources ou SAÉ) """Fonctions de calcul des moyennes de modules (modules, ressources ou SAÉ)
Pour les formations classiques et le BUT
Rappel: pour éviter les confusions, on appelera *poids* les coefficients d'une Rappel: pour éviter les confusions, on appelera *poids* les coefficients d'une
évaluation dans un module, et *coefficients* ceux utilisés pour le calcul de la évaluation dans un module, et *coefficients* ceux utilisés pour le calcul de la
moyenne générale d'une UE. moyenne générale d'une UE.
@ -49,10 +51,11 @@ class EvaluationEtat:
is_complete: bool is_complete: bool
class ModuleImplResultsAPC: class ModuleImplResults:
"""Les notes des étudiants d'un moduleimpl. """Classe commune à toutes les formations (standard et APC).
Les poids des évals sont à part car on a a besoin sans les notes pour les tableaux Les notes des étudiants d'un moduleimpl.
de bord. Les poids des évals sont à part car on en a besoin sans les notes pour les
tableaux de bord.
Les attributs sont tous des objets simples cachables dans Redis; Les attributs sont tous des objets simples cachables dans Redis;
les caches sont gérés par ResultatsSemestre. les caches sont gérés par ResultatsSemestre.
""" """
@ -181,6 +184,31 @@ class ModuleImplResultsAPC:
) )
] ]
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
"""Coefficients des évaluations, met à zéro ceux des évals incomplètes.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[e.coefficient for e in moduleimpl.evaluations],
dtype=float,
)
* self.evaluations_completes
).reshape(-1, 1)
def get_eval_notes_sur_20(self, moduleimpl: ModuleImpl) -> np.array:
"""Les notes des évaluations,
remplace les ATT, EXC, ABS, NaN par zéro et mets les notes sur 20.
Résultat: 2d array of floats, shape nb_etuds x nb_evaluations
"""
return np.where(
self.evals_notes.values > scu.NOTES_ABSENCE, self.evals_notes.values, 0.0
) / [e.note_max / 20.0 for e in moduleimpl.evaluations]
class ModuleImplResultsAPC(ModuleImplResults):
"Calcul des moyennes de modules à la mode BUT"
def compute_module_moy( def compute_module_moy(
self, self,
evals_poids_df: pd.DataFrame, evals_poids_df: pd.DataFrame,
@ -200,22 +228,14 @@ class ModuleImplResultsAPC:
assert evals_poids_df.shape[0] == nb_evals # compat notes/poids assert evals_poids_df.shape[0] == nb_evals # compat notes/poids
if nb_etuds == 0: if nb_etuds == 0:
return pd.DataFrame(index=[], columns=evals_poids_df.columns) return pd.DataFrame(index=[], columns=evals_poids_df.columns)
# Coefficients des évaluations, met à zéro ceux des évals incomplètes: evals_coefs = self.get_evaluations_coefs(moduleimpl)
evals_coefs = (
np.array(
[e.coefficient for e in moduleimpl.evaluations],
dtype=float,
)
* self.evaluations_completes
).reshape(-1, 1)
evals_poids = evals_poids_df.values * evals_coefs evals_poids = evals_poids_df.values * evals_coefs
# -> evals_poids 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)
# Remplace les notes ATT, EXC, ABS, NaN par zéro et mets les notes sur 20: evals_notes_20 = self.get_eval_notes_sur_20(moduleimpl)
evals_notes = np.where(
self.evals_notes.values > scu.NOTES_ABSENCE, self.evals_notes.values, 0.0 # Les poids des évals pour chaque étudiant: là où il a des notes
) / [e.note_max / 20.0 for e in moduleimpl.evaluations] # non neutralisées
# Les poids des évals pour les étudiant: là où il a des notes non neutralisées
# (ABS n'est pas neutralisée, mais ATTENTE et NEUTRALISE oui) # (ABS n'est pas neutralisée, mais ATTENTE et NEUTRALISE oui)
# Note: les NaN sont remplacés par des 0 dans evals_notes # Note: les NaN sont remplacés par des 0 dans evals_notes
# et dans dans evals_poids_etuds # et dans dans evals_poids_etuds
@ -228,7 +248,7 @@ class ModuleImplResultsAPC:
0, 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_20] * 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(
evals_poids_etuds * evals_notes_stacked, axis=1 evals_poids_etuds * evals_notes_stacked, axis=1
@ -288,3 +308,45 @@ def moduleimpl_is_conforme(
== module_evals_poids == module_evals_poids
) )
return check return check
class ModuleImplResultsClassic(ModuleImplResults):
"Calcul des moyennes de modules des formations classiques"
def compute_module_moy(self) -> pd.Series:
"""Calcule les moyennes des étudiants dans ce module
Résultat: Series, lignes etud
= la note (moyenne) de l'étudiant pour ce module.
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
ne donnent pas de coef.
"""
modimpl = ModuleImpl.query.get(self.moduleimpl_id)
nb_etuds, nb_evals = self.evals_notes.shape
if nb_etuds == 0:
return pd.Series()
evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1)
assert evals_coefs.shape == (nb_evals,)
evals_notes_20 = self.get_eval_notes_sur_20(modimpl)
# Les coefs des évals pour chaque étudiant: là où il a des notes
# non neutralisées
# (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)
coefs_stacked = np.stack([evals_coefs] * nb_etuds)
evals_coefs_etuds = np.where(
self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked, 0
)
# Calcule la moyenne pondérée sur les notes disponibles:
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum(
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
self.etuds_moy_module = pd.Series(
etuds_moy_module,
index=self.evals_notes.index,
)
return self.etuds_moy_module

View File

@ -31,7 +31,7 @@ import numpy as np
import pandas as pd import pandas as pd
def compute_sem_moys(etud_moy_ue_df, modimpl_coefs_df): def compute_sem_moys_apc(etud_moy_ue_df, modimpl_coefs_df):
"""Calcule la moyenne générale indicative """Calcule la moyenne générale indicative
= moyenne des moyennes d'UE, pondérée par la somme de leurs coefs = moyenne des moyennes d'UE, pondérée par la somme de leurs coefs

View File

@ -25,7 +25,7 @@
# #
############################################################################## ##############################################################################
"""Fonctions de calcul des moyennes d'UE """Fonctions de calcul des moyennes d'UE (classiques ou BUT)
""" """
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@ -34,13 +34,14 @@ from app import db
from app import models from app import models
from app.models import UniteEns, Module, ModuleImpl, ModuleUECoef from app.models import UniteEns, Module, ModuleImpl, ModuleUECoef
from app.comp import moy_mod from app.comp import moy_mod
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.DataFrame: def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.DataFrame:
"""Charge les coefs des modules de la formation pour le semestre indiqué. """Charge les coefs APC des modules de la formation pour le semestre indiqué.
Ces coefs lient les modules à chaque UE. En APC, ces coefs lient les modules à chaque UE.
Résultat: (module_coefs_df, ues, modules) Résultat: (module_coefs_df, ues, modules)
DataFrame rows = UEs, columns = modules, value = coef. DataFrame rows = UEs, columns = modules, value = coef.
@ -85,7 +86,7 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
def df_load_modimpl_coefs( def df_load_modimpl_coefs(
formsemestre: models.FormSemestre, ues=None, modimpls=None formsemestre: models.FormSemestre, ues=None, modimpls=None
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Charge les coefs des modules du formsemestre indiqué. """Charge les coefs APC des modules du formsemestre indiqué.
Comme df_load_module_coefs mais prend seulement les UE Comme df_load_module_coefs mais prend seulement les UE
et modules du formsemestre. et modules du formsemestre.
@ -126,7 +127,7 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray:
return modimpls_notes.swapaxes(0, 1) return modimpls_notes.swapaxes(0, 1)
def notes_sem_load_cube(formsemestre): def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
"""Calcule le cube des notes du semestre """Calcule le cube des notes du semestre
(charge toutes les notes, calcule les moyenne des modules (charge toutes les notes, calcule les moyenne des modules
et assemble le cube) et assemble le cube)
@ -151,7 +152,7 @@ def notes_sem_load_cube(formsemestre):
) )
def compute_ue_moys( def compute_ue_moys_apc(
sem_cube: np.array, sem_cube: np.array,
etuds: list, etuds: list,
modimpls: list, modimpls: list,
@ -159,7 +160,7 @@ def compute_ue_moys(
modimpl_inscr_df: pd.DataFrame, modimpl_inscr_df: pd.DataFrame,
modimpl_coefs_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Calcul de la moyenne d'UE """Calcul de la moyenne d'UE en mode APC (BUT).
La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR
NI non inscrit à (au moins un) module de cette UE NI non inscrit à (au moins un) module de cette UE
NA pas de notes disponibles NA pas de notes disponibles
@ -168,11 +169,11 @@ def compute_ue_moys(
sem_cube: notes moyennes aux modules sem_cube: notes moyennes aux modules
ndarray (etuds x modimpls x UEs) ndarray (etuds x modimpls x UEs)
(floats avec des NaN) (floats avec des NaN)
etuds : lites des étudiants (dim. 0 du cube) etuds : listes des étudiants (dim. 0 du cube)
modimpls : liste des modules à considérer (dim. 1 du cube) modimpls : liste des modules à considérer (dim. 1 du cube)
ues : liste des UE (dim. 2 du cube) ues : liste des UE (dim. 2 du cube)
module_inscr_df: matrice d'inscription du semestre (etud x modimpl) modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
module_coefs_df: matrice coefficients (UE x modimpl) modimpl_coefs_df: matrice coefficients (UE x modimpl)
Resultat: DataFrame columns UE, rows etudid Resultat: DataFrame columns UE, rows etudid
""" """
@ -214,3 +215,70 @@ def compute_ue_moys(
return pd.DataFrame( return pd.DataFrame(
etud_moy_ue, index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index etud_moy_ue, index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index
) )
def compute_ue_moys_classic(
formsemestre: FormSemestre,
sem_matrix: np.array,
ues: list,
modimpl_inscr_df: pd.DataFrame,
modimpl_coefs: np.array,
) -> pd.DataFrame:
"""Calcul de la moyenne d'UE en mode classique.
La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR
NI non inscrit à (au moins un) module de cette UE
NA pas de notes disponibles
ERR erreur dans une formule utilisateur. [XXX pas encore gérées ici]
sem_matrix: notes moyennes aux modules
ndarray (etuds x modimpls)
(floats avec des NaN)
etuds : listes des étudiants (dim. 0 de la matrice)
ues : liste des UE
modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
modimpl_coefs: vecteur des coefficients de modules
Résultat:
- moyennes générales: pd.Series, index etudid
- moyennes d'UE: DataFrame columns UE, rows etudid
"""
nb_etuds, nb_modules = sem_matrix.shape
assert len(modimpl_coefs) == nb_modules
nb_ues = len(ues)
modimpl_inscr = modimpl_inscr_df.values
# Enlève les NaN du numérateur:
sem_matrix_no_nan = np.nan_to_num(sem_matrix, nan=0.0)
# Ne prend pas en compte les notes des étudiants non inscrits au module:
# Annule les notes:
sem_matrix_inscrits = np.where(modimpl_inscr, sem_matrix_no_nan, 0.0)
# Annule les coefs des modules où l'étudiant n'est pas inscrit:
modimpl_coefs_etuds = np.where(
modimpl_inscr, np.stack([modimpl_coefs.T] * nb_etuds), 0.0
)
# Annule les coefs des modules NaN (nb_etuds x nb_mods)
modimpl_coefs_etuds_no_nan = np.where(
np.isnan(sem_matrix), 0.0, modimpl_coefs_etuds
)
# Calcul des moyennes générales:
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etud_moy_gen = np.sum(
modimpl_coefs_etuds_no_nan * sem_matrix_inscrits, axis=1
) / np.sum(modimpl_coefs_etuds_no_nan, axis=1)
etud_moy_gen_s = pd.Series(etud_moy_gen, index=modimpl_inscr_df.index)
# Calcul des moyennes d'UE
ue_modules = np.array(
[[m.module.ue == ue for m in formsemestre.modimpls] for ue in ues]
)[..., np.newaxis]
modimpl_coefs_etuds_no_nan_stacked = np.stack(
[modimpl_coefs_etuds_no_nan.T] * nb_ues
)
# nb_ue x nb_etuds x nb_mods : coefs prenant en compte NaN et inscriptions
coefs = (modimpl_coefs_etuds_no_nan_stacked * ue_modules).swapaxes(1, 2)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etud_moy_ue = (
np.sum(coefs * sem_matrix_inscrits, axis=2) / np.sum(coefs, axis=2)
).T
etud_moy_ue_df = pd.DataFrame(
etud_moy_ue, index=modimpl_inscr_df.index, columns=[ue.id for ue in ues]
)
return etud_moy_gen_s, etud_moy_ue_df

65
app/comp/res_but.py Normal file
View File

@ -0,0 +1,65 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Résultats semestres BUT
"""
from app.comp import moy_ue, moy_sem, inscr_mod
from app.comp.res_sem import NotesTableCompat
class ResultatsSemestreBUT(NotesTableCompat):
"""Résultats BUT: organisation des calculs"""
_cached_attrs = NotesTableCompat._cached_attrs + (
"modimpl_coefs_df",
"modimpls_evals_poids",
"sem_cube",
)
def __init__(self, formsemestre):
super().__init__(formsemestre)
if not self.load_cached():
self.compute()
self.store()
def compute(self):
"Charge les notes et inscriptions et calcule les moyennes d'UE et gen."
(
self.sem_cube,
self.modimpls_evals_poids,
self.modimpls_results,
) = moy_ue.notes_sem_load_cube(self.formsemestre)
self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre)
self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
self.formsemestre, ues=self.ues, modimpls=self.modimpls
)
# l'idx de la colonne du mod modimpl.id est
# modimpl_coefs_df.columns.get_loc(modimpl.id)
# idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id)
self.etud_moy_ue = moy_ue.compute_ue_moys_apc(
self.sem_cube,
self.etuds,
self.modimpls,
self.ues,
self.modimpl_inscr_df,
self.modimpl_coefs_df,
)
self.etud_moy_gen = moy_sem.compute_sem_moys_apc(
self.etud_moy_ue, self.modimpl_coefs_df
)
self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen)
def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float:
"""La moyenne de l'étudiant dans le moduleimpl
En APC, il s'agit d'une moyenne indicative sans valeur.
Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM)
"""
mod_idx = self.modimpl_coefs_df.columns.get_loc(moduleimpl_id)
etud_idx = self.etud_index[etudid]
# moyenne sur les UE:
return self.sem_cube[etud_idx, mod_idx].mean()

95
app/comp/res_classic.py Normal file
View File

@ -0,0 +1,95 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Résultats semestres classiques (non APC)
"""
import numpy as np
import pandas as pd
from app.comp import moy_mod, moy_ue, moy_sem, inscr_mod
from app.comp.res_sem import NotesTableCompat
from app.models.formsemestre import FormSemestre
class ResultatsSemestreClassic(NotesTableCompat):
"""Résultats du semestre (formation classique): organisation des calculs."""
_cached_attrs = NotesTableCompat._cached_attrs + (
"modimpl_coefs",
"modimpl_idx",
"sem_matrix",
)
def __init__(self, formsemestre):
super().__init__(formsemestre)
if not self.load_cached():
self.compute()
self.store()
# recalculé (aussi rapide que de les cacher)
self.moy_min = self.etud_moy_gen.min()
self.moy_max = self.etud_moy_gen.max()
self.moy_moy = self.etud_moy_gen.mean()
def compute(self):
"Charge les notes et inscriptions et calcule les moyennes d'UE et gen."
self.sem_matrix, self.modimpls_results = notes_sem_load_matrix(
self.formsemestre
)
self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre)
self.modimpl_coefs = np.array(
[m.module.coefficient for m in self.formsemestre.modimpls]
)
self.modimpl_idx = {m.id: i for i, m in enumerate(self.formsemestre.modimpls)}
"l'idx de la colonne du mod modimpl.id est modimpl_idx[modimpl.id]"
self.etud_moy_gen, self.etud_moy_ue = moy_ue.compute_ue_moys_classic(
self.formsemestre,
self.sem_matrix,
self.ues,
self.modimpl_inscr_df,
self.modimpl_coefs,
)
self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen)
def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float:
"""La moyenne de l'étudiant dans le moduleimpl
Result: valeur float (peut être NaN) ou chaîne "NI" (non inscrit ou DEM)
"""
return self.modimpls_results[moduleimpl_id].etuds_moy_module.get(etudid, "NI")
def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple:
"""Calcule la matrice des notes du semestre
(charge toutes les notes, calcule les moyenne des modules
et assemble la matrice)
Resultat:
sem_matrix : 2d-array (etuds x modimpls)
modimpls_results dict { modimpl.id : ModuleImplResultsClassic }
"""
modimpls_results = {}
modimpls_notes = []
for modimpl in formsemestre.modimpls:
mod_results = moy_mod.ModuleImplResultsClassic(modimpl)
etuds_moy_module = mod_results.compute_module_moy()
modimpls_results[modimpl.id] = mod_results
modimpls_notes.append(etuds_moy_module)
return (
notes_sem_assemble_matrix(modimpls_notes),
modimpls_results,
)
def notes_sem_assemble_matrix(modimpls_notes: list[pd.Series]) -> np.ndarray:
"""Réuni les notes moyennes des modules du semestre en une matrice
modimpls_notes : liste des moyennes de module
(Series rendus par compute_module_moy, index: etud)
Resultat: ndarray (etud x module)
"""
modimpls_notes_arr = [s.values for s in modimpls_notes]
modimpls_notes = np.stack(modimpls_notes_arr)
# passe de (mod x etud) à (etud x mod)
return modimpls_notes.T

View File

@ -9,10 +9,10 @@ from functools import cached_property
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from app.comp.aux import StatsMoyenne from app.comp.aux import StatsMoyenne
from app.models import ModuleImpl from app.models import FormSemestre, ModuleImpl
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_cache import ResultatsSemestreCache from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_codes_parcours import UE_SPORT, ATT, DEF
# Il faut bien distinguer # Il faut bien distinguer
# - ce qui est caché de façon persistente (via redis): # - ce qui est caché de façon persistente (via redis):
@ -25,17 +25,14 @@ from app.scodoc.sco_codes_parcours import UE_SPORT
# #
class ResultatsSemestre: class ResultatsSemestre:
_cached_attrs = ( _cached_attrs = (
"sem_cube",
"modimpl_inscr_df",
"modimpl_coefs_df",
"etud_moy_ue",
"modimpls_evals_poids",
"modimpls_results",
"etud_moy_gen",
"etud_moy_gen_ranks", "etud_moy_gen_ranks",
"etud_moy_gen",
"etud_moy_ue",
"modimpl_inscr_df",
"modimpls_results",
) )
def __init__(self, formsemestre): def __init__(self, formsemestre: FormSemestre):
self.formsemestre = formsemestre self.formsemestre = formsemestre
# BUT ou standard ? (apc == "approche par compétences") # BUT ou standard ? (apc == "approche par compétences")
self.is_apc = formsemestre.formation.is_apc() self.is_apc = formsemestre.formation.is_apc()
@ -70,7 +67,7 @@ class ResultatsSemestre:
@cached_property @cached_property
def etuds(self): def etuds(self):
"Liste des inscrits au semestre, sans les démissionnaires" "Liste des inscrits au semestre, sans les démissionnaires"
# nb: si les liste des inscrits change, ResultatsSemestre devient invalide # nb: si la liste des inscrits change, ResultatsSemestre devient invalide
return self.formsemestre.get_inscrits(include_dem=False) return self.formsemestre.get_inscrits(include_dem=False)
@cached_property @cached_property
@ -119,12 +116,16 @@ class ResultatsSemestre:
# Pour raccorder le code des anciens codes qui attendent une NoteTable # Pour raccorder le code des anciens codes qui attendent une NoteTable
class NotesTableCompat(ResultatsSemestre): class NotesTableCompat(ResultatsSemestre):
"""Implementation partielle de NotesTable WIP TODO """Implementation partielle de NotesTable WIP TODO
Accès aux notes et rangs.
Les méthodes définies dans cette classe sont
pour conserver la compatibilité abvec les codes anciens et
il n'est pas recommandé de les utiliser dans de nouveaux
développements (API malcommode et peu efficace).
""" """
_cached_attrs = ResultatsSemestre._cached_attrs + () _cached_attrs = ResultatsSemestre._cached_attrs + ()
def __init__(self, formsemestre): def __init__(self, formsemestre: FormSemestre):
super().__init__(formsemestre) super().__init__(formsemestre)
nb_etuds = len(self.etuds) nb_etuds = len(self.etuds)
self.bonus = defaultdict(lambda: 0.0) # XXX TODO self.bonus = defaultdict(lambda: 0.0) # XXX TODO
@ -132,6 +133,30 @@ class NotesTableCompat(ResultatsSemestre):
self.mod_rangs = { self.mod_rangs = {
m.id: (defaultdict(lambda: 0), nb_etuds) for m in self.modimpls m.id: (defaultdict(lambda: 0), nb_etuds) for m in self.modimpls
} }
self.moy_min = "NA"
self.moy_max = "NA"
def get_etudids(self, sorted=False) -> list[int]:
"""Liste des etudids inscrits, incluant les démissionnaires.
Si sorted, triée par moy. générale décroissante
Sinon, triée par ordre alphabetique de NOM
"""
# Note: pour avoir les inscrits non triés,
# utiliser [ ins.etudid for ins in self.formsemestre.inscriptions ]
if sorted:
# Tri par moy. generale décroissante
return [x[-1] for x in self.T]
return [x["etudid"] for x in self.inscrlist]
@cached_property
def inscrlist(self) -> list[dict]: # utilisé par PE seulement
"""Liste de dict etud, avec démissionnaires
classée dans l'ordre alphabétique de noms.
"""
etuds = self.formsemestre.get_inscrits(include_dem=True)
etuds.sort(key=lambda e: e.sort_key)
return [e.to_dict_scodoc7() for e in etuds]
@cached_property @cached_property
def stats_moy_gen(self): def stats_moy_gen(self):
@ -161,6 +186,33 @@ class NotesTableCompat(ResultatsSemestre):
else: else:
return [m.to_dict() for m in self.modimpls if m.module.ue.id == ue_id] return [m.to_dict() for m in self.modimpls if m.module.ue.id == ue_id]
def get_etud_decision_sem(self, etudid: int) -> dict:
"""Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu.
{ 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id }
Si état défaillant, force le code a DEF
"""
if self.get_etud_etat(etudid) == DEF:
return {
"code": DEF,
"assidu": False,
"event_date": "",
"compense_formsemestre_id": None,
}
else:
return {
"code": ATT, # XXX TODO
"assidu": True, # XXX TODO
"event_date": "",
"compense_formsemestre_id": None,
}
def get_etud_etat(self, etudid: int) -> str:
"Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)"
ins = self.formsemestre.etuds_inscriptions.get(etudid, None)
if ins is None:
return ""
return ins.etat
def get_etud_moy_gen(self, etudid): # -> float | str def get_etud_moy_gen(self, etudid): # -> float | str
"""Moyenne générale de cet etudiant dans ce semestre. """Moyenne générale de cet etudiant dans ce semestre.
Prend en compte les UE capitalisées. (TODO) Prend en compte les UE capitalisées. (TODO)
@ -169,38 +221,27 @@ class NotesTableCompat(ResultatsSemestre):
""" """
return self.etud_moy_gen[etudid] return self.etud_moy_gen[etudid]
def get_moduleimpls_attente(self): def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float:
return [] # XXX TODO """La moyenne de l'étudiant dans le moduleimpl
En APC, il s'agira d'une moyenne indicative sans valeur.
Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM)
"""
raise NotImplementedError() # virtual method
def get_etud_rang(self, etudid): def get_etud_ue_status(self, etudid: int, ue_id: int):
return self.etud_moy_gen_ranks[etudid]
def get_etud_rang_group(self, etudid, group_id):
return (None, 0) # XXX unimplemented TODO
def get_etud_ue_status(self, etudid, ue_id):
return { return {
"cur_moy_ue": self.results.etud_moy_ue[ue_id][etudid], "cur_moy_ue": self.results.etud_moy_ue[ue_id][etudid],
"is_capitalized": False, # XXX TODO "is_capitalized": False, # XXX TODO
} }
def get_etud_mod_moy(self, moduleimpl_id, etudid): def get_etud_rang(self, etudid: int):
mod_idx = self.results.modimpl_coefs_df.columns.get_loc(moduleimpl_id) return self.etud_moy_gen_ranks.get(etudid, 99999) # XXX
etud_idx = self.results.etud_index[etudid]
# moyenne sur les UE:
self.results.sem_cube[etud_idx, mod_idx].mean()
def get_mod_stats(self, moduleimpl_id): def get_etud_rang_group(self, etudid: int, group_id: int):
return { return (None, 0) # XXX unimplemented TODO
"moy": "-",
"max": "-",
"min": "-",
"nb_notes": "-",
"nb_missing": "-",
"nb_valid_evals": "-",
}
def get_evals_in_mod(self, moduleimpl_id): def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]:
"liste des évaluations valides dans un module"
mi = ModuleImpl.query.get(moduleimpl_id) mi = ModuleImpl.query.get(moduleimpl_id)
evals_results = [] evals_results = []
for e in mi.evaluations: for e in mi.evaluations:
@ -219,3 +260,78 @@ class NotesTableCompat(ResultatsSemestre):
} }
evals_results.append(d) evals_results.append(d)
return evals_results return evals_results
def get_moduleimpls_attente(self):
return [] # XXX TODO
def get_mod_stats(self, moduleimpl_id):
return {
"moy": "-",
"max": "-",
"min": "-",
"nb_notes": "-",
"nb_missing": "-",
"nb_valid_evals": "-",
}
def get_nom_short(self, etudid):
"formatte nom d'un etud (pour table recap)"
etud = self.identdict[etudid]
return (
(etud["nom_usuel"] or etud["nom"]).upper()
+ " "
+ etud["prenom"].capitalize()[:2]
+ "."
)
@cached_property
def T(self):
return self.get_table_moyennes_triees()
def get_table_moyennes_triees(self) -> list:
"""Result: liste de tuples
moy_gen, moy_ue_0, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid
"""
table_moyennes = []
etuds_inscriptions = self.formsemestre.etuds_inscriptions
for etudid in etuds_inscriptions:
moy_gen = self.etud_moy_gen.get(etudid, False)
if moy_gen is False:
# pas de moyenne: démissionnaire ou def
t = ["-"] + ["0.00"] * len(self.ues) + ["NI"] * len(self.modimpls)
else:
moy_ues = self.etud_moy_ue.loc[etudid]
t = [moy_gen] + list(moy_ues)
# TODO UE capitalisées: ne pas afficher moyennes modules
for modimpl in self.modimpls:
val = self.get_etud_mod_moy(modimpl.id, etudid)
t.append(val)
t.append(etudid)
table_moyennes.append(t)
# tri par moyennes décroissantes,
# en laissant les démissionnaires à la fin, par ordre alphabetique
etuds = [ins.etud for ins in etuds_inscriptions.values()]
etuds.sort(key=lambda e: e.sort_key)
self._rang_alpha = {e.id: i for i, e in enumerate(etuds)}
table_moyennes.sort(key=self._row_key)
return table_moyennes
def _row_key(self, x):
"""clé de tri par moyennes décroissantes,
en laissant les demissionnaires à la fin, par ordre alphabetique.
(moy_gen, rang_alpha)
"""
try:
moy = -float(x[0])
except (ValueError, TypeError):
moy = 1000.0
return (moy, self._rang_alpha[x[-1]])
@cached_property
def identdict(self) -> dict:
"""{ etudid : etud_dict } pour tous les inscrits au semestre"""
return {
ins.etud.id: ins.etud.to_dict_scodoc7()
for ins in self.formsemestre.inscriptions
}

View File

@ -13,6 +13,7 @@ from app import models
from app.models import APO_CODE_STR_LEN from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN from app.models import CODE_STR_LEN
from app.scodoc import notesdb as ndb
class Identite(db.Model): class Identite(db.Model):
@ -105,10 +106,25 @@ class Identite(db.Model):
r.append("-".join([x.lower().capitalize() for x in fields])) r.append("-".join([x.lower().capitalize() for x in fields]))
return " ".join(r) return " ".join(r)
@cached_property
def sort_key(self) -> tuple:
"clé pour tris par ordre alphabétique"
return (self.nom_usuel or self.nom).lower(), self.prenom.lower()
def get_first_email(self, field="email") -> str: def get_first_email(self, field="email") -> str:
"Le mail associé à la première adrese de l'étudiant, ou None" "Le mail associé à la première adrese de l'étudiant, ou None"
return self.adresses[0].email or None if self.adresses.count() > 0 else None return self.adresses[0].email or None if self.adresses.count() > 0 else None
def to_dict_scodoc7(self):
"""Représentation dictionnaire,
compatible ScoDoc7 mais sans infos admission
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators: (backward compat)
e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"])
return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty
def to_dict_bul(self, include_urls=True): def to_dict_bul(self, include_urls=True):
"""Infos exportées dans les bulletins""" """Infos exportées dans les bulletins"""
from app.scodoc import sco_photos from app.scodoc import sco_photos

View File

@ -3,6 +3,7 @@
"""ScoDoc models: formsemestre """ScoDoc models: formsemestre
""" """
import datetime import datetime
from functools import cached_property
import flask_sqlalchemy import flask_sqlalchemy
@ -251,7 +252,7 @@ class FormSemestre(db.Model):
etudid, self.date_debut.isoformat(), self.date_fin.isoformat() etudid, self.date_debut.isoformat(), self.date_fin.isoformat()
) )
def get_inscrits(self, include_dem=False) -> list: def get_inscrits(self, include_dem=False) -> list[Identite]:
"""Liste des étudiants inscrits à ce semestre """Liste des étudiants inscrits à ce semestre
Si all, tous les étudiants, avec les démissionnaires. Si all, tous les étudiants, avec les démissionnaires.
""" """
@ -260,6 +261,11 @@ class FormSemestre(db.Model):
else: else:
return [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] return [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT]
@cached_property
def etuds_inscriptions(self) -> dict:
"""Map { etudid : inscription }"""
return {ins.etud.id: ins for ins in self.inscriptions}
# Association id des utilisateurs responsables (aka directeurs des etudes) du semestre # Association id des utilisateurs responsables (aka directeurs des etudes) du semestre
notes_formsemestre_responsables = db.Table( notes_formsemestre_responsables = db.Table(

View File

@ -202,7 +202,7 @@ class NotesTable:
self.inscrlist.sort(key=itemgetter("nomp")) self.inscrlist.sort(key=itemgetter("nomp"))
# { etudid : rang dans l'ordre alphabetique } # { etudid : rang dans l'ordre alphabetique }
self.rang_alpha = {e["etudid"]: i for i, e in enumerate(self.inscrlist)} self._rang_alpha = {e["etudid"]: i for i, e in enumerate(self.inscrlist)}
self.bonus = scu.DictDefault(defaultvalue=0) self.bonus = scu.DictDefault(defaultvalue=0)
# Notes dans les modules { moduleimpl_id : { etudid: note_moyenne_dans_ce_module } } # Notes dans les modules { moduleimpl_id : { etudid: note_moyenne_dans_ce_module } }
@ -366,7 +366,7 @@ class NotesTable:
moy = -float(x[0]) moy = -float(x[0])
except (ValueError, TypeError): except (ValueError, TypeError):
moy = 1000.0 moy = 1000.0
return (moy, self.rang_alpha[x[-1]]) return (moy, self._rang_alpha[x[-1]])
def get_etudids(self, sorted=False): def get_etudids(self, sorted=False):
if sorted: if sorted:

View File

@ -37,6 +37,7 @@ from flask import make_response
from app import log from app import log
from app.but import bulletin_but from app.but import bulletin_but
from app.comp.res_classic import ResultatsSemestreClassic
from app.models import FormSemestre from app.models import FormSemestre
from app.models.etudiants import Identite from app.models.etudiants import Identite
@ -303,6 +304,8 @@ def make_formsemestre_recapcomplet(
args={"formsemestre_id": formsemestre_id} args={"formsemestre_id": formsemestre_id}
)[0] )[0]
nt = sco_cache.NotesTableCache.get(formsemestre_id) nt = sco_cache.NotesTableCache.get(formsemestre_id)
# XXX EXPERIMENTAL
# nt = ResultatsSemestreClassic(formsemestre)
modimpls = nt.get_modimpls_dict() modimpls = nt.get_modimpls_dict()
ues = nt.get_ues_stat_dict() # incluant le(s) UE de sport ues = nt.get_ues_stat_dict() # incluant le(s) UE de sport
# #

View File

@ -71,7 +71,7 @@ def test_ue_moy(test_client):
# Recalcul des moyennes # Recalcul des moyennes
sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre) sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre)
etuds = formsemestre.etuds.all() etuds = formsemestre.etuds.all()
etud_moy_ue = moy_ue.compute_ue_moys( etud_moy_ue = moy_ue.compute_ue_moys_apc(
sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df
) )
return etud_moy_ue return etud_moy_ue
@ -114,7 +114,7 @@ def test_ue_moy(test_client):
# Recalcule les notes: # Recalcule les notes:
sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre) sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre)
etuds = formsemestre.etuds.all() etuds = formsemestre.etuds.all()
etud_moy_ue = moy_ue.compute_ue_moys( etud_moy_ue = moy_ue.compute_ue_moys_apc(
sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df
) )
assert etud_moy_ue[ue1.id][etudid] == n1 assert etud_moy_ue[ue1.id][etudid] == n1