# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Emmanuel Viennet emmanuel.viennet@viennet.net # ############################################################################## """Fonctions de calcul des moyennes d'UE (classiques ou BUT) """ import numpy as np import pandas as pd import app from app import db from app import models from app.models import ( FormSemestre, Module, ModuleImpl, ModuleUECoef, UniteEns, ) from app.comp import moy_mod from app.scodoc import codes_cursus from app.scodoc import sco_preferences from app.scodoc.codes_cursus import UE_SPORT from app.scodoc.sco_utils import ModuleType def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.DataFrame: """Charge les coefs APC des modules de la formation pour le semestre indiqué. En APC, ces coefs lient les modules à chaque UE. Résultat: (module_coefs_df, ues_no_bonus, modules) DataFrame rows = UEs, columns = modules, value = coef. Considère toutes les UE sauf bonus et tous les modules du semestre. Les coefs non définis (pas en base) sont mis à zéro. Si semestre_idx None, prend toutes les UE de la formation. """ ues = ( UniteEns.query.filter_by(formation_id=formation_id) .filter(UniteEns.type != codes_cursus.UE_SPORT) .order_by(UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme) ) modules = ( Module.query.filter_by(formation_id=formation_id) .filter( (Module.module_type == ModuleType.RESSOURCE) | (Module.module_type == ModuleType.SAE) | ((Module.ue_id == UniteEns.id) & (UniteEns.type == codes_cursus.UE_SPORT)) ) .order_by(Module.semestre_id, Module.module_type, Module.numero, Module.code) ) if semestre_idx is not None: ues = ues.filter_by(semestre_idx=semestre_idx) modules = modules.filter_by(semestre_id=semestre_idx) ues = ues.all() modules = modules.all() ue_ids = [ue.id for ue in ues] module_ids = [module.id for module in modules] module_coefs_df = pd.DataFrame(columns=module_ids, index=ue_ids, dtype=float) query = ( db.session.query(ModuleUECoef) .filter(UniteEns.formation_id == formation_id) .filter(ModuleUECoef.ue_id == UniteEns.id) ) if semestre_idx is not None: query = query.filter(UniteEns.semestre_idx == semestre_idx) for mod_coef in query: if mod_coef.module_id in module_coefs_df: module_coefs_df[mod_coef.module_id][mod_coef.ue_id] = mod_coef.coef # silently ignore coefs associated to other modules (ie when module_type is changed) # Initialisation des poids non fixés: # 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse # sur toutes les UE) default_poids = { mod.id: ( 1.0 if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT) else 0.0 ) for mod in modules } module_coefs_df.fillna(value=default_poids, inplace=True) return module_coefs_df, ues, modules def df_load_modimpl_coefs( formsemestre: models.FormSemestre, ues=None, modimpls=None ) -> pd.DataFrame: """Charge les coefs APC des modules du formsemestre indiqué. Comme df_load_module_coefs mais prend seulement les UE et modules du formsemestre. Si ues et modimpls sont None, prend tous ceux du formsemestre (sauf ue bonus). Résultat: (module_coefs_df, ues, modules) DataFrame rows = UEs (sans bonus), columns = modimpl, value = coef. """ if ues is None: ues = formsemestre.get_ues() ue_ids = [x.id for x in ues] if modimpls is None: modimpls = formsemestre.modimpls_sorted modimpl_ids = [x.id for x in modimpls] mod2impl = {m.module.id: m.id for m in modimpls} modimpl_coefs_df = pd.DataFrame(columns=modimpl_ids, index=ue_ids, dtype=float) mod_coefs = ( db.session.query(ModuleUECoef) .filter(ModuleUECoef.module_id == ModuleImpl.module_id) .filter(ModuleImpl.formsemestre_id == formsemestre.id) ) for mod_coef in mod_coefs: try: modimpl_coefs_df.loc[mod_coef.ue_id, mod2impl[mod_coef.module_id]] = ( mod_coef.coef ) except IndexError: # il peut y avoir en base des coefs sur des modules ou UE # qui ont depuis été retirés de la formation pass # Initialisation des poids non fixés: # 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse # sur toutes les UE) default_poids = { modimpl.id: ( 1.0 if (modimpl.module.module_type == ModuleType.STANDARD) and (modimpl.module.ue.type == UE_SPORT) else 0.0 ) for modimpl in formsemestre.modimpls_sorted } modimpl_coefs_df.fillna(value=default_poids, inplace=True) return modimpl_coefs_df, ues, modimpls def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray: """Réuni les notes moyennes des modules du semestre en un "cube" modimpls_notes : liste des moyennes de module (DataFrames rendus par compute_module_moy, (etud x UE)) Resultat: ndarray (etud x module x UE) """ assert len(modimpls_notes) modimpls_notes_arr = [df.values for df in modimpls_notes] try: modimpls_notes = np.stack(modimpls_notes_arr) # passe de (mod x etud x ue) à (etud x mod x ue) except ValueError: app.critical_error( f"""notes_sem_assemble_cube: shapes { ", ".join([str(x.shape) for x in modimpls_notes_arr])}""" ) return modimpls_notes.swapaxes(0, 1) def notes_sem_load_cube( formsemestre: FormSemestre, modimpl_coefs_df: pd.DataFrame ) -> tuple: """Construit le "cube" (tenseur) des notes du semestre. Charge toutes les notes (sql), calcule les moyennes des modules et assemble le cube. etuds: tous les inscrits au semestre (avec dem. et def.) modimpls: _tous_ les modimpls de ce semestre (y compris bonus sport) UEs: toutes les UE du semestre (même si pas d'inscrits) SAUF le sport. Attention: la liste des modimpls inclut les modules des UE sport, mais elles ne sont pas dans la troisième dimension car elles n'ont pas de "moyenne d'UE". Résultat: sem_cube : ndarray (etuds x modimpls x UEs) modimpls_evals_poids dict { modimpl.id : evals_poids } modimpls_results dict { modimpl.id : ModuleImplResultsAPC } """ modimpls_results = {} modimpls_evals_poids = {} modimpls_notes = [] etudids, etudids_actifs = formsemestre.etudids_actifs() for modimpl in formsemestre.modimpls_sorted: mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs) evals_poids = modimpl.get_evaluations_poids() etuds_moy_module = mod_results.compute_module_moy(evals_poids, modimpl_coefs_df) modimpls_results[modimpl.id] = mod_results modimpls_evals_poids[modimpl.id] = evals_poids modimpls_notes.append(etuds_moy_module) if len(modimpls_notes) > 0: cube = notes_sem_assemble_cube(modimpls_notes) else: nb_etuds = formsemestre.etuds.count() cube = np.zeros((nb_etuds, 0, 0), dtype=float) return ( cube, modimpls_evals_poids, modimpls_results, ) def compute_ue_moys_apc( sem_cube: np.array, etuds: list, modimpls: list, modimpl_inscr_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame, modimpl_mask: np.array, dispense_ues: set[tuple[int, int]], block: bool = False, ) -> pd.DataFrame: """Calcul de la moyenne d'UE en mode APC (BUT). La moyenne d'UE est un nombre (note/20), ou NaN si pas de notes disponibles sem_cube: notes moyennes aux modules ndarray (etuds x modimpls x UEs) (floats avec des NaN) etuds : liste des étudiants (dim. 0 du cube) modimpls : liste des module_impl (dim. 1 du cube) ues : liste des UE (dim. 2 du cube) modimpl_inscr_df: matrice d'inscription aux modules du semestre (etud x modimpl) modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport modimpl_mask: liste de booléens, indiquants le module doit être pris ou pas. (utilisé pour éliminer les bonus, et pourra servir à cacluler sur des sous-ensembles de modules) block: si vrai, ne calcule rien et renvoie des NaNs Résultat: DataFrame columns UE (sans bonus), rows etudid """ nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape assert len(modimpls) == nb_modules if block or nb_modules == 0 or nb_etuds == 0 or nb_ues_no_bonus == 0: return pd.DataFrame( index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index ) assert len(etuds) == nb_etuds assert modimpl_inscr_df.shape[0] == nb_etuds assert modimpl_inscr_df.shape[1] == nb_modules assert modimpl_coefs_df.shape[0] == nb_ues_no_bonus assert modimpl_coefs_df.shape[1] == nb_modules modimpl_inscr = modimpl_inscr_df.values # Met à zéro tous les coefs des modules non sélectionnés dans le masque: modimpl_coefs = np.where(modimpl_mask, modimpl_coefs_df.values, 0.0) # Duplique les inscriptions sur les UEs non bonus: modimpl_inscr_stacked = np.stack([modimpl_inscr] * nb_ues_no_bonus, axis=2) # Enlève les NaN du numérateur: # si on veut prendre en compte les modules avec notes neutralisées ? sem_cube_no_nan = np.nan_to_num(sem_cube, nan=0.0) # Ne prend pas en compte les notes des étudiants non inscrits au module: # Annule les notes: sem_cube_inscrits = np.where(modimpl_inscr_stacked, sem_cube_no_nan, 0.0) # Annule les coefs des modules où l'étudiant n'est pas inscrit: modimpl_coefs_etuds = np.where( modimpl_inscr_stacked, np.stack([modimpl_coefs.T] * nb_etuds), 0.0 ) # Annule les coefs des modules NaN modimpl_coefs_etuds_no_nan = np.where(np.isnan(sem_cube), 0.0, modimpl_coefs_etuds) if modimpl_coefs_etuds_no_nan.dtype == object: # arrive sur des tableaux vides modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float) # # Version vectorisée # with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) etud_moy_ue = np.sum( modimpl_coefs_etuds_no_nan * sem_cube_inscrits, axis=1 ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) etud_moy_ue_df = pd.DataFrame( etud_moy_ue, index=modimpl_inscr_df.index, # les etudids columns=modimpl_coefs_df.index, # les UE sans les UE bonus sport ) # Les "dispenses" sont très peu nombreuses et traitées en python: for dispense_ue in dispense_ues: etud_moy_ue_df[dispense_ue[1]][dispense_ue[0]] = 0.0 return etud_moy_ue_df def compute_ue_moys_classic( formsemestre: FormSemestre, sem_matrix: np.array, ues: list, modimpl_inscr_df: pd.DataFrame, modimpl_coefs: np.array, modimpl_mask: np.array, block: bool = False, ) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]: """Calcul de la moyenne d'UE et de la moy. générale en mode classique (DUT, LMD, ...). 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] L'éventuel bonus sport n'est PAS appliqué ici. Le masque modimpl_mask est un tableau de booléens (un par modimpl) qui permet de sélectionner un sous-ensemble de modules (SAEs, tout sauf sport, ...). sem_matrix: notes moyennes aux modules (tous les étuds x tous les modimpls) ndarray (etuds x modimpls) (floats avec des NaN) etuds : listes des étudiants (dim. 0 de la matrice) ues : liste des UE du semestre modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) modimpl_coefs: vecteur des coefficients de modules modimpl_mask: masque des modimpls à prendre en compte block: si vrai, ne calcule rien et renvoie des NaNs Résultat: - moyennes générales: pd.Series, index etudid - moyennes d'UE: DataFrame columns UE, rows etudid - coefficients d'UE: DataFrame, columns UE, rows etudid les coefficients effectifs de chaque UE pour chaque étudiant (sommes de coefs de modules pris en compte) """ if ( block or (len(modimpl_mask) == 0) or (sem_matrix.shape[0] == 0) ): # aucun module ou aucun étudiant # etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df val = np.nan if block else 0.0 return ( pd.Series( [val] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index ), pd.DataFrame( columns=[ue.id for ue in ues], index=modimpl_inscr_df.index, dtype=float ), pd.DataFrame( columns=[ue.id for ue in ues], index=modimpl_inscr_df.index, dtype=float ), ) # Restreint aux modules sélectionnés: sem_matrix = sem_matrix[:, modimpl_mask] modimpl_inscr = modimpl_inscr_df.values[:, modimpl_mask] modimpl_coefs = modimpl_coefs[modimpl_mask] nb_etuds, nb_modules = sem_matrix.shape assert len(modimpl_coefs) == nb_modules nb_ues = len(ues) # en comptant bonus # 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 ) if modimpl_coefs_etuds_no_nan.dtype == object: # arrive sur des tableaux vides modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float) # --------------------- Calcul des moyennes d'UE ue_modules = np.array( [[m.module.ue == ue for m in formsemestre.modimpls_sorted] for ue in ues] )[..., np.newaxis][:, modimpl_mask, :] 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) if coefs.dtype == object: # arrive sur des tableaux vides coefs = coefs.astype(np.float) 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] ) # --------------------- Calcul des moyennes générales if sco_preferences.get_preference("use_ue_coefs", formsemestre.id): # Cas avec coefficients d'UE forcés: (on met à zéro l'UE bonus) etud_coef_ue_df = pd.DataFrame( { ue.id: (ue.coefficient or 0.0) if ue.type != UE_SPORT else 0.0 for ue in ues }, index=modimpl_inscr_df.index, columns=[ue.id for ue in ues], dtype=float, ) # remplace NaN par zéros dans les moyennes d'UE etud_moy_ue_df_no_nan = etud_moy_ue_df.fillna(0.0, inplace=False) # Si on voulait annuler les coef d'UE dont la moyenne d'UE est NaN # etud_coef_ue_df_no_nan = etud_coef_ue_df.where(etud_moy_ue_df.notna(), 0.0) with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) etud_moy_gen_s = (etud_coef_ue_df * etud_moy_ue_df_no_nan).sum( axis=1 ) / etud_coef_ue_df.sum(axis=1) else: # Cas normal: pondère directement les modules etud_coef_ue_df = pd.DataFrame( coefs.sum(axis=2).T, index=modimpl_inscr_df.index, # etudids columns=[ue.id for ue in ues], dtype=float, ) 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) return etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df def compute_mat_moys_classic( sem_matrix: np.array, modimpl_inscr_df: pd.DataFrame, modimpl_coefs: np.array, modimpl_mask: np.array, ) -> pd.Series: """Calcul de la moyenne sur un sous-enemble de modules en formation CLASSIQUE La moyenne est un nombre (note/20 ou NaN. Le masque modimpl_mask est un tableau de booléens (un par modimpl) qui permet de sélectionner un sous-ensemble de modules (ceux de la matière d'intérêt). sem_matrix: notes moyennes aux modules (tous les étuds x tous les modimpls) ndarray (etuds x modimpls) (floats avec des NaN) etuds : listes des étudiants (dim. 0 de la matrice) modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) modimpl_coefs: vecteur des coefficients de modules modimpl_mask: masque des modimpls à prendre en compte Résultat: - moyennes: pd.Series, index etudid """ if (0 == len(modimpl_mask)) or ( sem_matrix.shape[0] == 0 ): # aucun module ou aucun étudiant # etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df return pd.Series( [0.0] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index ) # Restreint aux modules sélectionnés: sem_matrix = sem_matrix[:, modimpl_mask] modimpl_inscr = modimpl_inscr_df.values[:, modimpl_mask] modimpl_coefs = modimpl_coefs[modimpl_mask] nb_etuds, nb_modules = sem_matrix.shape assert len(modimpl_coefs) == nb_modules # 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 ) if modimpl_coefs_etuds_no_nan.dtype == object: # arrive sur des tableaux vides modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float) with np.errstate(invalid="ignore"): # il peut y avoir des NaN etud_moy_mat = (modimpl_coefs_etuds_no_nan * sem_matrix_inscrits).sum( axis=1 ) / modimpl_coefs_etuds_no_nan.sum(axis=1) return pd.Series(etud_moy_mat, index=modimpl_inscr_df.index) def compute_malus( formsemestre: FormSemestre, sem_modimpl_moys: np.array, ues: list[UniteEns], modimpl_inscr_df: pd.DataFrame, ) -> pd.DataFrame: """Calcul le malus sur les UE Dans chaque UE, on peut avoir un ou plusieurs modules de MALUS. Leurs notes sont positives ou négatives. La somme des notes de malus somme est _soustraite_ à la moyenne de chaque UE. Arguments: - sem_modimpl_moys : notes moyennes aux modules (tous les étuds x tous les modimpls) floats avec des NaN. En classique: sem_matrix, ndarray (etuds x modimpls) En APC: sem_cube, ndarray (etuds x modimpls x UEs non bonus) - ues: les ues du semestre (incluant le bonus sport) - modimpl_inscr_df: matrice d'inscription aux modules du semestre (etud x modimpl) Résultat: DataFrame de float, index etudid, columns: ue.id (sans NaN) """ ues_idx = [ue.id for ue in ues] malus = pd.DataFrame(index=modimpl_inscr_df.index, columns=ues_idx, dtype=float) if len(sem_modimpl_moys.flat) == 0: # vide return malus if len(sem_modimpl_moys.shape) > 2: # BUT: ne retient que la 1er composante du malus qui est scalaire # au sens ou chaque note de malus n'affecte que la moyenne de l'UE # de rattachement de son module. sem_modimpl_moys_scalar = sem_modimpl_moys[:, :, 0] else: # classic sem_modimpl_moys_scalar = sem_modimpl_moys for ue in ues: if ue.type != UE_SPORT: modimpl_mask = np.array( [ (m.module.module_type == ModuleType.MALUS) and (m.module.ue.id == ue.id) # UE de rattachement for m in formsemestre.modimpls_sorted ] ) if len(modimpl_mask): malus_moys = sem_modimpl_moys_scalar[:, modimpl_mask].sum(axis=1) malus[ue.id] = malus_moys malus.fillna(0.0, inplace=True) return malus