############################################################################## # ScoDoc # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """Résultats semestres classiques (non APC) """ import time import numpy as np import pandas as pd from sqlalchemy.sql import text from flask import g, url_for from app import db from app import log from app.comp import moy_mat, moy_mod, moy_sem, moy_ue, inscr_mod from app.comp.res_compat import NotesTableCompat from app.comp.bonus_spo import BonusSport from app.models import ScoDocSiteConfig from app.models.etudiants import Identite from app.models.formsemestre import FormSemestre from app.models.ues import UniteEns from app.scodoc.codes_cursus import UE_SPORT from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import sco_preferences from app.scodoc.sco_utils import ModuleType class ResultatsSemestreClassic(NotesTableCompat): """Résultats du semestre (formation classique): organisation des calculs.""" _cached_attrs = NotesTableCompat._cached_attrs + ( "modimpl_coefs", "modimpl_idx", "sem_matrix", "mod_rangs", ) def __init__(self, formsemestre): super().__init__(formsemestre) self.sem_matrix: np.ndarray = None "sem_matrix : 2d-array (etuds x modimpls)" if not self.load_cached(): t0 = time.time() self.compute() t1 = time.time() self.store() t2 = time.time() log( f"""+++ ResultatsSemestreClassic: cached formsemestre_id={ formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s) +++""" ) # 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 or 0.0 for m in self.formsemestre.modimpls_sorted] ) self.modimpl_idx = { m.id: i for i, m in enumerate(self.formsemestre.modimpls_sorted) } "l'idx de la colonne du mod modimpl.id est modimpl_idx[modimpl.id]" modimpl_standards_mask = np.array( [ (m.module.module_type == ModuleType.STANDARD) and (m.module.ue.type != UE_SPORT) for m in self.formsemestre.modimpls_sorted ] ) ( self.etud_moy_gen, self.etud_moy_ue, self.etud_coef_ue_df, ) = moy_ue.compute_ue_moys_classic( self.formsemestre, self.sem_matrix, self.ues, self.modimpl_inscr_df, self.modimpl_coefs, modimpl_standards_mask, block=self.formsemestre.block_moyennes, ) # --- Modules de MALUS sur les UEs et la moyenne générale self.malus = moy_ue.compute_malus( self.formsemestre, self.sem_matrix, self.ues, self.modimpl_inscr_df ) self.etud_moy_ue -= self.malus # ajuste la moyenne générale (à l'aide des coefs d'UE) self.etud_moy_gen -= (self.etud_coef_ue_df * self.malus).sum( axis=1 ) / self.etud_coef_ue_df.sum(axis=1) # --- Bonus Sport & Culture bonus_class = ScoDocSiteConfig.get_bonus_sport_class() if bonus_class is not None: bonus: BonusSport = bonus_class( self.formsemestre, self.sem_matrix, self.ues, self.modimpl_inscr_df, self.modimpl_coefs, self.etud_moy_gen, self.etud_moy_ue, ) self.bonus_ues = bonus.get_bonus_ues() if self.bonus_ues is not None: self.etud_moy_ue += self.bonus_ues # somme les dataframes self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) bonus_mg = bonus.get_bonus_moy_gen() if bonus_mg is None and self.bonus_ues is not None: # pas de bonus explicite sur la moyenne générale # on l'ajuste pour refléter les modifs d'UE, à l'aide des coefs d'UE. bonus_mg = (self.etud_coef_ue_df * self.bonus_ues).sum( axis=1 ) / self.etud_coef_ue_df.sum(axis=1) self.etud_moy_gen += bonus_mg elif bonus_mg is not None: # Applique le bonus moyenne générale renvoyé self.etud_moy_gen += bonus_mg # compat nt, utilisé pour l'afficher sur les bulletins: self.bonus = bonus_mg # --- UE capitalisées self.apply_capitalisation() # Clippe toutes les moyennes dans [0,20] self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) self.etud_moy_gen.clip(lower=0.0, upper=20.0, inplace=True) # --- Classements: self.compute_rangs() # --- En option, moyennes par matières if sco_preferences.get_preference("bul_show_matieres", self.formsemestre.id): self.compute_moyennes_matieres() def compute_rangs(self): """Calcul des rangs (classements) dans le semestre (moy. gen.), les UE et les modules. """ # rangs moy gen et UEs sont calculées par la méthode commune à toutes les formations: super().compute_rangs() # les rangs des modules n'existent que dans les formations classiques: self.mod_rangs = {} for modimpl_result in self.modimpls_results.values(): # ne prend que les rangs sous forme de chaines: rangs = moy_sem.comp_ranks_series(modimpl_result.etuds_moy_module)[0] self.mod_rangs[modimpl_result.moduleimpl_id] = ( rangs, modimpl_result.nb_inscrits_module, ) 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) """ try: if self.modimpl_inscr_df[moduleimpl_id][etudid]: return self.modimpls_results[moduleimpl_id].etuds_moy_module[etudid] except KeyError: pass return "NI" def get_mod_stats(self, moduleimpl_id: int) -> dict: """Stats sur les notes obtenues dans un modimpl""" notes_series: pd.Series = self.modimpls_results[moduleimpl_id].etuds_moy_module nb_notes = len(notes_series) if not nb_notes: super().get_mod_stats(moduleimpl_id) return { # Series: Statistical methods from ndarray have been overridden to automatically # exclude missing data (currently represented as NaN) "moy": notes_series.mean(), # donc sans prendre en compte les NaN "max": notes_series.max(), "min": notes_series.min(), "nb_notes": nb_notes, "nb_missing": sum(notes_series.isna()), "nb_valid_evals": sum( self.modimpls_results[moduleimpl_id].evaluations_completes ), } def modimpl_notes( self, modimpl_id: int, ue_id: int = None, ) -> np.ndarray: """Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue. Utile pour stats bottom tableau recap. ue_id n'est pas utilisé ici (formations classiques) Résultat: 1d array of float """ i = self.modimpl_idx[modimpl_id] return self.sem_matrix[:, i] def compute_moyennes_matieres(self): """Calcul les moyennes par matière. Doit être appelée au besoin, en fin de compute.""" self.moyennes_matieres = moy_mat.compute_mat_moys_classic( self.formsemestre, self.sem_matrix, self.ues, self.modimpl_inscr_df, self.modimpl_coefs, ) def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float: """Détermine le coefficient de l'UE pour cet étudiant. N'est utilisé que pour l'injection des UE capitalisées dans la moyenne générale. Coef = somme des coefs des modules de l'UE auxquels il est inscrit """ coef = comp_etud_sum_coef_modules_ue(self.formsemestre.id, etudid, ue["ue_id"]) if coef is not None: # inscrit à au moins un module de cette UE return coef # arfff: aucun moyen de déterminer le coefficient de façon sûre log( f"""* oups: calcul coef UE impossible\nformsemestre_id='{self.formsemestre.id }'\netudid='{etudid}'\nue={ue}""" ) etud = Identite.get_etud(etudid) raise ScoValueError( f"""<div class="scovalueerror"><p>Coefficient de l'UE capitalisée {ue.acronyme} impossible à déterminer pour l'étudiant <a href="{ url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) }" class="discretelink">{etud.nom_disp()}</a></p> <p>Il faut <a href="{ url_for("notes.formsemestre_edit_uecoefs", scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, err_ue_id=ue["ue_id"], ) }">saisir le coefficient de cette UE avant de continuer</a></p> </div> """ ) def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]: """Calcule la matrice des notes du semestre (charge toutes les notes, calcule les moyennes 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_sorted: 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) """ if not modimpls_notes: return np.zeros((0, 0), dtype=float) 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 def comp_etud_sum_coef_modules_ue(formsemestre_id, etudid, ue_id): """Somme des coefficients des modules de l'UE dans lesquels cet étudiant est inscrit ou None s'il n'y a aucun module. """ # comme l'ancien notes_table.comp_etud_sum_coef_modules_ue # mais en raw sqlalchemy et la somme en SQL sql = text( """ SELECT sum(mod.coefficient) FROM notes_modules mod, notes_moduleimpl mi, notes_moduleimpl_inscription ins WHERE mod.id = mi.module_id and ins.etudid = :etudid and ins.moduleimpl_id = mi.id and mi.formsemestre_id = :formsemestre_id and mod.ue_id = :ue_id """ ) cursor = db.session.execute( sql, {"etudid": etudid, "formsemestre_id": formsemestre_id, "ue_id": ue_id} ) r = cursor.fetchone() if r is None: return None return r[0]