From ef11721a43ab476ecd0c7cd577a8823e23be4dc9 Mon Sep 17 00:00:00 2001 From: Anthony Parisot Date: Tue, 5 May 2026 14:00:00 +0200 Subject: [PATCH 1/2] v2.1.2 --- CHANGELOG.md | 28 + ourocode/eurocode/core/combinaison.py | 35 + ourocode/eurocode/core/model_generator.py | 882 ++++++++++++++++++ ourocode/eurocode/core/objet.py | 55 ++ ourocode/eurocode/ec5/__init__.py | 3 + .../eurocode/ec5/element_droit/__init__.py | 2 + .../eurocode/ec5/element_droit/compression.py | 37 +- .../ec5/element_droit/verification_EC5.py | 780 ++++++++++++++++ ourocode/eurocode/mixins/math_utils.py | 28 + pyproject.toml | 2 +- tests/test_EC5_Element_droit.py | 2 +- tests/test_EC5_Feu.py | 4 +- tests/test_EC5_Verification.py | 314 +++++++ tests/test_model_generator.py | 280 ++++++ 14 files changed, 2433 insertions(+), 19 deletions(-) create mode 100644 ourocode/eurocode/ec5/element_droit/verification_EC5.py create mode 100644 tests/test_EC5_Verification.py create mode 100644 tests/test_model_generator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e49af..e4a2af2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ et ce projet adhère au [Semantic Versioning](https://semver.org/lang/fr/). ## [Unreleased] +### Fixed +- **`detect_continuous_members`** : correction du bug qui empêchait la détection de la continuité d'un arbalétrier (ou de toute barre) lorsqu'une contrefiche (ou autre barre transversale) était fixée en nœud intermédiaire (jonction T/Y). La contrainte de degré 2 au nœud a été supprimée : la colinéarité seule suffit à distinguer les barres continues des branchements. La recherche du voisin parcourt désormais toutes les barres du nœud pour trouver celle qui passe le test de continuité, et non plus uniquement la première trouvée. Les tests de `TestDetectContinuousMembers` ont été mis à jour en conséquence (2 nouveaux cas de régression : jonction T perpendiculaire et jonction T avec relâchement). +- **`Compression` / `_determine_compression_params` / `verification_EC5`** : correction du calcul des longueurs et conditions d'appui de flambement (EC5 §6.3.2). Trois bugs corrigés simultanément : (1) `Compression.__init__` utilisait un seul `type_appuis`/`coef_lef` appliqué identiquement aux axes y et z — remplacé par `type_appuis_y`/`type_appuis_z` et `coef_lef_y`/`coef_lef_z` indépendants (`coef_lef` conservé pour rétro-compatibilité) ; (2) `_determine_type_appuis` utilisait `Noeuds[0/1]` pour les nœuds d'extrémité (bug topologique) et ignorait les appuis intermédiaires — remplacé par `_determine_compression_params` qui découpe la barre en sous-portées via `_compute_spans` et détermine le `type_appuis` à chaque sous-portée via le nouveau helper `_type_appuis_between_nodes` ; (3) `verification_EC5` passait `type_appuis_z` pour les deux axes à `Compression` — désormais `lo_flamb_y`/`lo_flamb_z` (flambement, stockés séparément de `lo_rel_y`/`lo_rel_z`) et `type_appuis_y`/`type_appuis_z` sont transmis correctement par axe. Rétro-compatibilité assurée via fallback `lo_flamb_y → lo_rel_y` si la clé est absente du Design. +- **`_determine_flexion_params`** : correction du calcul des coefficients `coeflef_y` / `coeflef_z` sur les barres structurales multi-membrures. Un seul `coeflef` global était calculé pour l'ensemble de la barre et appliqué identiquement aux deux axes, sans tenir compte de la découpe en sous-portées par les appuis latéraux ni des différences de chargement par sous-portée. La méthode calcule désormais `(lo_rel, coeflef)` **par axe et par sous-portée** : pour chaque sous-portée délimitée par les appuis latéraux d'un axe, `_coeflef_for_span` détermine le coeflef local en fonction des charges verticales (distribuées, ponctuelle centrale, ponctuelle en bout) effectivement présentes sur ce segment ; la paire `(lo_rel × coeflef)` maximale — c'est-à-dire la plus défavorable — est retenue indépendamment pour Y et Z. Le helper `_compute_spans` a été introduit (refactorisation de `_compute_lo_rel`) pour exposer la liste des segments entre appuis. +- **`_compute_lo_rel` / `_determine_flexion_params`** : correction du bug de parcours topologique sur les barres structurales multi-membrures. Les deux méthodes utilisaient `Noeuds[0]`/`Noeuds[1]` directement, supposant à tort que toutes les barres FEM d'une chaîne sont orientées dans le même sens. Les nœuds d'extrémité et intermédiaires pouvaient ainsi être incorrects (ex : dernier nœud pointant vers le milieu de la chaîne plutôt que vers l'extrémité libre). Correction via le nouveau helper **`_ordered_chain_nodes(member_ids)`** qui parcourt la topologie réelle de la chaîne (en déduisant le sens de chaque barre depuis le nœud précédent), produisant une liste ordonnée `[(node_id, abscisse_mm), …]` fiable quelle que soit l'orientation des barres FEM individuelles. + +### Added +- **`ourocode/eurocode/ec5/verification.py`** : nouvelle **classe `Verification_EC5(Projet)`** de vérification EC5 d'un modèle MEF complet (barres structurales continues ou non). + - Factory :meth:`Verification_EC5.from_combinaison(combinaison, model_result)` qui hérite via `_from_parent_class([model_generator, combinaison])` des attributs Projet (ingénieur, nom, code INSEE, altitude) et attache la combinaison + le résultat MEF. + - :meth:`verify(name, elu_filter="ELU_ALL", type_bat, pos_charge, coeflef_y, coeflef_z, type_appuis_compression, n_points)` **boucle sur toutes les combinaisons ELU** : pour chaque combo, `kmod` est dérivé via :meth:`Combinaison.min_type_load` et `γM` via :meth:`Combinaison.type_combi`, puis les efforts gouvernants (Nx signé, Vy, My, Mz) sont extraits via :meth:`Model_result.get_internal_force` (ciblage par `combo_name`). Pour chaque vérification (Flexion, Cisaillement, Traction **ou** Compression selon le signe de Nx), le taux maximum rencontré et la combinaison gouvernante associée sont retenus. La flexion inclut l'interaction flexo-compression/flexo-traction via `Flexion.taux_m_d(compression=…, traction=…)`. La flèche ELS (W_inst(Q), W_net,fin) est calculée indépendamment via le max tag-based. + - :meth:`synthese(**kwargs)` : agrège toutes les barres structurales en un unique DataFrame trié par taux décroissant, avec gestion des erreurs (section manuelle, classe inconnue) en DataFrame secondaire. + - Helpers privés : `_verify_combo_elu`, `_verify_fleche`, `_efforts_for_combo`, `_deflection`, `_resolve_section`, `_resolve_classe_bois`, `_design_or_default`, `_lo_mm`, `_check_state`, `_taux_to_df`. + - Exposée depuis `ourocode.eurocode.ec5` via `__init__.py`. +- **`ourocode/eurocode/core/combinaison.py`** : nouvelle méthode `Combinaison.type_combi(name_combi) -> "Fondamentales" | "Accidentelles"` utilisée par `Verification_EC5` pour résoudre dynamiquement le coefficient partiel `γM` selon l'EC5 §2.4.1 (EN 1990 §6.4.3.2/3.3). Lève `ValueError` pour une combinaison non-ELU. +- **`tests/test_EC5_Verification.py`** : 18 tests d'intégration couvrant : + - `Combinaison.type_combi` (Fondamentales, Accidentelles, ValueError hors ELU). + - Factory `Verification_EC5.from_combinaison` : instanciation, héritage des attributs Projet depuis le modèle MEF, état valide pour `verify`. + - Poutre simple 1 travée (DataFrame non vide, Flexion+Cisaillement+Flèche présents, Traction/Compression absents sans charge axiale, ordres de grandeur, combo gouvernante `ELU_STR 1.35G + 1.5Q`). + - Boucle par combinaison : `min_type_load` retourne les bonnes durées par combo, cohérence entre `ELU_ALL` et `ELU_STR` en l'absence d'accidentelles. + - Poutre continue 2 travées (auto-groupage + synthèse, tri décroissant, présence de la combo gouvernante). +- **`ourocode/eurocode/core/model_generator.py`** : nouveau concept de **barre structurale** regroupant une ou plusieurs barres FEM continues, pour préparer l'itération et la vérification Eurocode à l'échelle d'une barre physique (solive continue, panne sur plusieurs appuis, etc.). + - `detect_continuous_members(angle_tol_deg=1.0)` : détection automatique des chaînes colinéaires de barres FEM partageant matériau/section, sans rotule (`teta_y`/`teta_z`) aux extrémités communes et sans T-junction. + - `group_members(name, member_ids, role, classe_bois, cs, Hi, Hf, effet_systeme, type_element_fleche, lo_rel_y, lo_rel_z, comment)` : regroupement manuel avec configuration de design EC5. + - `auto_group_continuous_members(angle_tol_deg, role, name_prefix, only_continuous, **design_kwargs)` : détection + regroupement en une passe. + - `get_structural_member`, `get_all_structural_members`, `del_structural_member`, `iter_structural_members` : accès et itération. + - Ajout de `"structural_members"` dans `self._data`. +- **`tests/test_model_generator.py`** : 17 tests couvrant la détection (continuité, rupture par section/matériau/rotule/T-junction/angle, tolérance angulaire, orientation inversée des extrémités) et la gestion des barres structurales (regroupement, validations, itération, auto-groupage). + ## [2.0.0] - 2026-04-16 ### Added diff --git a/ourocode/eurocode/core/combinaison.py b/ourocode/eurocode/core/combinaison.py index 9c81dee..20e17bb 100644 --- a/ourocode/eurocode/core/combinaison.py +++ b/ourocode/eurocode/core/combinaison.py @@ -1804,6 +1804,41 @@ def _get_combi_factor_load(self, nom: str) -> dict: return dict_loads + def type_combi(self, name_combi: str) -> str: + """Retourne le type de combinaison EC0 (``"Fondamentales"`` ou ``"Accidentelles"``). + + Déduit le type à partir du préfixe du nom de la combinaison tel que + généré par :class:`Combinaison` : + + - ``"ELU_STR_ACC ..."`` -> ``"Accidentelles"`` (EC0 §6.4.3.3) + - ``"ELU_STR ..."`` ou tout autre ELU -> ``"Fondamentales"`` (EC0 §6.4.3.2) + + Cette information sert notamment à déterminer le coefficient partiel + ``γM`` pour les vérifications Eurocode (EC5 §2.4.1, EC3 §6.1…) selon + que la situation est durable/transitoire ou accidentelle. + + Args: + name_combi (str): Nom de la combinaison à analyser + (ex. ``"ELU_STR 1.35G + 1.5Q"`` ou ``"ELU_STR_ACC G + Ae"``). + + Returns: + str: ``"Accidentelles"`` si le nom débute par ``ELU_STR_ACC``, + sinon ``"Fondamentales"``. + + Raises: + ValueError: Si ``name_combi`` ne correspond pas à une combinaison + ELU reconnue (ni ``ELU_STR`` ni ``ELU_STR_ACC``). + """ + if name_combi.startswith("ELU_STR_ACC"): + return "Accidentelles" + if name_combi.startswith("ELU_STR"): + return "Fondamentales" + raise ValueError( + f"La combinaison '{name_combi}' n'est pas une combinaison ELU " + f"(préfixe attendu : 'ELU_STR' ou 'ELU_STR_ACC')." + ) + + def min_type_load(self, name_combi: str) -> str: """Retourne la classe de durée de chargement la plus courte présente dans une combinaison. diff --git a/ourocode/eurocode/core/model_generator.py b/ourocode/eurocode/core/model_generator.py index 8a44bc4..a39401d 100644 --- a/ourocode/eurocode/core/model_generator.py +++ b/ourocode/eurocode/core/model_generator.py @@ -58,6 +58,7 @@ def __init__(self, *args, **kwargs): "members": {}, "supports": {"classic": {}, "spring": {}}, "loads": {}, + "structural_members": {}, } self._model = None @@ -1241,6 +1242,887 @@ def get_member_loads(self, member_id: str) -> list: if load["N° barre"] == member_id ] + ################## Barre structurale (regroupement de barres FEM continues) ################## + + def _member_unit_vector(self, member_id: str, from_node: str) -> np.ndarray: + """Retourne le vecteur unitaire d'une barre, orienté depuis ``from_node``. + + Args: + member_id (str): identifiant de la barre FEM. + from_node (str): noeud de départ (doit être une extrémité de la barre). + + Returns: + np.ndarray: vecteur unitaire 3D (sans dimension). + """ + n1, n2 = self._data["members"][member_id]["Noeuds"] + other = n2 if n1 == from_node else n1 + c_from = self._data["nodes"][from_node] + c_to = self._data["nodes"][other] + v = np.array( + [ + (c_to["X"] - c_from["X"]).value, + (c_to["Y"] - c_from["Y"]).value, + (c_to["Z"] - c_from["Z"]).value, + ] + ) + norm = np.linalg.norm(v) + return v / norm if norm > 0 else v + + def _end_at_node(self, member_id: str, node_id: str) -> str: + """Retourne ``"start"`` ou ``"end"`` selon l'extrémité de la barre au noeud donné.""" + n1, _ = self._data["members"][member_id]["Noeuds"] + return "start" if node_id == n1 else "end" + + def _has_flexural_release_at(self, member_id: str, end: str) -> bool: + """Indique si la barre a un relâchement en rotation (rotule fléchie) à l'extrémité. + + Un relâchement ``teta_y`` ou ``teta_z`` rompt la continuité de la flexion + et donc la barre structurale. + """ + rel = self._data["members"][member_id]["Relaxation"].get(end) + if not rel: + return False + return bool(rel.get("teta_y") or rel.get("teta_z")) + + def detect_continuous_members( + self, angle_tol_deg: float = 1.0 + ) -> list[list[str]]: + """Détecte automatiquement les chaînes de barres FEM formant une barre continue. + + Deux barres FEM sont considérées comme continues au travers d'un noeud partagé si : + + 1. Elles utilisent le même matériau et la même section. + 2. Aucune des deux n'a de relâchement en rotation (``teta_y`` ou ``teta_z``) + à l'extrémité partagée — une rotule rompt la continuité en flexion. + 3. Elles sont colinéaires au noeud partagé à ``angle_tol_deg`` près : + les deux vecteurs sortants du noeud partagé sont quasi-opposés + (``cos(angle) ≈ -1``). + + Le degré du noeud n'est pas contraint : un noeud T ou Y (contrefiche fixée + en milieu d'arbalétrier par ex.) ne rompt pas la continuité de l'arbalétrier + tant que les barres de l'arbalétrier sont colinéaires et sans relâchement. + La présence d'un appui intermédiaire ne rompt pas non plus la continuité. + + Args: + angle_tol_deg (float): Tolérance angulaire en degrés pour la colinéarité. + Valeurs typiques : 0.5° (strict) à 5° (tolérant sur CAO imprécise). + Defaults to 1.0. + + Returns: + list[list[str]]: Liste de chaînes ordonnées de ``member_id``. + Chaque chaîne est orientée d'une extrémité libre vers l'autre. + Une barre isolée (non continue) est retournée comme une chaîne + d'un seul élément. + + Exemple: + >>> chains = model.detect_continuous_members() + >>> # [["M1"], ["M2", "M3", "M4"], ["M5"]] + >>> # M2→M3→M4 forment une solive continue sur 4 appuis par exemple. + + Note: + La méthode ne modifie pas le modèle. Pour créer effectivement les + barres structurales à partir du résultat, utilisez ``group_members`` + ou ``auto_group_continuous_members``. + """ + from collections import defaultdict + + # 1. Index noeud -> [(member_id, end)] + node_to_ends: dict[str, list[tuple[str, str]]] = defaultdict(list) + for mid, m in self._data["members"].items(): + n1, n2 = m["Noeuds"] + node_to_ends[n1].append((mid, "start")) + node_to_ends[n2].append((mid, "end")) + + cos_threshold = -np.cos(np.radians(angle_tol_deg)) + + def can_connect(m1: str, m2: str, shared: str) -> bool: + mem1 = self._data["members"][m1] + mem2 = self._data["members"][m2] + if mem1["Matériaux"] != mem2["Matériaux"]: + return False + if mem1["Section"] != mem2["Section"]: + return False + # Ne pas exiger degré 2 au noeud : une contrefiche (T/Y) ne rompt + # pas la continuité de l'arbalétrier si les deux barres sont + # colinéaires et sans relâchement. + if self._has_flexural_release_at( + m1, self._end_at_node(m1, shared) + ) or self._has_flexural_release_at(m2, self._end_at_node(m2, shared)): + return False + v1 = self._member_unit_vector(m1, shared) + v2 = self._member_unit_vector(m2, shared) + # Continuité : v1 et v2 sortent du noeud partagé en directions opposées + return float(np.dot(v1, v2)) <= cos_threshold + + # 2. Construire la table des voisins : member_id -> {node_partage: voisin} + # Au noeud partagé il peut y avoir N barres (T, Y…) : on cherche parmi + # toutes celles qui passent le test de continuité (même matériau, même + # section, colinéaires, sans relâchement). Au plus une sera retenue. + neighbors: dict[str, dict[str, str]] = {} + for mid, m in self._data["members"].items(): + n1, n2 = m["Noeuds"] + nb: dict[str, str] = {} + for shared in (n1, n2): + ends = node_to_ends[shared] + other = next( + (om for om, _ in ends if om != mid and can_connect(mid, om, shared)), + None, + ) + if other is not None: + nb[shared] = other + neighbors[mid] = nb + + # 3. Parcourir les chaînes (graphe localement linéaire) + visited: set[str] = set() + chains: list[list[str]] = [] + for start_mid in self._data["members"].keys(): + if start_mid in visited: + continue + n1, n2 = self._data["members"][start_mid]["Noeuds"] + + def walk(direction_node: str) -> list[str]: + chain_ids: list[str] = [] + cur_mid = start_mid + out_node = direction_node + while True: + nxt = neighbors[cur_mid].get(out_node) + if nxt is None or nxt == start_mid or nxt in chain_ids: + break + chain_ids.append(nxt) + nxt_nodes = self._data["members"][nxt]["Noeuds"] + out_node = ( + nxt_nodes[0] if nxt_nodes[1] == out_node else nxt_nodes[1] + ) + cur_mid = nxt + return chain_ids + + forward = walk(n2) + backward = walk(n1) + chain = list(reversed(backward)) + [start_mid] + forward + visited.update(chain) + chains.append(chain) + return chains + + def group_members( + self, + name: str, + member_ids: list[str], + role: str = None, + design_params: dict = None, + comment: str = None, + ) -> str: + """Crée une barre structurale (regroupement de barres FEM continues). + + Regroupe une ou plusieurs barres FEM en un unique élément structurel, + destiné à être vérifié comme un tout selon un Eurocode (EC2, EC3, EC5, etc.). + La continuité n'est pas vérifiée ici : utilisez ``detect_continuous_members`` + en amont si vous voulez une vérification automatique. + + Args: + name (str): Nom unique de la barre structurale (ex: "Solive_S1"). + member_ids (list[str]): Liste ordonnée des identifiants de barres FEM + constituant la barre structurale (ex: ["M12", "M13", "M14"]). + Pour une barre simple non continue : ``[member_id]``. + role (str, optional): Rôle structurel (ex: "Solive", "Panne", "Poteau", + "Arbalétrier", "Moise", "Poutre", "Voile"). Utilisé par les modules + de vérification. + design_params (dict, optional): Paramètres de design spécifiques à l'Eurocode + utilisé. Le contenu dépend du matériau (EC2=béton, EC3=acier, EC5=bois). + Exemple EC5: {"classe_bois": "C24", "cs": 1, "Hi": 12, "Hf": 12, + "effet_systeme": False, "type_element_fleche": "Solives"}. + Exemple EC3: {"classe_acier": "S355", "classe_section": 1}. + lo_rel_y (float, optional): Longueur de flambement/déversement autour + de l'axe y, en mm. Si None, à déterminer automatiquement au moment + de la vérification. + lo_rel_z (float, optional): Longueur de flambement/déversement autour + de l'axe z, en mm. + comment (str, optional): Commentaire descriptif. + + Returns: + str: Nom de la barre structurale créée (= ``name``). + + Raises: + ValueError: Si une barre FEM listée n'existe pas, si ``member_ids`` est + vide, ou si ``name`` existe déjà. + + Exemple: + >>> chains = model.detect_continuous_members() + >>> # supposons chains[0] == ["M1", "M2", "M3"] + >>> model.group_members("Solive_S1", chains[0], + ... role="Solive", + ... design_params={"classe_bois": "C24",}) + """ + if not member_ids: + raise ValueError("`member_ids` ne peut pas être vide.") + if name in self._data["structural_members"]: + raise ValueError( + f"La barre structurale '{name}' existe déjà. " + f"Utilisez `del_structural_member` d'abord pour la remplacer." + ) + for mid in member_ids: + if mid not in self._data["members"]: + raise ValueError(f"La barre FEM '{mid}' n'existe pas dans le modèle.") + + total_length = sum( + (self._data["members"][mid]["Longueur"] for mid in member_ids), + start=0 * si.mm, + ) + + design = dict(design_params) if design_params else {} + + # Paramètres de flambement (EC5 §6.3.2) — longueurs et types d'appui par axe + comp_params = self._determine_compression_params(member_ids) + design["lo_flamb_y"] = comp_params["lo_flamb_y"] + design["lo_flamb_z"] = comp_params["lo_flamb_z"] + design["type_appuis_y"] = comp_params["type_appuis_y"] + design["type_appuis_z"] = comp_params["type_appuis_z"] + + # Paramètres de déversement en flexion (EC5 §6.3.3) — longueurs et coeflef par axe + flexion_params = self._determine_flexion_params(member_ids) + design["lo_rel_y"] = flexion_params["lo_rel_y"] + design["lo_rel_z"] = flexion_params["lo_rel_z"] + design["coeflef_y"] = flexion_params["coeflef_y"] + design["coeflef_z"] = flexion_params["coeflef_z"] + + self._data["structural_members"][name] = { + "Barres FEM": list(member_ids), + "Rôle": role, + "Longueur totale": total_length, + "Design": design, + "Commentaire": comment, + } + return name + + def _node_is_rotationally_fixed( + self, node_id: str, member_id: str, end: str, axis: str + ) -> bool: + """Indique si une extrémité de barre est effectivement encastrée en rotation. + + Un encastrement en rotation sur ``axis`` requiert **deux conditions** + simultanées : + + 1. Le nœud bloque la rotation via un appui classique (``R{axis}=True``). + 2. La barre elle-même n'a pas de relâchement ``teta_{axis}`` à cette + extrémité — sinon la rotule de barre annule l'appui en rotation. + + Si le nœud n'a pas d'appui classique (nœud de ferme interne, faîtage…), + la rotation est libre quelle que soit la continuité de la barre. + + Args: + node_id: Identifiant du nœud à analyser. + member_id: Barre FEM dont l'extrémité est au nœud. + end: ``"start"`` ou ``"end"``. + axis: ``"y"`` ou ``"z"``. + + Returns: + bool: ``True`` si le nœud constitue un encastrement en rotation + pour la barre analysée. + """ + rot_key = "R" + axis.upper() + support = self._get_support_conditions_at_node(node_id) + if not support[rot_key]: + return False + return not self._has_rotation_release_at(member_id, end, axis) + + def _type_appuis_between_nodes( + self, + node_start: str, node_end: str, + member_start: str, member_end: str, + axis: str, + end_str_start: str, end_str_end: str, + ) -> str: + """Retourne le type d'appui en flambement entre deux nœuds consécutifs. + + Args: + node_start: nœud de début du segment. + node_end: nœud de fin du segment. + member_start: barre FEM dont l'extrémité est en ``node_start``. + member_end: barre FEM dont l'extrémité est en ``node_end``. + axis: ``"y"`` ou ``"z"``. + end_str_start: ``"start"`` ou ``"end"`` pour ``member_start`` à ``node_start``. + end_str_end: ``"start"`` ou ``"end"`` pour ``member_end`` à ``node_end``. + + Returns: + str: Type d'appui selon ``Compression.COEF_LF``. + + Note: + L'encastrement en rotation est déterminé par ``_node_is_rotationally_fixed`` : + il requiert à la fois un appui classique bloquant ``R{axis}`` **et** l'absence + de relâchement ``teta_{axis}`` sur la barre analysée à cette extrémité. + Un nœud sans appui classique (nœud de ferme, faîtage…) est toujours une rotule, + même si la barre n'a pas de relâchement explicite. + """ + start_fixed = self._node_is_rotationally_fixed( + node_start, member_start, end_str_start, axis + ) + end_fixed = self._node_is_rotationally_fixed( + node_end, member_end, end_str_end, axis + ) + if start_fixed and end_fixed: + return "Encastré - Encastré" + if start_fixed or end_fixed: + # Un seul côté est encastré (rotation bloquée sans relâchement). + # L'autre extrémité doit être libre (pas de translation latérale bloquée) + # pour que ce soit un porte-à-faux, sinon c'est Encastré-Rotule. + lat_dof = "DZ" if axis == "y" else "DY" + s_lat = self._get_support_conditions_at_node(node_start) + e_lat = self._get_support_conditions_at_node(node_end) + other_lat_free = (start_fixed and not e_lat[lat_dof]) or (end_fixed and not s_lat[lat_dof]) + if other_lat_free: + return "Encastré 1 côté" + return "Encastré - Rotule" + return "Rotule - Rotule" + + def _determine_type_appuis(self, member_ids: list[str], axis: str) -> str: + """Détermine le type d'appui aux **extrémités** de la barre structurale + pour le calcul de flambement. + + Utilise ``_ordered_chain_nodes`` pour respecter la topologie réelle + (barres éventuellement à sens alternés). + + Args: + member_ids (list[str]): Liste des identifiants de barres FEM. + axis (str): Axe de flambement ("y" ou "z"). + + Returns: + str: Type d'appui selon Compression.COEF_LF. + """ + chain_nodes = self._ordered_chain_nodes(member_ids) + start_node = chain_nodes[0][0] + end_node = chain_nodes[-1][0] + end_str_start = self._end_at_node(member_ids[0], start_node) + end_str_end = self._end_at_node(member_ids[-1], end_node) + return self._type_appuis_between_nodes( + start_node, end_node, + member_ids[0], member_ids[-1], + axis, + end_str_start, end_str_end, + ) + + def _determine_compression_params( + self, member_ids: list[str] + ) -> dict: + """Calcule les paramètres de flambement (EC5 §6.3.2) par axe. + + Pour chaque axe (y, z) : + + - Les appuis en **translation** latérale (``DZ`` pour axe y, ``DY`` pour + axe z) découpent la barre structurale en sous-portées. + - La longueur de flambement ``lo_flamb`` retenue est la longueur de la + sous-portée la plus longue (cas le plus défavorable). + - Le ``type_appuis`` est déterminé aux extrémités de cette sous-portée + par analyse des conditions de rotation (``RY``/``RZ``) et des + relâchements éventuels. + + La convention de DDL est : + + ========= ================= ================== + Axe Translation lat. Rotation bloquante + ========= ================= ================== + y (plan XZ) DZ RY + z (plan XY) DY RZ + ========= ================= ================== + + Args: + member_ids (list[str]): Liste ordonnée de barres FEM. + + Returns: + dict: Clés ``lo_flamb_y``, ``lo_flamb_z``, + ``type_appuis_y``, ``type_appuis_z`` (flottants mm et str). + """ + chain_nodes = self._ordered_chain_nodes(member_ids) # [(node, abs), …] + node_at_abs: dict[float, str] = {pos: nid for nid, pos in chain_nodes} + + def _worst_span_for_axis(lat_dof: str, axis: str) -> tuple[float, str]: + rot_dof = "R" + axis.upper() + spans = self._compute_spans(member_ids, lat_dof, rot_dof) + worst_lo = 0.0 + worst_type = "Rotule - Rotule" + for x0, x1, span_len in spans: + node_s = node_at_abs[x0] + node_e = node_at_abs[x1] + # Trouve les barres FEM dont les extrémités tombent sur ces nœuds + mid_s = next( + m for m in member_ids + if node_s in self._data["members"][m]["Noeuds"] + ) + mid_e = next( + m for m in reversed(member_ids) + if node_e in self._data["members"][m]["Noeuds"] + ) + end_s = self._end_at_node(mid_s, node_s) + end_e = self._end_at_node(mid_e, node_e) + t = self._type_appuis_between_nodes( + node_s, node_e, mid_s, mid_e, axis, end_s, end_e + ) + if span_len >= worst_lo: + worst_lo = span_len + worst_type = t + return worst_lo, worst_type + + lo_flamb_y, type_appuis_y = _worst_span_for_axis("DY", "z") #inversé pour convertir le repère locale à l'EUROCODE 5 + lo_flamb_z, type_appuis_z = _worst_span_for_axis("DZ", "y") #inversé pour convertir le repère locale à l'EUROCODE 5 + return { + "lo_flamb_y": lo_flamb_y, + "lo_flamb_z": lo_flamb_z, + "type_appuis_y": type_appuis_y, + "type_appuis_z": type_appuis_z, + } + + def _ordered_chain_nodes( + self, member_ids: list[str] + ) -> list[tuple[str, float]]: + """Retourne la liste ordonnée ``[(node_id, abscisse_mm), …]`` pour une chaîne + de barres FEM consécutives, en respectant la topologie réelle (sens des + barres potentiellement alternés). + + Args: + member_ids (list[str]): Barres FEM de la chaîne, dans l'ordre structural. + + Returns: + list[tuple[str, float]]: Du premier nœud au dernier, avec abscisse + cumulée en mm. + """ + if not member_ids: + return [] + + # Pour la première barre on choisit Noeuds[0] comme nœud de départ. + first = self._data["members"][member_ids[0]]["Noeuds"] + result: list[tuple[str, float]] = [(first[0], 0.0)] + prev_node = first[0] + cumul = 0.0 + + for mid in member_ids: + n1, n2 = self._data["members"][mid]["Noeuds"] + length_mm = self._data["members"][mid]["Longueur"].value * 1000.0 + # Le nœud de sortie est celui qui n'est pas le nœud d'entrée + next_node = n2 if n1 == prev_node else n1 + cumul += length_mm + result.append((next_node, cumul)) + prev_node = next_node + + return result + + def _compute_spans( + self, + member_ids: list[str], + lateral_dof: str, + rot_dof: str, + ) -> list[tuple[float, float, float]]: + """Retourne la liste des sous-portées entre appuis latéraux consécutifs. + + Parcourt la séquence ordonnée de barres FEM et identifie les nœuds + qui bloquent le déversement. Chaque sous-portée est décrite par ses + abscisses de début et de fin ainsi que sa longueur. + + Args: + member_ids (list[str]): Liste ordonnée des identifiants de barres FEM. + lateral_dof (str): DDL en translation bloquant le déversement + (ex: ``"DZ"`` pour déversement selon Y). + rot_dof (str): DDL en rotation bloquant le déversement + (ex: ``"RY"`` pour déversement selon Y). + + Returns: + list[tuple[float, float, float]]: Liste de ``(x_debut, x_fin, longueur)`` + en mm, du premier au dernier segment entre appuis latéraux. + S'il n'y a aucun appui latéral, retourne un seul segment couvrant + toute la longueur. + + Note: + Un nœud est considéré appui latéral s'il bloque ``lateral_dof`` + ou ``rot_dof``. + """ + nodes_with_pos = self._ordered_chain_nodes(member_ids) + cumul = nodes_with_pos[-1][1] if nodes_with_pos else 0.0 + + bracing_positions: list[float] = [] + for node_id, pos in nodes_with_pos: + support = self._get_support_conditions_at_node(node_id) + if support[lateral_dof] or support[rot_dof]: + bracing_positions.append(pos) + + all_positions = sorted(set([0.0] + bracing_positions + [cumul])) + return [ + (all_positions[i], all_positions[i + 1], all_positions[i + 1] - all_positions[i]) + for i in range(len(all_positions) - 1) + ] + + def _compute_lo_rel( + self, + member_ids: list[str], + lateral_dof: str, + rot_dof: str, + ) -> float: + """Calcule la longueur de déversement maximale entre appuis latéraux. + + Délègue à ``_compute_spans`` et retourne la longueur du segment le plus long. + + Args: + member_ids (list[str]): Liste ordonnée des identifiants de barres FEM. + lateral_dof (str): DDL en translation bloquant le déversement. + rot_dof (str): DDL en rotation bloquant le déversement. + + Returns: + float: Longueur de déversement maximale en mm. + """ + spans = self._compute_spans(member_ids, lateral_dof, rot_dof) + return max(s[2] for s in spans) if spans else 0.0 + + def _determine_flexion_params(self, member_ids: list[str]) -> dict: + """Détermine les paramètres de déversement en flexion selon l'EC5. + + Analyse les charges appliquées et les conditions d'appui pour déterminer + les longueurs efficaces de déversement et les coefficients associés. + + Args: + member_ids (list[str]): Liste des identifiants de barres FEM. + + Returns: + dict: Paramètres de flexion avec clés : + - "lo_rel_y" (float): Longueur de déversement axe y en mm + - "lo_rel_z" (float): Longueur de déversement axe z en mm + - "coeflef_y" (float): Coefficient de longueur efficace axe y + - "coeflef_z" (float): Coefficient de longueur efficace axe z + - "pos_charge" (str): Position de la charge verticale + + Note: + Les coefficients coeflef dépendent du type de chargement : + - 1.0 : moment constant + - 0.9 : charge répartie (défaut) + - 0.8 : charge concentrée centrale + - 0.5 : porte-à-faux charge répartie + - 0.8 : porte-à-faux charge concentrée bout + + La position de la charge est déterminée par la direction des charges + verticales appliquées. + """ + # Vérifie si c'est un porte-à-faux (un côté encastré, l'autre libre) + # _ordered_chain_nodes respecte la topologie réelle des barres enchaînées. + chain_nodes = self._ordered_chain_nodes(member_ids) + start_node = chain_nodes[0][0] + end_node = chain_nodes[-1][0] + + start_support = self._get_support_conditions_at_node(start_node) + end_support = self._get_support_conditions_at_node(end_node) + + start_blocked = start_support["DX"] or start_support["DY"] or start_support["DZ"] + end_blocked = end_support["DX"] or end_support["DY"] or end_support["DZ"] + + is_cantilever = (start_blocked and not end_blocked) or (end_blocked and not start_blocked) + + # Abscisses cumulées des barres (calculé une seule fois, capturé par closure) + cumul_map: dict[str, tuple[float, float]] = {} + x_cur = 0.0 + for _mid in member_ids: + _len = self._data["members"][_mid]["Longueur"].value * 1000.0 + cumul_map[_mid] = (x_cur, x_cur + _len) + x_cur += _len + + def _coeflef_for_span( + x0: float, x1: float, is_cant: bool + ) -> float: + """Retourne le coeflef le plus défavorable pour un segment [x0, x1]. + + Pour chaque charge verticale portant sur une barre dont l'abscisse + locale chevauche le segment, détermine la contribution (distribuée, + ponctuelle centrale, ponctuelle en bout). Le coeflef le plus élevé + (le plus défavorable) est retenu ; 1.0 est utilisé si aucune charge + n'est trouvée (moment constant, hypothèse conservative). + """ + span_len = x1 - x0 + local_dist = False + local_center = False + local_end = False + + for load in self._data["loads"].values(): + if load["N° barre"] not in member_ids: + continue + if load["Axe"] not in ("Fy", "Fz", "FY", "FZ"): + continue + mid = load["N° barre"] + bar_x0, bar_x1 = cumul_map[mid] + # La barre doit chevaucher le segment + if bar_x1 <= x0 or bar_x0 >= x1: + continue + if load["Type de charge"] == "Distribuée": + local_dist = True + elif load["Type de charge"] == "Ponctuelle": + pos_rel = load.get("Position", 0.5) + # Abscisse absolue de la charge + x_load = bar_x0 + pos_rel * (bar_x1 - bar_x0) + if x0 <= x_load <= x1: + # Position relative dans le segment + pos_in_span = (x_load - x0) / span_len if span_len > 0 else 0.5 + if 0.4 <= pos_in_span <= 0.6: + local_center = True + else: + local_end = True + + if is_cant: + return 0.8 if local_end else 0.5 + else: + if local_center: + return 0.8 + if local_dist: + return 0.9 + return 1.0 # moment constant — hypothèse conservative + + def _worst_case_axis( + lateral_dof: str, rot_dof: str + ) -> tuple[float, float]: + """Retourne ``(lo_rel, coeflef)`` les plus défavorables pour un axe. + + Pour chaque sous-portée entre appuis latéraux, calcule le coeflef + associé. La paire ``(lo_rel × coeflef)`` maximale détermine la + longueur efficace de déversement la plus défavorable. + """ + spans = self._compute_spans(member_ids, lateral_dof, rot_dof) + worst_lo = 0.0 + worst_coeflef = 0.9 # valeur par défaut + for x0, x1, span_len in spans: + c = _coeflef_for_span(x0, x1, is_cantilever) + if span_len * c >= worst_lo * worst_coeflef: + worst_lo = span_len + worst_coeflef = c + return worst_lo, worst_coeflef + + lo_rel_y, coeflef_y = _worst_case_axis("DZ", "RY") #inversé pour convertir le repère locale à l'EUROCODE 5 + lo_rel_z, coeflef_z = _worst_case_axis("DY", "RZ") #inversé pour convertir le repère locale à l'EUROCODE 5 + + return { + "lo_rel_y": lo_rel_y, + "lo_rel_z": lo_rel_z, + "coeflef_y": coeflef_y, + "coeflef_z": coeflef_z, + } + + def auto_group_continuous_members( + self, + angle_tol_deg: float = 1.0, + role: str = None, + name_prefix: str = "SM", + only_continuous: bool = ("False", "True"), + design_params: dict = None, + ) -> list[str]: + """Détecte et regroupe automatiquement toutes les barres continues du modèle. + + Combine ``detect_continuous_members`` et ``group_members`` : chaque chaîne + détectée devient une barre structurale nommée ``{name_prefix}{i}``. + + Args: + angle_tol_deg (float): Tolérance angulaire pour la détection. + Defaults to 1.0. + role (str, optional): Rôle appliqué à toutes les barres créées. + name_prefix (str): Préfixe pour les noms auto-générés. + Defaults to "SM". + only_continuous (bool): Si True, ignore les chaînes d'une seule barre FEM + (barres isolées non continues). Defaults to False. + design_params (dict, optional): Paramètres de design spécifiques à l'Eurocode. + Transmis à ``group_members`` pour chaque barre créée. + + Returns: + list[str]: Noms des barres structurales créées, dans l'ordre de détection. + + Exemple: + >>> names = model.auto_group_continuous_members( + ... role="Solive", design_params={"classe_bois": "C24", "cs": 1}) + >>> # ["SM1", "SM2", ...] + """ + chains = self.detect_continuous_members(angle_tol_deg=angle_tol_deg) + created: list[str] = [] + idx = 1 + for chain in chains: + if only_continuous and len(chain) < 2: + continue + name = f"{name_prefix}{idx}" + while name in self._data["structural_members"]: + idx += 1 + name = f"{name_prefix}{idx}" + self.group_members( + name, chain, role=role, + design_params=design_params, + ) + created.append(name) + idx += 1 + return created + + def get_structural_member(self, name: str) -> dict: + """Retourne les données d'une barre structurale par son nom. + + Args: + name (str): Nom de la barre structurale (ex: "Solive_S1"). + + Returns: + dict: Dictionnaire contenant : + + - ``"Barres FEM"`` : liste ordonnée des ``member_id`` FEM + - ``"Rôle"`` : rôle structurel + - ``"Longueur totale"`` : longueur cumulée avec unité (``si.mm``) + - ``"Design"`` : paramètres de design spécifiques à l'Eurocode + - ``"Commentaire"`` : texte libre + + Raises: + KeyError: Si le nom n'existe pas. + """ + return self._data["structural_members"][name] + + def get_all_structural_members(self) -> dict: + """Retourne toutes les barres structurales du modèle. + + Returns: + dict: Dictionnaire ``{name: données}`` de toutes les barres structurales. + """ + return self._data["structural_members"] + + def del_structural_member(self, name: str) -> dict: + """Supprime une barre structurale par son nom. + + Args: + name (str): Nom de la barre structurale à supprimer. + + Returns: + dict: Données de la barre structurale supprimée. + + Raises: + KeyError: Si le nom n'existe pas. + """ + return self._data["structural_members"].pop(name) + + def _iter_structural_members(self): + """Itère sur toutes les barres structurales du modèle. + + Yields: + tuple[str, dict]: Couple ``(name, data)`` pour chaque barre structurale. + + Exemple: + >>> for name, sm in model.iter_structural_members(): + ... print(name, sm["Rôle"], sm["Longueur totale"]) + """ + yield from self._data["structural_members"].items() + + def debug_node_compression(self, structural_member_name: str, axis: str = "y") -> None: + """Affiche les informations de debug pour le calcul du type d'appui en compression. + + Affiche les conditions d'appui et de relâchement aux nœuds d'extrémité de la + barre structurale, ainsi que le résultat final du type d'appui. + + Args: + structural_member_name (str): Nom de la barre structurale à analyser. + axis (str): Axe de flambement ("y" ou "z"). Defaults to "y". + """ + sm = self._data["structural_members"][structural_member_name] + member_ids = sm["Barres FEM"] + chain_nodes = self._ordered_chain_nodes(member_ids) + start_node = chain_nodes[0][0] + end_node = chain_nodes[-1][0] + mid_s = member_ids[0] + mid_e = member_ids[-1] + end_s = self._end_at_node(mid_s, start_node) + end_e = self._end_at_node(mid_e, end_node) + s_sup = self._get_support_conditions_at_node(start_node) + e_sup = self._get_support_conditions_at_node(end_node) + s_rel = self._has_rotation_release_at(mid_s, end_s, axis) + e_rel = self._has_rotation_release_at(mid_e, end_e, axis) + rot_key = "R" + axis.upper() + start_fixed = s_sup[rot_key] and not s_rel + end_fixed = e_sup[rot_key] and not e_rel + print(f"=== debug_node_compression : '{structural_member_name}' axe={axis} ===") + print(f" Barres FEM : {member_ids}") + print(f" Nœud début : {start_node} (barre {mid_s}, extrémité '{end_s}')") + print(f" Appui classique : {s_sup}") + print(f" Relâchement teta_{axis} : {s_rel}") + print(f" → start_fixed = {start_fixed}") + print(f" Nœud fin : {end_node} (barre {mid_e}, extrémité '{end_e}')") + print(f" Appui classique : {e_sup}") + print(f" Relâchement teta_{axis} : {e_rel}") + print(f" → end_fixed = {end_fixed}") + result = self._determine_type_appuis(member_ids, axis) + print(f" → type_appuis : '{result}'") + + def get_type_appuis_for_compression( + self, + structural_member_name: str, + axis: str = "y", + ) -> str: + """Détermine le type d'appui pour le calcul de flambement en compression. + + Analyse les conditions d'appui aux extrémités d'une barre structurale + (membre continu ou simple) pour déterminer le coefficient β de longueur + efficace selon l'EC5. + + Args: + structural_member_name (str): Nom de la barre structurale à analyser + (ex: "Poteau_P1", "SM1"). + axis (str): Axe de flambement à considérer ("y" ou "z"). + Defaults to "y". + + Returns: + str: Type d'appui selon Compression.COEF_LF : + - "Encastré 1 côté" : β = 2.0 (console) + - "Rotule - Rotule" : β = 1.0 (articulé-articulé) + - "Encastré - Rotule" : β = 0.7 + - "Encastré - Encastré" : β = 0.5 + - "Encastré - Rouleau" : β = 1.0 (encastré-glissière) + + Raises: + KeyError: Si la barre structurale n'existe pas. + + Note: + La méthode analyse : + 1. Les appuis classiques (DX, DY, DZ, RX, RY, RZ) aux nœuds d'extrémité + 2. Les relâchements (releases) en rotation aux extrémités des barres FEM + 3. La continuité du membre (barre continue ou simple) + + Pour un axe donné (y ou z), une rotation bloquée sur cet axe + correspond à un encastrement, une rotation libre à une rotule. + """ + sm = self._data["structural_members"][structural_member_name] + member_ids = sm["Barres FEM"] + + return self._determine_type_appuis(member_ids, axis) + + def _get_support_conditions_at_node(self, node_id: str) -> dict: + """Retourne les conditions d'appui au nœud spécifié. + + Args: + node_id (str): Identifiant du nœud. + + Returns: + dict: Conditions d'appui avec clés DX, DY, DZ, RX, RY, RZ. + True = bloqué, False = libre. + Si aucun appui, retourne toutes les directions libres. + """ + # Cherche un appui classique sur ce nœud + for support_id, support in self._data["supports"]["classic"].items(): + if support["Noeud"] == node_id: + return { + "DX": support["DX"], + "DY": support["DY"], + "DZ": support["DZ"], + "RX": support["RX"], + "RY": support["RY"], + "RZ": support["RZ"], + } + # Pas d'appui = libre en translation et rotation + return {"DX": False, "DY": False, "DZ": False, "RX": False, "RY": False, "RZ": False} + + def _has_rotation_release_at(self, member_id: str, end: str, axis: str) -> bool: + """Indique si la barre a un relâchement en rotation à l'extrémité. + + Args: + member_id (str): Identifiant de la barre FEM. + end (str): Extrémité ("start" ou "end"). + axis (str): Axe de rotation ("y" ou "z"). + + Returns: + bool: True si relâchement en rotation sur l'axe, False sinon. + """ + rel = self._data["members"][member_id]["Relaxation"].get(end) + if not rel: + return False + rot_key = "teta_" + axis + return bool(rel.get(rot_key, False)) + def generate_model(self): """Génère et assemble le modèle MEF complet dans Pynite. diff --git a/ourocode/eurocode/core/objet.py b/ourocode/eurocode/core/objet.py index 08b77b5..34b387f 100644 --- a/ourocode/eurocode/core/objet.py +++ b/ourocode/eurocode/core/objet.py @@ -60,6 +60,61 @@ def objet(self): """ return self + def set_value(self, value: float | int | str = None, unit: str = MathUtilsMixin._SET_VALUE_UNITS): + """Retourne une valeur typee avec une unite physique optionnelle. + + Equivalent inverse de ``get_value`` : permet a l'utilisateur de + saisir une valeur numerique brute et de l'encapsuler dans un objet + ``Physical`` (forallpeople) avec l'unite choisie parmi la liste + definie dans ``_PHYSICAL_UNITS`` (m, mm, cm, km, m2, N, kN, daN, + N.m, kN/m, Pa, MPa, etc.). + + Args: + value (float|int|str): la valeur numerique a encapsuler. Les + chaines numeriques ("5", "5,3") sont converties. + unit (str): unite cible. La valeur + speciale ``"Aucune"`` (par defaut) retourne la valeur brute + sans conversion. + + Returns: + - ``Physical`` si une unite valide est specifiee (ex: ``5 * si.m``) + - valeur brute (int/float/str) si ``unit == "Aucune"`` ou unite + inconnue + + Exemple: + >>> obj.set_value(5, unit="m") + 5.000 m + >>> obj.set_value("5,3", unit="kN") + 5.300 kN + >>> obj.set_value(42, unit="Aucune") + 42 + """ + # "unit" arrive sous forme de tuple au premier chargement (valeur par + # defaut du widget combobox) ; on normalise en chaine. + if not isinstance(unit, str): + unit = "Aucune" + + if value is None: + return None + + if unit == "Aucune" or unit not in self._PHYSICAL_UNITS: + return value + + # Conversion robuste en float (gere int/float/str numerique) + if isinstance(value, (int, float)) and not isinstance(value, bool): + numeric = float(value) + elif isinstance(value, str): + try: + numeric = float(value.strip().replace(",", ".")) + except ValueError: + return value + else: + # Valeur non convertible (Physical deja type, objet, etc.) : + # on retourne tel quel pour ne pas ecraser le sens. + return value + + return numeric * self._PHYSICAL_UNITS[unit] + def get_value(self, value: dict|list|str, index: int=None, key: str=None, get_keys: bool=("False", "True"),): """Extrait et retourne une valeur depuis une structure de données complexe. diff --git a/ourocode/eurocode/ec5/__init__.py b/ourocode/eurocode/ec5/__init__.py index 6b297b0..5aea247 100644 --- a/ourocode/eurocode/ec5/__init__.py +++ b/ourocode/eurocode/ec5/__init__.py @@ -14,6 +14,8 @@ ) from ourocode.eurocode.ec5.blc import Poutre_simple_decroissance from ourocode.eurocode.ec5.cvt import MOB +from ourocode.eurocode.ec5.element_droit.verification_EC5 import Verification_EC5 + __all__ = [ "Barre", "Flexion", "Traction", @@ -25,4 +27,5 @@ "Feu", "Flexion_feu", "Traction_feu", "Compression_feu", "Cisaillement_feu", "Poutre_simple_decroissance", "MOB", + "Verification_EC5", ] diff --git a/ourocode/eurocode/ec5/element_droit/__init__.py b/ourocode/eurocode/ec5/element_droit/__init__.py index 27f7c9e..210c136 100644 --- a/ourocode/eurocode/ec5/element_droit/__init__.py +++ b/ourocode/eurocode/ec5/element_droit/__init__.py @@ -3,9 +3,11 @@ from ourocode.eurocode.ec5.element_droit.traction import Traction from ourocode.eurocode.ec5.element_droit.compression import Compression, Compression_perpendiculaire, Compression_inclinees from ourocode.eurocode.ec5.element_droit.cisaillement import Cisaillement +from ourocode.eurocode.ec5.element_droit.verification_EC5 import Verification_EC5 __all__ = [ "Barre", "Flexion", "Traction", "Compression", "Compression_perpendiculaire", "Compression_inclinees", "Cisaillement", + "Verification_EC5", ] diff --git a/ourocode/eurocode/ec5/element_droit/compression.py b/ourocode/eurocode/ec5/element_droit/compression.py index 4f48592..57fc710 100644 --- a/ourocode/eurocode/ec5/element_droit/compression.py +++ b/ourocode/eurocode/ec5/element_droit/compression.py @@ -34,19 +34,21 @@ class Compression(Barre): "Encastré - Encastré" : 0.5, "Encastré - Rouleau" : 1} - def __init__(self, lo_y: si.mm, lo_z: si.mm, type_appuis: str=COEF_LF, *args, **kwargs): + def __init__(self, lo_y: si.mm, lo_z: si.mm, + type_appuis_y: str = COEF_LF, type_appuis_z: str = COEF_LF, + *args, **kwargs): """Initialise un objet de vérification en compression axiale. - Définit les longueurs de flambement et le coefficient de longueur efficace - selon les conditions d'appui pour le calcul du flambement. + Définit les longueurs de flambement et les coefficients de longueur + efficace par axe selon les conditions d'appui. Args: - lo_y (si.mm): Longueur de flambement suivant l'axe y en mm. + lo_y (si.mm): Longueur de flambement suivant l'axe de rotation y en mm (flèche dans la direction z). Mettre 0 si pas de risque de flambement selon cet axe. - lo_z (si.mm): Longueur de flambement suivant l'axe z en mm. + lo_z (si.mm): Longueur de flambement suivant l'axe de rotation z en mm (flèche dans la direction y). Mettre 0 si pas de risque de flambement selon cet axe. - type_appuis (str): Type de conditions d'appui pour le coefficient β. - Détermine la longueur efficace lf = β · lo. + type_appuis_y (str): Conditions d'appui pour le flambement selon l'axe de rotation y. + Détermine β_y dans lf_y = β_y · lo_y. Valeurs possibles (voir COEF_LF): - "Encastré 1 côté" : β = 2.0 (console) - "Rotule - Rotule" : β = 1.0 (articulé-articulé) @@ -54,19 +56,23 @@ def __init__(self, lo_y: si.mm, lo_z: si.mm, type_appuis: str=COEF_LF, *args, ** - "Encastré - Encastré" : β = 0.5 - "Encastré - Rouleau" : β = 1.0 (encastré-glissière) Defaults to "Rotule - Rotule". + type_appuis_z (str, optional): Conditions d'appui pour le flambement + selon l'axe de rotation z. *args: Arguments positionnels transmis à Barre. **kwargs: Arguments nommés transmis à Barre (b, h, classe, etc.). Note: - La longueur efficace de flambement lf est calculée par : - lf = lo × coef_lef + La longueur efficace de flambement est calculée par axe : + lf_y = lo_y × β_y ; lf_z = lo_z × β_z """ super().__init__(*args, **kwargs) - self.lo_comp = {"y":lo_y * si.mm, "z":lo_z * si.mm} + self.lo_comp = {"y": lo_y * si.mm, "z": lo_z * si.mm} self.lo_y = self.lo_comp['y'] self.lo_z = self.lo_comp['z'] - self.type_appuis = type_appuis - self.coef_lef = self.COEF_LF[type_appuis] + self.type_appuis_y = type_appuis_y + self.type_appuis_z = type_appuis_z + self.coef_lef_y = self.COEF_LF[type_appuis_y] + self.coef_lef_z = self.COEF_LF[type_appuis_z] self._Anet = self.aire @property @@ -74,15 +80,16 @@ def lamb(self) -> tuple: """ Retourne l'élancement d'un poteau en compression avec risque de flambement suivant son axe de rotation """ lo_y = self.lo_comp['y'].value * 10**3 lo_z = self.lo_comp['z'].value * 10**3 - coef_lef = self.coef_lef + coef_lef_y = self.coef_lef_y + coef_lef_z = self.coef_lef_z I_y = self.inertie[0].value * 10**12 I_z = self.inertie[1].value * 10**12 A = self._Anet.value * 10**6 @handcalc(override="short", precision=2, jupyter_display=self.JUPYTER_DISPLAY, left="\\[", right="\\]") def val(): - lamb_y = (lo_y * coef_lef) / sqrt(I_y / A) - lamb_z = (lo_z * coef_lef) / sqrt(I_z / A) + lamb_y = (lo_y * coef_lef_y) / sqrt(I_y / A) + lamb_z = (lo_z * coef_lef_z) / sqrt(I_z / A) return {'y': lamb_y, 'z': lamb_z} return val() diff --git a/ourocode/eurocode/ec5/element_droit/verification_EC5.py b/ourocode/eurocode/ec5/element_droit/verification_EC5.py new file mode 100644 index 0000000..53805cc --- /dev/null +++ b/ourocode/eurocode/ec5/element_droit/verification_EC5.py @@ -0,0 +1,780 @@ +# coding in UTF-8 +# by Anthony PARISOT +"""Vérification EC5 d'un modèle MEF — classe :class:`Verification_EC5`. + +Ce module fournit une classe de haut niveau qui orchestre : + + MEF (Model_generator + Model_result) + Combinaisons (Combinaison) + | + v + ** boucle sur toutes les combinaisons ELU ** + | + v + Barre / Flexion / Cisaillement / Traction / Compression (EC5) + + + Flèche ELS (W_inst_Q, W_net_fin) + +Pour chaque barre structurale (:meth:`Model_generator.group_members` ou +:meth:`auto_group_continuous_members`) : + +1. Pour chaque combinaison ELU (ELU_STR + ELU_STR_ACC) : + - ``kmod`` est déterminé via :meth:`Combinaison.min_type_load` (durée de + chargement la plus courte présente dans la combinaison, EN 1995-1-1 §2.3.1.2) ; + - ``typecombi`` (``"Fondamentales"`` ou ``"Accidentelles"``) est + déterminé via :meth:`Combinaison.type_combi` pour le coefficient ``γM`` ; + - les efforts gouvernants (Nx signé, Vy, My, Mz) sont extraits pour cette + combinaison précise via :meth:`Model_result.get_min_max_internal_force` et + :meth:`Model_result.get_absolute_internal_force` ; + - les taux de travail Flexion / Cisaillement / Traction ou Compression + sont calculés. +2. Pour chaque type de vérification, le taux maximal rencontré et la + combinaison gouvernante associée sont retenus. +3. La flèche ELS est calculée séparément (pas de boucle par combo : on + prend la flèche max sur tous les ``W_inst_Q`` et ``W_net_fin``). + +Les paramètres de vérification (type de bâtiment, position de charge, +coefficients de longueur efficace…) sont configurés **globalement** à +l'initialisation et peuvent être **surchargés individuellement** via +:meth:`verify`. + +Utilisation typique : + +.. code-block:: python + + combi = Combinaison(model, ELU_STR=True, ELS_C=True, ELS_QP=True, kdef=0.6, ...) + result = Model_result(model, ...) + verif = Verification_EC5( + combi, result, + type_bat="Bâtiment courant", + ) + verif.synthese() # DataFrame agrégé de toutes les barres + verif.verify("SM1") # Détail d'une barre structurale + verif.verify("SM2", coeflef_y=0.7) # Surcharge individuelle +""" +from __future__ import annotations + +import pandas as pd + +from ourocode.eurocode.core.projet import Projet +from ourocode.eurocode.ec5.element_droit import ( + Barre, + Flexion, + Traction, + Compression, + Cisaillement, +) + + +# --------------------------------------------------------------------------- +# Conversions d'unités +# --------------------------------------------------------------------------- +# Pynite renvoie les efforts en unités cohérentes : N (forces), N·mm (moments) +# car le modèle MEF est alimenté en E [MPa], longueurs [mm], A [mm²], I [mm⁴]. +# - Force : N -> kN : /1000 +# - Moment : N·mm -> kN·m : /1e6 +# - Flèche via Physical ``.value`` : base SI = m -> mm : *1000 + +def _length_to_mm(physical) -> float: + return float(physical.value) * 1000.0 + + +def _as_tag_list(tags): + """Normalise ``tags`` en liste : Pynite considère une chaîne comme itérable.""" + if tags is None: + return None + if isinstance(tags, (list, tuple)): + return list(tags) + return [tags] + + +# --------------------------------------------------------------------------- +# Classe principale +# --------------------------------------------------------------------------- + +class Verification_EC5(Projet): + """Vérifie les barres structurales d'un modèle selon l'EC5 (ELU + Flèche ELS). + + Hérite de :class:`~ourocode.eurocode.core.projet.Projet` pour s'insérer + dans la hiérarchie ourocode et bénéficier des méthodes communes + (``_from_parent_class``, persistence, etc.). La classe stocke en interne + la :class:`~ourocode.eurocode.core.combinaison.Combinaison` et le + :class:`~ourocode.eurocode.core.model_result.Model_result` fournis, et + expose :meth:`verify` et :meth:`synthese` pour lancer les vérifications. + + Les paramètres de vérification (coefficients de longueur efficace, + types d'appui pour compression) sont automatiquement récupérés depuis + le champ ``Design`` de chaque barre structurale (déterminés par + ``group_members``). Les arguments ``type_bat``, ``type_ele``, ``pos_charge``, + ``Hi``, ``Hf``, ``effet_systeme``, ``classe_service`` définis à l'initialisation + s'appliquent à toutes les barres et peuvent être surchargés ponctuellement + via :meth:`verify`. + + Pour chaque combinaison ELU, :meth:`Combinaison.min_type_load` et + :meth:`Combinaison.type_combi` sont utilisées pour déterminer + dynamiquement ``kmod`` et ``γM`` (EN 1995-1-1 §2.3.1.2 et §2.4.1). + Le taux maximal est ensuite retenu par type de vérification avec la + combinaison gouvernante associée. + """ + + #: Tag par défaut pour filtrer les combinaisons ELU à vérifier. + DEFAULT_ELU_FILTER = "ELU_ALL" + #: Tag ELS pour la flèche instantanée W_inst(Q). + DEFAULT_W_INST_TAG = "W_inst_Q" + #: Tag ELS pour la flèche nette finale W_net,fin. + DEFAULT_W_NET_FIN_TAG = "W_net_fin" + #: Valeurs acceptées pour ``elu_filter``. + ELU_FILTERS = ("ELU_ALL", "ELU_STR", "ELU_STR_ACC") + + def __init__( + self, + combinaison: object, + model_result: object, + elu_filter: str = ELU_FILTERS, + Hi: int = 12, + Hf: int = 12, + effet_systeme: bool = ("False", "True"), + classe_service: int = Barre.CS, + type_bat: str = Barre.TYPE_BAT, + type_ele: str = Barre.TYPE_ELE, + pos_charge: str = Flexion.LOAD_POS, + **kwargs, + ): + """Initialise une vérification EC5. + + Args: + combinaison (Combinaison): Instance :class:`~ourocode.eurocode.core.combinaison.Combinaison` + déjà instanciée et dont les combos ont été générées. + model_result (Model_result): Instance :class:`~ourocode.eurocode.core.model_result.Model_result` + déjà analysée. + elu_filter (str, optional): Tag pour filtrer les combinaisons ELU. + Valeurs acceptées : ``"ELU_ALL"``, ``"ELU_STR"``, ``"ELU_STR_ACC"``. + Defaults to ``"ELU_ALL"``. + Hi (int, optional): Humidité initiale du bois en service (%). + Defaults to ``12``. + Hf (int, optional): Humidité finale du bois en service (%). + Defaults to ``12``. + effet_systeme (bool, optional): Active l'effet système (EN 1995-1-1 §6.7). + Defaults to ``False``. + classe_service (str, optional): Classe de service globale (``Barre.CS``). + Defaults to 1. + type_bat (str, optional): Type de bâtiment pour les limites de flèche + (``Barre.TYPE_BAT``). Defaults to ``Barre.TYPE_BAT``. + type_ele (str, optional): Type d'élément pour les limites de flèche + (``Barre.TYPE_ELE``). Peut être surchargé par le champ ``Design`` + de la barre structurale. Defaults to ``Barre.TYPE_ELE``. + pos_charge (str, optional): Position de la charge verticale par rapport + à la section (``Flexion.LOAD_POS``). Defaults to ``Flexion.LOAD_POS``. + **kwargs: Transmis à :class:`~ourocode.eurocode.core.projet.Projet` + (``ingenieur``, ``name``, ``code_INSEE``, ``alt``…). + + Note: + Les paramètres de vérification (coefficients de longueur efficace, + types d'appui pour la compression) sont désormais automatiquement + récupérés depuis le champ ``Design`` de chaque barre structurale + (déterminés par :meth:`Model_generator.group_members`). + """ + super().__init__(**kwargs) + self._combinaison = combinaison + self._result = model_result + if combinaison is not None and not hasattr(self, "_model_generator"): + self._model_generator = combinaison._model_generator + + # Stockage des paramètres de vérification globaux + self.elu_filter = elu_filter + self.type_bat = type_bat + self.type_ele = type_ele + self.pos_charge = pos_charge + self.Hi = Hi + self.Hf = Hf + self.effet_systeme = effet_systeme + self.classe_service = int(classe_service) + + # Cache des objets EC5 par (structural_member_name, combo_name) + # Peuplé automatiquement lors de chaque appel à verify() + self._objects_cache: dict[tuple[str, str], dict] = {} + + + # ------------------------------------------------------------------ # + # API publique + # ------------------------------------------------------------------ # + def verify( + self, + name: str, + type_ele: str = Barre.TYPE_ELE, + pos_charge: str = Flexion.LOAD_POS, + Hi: int = 12, + Hf: int = 12, + effet_systeme: bool = ("False", "True"), + classe_service: int = Barre.CS, + coeflef_y: float = None, + coeflef_z: float = None, + type_appuis_y: str = None, + type_appuis_z: str = None, + ) -> dict: + """Vérifie une barre structurale en bouclant sur toutes les combos ELU. + + Pour chaque combinaison du filtre, les efforts sont extraits puis les + taux Flexion / Cisaillement / Traction ou Compression sont calculés + avec le ``kmod`` et la ``typecombi`` appropriés. Le taux maximal sur + toutes les combos est retenu par vérification. La flèche ELS est + calculée séparément via les tags ``W_inst_Q`` et ``W_net_fin``. + + Les paramètres ``coeflef_y``, ``coeflef_z`` ainsi que les types d'appui + pour la compression sont automatiquement récupérés depuis le champ + ``Design`` du membre structural (déterminés par ``group_members``). + Les arguments fournis ici permettent de surcharger ces valeurs. + + Args: + name (str): Nom de la barre structurale (clé dans + :meth:`Model_generator.get_structural_member`). + type_ele (str, optional): Surcharge du type d'élément pour les + limites de flèche. Priorité inférieure au champ ``Design`` + de la barre structurale. Defaults to ``self.type_ele``. + pos_charge (str, optional): Surcharge de la position de charge. + Defaults to ``self.pos_charge``. + Hi (int, optional): Surcharge de l'humidité initiale (%). + Defaults to 12. + Hf (int, optional): Surcharge de l'humidité finale (%). + Defaults to 12. + effet_systeme (bool, optional): Surcharge de l'effet système. + Defaults to 1. + classe_service (int, optional): Surcharge de la classe de service. + Defaults to 1. + coeflef_y (float, optional): Surcharge du coef. de longueur efficace y. + Si ``None`` (défaut), la valeur du champ ``Design`` est utilisée + (fallback 0.9 si absente). + coeflef_z (float, optional): Surcharge du coef. de longueur efficace z. + Même logique que ``coeflef_y``. + type_appuis_y (str, optional): Surcharge du type d'appui de flambement + selon y (ex: ``"Rotule - Rotule"``, ``"Encastré - Rotule"``…). + Si ``None`` (défaut), la valeur du champ ``Design`` est utilisée + (fallback ``"Rotule - Rotule"`` si absente). + type_appuis_z (str, optional): Surcharge du type d'appui de flambement + selon z. Même logique que ``type_appuis_y``. + + Returns: + dict: ``{"name", "taux", "dataframe"}`` où : + + - ``"name"`` (str) : nom de la barre structurale ; + - ``"taux"`` (dict) : ``{verif_name: {"taux": float, "combinaison": str}}`` + pour chaque vérification active (``None`` si non applicable) ; + - ``"dataframe"`` (pd.DataFrame) : tableau synthétique avec colonnes + ``["Barre structurale", "Vérification", "Taux", "Statut", "Combinaison"]``. + + Raises: + ValueError: Si la combinaison ou le résultat MEF est absent, ou si + la section est définie manuellement (``b``/``h`` inconnus). + KeyError: Si ``name`` n'existe pas dans le modèle. + """ + self._check_state() + + w_inst_tag = self.DEFAULT_W_INST_TAG + w_net_fin_tag = self.DEFAULT_W_NET_FIN_TAG + sm = self._model_generator.get_structural_member(name) + member_ids = sm["Barres FEM"] + + params = self._resolve_member_params( + sm, Hi, Hf, effet_systeme, classe_service, + coeflef_y=coeflef_y, coeflef_z=coeflef_z, + type_appuis_y=type_appuis_y, type_appuis_z=type_appuis_z, + ) + L_mm = params["L_mm"] + lo_y = params["lo_y"] + lo_z = params["lo_z"] + lo_flamb_y = params["lo_flamb_y"] + lo_flamb_z = params["lo_flamb_z"] + coeflef_y = params["coeflef_y"] + coeflef_z = params["coeflef_z"] + type_appuis_y = params["type_appuis_y"] + type_appuis_z = params["type_appuis_z"] + barre_kwargs = params["barre_kwargs"] + + # --- Accumulateur de taux ELU sur toutes les combos + max_taux: dict[str, dict] = { + "Flexion": {"taux": 0.0, "combinaison": None}, + "Cisaillement": {"taux": 0.0, "combinaison": None}, + "Traction": {"taux": 0.0, "combinaison": None}, + "Compression": {"taux": 0.0, "combinaison": None}, + } + + # --- Boucle sur les combinaisons ELU + combos = self._combinaison.get_list_combination(self.elu_filter) or [] + for combo_name in combos: + taux_combo, objects = self._verify_combo_elu( + combo_name=combo_name, + member_ids=member_ids, + barre_kwargs=barre_kwargs, + lo_y=lo_y, lo_z=lo_z, + lo_flamb_y=lo_flamb_y, lo_flamb_z=lo_flamb_z, + coeflef_y=coeflef_y, coeflef_z=coeflef_z, + pos_charge=pos_charge, + type_appuis_y=type_appuis_y, + type_appuis_z=type_appuis_z, + return_objects=True, + ) + # Stockage dans le cache pour get_combo_objects() + self._objects_cache[(name, combo_name)] = objects + for verif, taux in taux_combo.items(): + if taux is not None and taux > max_taux[verif]["taux"]: + max_taux[verif] = {"taux": float(taux), "combinaison": combo_name} + + # --- Flèche ELS (combo-indépendante : on prend le max sur tous les W_*) + fleche_results = self._verify_fleche( + member_ids=member_ids, + barre_kwargs=barre_kwargs, + L_mm=L_mm, + type_ele=type_ele, + type_bat=self.type_bat, + w_inst_tag=w_inst_tag, + w_net_fin_tag=w_net_fin_tag, + ) + + taux_final: dict = {} + for verif, data in max_taux.items(): + taux_final[verif] = data if data["taux"] > 0 else None + taux_final.update(fleche_results) + + return { + "name": name, + "taux": taux_final, + "dataframe": self._taux_to_df(name, taux_final), + } + + def get_combo_objects( + self, + name: str, + combo_name: str, + ) -> dict: + """Retourne les objets EC5 instanciés pour une barre et une combinaison. + + Lit directement depuis le cache interne peuplé par :meth:`verify`. + Aucun recalcul n'est effectué. + + Args: + name (str): Nom de la barre structurale. + combo_name (str): Nom de la combinaison ELU + (ex: ``"ELU_STR 1.35G + 1.5Q"``). + + Returns: + dict: ``{"Flexion", "Cisaillement", "Traction", "Compression"}`` + où chaque valeur est l'objet EC5 instancié et calculé, + ou ``None`` si la vérification n'est pas applicable + (ex: pas de compression → ``"Compression"`` est ``None``). + + Raises: + KeyError: Si ``verify(name)`` n'a pas été appelé au préalable, + ou si ``combo_name`` n'appartient pas au filtre ELU utilisé. + + Exemple: + >>> verif.verify("SM1") + >>> objs = verif.get_combo_objects("SM1", "ELU_STR 1.35G + 1.5Q") + >>> flexion = objs["Flexion"] + >>> display(Latex(flexion.taux_m_d()[0])) + """ + self._check_state() + key = (name, combo_name) + if key not in self._objects_cache: + raise KeyError( + f"Aucun résultat en cache pour '{name}' / '{combo_name}'. " + f"Appelez d'abord verify('{name}') pour peupler le cache." + ) + return self._objects_cache[key] + + def synthese(self) -> pd.DataFrame: + """Agrège les vérifications EC5 de toutes les barres structurales. + + Parcourt toutes les barres structurales via :meth:`Model_generator._iter_structural_members` + et concatène les tableaux individuels en un unique DataFrame trié par + barre structurale, puis par taux décroissant au sein de chaque barre. + + Les paramètres de vérification sont ceux définis à l'initialisation. + Pour des paramètres spécifiques à une barre, utilisez :meth:`verify` directement. + + Returns: + pd.DataFrame: Colonnes ``["Barre structurale", "Vérification", + "Taux", "Statut", "Combinaison"]``, triées par barre structurale + puis par taux décroissant. En cas d'erreur sur une barre + (section manuelle, classe inconnue…), un DataFrame + ``["Barre structurale", "Erreur"]`` est retourné à la place + si aucune barre n'a pu être vérifiée. + """ + self._check_state() + frames: list[pd.DataFrame] = [] + errors: list[tuple[str, str]] = [] + for name, _ in self._model_generator._iter_structural_members(): + try: + res = self.verify(name, + type_ele=self.type_ele, + pos_charge=self.pos_charge, + Hi=self.Hi, + Hf=self.Hf, + effet_systeme=self.effet_systeme, + classe_service=self.classe_service) + if not res["dataframe"].empty: + frames.append(res["dataframe"]) + except (ValueError, KeyError) as exc: + errors.append((name, str(exc))) + + if not frames: + if errors: + return pd.DataFrame( + errors, columns=["Barre structurale", "Erreur"] + ) + return pd.DataFrame( + columns=[ + "Barre structurale", "Vérification", + "Taux", "Statut", "Combinaison", + ] + ) + df = pd.concat(frames, ignore_index=True) + return df.sort_values(by=["Barre structurale", "Taux"], ascending=[True, False]).reset_index(drop=True) + + # ------------------------------------------------------------------ # + # Vérification d'une combinaison ELU (coeur de l'algorithme) + # ------------------------------------------------------------------ # + def _verify_combo_elu( + self, + combo_name: str, + member_ids: list[str], + barre_kwargs: dict, + lo_y: float, lo_z: float, + lo_flamb_y: float, lo_flamb_z: float, + coeflef_y: float, coeflef_z: float, + pos_charge: str, + type_appuis_y: str, + type_appuis_z: str, + return_objects: bool = False, + ) -> dict: + """Retourne les taux ELU (Flexion, Cisaillement, Traction ou Compression) + pour UNE combinaison spécifique, avec le ``kmod`` et la ``typecombi`` + propres à cette combinaison. + + La clef ``Traction`` et ``Compression`` sont mutuellement exclusives : + c'est le signe de Nx dominant en valeur absolue sur la combo qui + tranche. La flexion inclut systématiquement l'interaction + flexo-compression (eq. 6.19/6.20/6.23/6.24) ou flexo-traction + (eq. 6.17/6.18) via :meth:`Flexion.taux_m_d`. + + Args: + return_objects (bool): Si True, retourne aussi les objets EC5 + instanciés sous la clé ``"objects"`` du dictionnaire. + Defaults to False. + """ + load_time = self._combinaison.min_type_load(combo_name) + typecombi = self._combinaison.type_combi(combo_name) + + efforts = self._result.get_min_max_internal_force(member_ids, combo_name) + Vy_abs = self._result.get_absolute_internal_force(member_ids, combo_name, "Vy", get_combo_name=False).value + Vz_abs = self._result.get_absolute_internal_force(member_ids, combo_name, "Vz", get_combo_name=False).value + My_abs = self._result.get_absolute_internal_force(member_ids, combo_name, "My", get_combo_name=False).value + Mz_abs = self._result.get_absolute_internal_force(member_ids, combo_name, "Mz", get_combo_name=False).value + # efforts = self._efforts_for_combo(member_ids, combo_name, n_points) + + taux_combo = { + "Flexion": None, + "Cisaillement": None, + "Traction": None, + "Compression": None, + } + objects: dict[str, object] = { + "Flexion": None, + "Cisaillement": None, + "Traction": None, + "Compression": None, + } + + barre = Barre(**barre_kwargs) + + # --- Traction / Compression (exclusif, signe-dépendant) + traction_obj = None + compression_obj = None + N_pos = efforts["Nx"]["Max"][0].value # N, > 0 si compresion + N_neg = efforts["Nx"]["Min"][0].value # N, < 0 si traction + if N_pos > 1e-9 and N_pos >= abs(N_neg): + compression_obj = Compression._from_parent_class( + barre, + lo_y=lo_flamb_y, lo_z=lo_flamb_z, + type_appuis_y=type_appuis_y, type_appuis_z=type_appuis_z, + ) + compression_obj.f_c_0_d(loadtype=load_time, typecombi=typecombi) + compression_obj.sigma_c_0_d(Fc0d=abs(N_pos / 10**3)) + compression_obj.taux_c_0_d() + taux_combo["Compression"] = max(compression_obj.taux_c_0_rd.values()) + objects["Compression"] = compression_obj + elif abs(N_neg) > 1e-9: + traction_obj = Traction._from_parent_class(barre) + traction_obj.f_t_0_d(loadtype=load_time, typecombi=typecombi) + traction_obj.sigma_t_0_d(Ft0d=N_neg / 10**3) + traction_obj.taux_t_0_d() + taux_combo["Traction"] = max(traction_obj.taux_t_0_rd.values()) + objects["Traction"] = traction_obj + + # --- Flexion (incluant l'interaction) + flexion_obj = None + if Mz_abs > 1e-9 or My_abs > 1e-9: + flexion_obj = Flexion._from_parent_class( + barre, + lo_rel_y=lo_y, lo_rel_z=lo_z, + coeflef_y=coeflef_y, coeflef_z=coeflef_z, + pos=pos_charge, + ) + flexion_obj.f_m_d(loadtype=load_time, typecombi=typecombi) + flexion_obj.sigma_m_d( + My=My_abs / 10**3, Mz=Mz_abs / 10**3, + ) + flexion_obj.taux_m_d( + compression=compression_obj, traction=traction_obj, + ) + taux_combo["Flexion"] = max(flexion_obj.taux_m_rd.values()) + objects["Flexion"] = flexion_obj + + # --- Cisaillement + cisaillement_obj = None + if Vy_abs > 1e-9: + cisaillement_obj = Cisaillement._from_parent_class(barre) + cisaillement_obj.f_v_d(loadtype=load_time, typecombi=typecombi) + cisaillement_obj.tau_d(Vd=Vy_abs / 10**3) + cisaillement_obj.taux_tau_d() + taux_combo["Cisaillement"] = max(cisaillement_obj.taux_tau_rd.values()) + objects["Cisaillement"] = cisaillement_obj + + if return_objects: + return taux_combo, objects + return taux_combo + + # ------------------------------------------------------------------ # + # Helpers — résolution des paramètres d'un membre structural + # ------------------------------------------------------------------ # + def _resolve_member_params( + self, + sm: dict, + Hi, Hf, + effet_systeme, + classe_service, + coeflef_y: float = None, + coeflef_z: float = None, + type_appuis_y: str = None, + type_appuis_z: str = None, + ) -> dict: + """Résout tous les paramètres de calcul d'un membre structural. + + Centralise la lecture de la section, du matériau et des valeurs + ``Design`` pour éviter la duplication entre :meth:`verify` et + :meth:`get_combo_objects`. + + Returns: + dict: Clés ``L_mm``, ``lo_y``, ``lo_z``, ``lo_flamb_y``, + ``lo_flamb_z``, ``coeflef_y``, ``coeflef_z``, + ``type_appuis_y``, ``type_appuis_z``, ``barre_kwargs``. + + ``lo_y``/``lo_z`` sont les longueurs de déversement (flexion, + EC5 §6.3.3) ; ``lo_flamb_y``/``lo_flamb_z`` sont les longueurs + de flambement (compression, EC5 §6.3.2). + """ + b_mm, h_mm, section_type = self._resolve_section(sm) + classe = self._resolve_classe_bois(sm) + L_mm = _length_to_mm(sm["Longueur totale"]) + lo_y = self._lo_mm(sm, "lo_rel_y", L_mm) + lo_z = self._lo_mm(sm, "lo_rel_z", L_mm) + lo_flamb_y = self._lo_mm(sm, "lo_flamb_y", lo_y) + lo_flamb_z = self._lo_mm(sm, "lo_flamb_z", lo_z) + coeflef_y = coeflef_y if coeflef_y is not None else self._design_or_default(sm, "coeflef_y", 0.9) + coeflef_z = coeflef_z if coeflef_z is not None else self._design_or_default(sm, "coeflef_z", 0.9) + type_appuis_y = type_appuis_y if type_appuis_y is not None else self._design_or_default(sm, "type_appuis_y", "Rotule - Rotule") + type_appuis_z = type_appuis_z if type_appuis_z is not None else self._design_or_default(sm, "type_appuis_z", "Rotule - Rotule") + barre_kwargs = dict( + b=b_mm, h=h_mm, section=section_type, + Hi=Hi, Hf=Hf, classe=classe, cs=classe_service, + effet_systeme=effet_systeme, + ) + return { + "L_mm": L_mm, + "lo_y": lo_y, + "lo_z": lo_z, + "lo_flamb_y": lo_flamb_y, + "lo_flamb_z": lo_flamb_z, + "coeflef_y": coeflef_y, + "coeflef_z": coeflef_z, + "type_appuis_y": type_appuis_y, + "type_appuis_z": type_appuis_z, + "barre_kwargs": barre_kwargs, + } + + # ------------------------------------------------------------------ # + # Flèche ELS (pas de boucle par combo : tag-based) + # ------------------------------------------------------------------ # + def _verify_fleche( + self, + member_ids: list[str], + barre_kwargs: dict, + L_mm: float, + type_ele, + type_bat: str, + w_inst_tag: str, + w_net_fin_tag: str, + ) -> dict: + """Calcule les taux de flèche ELS max via ``Barre.fleche``. + + Retourne ``{"Flèche W_inst(Q)": {...}, "Flèche W_net,fin": {...}}`` + avec des entrées ``None`` si la combinaison ou le type d'élément + est indisponible. + """ + out = { + "Flèche W_inst(Q)": None, + "Flèche W_net,fin": None, + } + if type_ele is None: + return out + Ed_WinstQ = self._deflection(member_ids, w_inst_tag) + Ed_Wnetfin = self._deflection(member_ids, w_net_fin_tag) + if Ed_WinstQ == 0 and Ed_Wnetfin == 0: + return out + barre_fleche = Barre(**barre_kwargs) + barre_fleche.fleche( + long=L_mm, + Ed_WinstQ=_length_to_mm(Ed_WinstQ["Flèche"]), + Ed_Wnetfin=_length_to_mm(Ed_Wnetfin["Flèche"]), + Ed_Wfin=0, Ed_W2=0, + type_ele=type_ele, + type_bat=type_bat, + ) + els = barre_fleche.taux_ELS + if "Winst(Q)" in els: + out["Flèche W_inst(Q)"] = { + "taux": float(els["Winst(Q)"]), + "combinaison": Ed_WinstQ["Combinaison"], + } + if "Wnet,fin" in els: + out["Flèche W_net,fin"] = { + "taux": float(els["Wnet,fin"]), + "combinaison": Ed_Wnetfin["Combinaison"], + } + return out + + # ------------------------------------------------------------------ # + # Helpers — extraction efforts par combinaison via Pynite + # ------------------------------------------------------------------ # + def _efforts_for_combo( + self, member_ids: list[str], combo_name: str, n_points: int, + ) -> dict: + """Retourne les efforts gouvernants pour une combo en kN / kN·m. + + Utilise :meth:`Model_result.get_internal_force` qui cible une + combinaison précise par ``combo_name`` (et non par tag). Itère sur + toutes les barres FEM et tous les points de discrétisation. + + Returns: + dict: ``{"Nx_min", "Nx_max", "Vy_abs", "My_abs", "Mz_abs"}`` + (kN ou kN·m, ``Nx_min/max`` signés). + """ + nx_min = 0.0 + nx_max = 0.0 + vy_abs = 0.0 + my_abs = 0.0 + mz_abs = 0.0 + for mid in member_ids: + _, nx_arr = self._result.get_internal_force(mid, combo_name, "Nx", n_points) + _, vy_arr = self._result.get_internal_force(mid, combo_name, "Vy", n_points) + _, my_arr = self._result.get_internal_force(mid, combo_name, "My", n_points) + _, mz_arr = self._result.get_internal_force(mid, combo_name, "Mz", n_points) + nx_min = min(nx_min, float(min(nx_arr))) + nx_max = max(nx_max, float(max(nx_arr))) + vy_abs = max(vy_abs, float(max(abs(v) for v in vy_arr))) + my_abs = max(my_abs, float(max(abs(v) for v in my_arr))) + mz_abs = max(mz_abs, float(max(abs(v) for v in mz_arr))) + # Pynite : forces en N, moments en N·mm -> kN et kN·m + return { + "Nx_min": nx_min / 1_000.0, + "Nx_max": nx_max / 1_000.0, + "Vy_abs": vy_abs / 1_000.0, + "My_abs": my_abs / 1_000_000.0, + "Mz_abs": mz_abs / 1_000_000.0, + } + + def _deflection(self, member_ids, tag, direction: str = "dy") -> float: + """Flèche absolue max en mm pour un tag donné (tous combos du tag).""" + phys = self._result.get_absolute_max_deflection( + member_ids, _as_tag_list(tag), direction, get_combo_name=True, + ) + return phys + + # ------------------------------------------------------------------ # + # Helpers — résolution de la géométrie / matériau depuis le modèle + # ------------------------------------------------------------------ # + def _resolve_section(self, sm: dict) -> tuple[float, float, str]: + first_mid = sm["Barres FEM"][0] + section_id = self._model_generator.get_member(first_mid)["Section"] + section = self._model_generator.get_section(section_id) + if section["Section"] == "Manuel" or "b" not in section: + raise ValueError( + f"Section '{section_id}' définie par propriétés : " + f"dimensions b, h inconnues. Utilisez `add_section`." + ) + b_mm = float(section["b"].value) * 1000.0 + h_mm = float(section["h"].value) * 1000.0 + return b_mm, h_mm, section["Section"] + + def _resolve_classe_bois(self, sm: dict) -> str: + design = sm.get("Design") or {} + if design.get("classe_bois"): + return design["classe_bois"] + first_mid = sm["Barres FEM"][0] + mat_id = self._model_generator.get_member(first_mid)["Matériaux"] + mat = self._model_generator.get_material(mat_id) + if mat["classe"] == "Manuel": + raise ValueError( + f"Matériau '{mat_id}' manuel : classe EC5 inconnue. " + f"Renseignez `classe_bois` ou utilisez `add_material_by_class`." + ) + return mat_id + + @staticmethod + def _design_or_default(sm: dict, key: str, default): + val = (sm.get("Design") or {}).get(key) + return default if val is None else val + + @classmethod + def _lo_mm(cls, sm: dict, key: str, default_mm: float) -> float: + val = (sm.get("Design") or {}).get(key) + if val is None: + return default_mm + if hasattr(val, "value"): + return _length_to_mm(val) + return float(val) + + # ------------------------------------------------------------------ # + # Autres + # ------------------------------------------------------------------ # + def _check_state(self) -> None: + if self._combinaison is None: + raise ValueError( + "Verification_EC5 : aucune Combinaison fournie. " + ) + if self._result is None: + raise ValueError( + "Verification_EC5 : aucun Model_result fourni. " + ) + + @staticmethod + def _taux_to_df(name: str, taux: dict) -> pd.DataFrame: + rows = [] + for verif, data in taux.items(): + if data is None: + continue + t = float(data["taux"]) + rows.append( + { + "Barre structurale": name, + "Vérification": verif, + "Taux": round(t, 3), + "Statut": "OK" if t <= 1.0 else "NOK", + "Combinaison": data["combinaison"], + } + ) + return pd.DataFrame( + rows, + columns=[ + "Barre structurale", "Vérification", + "Taux", "Statut", "Combinaison", + ], + ) diff --git a/ourocode/eurocode/mixins/math_utils.py b/ourocode/eurocode/mixins/math_utils.py index 4389693..dec3dc3 100644 --- a/ourocode/eurocode/mixins/math_utils.py +++ b/ourocode/eurocode/mixins/math_utils.py @@ -20,6 +20,34 @@ class MathUtilsMixin: """ OPERATOR = ("+", "-", "x", "/") + # Registre des unites physiques supportees par set_value. + # L'ordre du tuple SET_VALUE_UNITS sert de valeurs du combobox dans l'UI. + # Les cles sont en ASCII (pas d'exposants) pour la saisie utilisateur. + _PHYSICAL_UNITS = { + # Longueurs + "m": si.m, "mm": si.mm, + # Surfaces + "m2": si.m**2, "mm2": si.mm**2, + # Volumes + "m3": si.m**3, "mm3": si.mm**3, + # Inerties + "m4": si.m**4, "mm4": si.mm**4, + # Forces + "N": si.N, "kN": si.kN, + # Moments + "N.m": si.N * si.m, "kN.m": si.kN * si.m, + "N.mm": si.N * si.mm, + # Forces lineiques + "N/m": si.N / si.m, "kN/m": si.kN / si.m, + "N/mm": si.N / si.mm, + # Pressions / contraintes + "Pa": si.Pa, "kPa": si.kPa, "MPa": si.MPa, + "N/m2": si.N / si.m**2, "kN/m2": si.kN / si.m**2, + "N/mm2": si.N / si.mm**2, + } + # Tuple d'unites propose a l'utilisateur (sert de widget combobox). + # "none" = valeur brute retournee telle quelle (int/float/str). + _SET_VALUE_UNITS = ("Aucune",) + tuple(_PHYSICAL_UNITS.keys()) def abs_value(self, value: float): """Retourne la valeur absolue (module) d'un nombre. diff --git a/pyproject.toml b/pyproject.toml index b576f47..9ebf220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ include = ["ourocode*"] [project] name = "ourocode" -version = "2.0.0" +version = "2.1.2" description = "Ceci est un catalogue de fonction permettant une utilisation rapide pour la réalisation de note de calcul personnalisée." readme = "README.md" authors = [{name = "Anthony PARISOT", email = "contact@ourea-structure.fr"}] diff --git a/tests/test_EC5_Element_droit.py b/tests/test_EC5_Element_droit.py index e53b7be..847c194 100644 --- a/tests/test_EC5_Element_droit.py +++ b/tests/test_EC5_Element_droit.py @@ -33,7 +33,7 @@ def traction(barre, load_and_combi_data): @pytest.fixture def compression(barre, load_and_combi_data): compression = EC5.Compression._from_parent_class( - barre, lo_y=7000, lo_z=7000, type_appuis="Rotule - Rotule" + barre, lo_y=7000, lo_z=7000, type_appuis_y="Rotule - Rotule", type_appuis_z="Rotule - Rotule", ) compression.sigma_c_0_d(50) compression.f_c_0_d(**load_and_combi_data) diff --git a/tests/test_EC5_Feu.py b/tests/test_EC5_Feu.py index 710e020..dac5e1d 100644 --- a/tests/test_EC5_Feu.py +++ b/tests/test_EC5_Feu.py @@ -67,7 +67,7 @@ def traction(feu): @pytest.fixture def compression(feu): compression = EC5_feu.Compression_feu._from_parent_class( - feu, lo_y=7000, lo_z=7000, type_appuis="Rotule - Rotule" + feu, lo_y=7000, lo_z=7000, type_appuis_y="Rotule - Rotule", type_appuis_z="Rotule - Rotule", ) compression.sigma_c_0_d(50) compression.f_c_0_d() @@ -258,7 +258,7 @@ class Test_Compression_feu_faible_elancement: def test_compression_feu_faible_elancement(self, feu): """lo court -> faible élancement -> chemin equ6.23 ou equ6.19/equ6.20.""" comp = EC5_feu.Compression_feu._from_parent_class( - feu, lo_y=500, lo_z=500, type_appuis="Rotule - Rotule" + feu, lo_y=500, lo_z=500, type_appuis_y="Rotule - Rotule", type_appuis_z="Rotule - Rotule", ) comp.sigma_c_0_d(10) comp.f_c_0_d() diff --git a/tests/test_EC5_Verification.py b/tests/test_EC5_Verification.py new file mode 100644 index 0000000..b675d91 --- /dev/null +++ b/tests/test_EC5_Verification.py @@ -0,0 +1,314 @@ +# Encoding in UTF-8 by Anthony PARISOT +"""Tests d'intégration bout-à-bout de la classe Verification_EC5. + +Scénarios : +- Poutre simple sur deux appuis (M1) : flexion + cisaillement + flèche. +- Poutre continue sur trois appuis (M1-M2 groupés) : auto-group + verify. +- Boucle par combinaison : kmod et typecombi corrects selon la combo gouvernante. +""" +import sys +from pathlib import Path + +import pandas as pd +import pytest + +sys.path.append(str(Path(__file__).parent.parent)) + +from ourocode.eurocode.core.combinaison import Combinaison +from ourocode.eurocode.core.model_generator import Model_generator +from ourocode.eurocode.core.model_result import Model_result +from ourocode.eurocode.ec5.element_droit.verification_EC5 import Verification_EC5 + + +# --------------------------------------------------------------------------- +# Fixtures : poutre simple (1 travée) et poutre continue (2 travées) +# --------------------------------------------------------------------------- + +def _new_model(name: str = "test") -> Model_generator: + return Model_generator(ingenieur="Testeur", name=name, code_INSEE=75056, alt=100) + + +def _build_beam(model: Model_generator, nodes_xy, b=100, h=220, classe="C24"): + mat = model.add_material_by_class(classe) + sec = model.add_section(b, h, 0, "Rectangulaire") + for x, y in nodes_xy: + model.add_node(X=x, Y=y, Z=0) + for i in range(len(nodes_xy) - 1): + model.add_member(f"N{i + 1}", f"N{i + 2}", mat, sec, poids_propre=False) + model.add_support("N1", DX=True, DY=True, DZ=True, RX=True, RY=False, RZ=False) + for i in range(1, len(nodes_xy)): + model.add_support( + f"N{i + 1}", DX=False, DY=True, DZ=True, RX=True, RY=False, RZ=False + ) + return mat, sec + + +def _apply_gq_loads(model: Model_generator, gkNm: float, qkNm: float): + for mid in model.get_all_members().keys(): + model.create_dist_load( + member_id=mid, name=f"G_{mid}", start_load=-gkNm, end_load=-gkNm, + start_pos="start", end_pos="end", action="Permanente G", direction="FY", + ) + model.create_dist_load( + member_id=mid, name=f"Q_{mid}", start_load=-qkNm, end_load=-qkNm, + start_pos="start", end_pos="end", action="Exploitation Q", direction="FY", + ) + + +@pytest.fixture +def simple_beam_verif(): + """Poutre simple 5 m, 100x220 C24, G=1 kN/m + Q=2 kN/m.""" + model = _new_model("Poutre simple") + _build_beam(model, [(0, 0), (5000, 0)]) + _apply_gq_loads(model, gkNm=1.0, qkNm=2.0) + combi = Combinaison( + model, ELU_STR=True, ELU_STR_ACC=False, ELS_C=True, ELS_QP=True, + cat="Cat A : habitation", kdef=0.6, type_psy_2="Moyen terme", + ) + result = Model_result(model, analyze_type="Général", check_stability=False) + model.group_members( + "Solive", ["M1"], + role="Solive", + design_params={ + "classe_bois": "C24", "cs": 1, + "type_element_fleche": "Élément structuraux", + }, + ) + verif = Verification_EC5(combi, result, "ELU_ALL", 12, 12, False, 1, "Bâtiments courants", "Élément structuraux") + return model, combi, result, verif + + +@pytest.fixture +def continuous_beam_verif(): + """Poutre continue 2x3 m, 100x200 C24, G=1 kN/m + Q=2 kN/m.""" + model = _new_model("Poutre continue") + _build_beam(model, [(0, 0), (3000, 0), (6000, 0)], b=100, h=200) + _apply_gq_loads(model, gkNm=1.0, qkNm=2.0) + combi = Combinaison( + model, ELU_STR=True, ELU_STR_ACC=False, ELS_C=True, ELS_QP=True, + cat="Cat A : habitation", kdef=0.6, type_psy_2="Moyen terme", + ) + result = Model_result(model, analyze_type="Général", check_stability=False) + model.auto_group_continuous_members( + role="Solive", + design_params={ + "classe_bois": "C24", "cs": 1, + "type_element_fleche": "Élément structuraux", + }, + ) + verif = Verification_EC5(combi, result, "ELU_ALL", 12, 12, False, 1, "Bâtiments courants", "Élément structuraux") + return model, combi, result, verif + + +# --------------------------------------------------------------------------- +# Combinaison.type_combi +# --------------------------------------------------------------------------- + +class TestTypeCombi: + def test_elu_str_is_fondamental(self, simple_beam_verif): + _, combi, _, _ = simple_beam_verif + assert combi.type_combi("ELU_STR 1.35G + 1.5Q") == "Fondamentales" + assert combi.type_combi("ELU_STR G") == "Fondamentales" + + def test_elu_acc_is_accidental(self, simple_beam_verif): + _, combi, _, _ = simple_beam_verif + assert combi.type_combi("ELU_STR_ACC G + Ae") == "Accidentelles" + + def test_non_elu_raises(self, simple_beam_verif): + _, combi, _, _ = simple_beam_verif + with pytest.raises(ValueError): + combi.type_combi("ELS_C G + Q") + + +# --------------------------------------------------------------------------- +# Verification_EC5 : factory et état +# --------------------------------------------------------------------------- + +class TestFactory: + def test_from_combinaison_instantiable(self, simple_beam_verif): + _, _, _, verif = simple_beam_verif + assert isinstance(verif, Verification_EC5) + assert verif._combinaison is not None + assert verif._result is not None + assert verif._model_generator is not None + + def test_direct_init_requires_check(self, simple_beam_verif): + """Verification_EC5 nécessite combinaison et model_result pour verify().""" + model, combi, result, verif = simple_beam_verif + # Le constructeur vérifie déjà la présence des arguments requis + # Si des arguments sont manquants, il lève TypeError + assert verif._combinaison is not None + assert verif._result is not None + + +# --------------------------------------------------------------------------- +# Verification_EC5.verify — poutre simple +# --------------------------------------------------------------------------- + +class TestVerifySimpleBeam: + def test_returns_dataframe(self, simple_beam_verif): + _, _, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + assert res["name"] == "Solive" + assert isinstance(res["dataframe"], pd.DataFrame) + assert not res["dataframe"].empty + + def test_flexion_and_cisaillement_present(self, simple_beam_verif): + _, _, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + assert res["taux"]["Flexion"] is not None + assert res["taux"]["Cisaillement"] is not None + + def test_no_axial(self, simple_beam_verif): + _, _, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + assert res["taux"]["Traction"] is None + assert res["taux"]["Compression"] is None + + def test_taux_values_reasonable(self, simple_beam_verif): + _, _, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + tflex = res["taux"]["Flexion"]["taux"] + tcis = res["taux"]["Cisaillement"]["taux"] + assert 0.3 < tflex < 3.0 + assert 0.0 < tcis < 1.5 + + def test_fleche_present(self, simple_beam_verif): + _, _, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + assert res["taux"]["Flèche W_inst(Q)"] is not None + + def test_governing_combo_is_1_35G_1_5Q(self, simple_beam_verif): + """La combo gouvernante de la flexion doit être ELU_STR 1.35G + 1.5Q.""" + _, _, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + combo = res["taux"]["Flexion"]["combinaison"] + assert "1.35G" in combo and "1.5Q" in combo + + +# --------------------------------------------------------------------------- +# Verification_EC5.get_combo_objects +# --------------------------------------------------------------------------- + +class TestGetComboObjects: + COMBO = "ELU_STR 1.35G + 1.5Q" + + def test_returns_flexion_and_cisaillement(self, simple_beam_verif): + _, _, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + objs = verif.get_combo_objects("Solive", self.COMBO) + assert objs["Flexion"] is not None + assert objs["Cisaillement"] is not None + + def test_compression_and_traction_none(self, simple_beam_verif): + """Poutre sans effort axial : Compression et Traction doivent être None.""" + _, _, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + objs = verif.get_combo_objects("Solive", self.COMBO) + assert objs["Compression"] is None + assert objs["Traction"] is None + + def test_flexion_object_has_taux_attribute(self, simple_beam_verif): + """L'objet Flexion retourné doit posséder taux_m_rd calculé.""" + _, _, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + objs = verif.get_combo_objects("Solive", self.COMBO) + flexion = objs["Flexion"] + assert hasattr(flexion, "taux_m_rd") + assert len(flexion.taux_m_rd) > 0 + + def test_cisaillement_object_has_taux_attribute(self, simple_beam_verif): + """L'objet Cisaillement retourné doit posséder taux_tau_rd calculé.""" + _, _, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + objs = verif.get_combo_objects("Solive", self.COMBO) + cis = objs["Cisaillement"] + assert hasattr(cis, "taux_tau_rd") + assert len(cis.taux_tau_rd) > 0 + + def test_raises_if_verify_not_called(self, simple_beam_verif): + """KeyError attendu si verify() n'a pas été appelé.""" + _, _, _, verif = simple_beam_verif + with pytest.raises(KeyError, match="verify"): + verif.get_combo_objects("Solive", self.COMBO) + + def test_taux_matches_combo_elu(self, simple_beam_verif): + """Le taux Flexion de get_combo_objects (combo gouvernante) doit être > 0.""" + _, combi, _, verif = simple_beam_verif + res = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + objs = verif.get_combo_objects("Solive", self.COMBO) + taux_flexion = max(objs["Flexion"].taux_m_rd.values()) + assert taux_flexion > 0.0 + # La combo G seule produit un taux plus faible + objs_g = verif.get_combo_objects("Solive", "ELU_STR 1.35G") + taux_g = max(objs_g["Flexion"].taux_m_rd.values()) + assert taux_flexion > taux_g + + +# --------------------------------------------------------------------------- +# Boucle par combinaison : kmod dépend de la combo +# --------------------------------------------------------------------------- + +class TestKmodPerCombo: + def test_kmod_permanente_stricter_than_moyen_terme(self, simple_beam_verif): + """Vérifie que la vérification utilise effectivement le bon kmod + par combinaison : pour une combo G seule (Permanente), kmod est + plus faible que pour G+Q (Moyen terme), donc taux plus élevé + à effort égal. + + On compare les taux de flexion calculés sur la combo ELU_STR G + (Permanente, kmod=0.6) vs ELU_STR 1.35G + 1.5Q (Moyen terme, + kmod=0.8). Vu la différence de chargement (G vs 1.35G+1.5Q), + ce n'est pas strictement prouvable, mais on peut vérifier que + min_type_load renvoie bien les bonnes durées. + """ + _, combi, _, _ = simple_beam_verif + assert combi.min_type_load("ELU_STR 1.35G") == "Permanente" + assert combi.min_type_load("ELU_STR 1.35G + 1.5Q") == "Moyen terme" + + def test_verify_respects_combo_governance(self, simple_beam_verif): + """Si l'on filtre sur ELU_STR_ACC uniquement (pas d'accidentelle + définie), aucune vérification ne doit sortir.""" + _, _, _, verif = simple_beam_verif + # Le filtre ELU_STR_ACC n'existe pas dans get_list_combination -- + # on utilise 'ELU_STR' qui doit fonctionner et donner le même + # résultat que ELU_ALL puisqu'il n'y a pas d'accidentelle. + res_all = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + res_str = verif.verify("Solive", "Élément structuraux", "Charge sur fibre comprimée", 12, 12, False, 1) + assert res_all["taux"]["Flexion"]["taux"] == pytest.approx( + res_str["taux"]["Flexion"]["taux"], rel=1e-6 + ) + + +# --------------------------------------------------------------------------- +# Verification_EC5.synthese — poutre continue +# --------------------------------------------------------------------------- + +class TestSyntheseContinuousBeam: + def test_auto_group_one_sm(self, continuous_beam_verif): + model, _, _, _ = continuous_beam_verif + names = [n for n, _ in model._iter_structural_members()] + assert len(names) == 1 + sm = model.get_structural_member(names[0]) + assert sm["Barres FEM"] == ["M1", "M2"] + + def test_synthese_returns_dataframe(self, continuous_beam_verif): + _, _, _, verif = continuous_beam_verif + df = verif.synthese() + assert isinstance(df, pd.DataFrame) + assert not df.empty + for col in ["Barre structurale", "Vérification", "Taux", "Statut"]: + assert col in df.columns + + def test_synthese_sorted_desc(self, continuous_beam_verif): + _, _, _, verif = continuous_beam_verif + df = verif.synthese() + taux = df["Taux"].tolist() + assert taux == sorted(taux, reverse=True) + + def test_synthese_governing_combo_present(self, continuous_beam_verif): + """La synthèse doit contenir au moins une ligne avec ELU_STR 1.35G + 1.5Q.""" + _, _, _, verif = continuous_beam_verif + df = verif.synthese() + combos = df["Combinaison"].tolist() + assert any("1.35G" in c and "1.5Q" in c for c in combos if c is not None) diff --git a/tests/test_model_generator.py b/tests/test_model_generator.py new file mode 100644 index 0000000..42d9057 --- /dev/null +++ b/tests/test_model_generator.py @@ -0,0 +1,280 @@ +# Encoding in UTF-8 by Anthony PARISOT +# Tests pour la détection et le regroupement de barres structurales +# dans Model_generator. +import sys +from pathlib import Path + +import pytest + +sys.path.append(str(Path(__file__).parent.parent)) + +from ourocode.eurocode.core.model_generator import Model_generator + + +def _make_model() -> Model_generator: + return Model_generator( + ingenieur="Testeur", name="Projet Test", code_INSEE=75056, alt=100 + ) + + +def _make_colinear_chain(nb_members: int, material: str = "C24", section=None): + """Crée une chaîne colinéaire de ``nb_members`` barres alignées sur l'axe X. + + Returns: + tuple[Model_generator, str, list[str]]: (model, material_id, member_ids) + """ + model = _make_model() + mat = model.add_material_by_class(material) + sec = section if section else model.add_section(100, 200, 0, "Rectangulaire") + # Création des noeuds N1..N{nb+1} alignés sur X + for i in range(nb_members + 1): + model.add_node(X=1000 * i, Y=0, Z=0) + member_ids = [] + for i in range(nb_members): + mid = model.add_member( + f"N{i + 1}", f"N{i + 2}", mat, sec, poids_propre=False + ) + member_ids.append(mid) + return model, mat, member_ids + + +# --------------------------------------------------------------------------- +# detect_continuous_members +# --------------------------------------------------------------------------- + +class TestDetectContinuousMembers: + def test_single_member(self): + model, _, ids = _make_colinear_chain(1) + chains = model.detect_continuous_members() + assert chains == [ids] + + def test_continuous_chain_of_three(self): + model, _, ids = _make_colinear_chain(3) + chains = model.detect_continuous_members() + assert len(chains) == 1 + assert chains[0] == ids + + def test_break_on_different_section(self): + model = _make_model() + mat = model.add_material_by_class("C24") + sec1 = model.add_section(100, 200, 0, "Rectangulaire") + sec2 = model.add_section(150, 250, 0, "Rectangulaire") + for i in range(3): + model.add_node(X=1000 * i, Y=0, Z=0) + m1 = model.add_member("N1", "N2", mat, sec1, poids_propre=False) + m2 = model.add_member("N2", "N3", mat, sec2, poids_propre=False) + chains = model.detect_continuous_members() + # Les deux barres ne doivent pas fusionner (section différente) + assert sorted(map(tuple, chains)) == sorted([(m1,), (m2,)]) + + def test_break_on_different_material(self): + model = _make_model() + mat1 = model.add_material_by_class("C24") + mat2 = model.add_material_by_class("GL24h") + sec = model.add_section(100, 200, 0, "Rectangulaire") + for i in range(3): + model.add_node(X=1000 * i, Y=0, Z=0) + model.add_member("N1", "N2", mat1, sec, poids_propre=False) + model.add_member("N2", "N3", mat2, sec, poids_propre=False) + chains = model.detect_continuous_members() + assert all(len(c) == 1 for c in chains) + assert len(chains) == 2 + + def test_break_on_hinge_release(self): + model, _, ids = _make_colinear_chain(3) + # Rotule en rotation z à la fin de M1 → rompt la continuité M1/M2 + model.add_release( + ids[0], position="end", + u=False, v=False, w=False, + teta_x=False, teta_y=False, teta_z=True, + ) + chains = model.detect_continuous_members() + chains_sorted = sorted(map(tuple, chains), key=lambda c: c[0]) + assert chains_sorted == [(ids[0],), (ids[1], ids[2])] + + def test_t_junction_perpendicular_branch_does_not_break_chain(self): + """Une contrefiche perpendiculaire fixée en milieu d'arbalétrier ne doit pas + rompre la continuité de l'arbalétrier (les 2 barres de l'arbalétrier sont + colinéaires — la non-colinéarité de la contrefiche l'exclut naturellement).""" + model, mat, ids = _make_colinear_chain(2) + sec = list(model.get_all_sections().keys())[0] + # Contrefiche perpendiculaire fixée au noeud intermédiaire N2 + model.add_node(X=1000, Y=1000, Z=0) # N4 + contrefiche = model.add_member("N2", "N4", mat, sec, poids_propre=False) + chains = model.detect_continuous_members() + # L'arbalétrier (ids[0]+ids[1]) forme une chaîne continue ; + # la contrefiche reste isolée. + assert len(chains) == 2 + arba_chain = next(c for c in chains if len(c) == 2) + assert set(arba_chain) == set(ids) + contrefiche_chain = next(c for c in chains if len(c) == 1) + assert contrefiche_chain == [contrefiche] + + def test_t_junction_with_release_breaks_chain(self): + """Si la contrefiche a un relâchement en rotation à son noeud de fixation + sur l'arbalétrier, la continuité de l'arbalétrier reste intacte (le + relâchement ne concerne que la contrefiche).""" + model, mat, ids = _make_colinear_chain(2) + sec = list(model.get_all_sections().keys())[0] + model.add_node(X=1000, Y=1000, Z=0) # N4 + contrefiche = model.add_member("N2", "N4", mat, sec, poids_propre=False) + # Relâchement en rotation au pied de la contrefiche (noeud N2) + model.add_release( + contrefiche, position="start", + u=False, v=False, w=False, + teta_x=False, teta_y=False, teta_z=True, + ) + chains = model.detect_continuous_members() + # L'arbalétrier reste continu ; la contrefiche isolée. + assert len(chains) == 2 + arba_chain = next(c for c in chains if len(c) == 2) + assert set(arba_chain) == set(ids) + + def test_break_on_non_colinearity(self): + model = _make_model() + mat = model.add_material_by_class("C24") + sec = model.add_section(100, 200, 0, "Rectangulaire") + model.add_node(X=0, Y=0, Z=0) # N1 + model.add_node(X=1000, Y=0, Z=0) # N2 + model.add_node(X=1000, Y=1000, Z=0) # N3 -> angle 90° + m1 = model.add_member("N1", "N2", mat, sec, poids_propre=False) + m2 = model.add_member("N2", "N3", mat, sec, poids_propre=False) + chains = model.detect_continuous_members() + assert sorted(map(tuple, chains)) == sorted([(m1,), (m2,)]) + + def test_angle_tolerance(self): + """Un léger désalignement est accepté si dans la tolérance.""" + model = _make_model() + mat = model.add_material_by_class("C24") + sec = model.add_section(100, 200, 0, "Rectangulaire") + model.add_node(X=0, Y=0, Z=0) # N1 + model.add_node(X=1000, Y=0, Z=0) # N2 + # Angle de ~0.5° (dy=10 sur dx=1000 -> atan ≈ 0.57°) + model.add_node(X=2000, Y=10, Z=0) # N3 + ids = [ + model.add_member("N1", "N2", mat, sec, poids_propre=False), + model.add_member("N2", "N3", mat, sec, poids_propre=False), + ] + # Tolérance 1° : doivent fusionner + assert model.detect_continuous_members(angle_tol_deg=1.0) == [ids] + # Tolérance 0.1° : ne doivent pas fusionner + chains_strict = model.detect_continuous_members(angle_tol_deg=0.1) + assert all(len(c) == 1 for c in chains_strict) + + def test_reversed_member_orientation(self): + """Deux barres partageant un noeud via leurs extrémités "start" doivent aussi fusionner.""" + model = _make_model() + mat = model.add_material_by_class("C24") + sec = model.add_section(100, 200, 0, "Rectangulaire") + model.add_node(X=-1000, Y=0, Z=0) # N1 + model.add_node(X=0, Y=0, Z=0) # N2 (partagé, deux "start") + model.add_node(X=1000, Y=0, Z=0) # N3 + # Les deux barres partent de N2 -> même extrémité "start" + m1 = model.add_member("N2", "N1", mat, sec, poids_propre=False) + m2 = model.add_member("N2", "N3", mat, sec, poids_propre=False) + chains = model.detect_continuous_members() + assert len(chains) == 1 + assert set(chains[0]) == {m1, m2} + + +# --------------------------------------------------------------------------- +# group_members / auto_group_continuous_members / iter / get / del +# --------------------------------------------------------------------------- + +class TestGroupMembers: + def test_group_and_get(self): + model, _, ids = _make_colinear_chain(3) + model.group_members( + "Solive_1", ids, role="Solive", + design_params={"classe_bois": "C24", "cs": 1, "Hi": 12, "Hf": 12}, + ) + sm = model.get_structural_member("Solive_1") + assert sm["Barres FEM"] == ids + assert sm["Rôle"] == "Solive" + assert sm["Design"]["classe_bois"] == "C24" + # Vérification des types d'appuis automatiques + assert "type_appuis_y" in sm["Design"] + assert "type_appuis_z" in sm["Design"] + # Sans appuis définis, les rotations sont libres des deux côtés + assert sm["Design"]["type_appuis_y"] == "Rotule - Rotule" + assert sm["Design"]["type_appuis_z"] == "Rotule - Rotule" + # Vérification des paramètres de flexion automatiques (déversement) + assert "lo_rel_y" in sm["Design"] + assert "lo_rel_z" in sm["Design"] + assert "coeflef_y" in sm["Design"] + assert "coeflef_z" in sm["Design"] + # Sans charges définies, coeflef = 1.0 (moment constant) + assert sm["Design"]["coeflef_y"] == 1.0 + assert sm["Design"]["coeflef_z"] == 1.0 + # Longueur totale = somme des 3 barres de 1000 mm + assert abs(sm["Longueur totale"].value - 3.0) < 1e-6 # en mètres (si.m) + + def test_empty_member_ids_raises(self): + model = _make_model() + with pytest.raises(ValueError, match="ne peut pas être vide"): + model.group_members("X", []) + + def test_unknown_member_raises(self): + model, _, ids = _make_colinear_chain(1) + with pytest.raises(ValueError, match="n'existe pas"): + model.group_members("X", ids + ["M999"]) + + def test_duplicate_name_raises(self): + model, _, ids = _make_colinear_chain(1) + model.group_members("X", ids) + with pytest.raises(ValueError, match="existe déjà"): + model.group_members("X", ids) + + def test_del_structural_member(self): + model, _, ids = _make_colinear_chain(1) + model.group_members("X", ids, role="Poteau") + data = model.del_structural_member("X") + assert data["Rôle"] == "Poteau" + assert "X" not in model.get_all_structural_members() + + def test_iter_structural_members(self): + model, _, ids = _make_colinear_chain(2) + model.group_members("A", [ids[0]]) + model.group_members("B", [ids[1]]) + names = [name for name, _ in model._iter_structural_members()] + assert names == ["A", "B"] + + def test_auto_group_creates_all(self): + model, _, ids = _make_colinear_chain(3) + created = model.auto_group_continuous_members( + role="Solive", design_params={"classe_bois": "C24", "cs": 1}, + ) + assert created == ["SM1"] + sm = model.get_structural_member("SM1") + assert sm["Barres FEM"] == ids + assert sm["Rôle"] == "Solive" + + def test_auto_group_only_continuous(self): + """Une barre isolée doit être ignorée avec only_continuous=True.""" + # Chaîne continue M1-M2 + barre isolée M3 (autre section -> rupture) + model = _make_model() + mat = model.add_material_by_class("C24") + sec1 = model.add_section(100, 200, 0, "Rectangulaire") + sec2 = model.add_section(150, 250, 0, "Rectangulaire") + for i in range(3): + model.add_node(X=1000 * i, Y=0, Z=0) + model.add_member("N1", "N2", mat, sec1, poids_propre=False) + model.add_member("N2", "N3", mat, sec2, poids_propre=False) + created = model.auto_group_continuous_members(only_continuous=True) + assert created == [] # aucune chaîne de longueur ≥ 2 + + def test_lo_rel_with_lateral_bracing(self): + """Appui DZ intermédiaire en N2 → lo_rel_y = 1000 mm (demi-portée).""" + model, _, ids = _make_colinear_chain(2) # N1-N2-N3, chaque barre = 1000 mm + # Appui simple en N1 et N3 (bloquant DY et DZ → appuis d'about) + model.add_support("N1", DX=False, DY=True, DZ=True, RX=False, RY=False, RZ=False) + model.add_support("N3", DX=False, DY=True, DZ=True, RX=False, RY=False, RZ=False) + # Appui latéral intermédiaire en N2 : bloque DZ uniquement (contreventement Z) + model.add_support("N2", DX=False, DY=False, DZ=True, RX=False, RY=False, RZ=False) + model.group_members("Poutre", ids) + sm = model.get_structural_member("Poutre") + # lo_rel_y (déversement selon Y) bloqué par DZ : segments de 1000 mm + assert sm["Design"]["lo_rel_y"] == pytest.approx(1000.0) + # lo_rel_z (déversement selon Z) bloqué par DY : N2 ne bloque pas DY + # → longueur totale = 2000 mm + assert sm["Design"]["lo_rel_z"] == pytest.approx(2000.0) From 7cfa1d1b7567c3701f270383ff8602ba57d95e5a Mon Sep 17 00:00:00 2001 From: Anthony Parisot Date: Tue, 5 May 2026 14:00:41 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a2af2..b66509d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Toutes les modifications notables de ce projet seront documentées dans ce fichi Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et ce projet adhère au [Semantic Versioning](https://semver.org/lang/fr/). -## [Unreleased] +## [2.1.2] ### Fixed - **`detect_continuous_members`** : correction du bug qui empêchait la détection de la continuité d'un arbalétrier (ou de toute barre) lorsqu'une contrefiche (ou autre barre transversale) était fixée en nœud intermédiaire (jonction T/Y). La contrainte de degré 2 au nœud a été supprimée : la colinéarité seule suffit à distinguer les barres continues des branchements. La recherche du voisin parcourt désormais toutes les barres du nœud pour trouver celle qui passe le test de continuité, et non plus uniquement la première trouvée. Les tests de `TestDetectContinuousMembers` ont été mis à jour en conséquence (2 nouveaux cas de régression : jonction T perpendiculaire et jonction T avec relâchement).