diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7d99962..fe55bec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: version: "latest" - name: Install dependencies - run: uv sync + run: uv sync --extra dev - name: Run tests - run: uv run python tests/run_tests.py + run: uv run pytest diff --git a/.gitignore b/.gitignore index af905fb..eb7ea59 100644 --- a/.gitignore +++ b/.gitignore @@ -59,5 +59,6 @@ old* .weac-reference/ -# Folder for development and local testing -dev/ \ No newline at end of file +# Folders for development and local testing +dev/ +todo/ \ No newline at end of file diff --git a/README.md b/README.md index e1c439b..11123c8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ + -


WEAC @@ -84,18 +84,19 @@ WEAC implements closed-form analytical models for the [mechanical analysis of dr Systems Cite the repository as: -``` + +```text Rosendahl, P. L., Schneider, J., & Weissgraeber, P. (2022). Weak Layer Anticrack Nucleation Model (WEAC). Zenodo. https://doi.org/10.5281/zenodo.5773113 ``` Read the [📄 white paper](https://doi.org/10.5194/tc-17-1475-2023) for model derivations, illustrations, dimensions, material properties, and kinematics: -- Weißgraeber, P. & Rosendahl, P. L. (2023). A closed-form model for layered snow slabs. The Cryosphere, 17(4), 1475–1496. https://doi.org/10.5194/tc-17-1475-2023 +- Weißgraeber, P. & Rosendahl, P. L. (2023). A closed-form model for layered snow slabs. The Cryosphere, 17(4), 1475–1496. For more background info, please refer to the companion papers: -- Rosendahl, P. L. & Weißgraeber, P. (2020). Modeling snow slab avalanches caused by weak-layer failure – Part 1: Slabs on compliant and collapsible weak layers. The Cryosphere, 14(1), 115–130. https://doi.org/10.5194/tc-14-115-2020 -- Rosendahl, P. L. & Weißgraeber, P. (2020). Modeling snow slab avalanches caused by weak-layer failure – Part 2: Coupled mixed-mode criterion for skier-triggered anticracks. The Cryosphere, 14(1), 131–145. https://doi.org/10.5194/tc-14-131-2020 +- Rosendahl, P. L. & Weißgraeber, P. (2020). Modeling snow slab avalanches caused by weak-layer failure – Part 1: Slabs on compliant and collapsible weak layers. The Cryosphere, 14(1), 115–130. +- Rosendahl, P. L. & Weißgraeber, P. (2020). Modeling snow slab avalanches caused by weak-layer failure – Part 2: Coupled mixed-mode criterion for skier-triggered anticracks. The Cryosphere, 14(1), 131–145. Written in [🐍 Python](https://www.python.org) and built with [💻 Visual Studio Code](https://code.visualstudio.com), [🐙 GitKraken](https://www.gitkraken.com), and [🪐 Jupyter](https://jupyter.org). Note that [release v1.0](https://github.com/2phi/weac/releases/tag/v1.0.0) was written and built in [🌋 MATLAB](https://www.mathworks.com/products/matlab.html). @@ -105,19 +106,21 @@ Written in [🐍 Python](https://www.python.org) and built with [💻 Visual Stu [![Atom](https://img.shields.io/badge/Atom-498b60.svg?style=flat-square&logo=atom&logoColor=white&label&labelColor=gray)](https://atom.io) [![GitKraken](https://img.shields.io/badge/GitKraken-179287.svg?style=flat-square&logo=gitkraken&logoColor=white&label&labelColor=gray)](https://www.gitkraken.com) --> - - ## Installation Install globally using the `pip` Package Installer for Python + ```sh pip install -U weac ``` + or clone the repo + ```sh git clone https://github.com/2phi/weac ``` + for local use. Needs (runtime dependencies are declared in [pyproject.toml](https://github.com/2phi/weac/blob/main/pyproject.toml)): @@ -129,7 +132,6 @@ Needs (runtime dependencies are declared in [pyproject.toml](https://github.com/ - [Pydantic](https://docs.pydantic.dev/latest/) ≥ 2.11.7 - [Snowpylot](https://github.com/connellymk/snowpylot) ≥ 1.1.3 - ## Development Setup @@ -173,10 +175,6 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate Run the test suite using uv: ```bash -# Run all tests -uv run python tests/run_tests.py - -# Or use pytest directly (if installed) uv run pytest ``` @@ -387,6 +385,12 @@ See the [open issues](https://github.com/2phi/weac/issues) for a list of propose ## Release history +### v3.2 + +- Split `Layer` and `WeakLayer` into separate component models +- Weak-layer Young's modulus from Schöttner FC&DH density law (`E_method="schottner_fc_dh"`) +- Lowered weak-layer preset densities to 100/150/200 kg/m³ (within the Schöttner ≤250 kg/m³ range) + ### v3.0 - Refactored the codebase for improved structure and maintainability @@ -468,6 +472,7 @@ See the [open issues](https://github.com/2phi/weac/issues) for a list of propose ## Workflows + [![Publish Python 🐍 releases 📦 to PyPI ](https://github.com/2phi/weac/actions/workflows/release.yml/badge.svg)](https://github.com/2phi/weac/actions/workflows/release.yml)
[![Build and publish Sphinx 🪬 documentation ](https://github.com/2phi/weac/actions/workflows/docs.yml/badge.svg)](https://github.com/2phi/weac/actions/workflows/docs.yml) @@ -489,14 +494,10 @@ Under the following terms: - **ShareAlike** — If you remix, transform, or build upon the material, you must distribute your contributions under the [same license](https://creativecommons.org/licenses/by-nc-sa/4.0/?ref=chooser-v1#ref-same-license) as the original. - - ## Contact -E-mail: mail@2phi.de · Web: https://2phi.de · Project Link: [https://github.com/2phi/weac](https://github.com/2phi/weac) · Project DOI: [http://dx.doi.org/10.5281/zenodo.5773113](http://dx.doi.org/10.5281/zenodo.5773113) - - +E-mail: · Web: · Project Link: [https://github.com/2phi/weac](https://github.com/2phi/weac) · Project DOI: [http://dx.doi.org/10.5281/zenodo.5773113](http://dx.doi.org/10.5281/zenodo.5773113) @@ -528,4 +529,4 @@ E-mail: mail@2phi.de · Web: https://2phi.de · Project Link: [https://github.co [pypi-url]: https://pypi.org/project/weac/ [release-url]: https://github.com/2phi/weac/releases [weac-url]: https://github.com/2phi/weac/ -[doi-url]: https://zenodo.org/badge/latestdoi/203163531 \ No newline at end of file +[doi-url]: https://zenodo.org/badge/latestdoi/203163531 diff --git a/TODO.md b/TODO.md index 333e2ca..976a34c 100644 --- a/TODO.md +++ b/TODO.md @@ -10,8 +10,17 @@ ## Minor -- [ ] Swap to Pytest from Unittest -- [ ] resolve fracture criterion also when lower than strength criterion +- [ ] Free-end coupled criterion for finite lab specimens (e.g. 1 m slab): `CriteriaEvaluator` currently requires `system_type="skier"` with infinite-end BCs; support free-end (PST-style) mid-load CC without approximating ends as infinite +- [ ] Per-segment effective out-of-plane load length `l_eff` [mm] on `Segment` + - Needed for: converting a point mass `m` [kg] into the unit-width line-load jump used by the solver when the load does not span the global default ski length. Today `get_skier_point_load` and `Scenario._setup_scenario` always divide by `constants.LSKI_MM` (1000 mm), so short skis, long skis, or a narrow out-of-plane contact (e.g. a cube’s depth) cannot be represented correctly—especially when sample width `b` differs from 1000 mm. + - Formula: `F [N/mm] = 1e-3 * m * G_MM_S2 / l_eff` (same as today with `l_eff = LSKI_MM`). Smaller `l_eff` → larger intensity for the same mass. + - Requires: + - Add optional `Segment.l_eff: float` (default `LSKI_MM`) applying to the mass at that segment’s right edge (`m`). + - Thread `l_eff` through `get_skier_point_load(m, l_eff=…)` (or replace it), `Scenario.fi` assembly, and both `UnknownConstantsSolver` / `GeneralizedUnknownConstantsSolver` so no path still hardcodes `LSKI_MM`. + - Keep backward compatibility: omit/`None` → `LSKI_MM`; existing skier/CC tests unchanged. + - Document interaction with slab width `b`: `l_eff` is the out-of-plane contact extent of *this* load; clipping to `min(l_eff, b)` may be needed when the object is wider than the specimen. + - Unit tests: same `m`, two `l_eff` values → interface load scales as `1/l_eff`; default `l_eff` matches legacy `LSKI_MM` results. + - Out of scope for this item: finite along-slope contact length (patch/`qs`); full `AppliedLoad` objects; tilted-cube contact modes. - [ ] Florian CriterionEvaluator: clarify and fix damping behavior (find_minimum_force / evaluate_coupled_criterion) - Expected behavior - find_minimum_force: compute the critical skier weight w* [kg] such that max(stress_envelope) == 1 within tolerance_stress. This solver should not apply damping; it must return the numerically precise root of residual(weight) = max(stress_envelope) - 1 using a bracketed method and finite tolerances. @@ -133,7 +142,6 @@ - [ ] Make rasterize_solution smarter (iterative convergence) - [ ] SNOWPACK Parser - [ ] SMP Parser -- [ ] Build Tests: Integration -> Pure ## Patch diff --git a/docs/sphinx/weac.components.rst b/docs/sphinx/weac.components.rst index 46b5b37..29ff9d9 100644 --- a/docs/sphinx/weac.components.rst +++ b/docs/sphinx/weac.components.rst @@ -13,6 +13,7 @@ Submodules weac.components.model_input weac.components.scenario_config weac.components.segment + weac.components.weak_layer Module contents --------------- diff --git a/docs/sphinx/weac.components.weak_layer.rst b/docs/sphinx/weac.components.weak_layer.rst new file mode 100644 index 0000000..188f1e8 --- /dev/null +++ b/docs/sphinx/weac.components.weak_layer.rst @@ -0,0 +1,7 @@ +weac.components.weak_layer module +================================= + +.. automodule:: weac.components.weak_layer + :members: + :show-inheritance: + :undoc-members: diff --git a/pyproject.toml b/pyproject.toml index 80539db..a457cb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,9 @@ dev = [ "black>=24.4.0", "isort>=5.13.0", + # Testing + "pytest>=8", + # Versioning helper matching [tool.bumpversion] "bump-my-version", # e.g. bump-my-version bump patch ] @@ -85,6 +88,9 @@ where = ["src"] # [tool.uv.sources] # snowpylot = { path = "../snowpylot", editable = true } +[tool.pytest.ini_options] +testpaths = ["tests"] + [tool.ruff.lint] ignore = ["E741"] diff --git a/src/weac/analysis/plotter.py b/src/weac/analysis/plotter.py index 8376bee..64e0819 100644 --- a/src/weac/analysis/plotter.py +++ b/src/weac/analysis/plotter.py @@ -24,7 +24,7 @@ ) # Module imports -from weac.components.layer import WeakLayer +from weac.components.weak_layer import WeakLayer from weac.core.scenario import Scenario from weac.core.slab import Slab from weac.core.system_model import SystemModel diff --git a/src/weac/components/__init__.py b/src/weac/components/__init__.py index dbe0417..c6f5e92 100644 --- a/src/weac/components/__init__.py +++ b/src/weac/components/__init__.py @@ -4,7 +4,7 @@ from .config import Config from .criteria_config import CriteriaConfig -from .layer import Layer, WeakLayer +from .layer import Layer from .model_input import ModelInput from .presets import ( LESS_WEAK_LAYER, @@ -15,6 +15,7 @@ ) from .scenario_config import ScenarioConfig, SystemType, TouchdownMode from .segment import Segment +from .weak_layer import WeakLayer __all__ = [ "Config", diff --git a/src/weac/components/layer.py b/src/weac/components/layer.py index 5c88c91..f21dce0 100644 --- a/src/weac/components/layer.py +++ b/src/weac/components/layer.py @@ -2,33 +2,17 @@ Mechanical properties of snow-pack layers. * `Layer` - a regular slab layer (no foundation springs) -* `WeakLayer` - a slab layer that also acts as a Winkler-type foundation +* `WeakLayer` - re-exported from `weac.components.weak_layer` for compatibility """ from typing import Literal -import numpy as np -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, Field, model_validator -from weac.constants import CB0, CB1, CG0, CG1, NU, RHO_ICE, G_MM_S2 +from weac.constants import CB0, CB1, CG0, CG1, NU, RHO_ICE from weac.utils.snow_types import GrainType, HandHardness -def _collapse_height(h: float) -> float: - """ - Based on data from Herwijnen (van Herwijnen, 2016) - `Estimating the effective elastic modulus and specific fracture energy of - snowpack layers from field experiments` - Data collection 2005 - 2016. - - Arguments: - ---------- - h : float - Height/Thickness of the layer [mm]. - """ - return 4.70 * (1 - np.exp(-h / 7.78)) - - def _bergfeld_youngs_modulus(rho: float, C_0: float = CB0, C_1: float = CB1) -> float: """Young's modulus from Bergfeld et al. (2023) - returns MPa. @@ -220,135 +204,28 @@ def validate_positive_E_G(self): return self -class WeakLayer(BaseModel): - """ - Weak layer that also behaves as a Winkler foundation. - - Attributes - ---------- - rho : float - Density of the layer [kg m⁻³]. - h : float - Height/Thickness of the layer [mm]. - f : float - Resultant force of the layer [N/mm] - nu : float - Poisson's ratio [-] Defaults to `weac.constants.NU`). - E : float, optional - Young's modulus E [MPa]. If omitted it is derived from ``rho``. - G : float, optional - Shear modulus G [MPa]. If omitted it is derived from ``E`` and ``nu``. - kn : float, optional - Normal (compression) spring stiffness kₙ [N mm⁻³]. If omitted is - computed as ``E_plane / h`` where - ``E_plane = E / (1 - nu²)``. - kt : float, optional - Shear spring stiffness kₜ [N mm⁻³]. If omitted it is ``G / h``. - G_Ic : float - Mode-I fracture toughness GIc [J/m^2]. Default 0.56 J/m^2. - G_IIc : float - Mode-II fracture toughness GIIc [J/m^2]. Default 0.79 J/m^2. - """ - - rho: float = Field(default=125, gt=0, description="Density of the Slab [kg m⁻³]") - h: float = Field(default=20, gt=0, description="Height/Thickness of the slab [mm]") - f: float | None = Field( - default=None, description="Weight density of the weak layer [N/mm^3]" - ) - collapse_height: float = Field( - default=0.0, ge=0, description="Collapse height [mm]" - ) - nu: float = Field(default=NU, ge=0, lt=0.5, description="Poisson's ratio [-]") - - E: float = Field(default=0.0, ge=0, description="Young's modulus [MPa]") - G: float = Field(default=0.0, ge=0, description="Shear modulus [MPa]") - # Winkler springs (can be overridden by caller) - kn: float = Field(default=0.0, description="Normal stiffness [N mm⁻³]") - kt: float = Field(default=0.0, description="Shear stiffness [N mm⁻³]") - # fracture-mechanics parameters - G_Ic: float = Field( - default=0.56, gt=0, description="Mode-I fracture toughness GIc [J/m^2]" - ) - G_IIc: float = Field( - default=0.79, gt=0, description="Mode-II fracture toughness GIIc [J/m^2]" - ) - sigma_c: float = Field(default=6.16, gt=0, description="Tensile strength [kPa]") - tau_c: float = Field(default=5.09, gt=0, description="Shear strength [kPa]") - sigma_comp: float = Field( - default=2.6, gt=0, description="Compressive strength [kPa]" - ) - E_method: Literal["bergfeld", "scapazzo", "gerling"] = Field( - default="bergfeld", - description="Method to calculate the Young's modulus", - ) - constitutive_model: Literal["PlaneStrain", "PlaneStress", "Uniaxial"] = Field( - default="PlaneStrain", - description="Marks how interlinked the weak layer is in out-of-plane direction.", - ) - grain_type: GrainType | None = Field(default=None, description="Grain type") - grain_size: float | None = Field(default=None, description="Grain size [mm]") - hand_hardness: HandHardness | None = Field( - default=None, description="Hand hardness" - ) +def __getattr__(name: str): + """Lazy re-export to avoid circular imports with weak_layer.""" + if name == "WeakLayer": + from weac.components.weak_layer import WeakLayer - model_config = ConfigDict( - frozen=True, - extra="forbid", - ) + return WeakLayer + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - def model_post_init(self, _ctx): # pylint: disable=arguments-differ - if self.E_method == "bergfeld": - object.__setattr__(self, "E", self.E or _bergfeld_youngs_modulus(self.rho)) - elif self.E_method == "scapazzo": - object.__setattr__(self, "E", self.E or _scapozza_youngs_modulus(self.rho)) - elif self.E_method == "gerling": - object.__setattr__(self, "E", self.E or _gerling_youngs_modulus(self.rho)) - else: - raise ValueError(f"Invalid E_method: {self.E_method}") - object.__setattr__( - self, "collapse_height", self.collapse_height or _collapse_height(self.h) - ) - - # Validate that collapse height is smaller than layer height - if self.collapse_height >= self.h: - raise ValueError( - f"Collapse height ({self.collapse_height:.2f} mm) must be smaller than " - f"layer height ({self.h:.2f} mm). Consider reducing collapse_height or " - f"increasing layer thickness." - ) - - if self.constitutive_model == "PlaneStrain": - nu_eff = self.nu - E_eff = self.E - elif self.constitutive_model == "PlaneStress": - nu_eff = self.nu / (1 + self.nu) - E_eff = self.E * (1 + 2 * self.nu) / ((1 + self.nu) ** 2) - elif self.constitutive_model == "Uniaxial": - nu_eff = 0 - E_eff = self.E - object.__setattr__(self, "nu", nu_eff) - object.__setattr__(self, "E", E_eff) - object.__setattr__(self, "G", self.G or self.E / (2 * (1 + self.nu))) - E_plane = self.E / (1 - self.nu**2) # plane-strain Young - object.__setattr__(self, "kn", self.kn or E_plane / self.h) - object.__setattr__(self, "kt", self.kt or self.G / self.h) - object.__setattr__( - self, "f", self.f if self.f is not None else self.rho * 1e-12 * G_MM_S2 - ) - @model_validator(mode="after") - def validate_positive_E_G(self): - """Validate that E and G are positive.""" - if self.E <= 0: - raise ValueError("E must be positive") - if self.G <= 0: - raise ValueError("G must be positive") - return self +__all__ = [ + "Layer", + "_adam_tensile_strength", + "_bergfeld_youngs_modulus", + "_gerling_youngs_modulus", + "_scapozza_youngs_modulus", + "_sigrist_tensile_strength", +] if __name__ == "__main__": ly1 = Layer(rho=180, h=120) # E,G,k auto-computed ly2 = Layer(rho=250, h=80, E=50.0) # override E, derive G - wl = WeakLayer(rho=170, h=30) # full set incl. kn, kt - print(wl.model_dump()) + print(ly1.model_dump()) + print(ly2.model_dump()) diff --git a/src/weac/components/model_input.py b/src/weac/components/model_input.py index 13d39a5..03aef37 100644 --- a/src/weac/components/model_input.py +++ b/src/weac/components/model_input.py @@ -17,7 +17,8 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -from weac.components.layer import Layer, WeakLayer +from weac.components.layer import Layer +from weac.components.weak_layer import WeakLayer from weac.components.scenario_config import ScenarioConfig from weac.components.segment import Segment @@ -45,7 +46,7 @@ class ModelInput(BaseModel): ) weak_layer: WeakLayer = Field( - default_factory=lambda: WeakLayer(rho=125, h=20, E=1.0), + default_factory=lambda: WeakLayer(rho=150, h=20), description="Weak layer", ) layers: list[Layer] = Field( diff --git a/src/weac/components/presets.py b/src/weac/components/presets.py index 3bb93a4..03deb68 100644 --- a/src/weac/components/presets.py +++ b/src/weac/components/presets.py @@ -1,32 +1,29 @@ """Named presets for WEAC components.""" -from weac.components.layer import WeakLayer +from weac.components.weak_layer import WeakLayer _WEAK_LAYER_PARAMS: dict[str, dict] = { "very_weak": { - "rho": 125, + "rho": 100, "h": 10, "sigma_c": 5.16, "tau_c": 4.09, - "E": 2.0, "G_Ic": 0.36, "G_IIc": 0.5, }, "weak": { - "rho": 125, + "rho": 150, "h": 10, "sigma_c": 6.16, "tau_c": 5.09, - "E": 2.0, "G_Ic": 1.1, "G_IIc": 1.5, }, "less_weak": { - "rho": 125, + "rho": 200, "h": 10, "sigma_c": 7.16, "tau_c": 6.09, - "E": 2.0, "G_Ic": 1.8, "G_IIc": 2.5, }, diff --git a/src/weac/components/weak_layer.py b/src/weac/components/weak_layer.py new file mode 100644 index 0000000..8d2fa8e --- /dev/null +++ b/src/weac/components/weak_layer.py @@ -0,0 +1,189 @@ +""" +Mechanical properties of the weak layer (Winkler foundation). +""" + +from typing import Literal + +import numpy as np +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from weac.components.layer import ( + _bergfeld_youngs_modulus, + _gerling_youngs_modulus, + _scapozza_youngs_modulus, +) +from weac.constants import CS0, CS1, G_MM_S2, NU, RHO_ICE +from weac.utils.snow_types import GrainType, HandHardness + + +def _schottner_fc_dh_youngs_modulus( + rho: float, C_0: float = CS0, C_1: float = CS1 +) -> float: + """Young's modulus from Schöttner et al. FC&DH law — returns MPa. + + E = C_S0 * (rho / rho_ice) ** C_S1 for faceted crystals & depth hoar. + + Arguments + --------- + rho : float + Density (kg/m^3). + C_0 : float, optional + Prefactor of Young modulus parametrization (default CS0 = 2.72e4 MPa). + C_1 : float, optional + Exponent of Young modulus parameterization (default CS1 = 5.4). + """ + return C_0 * (rho / RHO_ICE) ** C_1 + + +def _collapse_height(h: float) -> float: + """ + Based on data from Herwijnen (van Herwijnen, 2016) + `Estimating the effective elastic modulus and specific fracture energy of + snowpack layers from field experiments` + Data collection 2005 - 2016. + + Arguments: + ---------- + h : float + Height/Thickness of the layer [mm]. + """ + return 4.70 * (1 - np.exp(-h / 7.78)) + + +class WeakLayer(BaseModel): + """ + Weak layer that also behaves as a Winkler foundation. + + Attributes + ---------- + rho : float + Density of the layer [kg m⁻³]. + h : float + Height/Thickness of the layer [mm]. + f : float + Resultant force of the layer [N/mm] + nu : float + Poisson's ratio [-] Defaults to `weac.constants.NU`). + E : float, optional + Young's modulus E [MPa]. If omitted it is derived from ``rho``. + G : float, optional + Shear modulus G [MPa]. If omitted it is derived from ``E`` and ``nu``. + kn : float, optional + Normal (compression) spring stiffness kₙ [N mm⁻³]. If omitted is + computed as ``E_plane / h`` where + ``E_plane = E / (1 - nu²)``. + kt : float, optional + Shear spring stiffness kₜ [N mm⁻³]. If omitted it is ``G / h``. + G_Ic : float + Mode-I fracture toughness GIc [J/m^2]. Default 0.56 J/m^2. + G_IIc : float + Mode-II fracture toughness GIIc [J/m^2]. Default 0.79 J/m^2. + """ + + rho: float = Field( + default=150, gt=0, description="Density of the Weak Layer [kg m⁻³]" + ) + h: float = Field( + default=20, gt=0, description="Height/Thickness of the weak layer [mm]" + ) + f: float | None = Field( + default=None, description="Weight density of the weak layer [N/mm^3]" + ) + collapse_height: float = Field( + default=0.0, ge=0, description="Collapse height [mm]" + ) + nu: float = Field(default=NU, ge=0, lt=0.5, description="Poisson's ratio [-]") + + E: float = Field(default=0.0, ge=0, description="Young's modulus [MPa]") + G: float = Field(default=0.0, ge=0, description="Shear modulus [MPa]") + # Winkler springs (can be overridden by caller) + kn: float = Field(default=0.0, description="Normal stiffness [N mm⁻³]") + kt: float = Field(default=0.0, description="Shear stiffness [N mm⁻³]") + # fracture-mechanics parameters + G_Ic: float = Field( + default=0.56, gt=0, description="Mode-I fracture toughness GIc [J/m^2]" + ) + G_IIc: float = Field( + default=0.79, gt=0, description="Mode-II fracture toughness GIIc [J/m^2]" + ) + sigma_c: float = Field(default=6.16, gt=0, description="Tensile strength [kPa]") + tau_c: float = Field(default=5.09, gt=0, description="Shear strength [kPa]") + sigma_comp: float = Field( + default=2.6, gt=0, description="Compressive strength [kPa]" + ) + E_method: Literal["schottner_fc_dh", "bergfeld", "scapazzo", "gerling"] = Field( + default="schottner_fc_dh", + description="Method to calculate the Young's modulus", + ) + constitutive_model: Literal["PlaneStrain", "PlaneStress", "Uniaxial"] = Field( + default="PlaneStrain", + description="Marks how interlinked the weak layer is in out-of-plane direction.", + ) + grain_type: GrainType | None = Field(default=None, description="Grain type") + grain_size: float | None = Field(default=None, description="Grain size [mm]") + hand_hardness: HandHardness | None = Field( + default=None, description="Hand hardness" + ) + + model_config = ConfigDict( + frozen=True, + extra="forbid", + ) + + def model_post_init(self, _ctx): # pylint: disable=arguments-differ + if self.E_method == "schottner_fc_dh": + object.__setattr__( + self, "E", self.E or _schottner_fc_dh_youngs_modulus(self.rho) + ) + elif self.E_method == "bergfeld": + object.__setattr__(self, "E", self.E or _bergfeld_youngs_modulus(self.rho)) + elif self.E_method == "scapazzo": + object.__setattr__(self, "E", self.E or _scapozza_youngs_modulus(self.rho)) + elif self.E_method == "gerling": + object.__setattr__(self, "E", self.E or _gerling_youngs_modulus(self.rho)) + else: + raise ValueError(f"Invalid E_method: {self.E_method}") + object.__setattr__( + self, "collapse_height", self.collapse_height or _collapse_height(self.h) + ) + + # Validate that collapse height is smaller than layer height + if self.collapse_height >= self.h: + raise ValueError( + f"Collapse height ({self.collapse_height:.2f} mm) must be smaller than " + f"layer height ({self.h:.2f} mm). Consider reducing collapse_height or " + f"increasing layer thickness." + ) + + if self.constitutive_model == "PlaneStrain": + nu_eff = self.nu + E_eff = self.E + elif self.constitutive_model == "PlaneStress": + nu_eff = self.nu / (1 + self.nu) + E_eff = self.E * (1 + 2 * self.nu) / ((1 + self.nu) ** 2) + elif self.constitutive_model == "Uniaxial": + nu_eff = 0 + E_eff = self.E + object.__setattr__(self, "nu", nu_eff) + object.__setattr__(self, "E", E_eff) + object.__setattr__(self, "G", self.G or self.E / (2 * (1 + self.nu))) + E_plane = self.E / (1 - self.nu**2) # plane-strain Young + object.__setattr__(self, "kn", self.kn or E_plane / self.h) + object.__setattr__(self, "kt", self.kt or self.G / self.h) + object.__setattr__( + self, "f", self.f if self.f is not None else self.rho * 1e-12 * G_MM_S2 + ) + + @model_validator(mode="after") + def validate_positive_E_G(self): + """Validate that E and G are positive.""" + if self.E <= 0: + raise ValueError("E must be positive") + if self.G <= 0: + raise ValueError("G must be positive") + return self + + +if __name__ == "__main__": + wl = WeakLayer(rho=170, h=30) # full set incl. kn, kt + print(wl.model_dump()) diff --git a/src/weac/constants.py b/src/weac/constants.py index cb011ac..b242210 100644 --- a/src/weac/constants.py +++ b/src/weac/constants.py @@ -35,3 +35,13 @@ # Exponent of Young modulus parameterization # according to Gerling et al. (2017) ) +CS0: Final[float] = ( + 2.72e4 + # Prefactor of Young modulus parametrization for faceted crystals + # and depth hoar (FC&DH) according to Schöttner et al. +) +CS1: Final[float] = ( + 5.4 + # Exponent of Young modulus parameterization for faceted crystals + # and depth hoar (FC&DH) according to Schöttner et al. +) diff --git a/src/weac/core/slab_touchdown.py b/src/weac/core/slab_touchdown.py index 08dcb3a..36603ee 100644 --- a/src/weac/core/slab_touchdown.py +++ b/src/weac/core/slab_touchdown.py @@ -8,7 +8,7 @@ from scipy.optimize import brentq -from weac.components.layer import WeakLayer +from weac.components.weak_layer import WeakLayer from weac.components.scenario_config import ScenarioConfig, TouchdownMode from weac.components.segment import Segment from weac.constants import STIFFNESS_COLLAPSE_FACTOR diff --git a/tests/analysis/test_analyzer.py b/tests/analysis/test_analyzer.py index 9c2014a..aacb8f0 100644 --- a/tests/analysis/test_analyzer.py +++ b/tests/analysis/test_analyzer.py @@ -2,11 +2,9 @@ This module contains tests for the Analyzer class. """ -# Standard library imports -import unittest - # Third party imports import numpy as np +import pytest from weac.analysis import Analyzer from weac.components import ( @@ -20,141 +18,153 @@ from weac.core.system_model import SystemModel -class TestAnalyzer(unittest.TestCase): - """Test suite for the Analyzer.""" +@pytest.fixture +def sm_ski(): + """Basic skier system model.""" + model_input_ski = ModelInput( + scenario_config=ScenarioConfig(phi=15.0, system_type="skier"), + layers=[Layer()], + weak_layer=WeakLayer(), + segments=[Segment(), Segment()], + ) + return SystemModel(model_input=model_input_ski, config=Config()) - def setUp(self): - """Set up systems for tests: a generic skier system and a PST system.""" - # Basic "skier" system - self.model_input_ski = ModelInput( - scenario_config=ScenarioConfig(phi=15.0, system_type="skier"), - layers=[Layer()], - weak_layer=WeakLayer(), - segments=[Segment(), Segment()], - ) - self.sm_ski = SystemModel(model_input=self.model_input_ski, config=Config()) - self.an_ski = Analyzer(system_model=self.sm_ski, printing_enabled=False) - - # PST system for potential energy related methods - self.model_input_pst = ModelInput( - scenario_config=ScenarioConfig(phi=10.0, system_type="pst-"), - layers=[Layer()], - weak_layer=WeakLayer(), - segments=[Segment(), Segment()], - ) - self.sm_pst = SystemModel(model_input=self.model_input_pst, config=Config()) - self.an_pst = Analyzer(system_model=self.sm_pst, printing_enabled=False) - def test_rasterize_solution_runs_and_shapes(self): +@pytest.fixture +def an_ski(sm_ski): + """Analyzer for the basic skier system.""" + return Analyzer(system_model=sm_ski, printing_enabled=False) + + +@pytest.fixture +def sm_pst(): + """PST system model for potential-energy related methods.""" + model_input_pst = ModelInput( + scenario_config=ScenarioConfig(phi=10.0, system_type="pst-"), + layers=[Layer()], + weak_layer=WeakLayer(), + segments=[Segment(), Segment()], + ) + return SystemModel(model_input=model_input_pst, config=Config()) + + +@pytest.fixture +def an_pst(sm_pst): + """Analyzer for the PST system.""" + return Analyzer(system_model=sm_pst, printing_enabled=False) + + +class TestAnalyzer: + """Test suite for the Analyzer.""" + + def test_rasterize_solution_runs_and_shapes(self, an_ski): """Test rasterize_solution runs and shapes.""" for mode in ("cracked", "uncracked"): - xs, Z, xs_supported = self.an_ski.rasterize_solution(mode=mode, num=200) - self.assertEqual(Z.shape[0], 6) - self.assertEqual(xs.shape[0], Z.shape[1]) - self.assertEqual(xs_supported.shape[0], xs.shape[0]) - self.assertTrue(np.all(np.diff(xs[~np.isnan(xs)]) >= 0)) + xs, Z, xs_supported = an_ski.rasterize_solution(mode=mode, num=200) + assert Z.shape[0] == 6 + assert xs.shape[0] == Z.shape[1] + assert xs_supported.shape[0] == xs.shape[0] + assert np.all(np.diff(xs[~np.isnan(xs)]) >= 0) - def test_get_zmesh_contains_expected_keys(self): + def test_get_zmesh_contains_expected_keys(self, an_ski): """Test get_zmesh contains expected keys.""" - zmesh = self.an_ski.get_zmesh(dz=5) + zmesh = an_ski.get_zmesh(dz=5) for key in ("z", "E", "nu", "rho", "tensile_strength"): - self.assertIn(key, zmesh) + assert key in zmesh # Non-empty mesh - self.assertGreater(len(zmesh["z"]), 1) + assert len(zmesh["z"]) > 1 z = np.asarray(zmesh["z"]) - self.assertTrue(np.all(np.diff(z) > 0)) + assert np.all(np.diff(z) > 0) - def test_stress_fields_shapes_and_finite(self): + def test_stress_fields_shapes_and_finite(self, an_ski, sm_ski): """Test stress fields shapes and finite values.""" - _, Z, _ = self.an_ski.rasterize_solution(num=150) - phi = self.sm_ski.scenario.phi - Sxx = self.an_ski.Sxx(Z=Z, phi=phi, dz=5) - Txz = self.an_ski.Txz(Z=Z, phi=phi, dz=5) - Szz = self.an_ski.Szz(Z=Z, phi=phi, dz=5) + _, Z, _ = an_ski.rasterize_solution(num=150) + phi = sm_ski.scenario.phi + Sxx = an_ski.Sxx(Z=Z, phi=phi, dz=5) + Txz = an_ski.Txz(Z=Z, phi=phi, dz=5) + Szz = an_ski.Szz(Z=Z, phi=phi, dz=5) # Consistent shapes - self.assertEqual(Sxx.shape, Txz.shape) - self.assertEqual(Sxx.shape, Szz.shape) + assert Sxx.shape == Txz.shape + assert Sxx.shape == Szz.shape # Finite values - self.assertTrue(np.isfinite(Sxx).all()) - self.assertTrue(np.isfinite(Txz).all()) - self.assertTrue(np.isfinite(Szz).all()) + assert np.isfinite(Sxx).all() + assert np.isfinite(Txz).all() + assert np.isfinite(Szz).all() - def test_stress_fields_unit_conversion(self): + def test_stress_fields_unit_conversion(self, an_ski, sm_ski): """Test stress fields unit conversion.""" - _, Z, _ = self.an_ski.rasterize_solution(num=150) - phi = self.sm_ski.scenario.phi - Sxx_kPa = self.an_ski.Sxx(Z=Z, phi=phi, dz=5, unit="kPa") - Sxx_MPa = self.an_ski.Sxx(Z=Z, phi=phi, dz=5, unit="MPa") - self.assertEqual(Sxx_kPa.shape, Sxx_MPa.shape) + _, Z, _ = an_ski.rasterize_solution(num=150) + phi = sm_ski.scenario.phi + Sxx_kPa = an_ski.Sxx(Z=Z, phi=phi, dz=5, unit="kPa") + Sxx_MPa = an_ski.Sxx(Z=Z, phi=phi, dz=5, unit="MPa") + assert Sxx_kPa.shape == Sxx_MPa.shape np.testing.assert_array_almost_equal(Sxx_kPa, Sxx_MPa * 1e3, decimal=8) - principal_stress_MPa = self.an_ski.principal_stress_slab( + principal_stress_MPa = an_ski.principal_stress_slab( Z=Z, phi=phi, dz=5, unit="MPa" ) - principal_stress_kPa = self.an_ski.principal_stress_slab( + principal_stress_kPa = an_ski.principal_stress_slab( Z=Z, phi=phi, dz=5, unit="kPa" ) - self.assertEqual(principal_stress_MPa.shape, principal_stress_kPa.shape) + assert principal_stress_MPa.shape == principal_stress_kPa.shape np.testing.assert_array_almost_equal( principal_stress_MPa * 1e3, principal_stress_kPa, decimal=8 ) # Test normalized is the same irrespective of unit - Sxx_kPa_norm = self.an_ski.Sxx(Z=Z, phi=phi, dz=5, unit="kPa", normalize=True) - Sxx_MPa_norm = self.an_ski.Sxx(Z=Z, phi=phi, dz=5, unit="MPa", normalize=True) - self.assertEqual(Sxx_kPa_norm.shape, Sxx_MPa_norm.shape) + Sxx_kPa_norm = an_ski.Sxx(Z=Z, phi=phi, dz=5, unit="kPa", normalize=True) + Sxx_MPa_norm = an_ski.Sxx(Z=Z, phi=phi, dz=5, unit="MPa", normalize=True) + assert Sxx_kPa_norm.shape == Sxx_MPa_norm.shape np.testing.assert_array_almost_equal(Sxx_kPa_norm, Sxx_MPa_norm, decimal=8) - principal_stress_MPa_norm = self.an_ski.principal_stress_slab( + principal_stress_MPa_norm = an_ski.principal_stress_slab( Z=Z, phi=phi, dz=5, unit="MPa", normalize=True ) - principal_stress_kPa_norm = self.an_ski.principal_stress_slab( + principal_stress_kPa_norm = an_ski.principal_stress_slab( Z=Z, phi=phi, dz=5, unit="kPa", normalize=True ) - self.assertEqual( - principal_stress_MPa_norm.shape, principal_stress_kPa_norm.shape - ) + assert principal_stress_MPa_norm.shape == principal_stress_kPa_norm.shape np.testing.assert_array_almost_equal( principal_stress_MPa_norm, principal_stress_kPa_norm, decimal=8 ) - def test_principal_stress_slab_variants(self): + def test_principal_stress_slab_variants(self, an_ski, sm_ski): """Test principal stress slab variants.""" - _, Z, _ = self.an_ski.rasterize_solution(num=120) - phi = self.sm_ski.scenario.phi + _, Z, _ = an_ski.rasterize_solution(num=120) + phi = sm_ski.scenario.phi for val in ("max", "min"): - Ps = self.an_ski.principal_stress_slab(Z=Z, phi=phi, dz=5, val=val) - self.assertTrue(np.isfinite(Ps).all()) + Ps = an_ski.principal_stress_slab(Z=Z, phi=phi, dz=5, val=val) + assert np.isfinite(Ps).all() # Normalized tensile principal stress - Ps_norm = self.an_ski.principal_stress_slab( + Ps_norm = an_ski.principal_stress_slab( Z=Z, phi=phi, dz=5, val="max", normalize=True ) - self.assertTrue(np.isfinite(Ps_norm).all()) + assert np.isfinite(Ps_norm).all() # Normalizing compressive should error - with self.assertRaises(ValueError): - _ = self.an_ski.principal_stress_slab( + with pytest.raises(ValueError): + _ = an_ski.principal_stress_slab( Z=Z, phi=phi, dz=5, val="min", normalize=True ) - def test_principal_stress_weaklayer_variants(self): + def test_principal_stress_weaklayer_variants(self, an_ski): """Test principal stress weaklayer variants.""" - _, Z, _ = self.an_ski.rasterize_solution(num=120) + _, Z, _ = an_ski.rasterize_solution(num=120) for val in ("max", "min"): - ps = self.an_ski.principal_stress_weaklayer(Z=Z, val=val) - self.assertTrue(np.isfinite(ps).all()) + ps = an_ski.principal_stress_weaklayer(Z=Z, val=val) + assert np.isfinite(ps).all() # Normalized compressive principal stress in weak layer - psn = self.an_ski.principal_stress_weaklayer(Z=Z, val="min", normalize=True) - self.assertTrue(np.isfinite(psn).all()) + psn = an_ski.principal_stress_weaklayer(Z=Z, val="min", normalize=True) + assert np.isfinite(psn).all() # Normalizing tensile should error - with self.assertRaises(ValueError): - _ = self.an_ski.principal_stress_weaklayer(Z=Z, val="max", normalize=True) + with pytest.raises(ValueError): + _ = an_ski.principal_stress_weaklayer(Z=Z, val="max", normalize=True) - def test_energy_release_rates_shapes(self): + def test_energy_release_rates_shapes(self, an_ski): """Test energy release rates shapes.""" - Ginc = self.an_ski.incremental_ERR() - self.assertEqual(Ginc.shape, (4,)) - self.assertTrue(np.isfinite(Ginc).all()) + Ginc = an_ski.incremental_ERR() + assert Ginc.shape == (4,) + assert np.isfinite(Ginc).all() - Gdif = self.an_ski.differential_ERR() - self.assertEqual(Gdif.shape, (4,)) - self.assertTrue(np.isfinite(Gdif).all()) + Gdif = an_ski.differential_ERR() + assert Gdif.shape == (4,) + assert np.isfinite(Gdif).all() def test_energy_release_rate_integrands_non_negative(self): """Test that ERR integrands are non-negative for matching stress/strain.""" @@ -182,23 +192,21 @@ def constant_solution(x): np.array([0.0, 1.0]), constant_solution, constant_solution ) - self.assertTrue(np.all(mode_i >= 0), "Mode I integrand should be non-negative") - self.assertTrue( - np.all(mode_ii >= 0), "Mode II integrand should be non-negative" - ) + assert np.all(mode_i >= 0), "Mode I integrand should be non-negative" + assert np.all(mode_ii >= 0), "Mode II integrand should be non-negative" - def test_internal_and_external_potentials_pst(self): + def test_internal_and_external_potentials_pst(self, an_pst): """Test internal and external potentials for PST.""" # Ensure PST-specific methods run - Pi_total = self.an_pst.total_potential() - self.assertTrue(np.isfinite(Pi_total)) + Pi_total = an_pst.total_potential() + assert np.isfinite(Pi_total) - Pi_ext = self.an_pst._external_potential() # pylint: disable=protected-access + Pi_ext = an_pst._external_potential() # pylint: disable=protected-access - self.assertTrue(np.isfinite(Pi_ext)) + assert np.isfinite(Pi_ext) - Pi_int = self.an_pst._internal_potential() # pylint: disable=protected-access + Pi_int = an_pst._internal_potential() # pylint: disable=protected-access - self.assertTrue(np.isfinite(Pi_int)) + assert np.isfinite(Pi_int) # Consistency: total ≈ int + ext - self.assertAlmostEqual(Pi_total, Pi_int + Pi_ext, places=6) + assert Pi_total == pytest.approx(Pi_int + Pi_ext, abs=0.5 * 10 ** (-6)) diff --git a/tests/analysis/test_criteria_evaluator.py b/tests/analysis/test_criteria_evaluator.py index 462b0a0..70ccc1b 100644 --- a/tests/analysis/test_criteria_evaluator.py +++ b/tests/analysis/test_criteria_evaluator.py @@ -3,12 +3,12 @@ """ # Standard library imports -import unittest from types import SimpleNamespace from unittest.mock import patch # Third party imports import numpy as np +import pytest # weac imports from weac.analysis.criteria_evaluator import ( @@ -29,43 +29,76 @@ from weac.core.system_model import SystemModel -class TestCriteriaEvaluator(unittest.TestCase): - """Test suite for the CriteriaEvaluator.""" +@pytest.fixture +def config(): + """Default WEAC config.""" + return Config() - def setUp(self): - """Set up common objects for testing.""" - self.config = Config() - self.criteria_config = CriteriaConfig() - self.evaluator = CriteriaEvaluator(self.criteria_config) - - self.layers = [ - Layer(rho=170, h=100), - Layer(rho=190, h=40), - Layer(rho=230, h=130), - Layer(rho=250, h=20), - Layer(rho=210, h=70), - Layer(rho=380, h=20), - Layer(rho=280, h=100), - ] - self.weak_layer = WeakLayer(rho=180, h=10, G_Ic=0.5, G_IIc=0.8, kn=100, kt=100) - self.phi = 30.0 - self.segments_length = 10000 - def test_fracture_toughness_criterion(self): +@pytest.fixture +def criteria_config(): + """Default criteria config.""" + return CriteriaConfig() + + +@pytest.fixture +def evaluator(criteria_config): + """CriteriaEvaluator bound to the criteria config fixture.""" + return CriteriaEvaluator(criteria_config) + + +@pytest.fixture +def layers(): + """Standard multi-layer slab profile used across evaluator tests.""" + return [ + Layer(rho=170, h=100), + Layer(rho=190, h=40), + Layer(rho=230, h=130), + Layer(rho=250, h=20), + Layer(rho=210, h=70), + Layer(rho=380, h=20), + Layer(rho=280, h=100), + ] + + +@pytest.fixture +def weak_layer(): + """Default weak layer for evaluator tests.""" + return WeakLayer(rho=180, h=10, G_Ic=0.5, G_IIc=0.8, kn=100, kt=100) + + +@pytest.fixture +def phi(): + """Default slope angle.""" + return 30.0 + + +@pytest.fixture +def segments_length(): + """Default segment length used in evaluator setups.""" + return 10000 + + +class TestCriteriaEvaluator: + """Test suite for the CriteriaEvaluator.""" + + def test_fracture_toughness_criterion(self, evaluator, weak_layer): """Test the fracture toughness criterion calculation.""" - g_delta = self.evaluator.fracture_toughness_envelope( - G_I=0.25, G_II=0.4, weak_layer=self.weak_layer + g_delta = evaluator.fracture_toughness_envelope( + G_I=0.25, G_II=0.4, weak_layer=weak_layer ) # Expected: (|0.25| / 0.5)^5.0 + (|0.4| / 0.8)^2.22 # = (0.5)^5 + (0.5)^2.22 = 0.03125 + 0.2146... np.testing.assert_almost_equal(g_delta, 0.2455609957, decimal=5) - def test_stress_envelope_adam_unpublished(self): + def test_stress_envelope_adam_unpublished( + self, criteria_config, evaluator, weak_layer + ): """Test the 'adam_unpublished' stress envelope.""" - self.criteria_config.stress_envelope_method = "adam_unpublished" + criteria_config.stress_envelope_method = "adam_unpublished" sigma, tau = np.array([2.0]), np.array([1.5]) - result = self.evaluator.stress_envelope(sigma, tau, self.weak_layer) - self.assertGreater(result[0], 0) + result = evaluator.stress_envelope(sigma, tau, weak_layer) + assert result[0] > 0 @patch("weac.analysis.criteria_evaluator.Analyzer") def test_calculate_maximal_stresses_applies_directional_low_density_exclusion( @@ -106,78 +139,86 @@ def test_calculate_maximal_stresses_applies_directional_low_density_exclusion( CriteriaConfig() )._calculate_maximal_stresses(system=system) # pylint: disable=protected-access - self.assertAlmostEqual(top_broken_result.slab_tensile_criterion, 1 / 2) - self.assertAlmostEqual(top_unbroken_result.slab_tensile_criterion, 1 / 4) + assert top_broken_result.slab_tensile_criterion == pytest.approx( + 1 / 2, abs=0.5 * 10 ** (-7) + ) + assert top_unbroken_result.slab_tensile_criterion == pytest.approx( + 1 / 4, abs=0.5 * 10 ** (-7) + ) - def test_find_minimum_force_convergence(self): + def test_find_minimum_force_convergence( + self, evaluator, layers, weak_layer, phi, segments_length, config + ): """Test the convergence of find_minimum_force.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), Segment(length=0, has_foundation=False, m=0), Segment(length=0, has_foundation=False, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), - config=self.config, - ) - results: FindMinimumForceResult = self.evaluator.find_minimum_force( - system=system + config=config, ) + results: FindMinimumForceResult = evaluator.find_minimum_force(system=system) skier_weight = results.critical_skier_weight new_segments = results.new_segments - self.assertGreater(skier_weight, 0) - self.assertIsNotNone(new_segments) + assert skier_weight > 0 + assert new_segments is not None - def test_find_crack_length_for_weight(self): + def test_find_crack_length_for_weight( + self, evaluator, layers, weak_layer, phi, segments_length, config + ): """Test the find_crack_length_for_weight method.""" skier_weight = 100 # A substantial weight segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), Segment(length=0, has_foundation=False, m=skier_weight), Segment(length=0, has_foundation=False, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi, cut_length=0), + scenario_config=ScenarioConfig(phi=phi, cut_length=0), ), - config=self.config, + config=config, ) - crack_len, segments = self.evaluator.find_crack_length_for_weight( + crack_len, segments = evaluator.find_crack_length_for_weight( system, skier_weight ) - self.assertGreaterEqual(crack_len, 0) - self.assertIsInstance(segments, list) - self.assertTrue(all(isinstance(s, Segment) for s in segments)) + assert crack_len >= 0 + assert isinstance(segments, list) + assert all(isinstance(s, Segment) for s in segments) - def test_check_crack_propagation_stable(self): + def test_check_crack_propagation_stable( + self, evaluator, layers, weak_layer, phi, segments_length, config + ): """Test check_crack_propagation for a stable scenario (no crack).""" - segments = [Segment(length=self.segments_length, has_foundation=True, m=0)] + segments = [Segment(length=segments_length, has_foundation=True, m=0)] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), - config=self.config, - ) - g_delta, can_propagate = self.evaluator.check_crack_self_propagation(system) - self.assertFalse(can_propagate) - self.assertLess( - g_delta, 1.0, "Stable scenario should be below the fracture envelope" + config=config, ) + g_delta, can_propagate = evaluator.check_crack_self_propagation(system) + assert not can_propagate + assert g_delta < 1.0, "Stable scenario should be below the fracture envelope" - def test_check_crack_propagation_unstable(self): + def test_check_crack_propagation_unstable( + self, evaluator, layers, phi, segments_length, config + ): """Test check_crack_propagation for an unstable scenario (pre-cracked).""" # A configuration with a very weak layer and a large crack that should # be unstable under its own weight. @@ -185,7 +226,7 @@ def test_check_crack_propagation_unstable(self): rho=180, h=10, G_Ic=0.01, G_IIc=0.01, kn=100, kt=100 ) crack_length = 4000 # 4m crack - side_length = (self.segments_length - crack_length) / 2 + side_length = (segments_length - crack_length) / 2 segments = [ Segment(length=side_length, has_foundation=True, m=0), Segment(length=crack_length, has_foundation=False, m=0), @@ -193,75 +234,81 @@ def test_check_crack_propagation_unstable(self): ] system = SystemModel( model_input=ModelInput( - layers=self.layers, + layers=layers, weak_layer=unstable_weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), - config=self.config, + config=config, ) - g_delta, can_propagate = self.evaluator.check_crack_self_propagation(system) - self.assertGreater(g_delta, 1) - self.assertTrue(can_propagate) + g_delta, can_propagate = evaluator.check_crack_self_propagation(system) + assert g_delta > 1 + assert can_propagate - def test_evaluate_coupled_criterion_full_run(self): + def test_evaluate_coupled_criterion_full_run( + self, evaluator, layers, weak_layer, phi, segments_length, config + ): """Test the main evaluate_coupled_criterion workflow.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), Segment(length=0, has_foundation=False, m=0), Segment(length=0, has_foundation=False, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), - config=self.config, + config=config, ) - results: CoupledCriterionResult = self.evaluator.evaluate_coupled_criterion( + results: CoupledCriterionResult = evaluator.evaluate_coupled_criterion( system=system ) - self.assertIsInstance(results, CoupledCriterionResult) - self.assertGreater(results.critical_skier_weight, 0) - self.assertIsNotNone(results.history) + assert isinstance(results, CoupledCriterionResult) + assert results.critical_skier_weight > 0 + assert results.history is not None history = results.history assert history is not None - self.assertEqual(len(history.sigma_maxs), len(history.skier_weights)) - self.assertGreater(len(history.sigma_maxs), 0) - self.assertEqual(len(history.tau_maxs), len(history.skier_weights)) - self.assertGreater(len(history.tau_maxs), 0) - - def test_evaluate_SteadyState(self): + assert len(history.sigma_maxs) == len(history.skier_weights) + assert len(history.sigma_maxs) > 0 + assert len(history.tau_maxs) == len(history.skier_weights) + assert len(history.tau_maxs) > 0 + + def test_evaluate_SteadyState( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test the evaluate_SteadyState method.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - results: SteadyStateResult = self.evaluator.evaluate_SteadyState(system) - self.assertTrue(results.converged) - self.assertGreater(results.energy_release_rate, 0) - self.assertGreater(results.touchdown_distance, 0) - self.assertLess(results.touchdown_distance, system.scenario.L) + results: SteadyStateResult = evaluator.evaluate_SteadyState(system) + assert results.converged + assert results.energy_release_rate > 0 + assert results.touchdown_distance > 0 + assert results.touchdown_distance < system.scenario.L max_principal_stress_norm = ( results.maximal_stress_result.max_principal_stress_norm ) max_Sxx_norm = results.maximal_stress_result.max_Sxx_norm - self.assertGreater(max_principal_stress_norm, 0) - self.assertGreater(max_Sxx_norm, 0) + assert max_principal_stress_norm > 0 + assert max_Sxx_norm > 0 - def test_evaluate_SteadyState_without_touchdown_in_config(self): + def test_evaluate_SteadyState_without_touchdown_in_config( + self, evaluator, layers, weak_layer, phi, segments_length + ): """ Test evaluate_SteadyState when SystemModel is initialized without touchdown=True. @@ -271,59 +318,63 @@ def test_evaluate_SteadyState_without_touchdown_in_config(self): using toggle_touchdown() to properly invalidate cached properties. """ segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] # Initialize system WITHOUT touchdown=True (default is False) system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(), # touchdown defaults to False ) # This should not raise AttributeError: 'NoneType' object has no attribute 'l_BC' - results: SteadyStateResult = self.evaluator.evaluate_SteadyState(system) + results: SteadyStateResult = evaluator.evaluate_SteadyState(system) # Verify results are valid - self.assertTrue(results.converged) - self.assertGreater(results.energy_release_rate, 0) - self.assertGreater(results.touchdown_distance, 0) - self.assertLess(results.touchdown_distance, results.system.scenario.L) + assert results.converged + assert results.energy_release_rate > 0 + assert results.touchdown_distance > 0 + assert results.touchdown_distance < results.system.scenario.L max_principal_stress_norm = ( results.maximal_stress_result.max_principal_stress_norm ) max_Sxx_norm = results.maximal_stress_result.max_Sxx_norm - self.assertGreater(max_principal_stress_norm, 0) - self.assertGreater(max_Sxx_norm, 0) + assert max_principal_stress_norm > 0 + assert max_Sxx_norm > 0 # Verify the original system's touchdown state was not modified - self.assertFalse(system.config.touchdown) + assert not system.config.touchdown - def test_find_minimum_crack_length(self): + def test_find_minimum_crack_length( + self, evaluator, layers, weak_layer, phi, segments_length, config + ): """Test the find_minimum_crack_length method.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), - config=self.config, + config=config, ) - crack_length, new_segments = self.evaluator.find_minimum_crack_length(system) - self.assertGreater(crack_length, 0) - self.assertIsInstance(new_segments, list) - self.assertTrue(all(isinstance(s, Segment) for s in new_segments)) - - def test_evaluate_SteadyState_modes(self): + crack_length, new_segments = evaluator.find_minimum_crack_length(system) + assert crack_length > 0 + assert isinstance(new_segments, list) + assert all(isinstance(s, Segment) for s in new_segments) + + def test_evaluate_SteadyState_modes( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test evaluate_SteadyState with various modes.""" test_cases = [ ("C_in_contact", "C_in_contact"), @@ -333,101 +384,91 @@ def test_evaluate_SteadyState_modes(self): ] for mode_param, expected_mode in test_cases: - with self.subTest(mode=mode_param): - segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), - ] - system = SystemModel( - model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, - segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), - ), - config=Config(touchdown=True), - ) - - if mode_param is None: - results: SteadyStateResult = self.evaluator.evaluate_SteadyState( - system - ) - else: - results: SteadyStateResult = self.evaluator.evaluate_SteadyState( - system, mode=mode_param - ) - - self.assertTrue(results.converged) - self.assertEqual( - results.system.slab_touchdown.touchdown_mode, - expected_mode, - ) - - def test_steady_state_maximal_stress_structure(self): + segments = [ + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + ] + system = SystemModel( + model_input=ModelInput( + layers=layers, + weak_layer=weak_layer, + segments=segments, + scenario_config=ScenarioConfig(phi=phi), + ), + config=Config(touchdown=True), + ) + + if mode_param is None: + results: SteadyStateResult = evaluator.evaluate_SteadyState(system) + else: + results = evaluator.evaluate_SteadyState(system, mode=mode_param) + + assert results.converged + assert results.system.slab_touchdown.touchdown_mode == expected_mode + + def test_steady_state_maximal_stress_structure( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test that maximal stress result has correct structure and valid values.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) + result = evaluator.evaluate_SteadyState(system) maximal_stress = result.maximal_stress_result # Check that all arrays have correct shape - self.assertEqual( - maximal_stress.principal_stress_kPa.shape, - maximal_stress.Sxx_kPa.shape, - ) - self.assertEqual( - maximal_stress.principal_stress_norm.shape, - maximal_stress.Sxx_norm.shape, + assert maximal_stress.principal_stress_kPa.shape == maximal_stress.Sxx_kPa.shape + assert ( + maximal_stress.principal_stress_norm.shape == maximal_stress.Sxx_norm.shape ) # Check that arrays are not empty - self.assertGreater(maximal_stress.principal_stress_kPa.size, 0) - self.assertGreater(maximal_stress.Sxx_kPa.size, 0) + assert maximal_stress.principal_stress_kPa.size > 0 + assert maximal_stress.Sxx_kPa.size > 0 # Check that maximum values are positive - self.assertGreater(maximal_stress.max_principal_stress_norm, 0) - self.assertGreater(maximal_stress.max_Sxx_norm, 0) + assert maximal_stress.max_principal_stress_norm > 0 + assert maximal_stress.max_Sxx_norm > 0 # Check that slab_tensile_criterion is between 0 and 1 - self.assertGreaterEqual(maximal_stress.slab_tensile_criterion, 0) - self.assertLessEqual(maximal_stress.slab_tensile_criterion, 1) + assert maximal_stress.slab_tensile_criterion >= 0 + assert maximal_stress.slab_tensile_criterion <= 1 - def test_steady_state_energy_release_rate_positive(self): + def test_steady_state_energy_release_rate_positive( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test that steady state ERR is always positive.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) - self.assertGreater( - result.energy_release_rate, - 0, - "Steady state ERR should be positive", - ) + result = evaluator.evaluate_SteadyState(system) + assert result.energy_release_rate > 0, "Steady state ERR should be positive" - def test_steady_state_with_different_weak_layers(self): + def test_steady_state_with_different_weak_layers( + self, evaluator, layers, phi, segments_length + ): """Test steady state evaluation with different weak layer properties.""" weak_layers = [ WeakLayer(rho=150, h=10, G_Ic=0.3, G_IIc=0.6, kn=50, kt=50), @@ -436,66 +477,68 @@ def test_steady_state_with_different_weak_layers(self): ] segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] for weak_layer in weak_layers: - with self.subTest(weak_layer=weak_layer): - system = SystemModel( - model_input=ModelInput( - layers=self.layers, - weak_layer=weak_layer, - segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), - ), - config=Config(touchdown=True), - ) - - result = self.evaluator.evaluate_SteadyState(system) - self.assertTrue(result.converged) - self.assertGreater(result.energy_release_rate, 0) - self.assertGreater(result.touchdown_distance, 0) - - def test_steady_state_with_different_slope_angles(self): + system = SystemModel( + model_input=ModelInput( + layers=layers, + weak_layer=weak_layer, + segments=segments, + scenario_config=ScenarioConfig(phi=phi), + ), + config=Config(touchdown=True), + ) + + result = evaluator.evaluate_SteadyState(system) + assert result.converged + assert result.energy_release_rate > 0 + assert result.touchdown_distance > 0 + + def test_steady_state_with_different_slope_angles( + self, evaluator, layers, weak_layer, segments_length + ): """Test steady state evaluation at different slope angles.""" slope_angles = [20.0, 30.0, 40.0, 45.0] segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] for phi in slope_angles: - with self.subTest(phi=phi): - system = SystemModel( - model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, - segments=segments, - scenario_config=ScenarioConfig(phi=phi), - ), - config=Config(touchdown=True), - ) - - result = self.evaluator.evaluate_SteadyState(system) - self.assertTrue(result.converged) - self.assertGreater(result.energy_release_rate, 0) - self.assertGreater(result.touchdown_distance, 0) - self.assertIsNotNone(result.maximal_stress_result) - - def test_steady_state_system_isolation(self): + system = SystemModel( + model_input=ModelInput( + layers=layers, + weak_layer=weak_layer, + segments=segments, + scenario_config=ScenarioConfig(phi=phi), + ), + config=Config(touchdown=True), + ) + + result = evaluator.evaluate_SteadyState(system) + assert result.converged + assert result.energy_release_rate > 0 + assert result.touchdown_distance > 0 + assert result.maximal_stress_result is not None + + def test_steady_state_system_isolation( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test that evaluate_SteadyState doesn't modify original system.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) @@ -504,54 +547,58 @@ def test_steady_state_system_isolation(self): original_phi = system.scenario.phi original_L = system.scenario.L - result = self.evaluator.evaluate_SteadyState(system) + result = evaluator.evaluate_SteadyState(system) # Verify original system is unchanged - self.assertEqual(len(system.scenario.segments), len(original_segments)) - self.assertEqual(system.scenario.phi, original_phi) - self.assertEqual(system.scenario.L, original_L) + assert len(system.scenario.segments) == len(original_segments) + assert system.scenario.phi == original_phi + assert system.scenario.L == original_L # Verify result system is different - self.assertEqual(result.system.scenario.phi, 0.0) + assert result.system.scenario.phi == 0.0 - def test_steady_state_message_format(self): + def test_steady_state_message_format( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test that steady state result message is correctly formatted.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) - self.assertIsInstance(result.message, str) - self.assertGreater(len(result.message), 0) - self.assertEqual(result.message, "Steady State evaluation successful.") + result = evaluator.evaluate_SteadyState(system) + assert isinstance(result.message, str) + assert len(result.message) > 0 + assert result.message == "Steady State evaluation successful." - def test_steady_state_normalized_stresses_consistency(self): + def test_steady_state_normalized_stresses_consistency( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test consistency between absolute and normalized stress values.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) + result = evaluator.evaluate_SteadyState(system) maximal_stress = result.maximal_stress_result # Verify that max normalized values match the max of the arrays @@ -569,137 +616,143 @@ def test_steady_state_normalized_stresses_consistency(self): decimal=5, ) - def test_steady_state_with_thin_weak_layer(self): + def test_steady_state_with_thin_weak_layer( + self, evaluator, layers, phi, segments_length + ): """Test steady state evaluation with a thin weak layer.""" thin_weak_layer = WeakLayer(rho=180, h=5, G_Ic=0.5, G_IIc=0.8, kn=100, kt=100) segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, + layers=layers, weak_layer=thin_weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) - self.assertTrue(result.converged) - self.assertGreater(result.touchdown_distance, 0) - self.assertGreater(result.energy_release_rate, 0) + result = evaluator.evaluate_SteadyState(system) + assert result.converged + assert result.touchdown_distance > 0 + assert result.energy_release_rate > 0 - def test_steady_state_with_thick_weak_layer(self): + def test_steady_state_with_thick_weak_layer( + self, evaluator, layers, phi, segments_length + ): """Test steady state evaluation with a thick weak layer.""" thick_weak_layer = WeakLayer(rho=180, h=20, G_Ic=0.5, G_IIc=0.8, kn=100, kt=100) segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, + layers=layers, weak_layer=thick_weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) - self.assertTrue(result.converged) - self.assertGreater(result.touchdown_distance, 0) - self.assertGreater(result.energy_release_rate, 0) + result = evaluator.evaluate_SteadyState(system) + assert result.converged + assert result.touchdown_distance > 0 + assert result.energy_release_rate > 0 - def test_steady_state_vertical_mode_warning(self): + def test_steady_state_vertical_mode_warning( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test that vertical mode raises a warning.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - with self.assertWarns(UserWarning): - result = self.evaluator.evaluate_SteadyState(system, vertical=True) - self.assertTrue(result.converged) + with pytest.warns(UserWarning): + result = evaluator.evaluate_SteadyState(system, vertical=True) + assert result.converged - def test_steady_state_slab_tensile_criterion_calculation(self): + def test_steady_state_slab_tensile_criterion_calculation( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test the slab tensile criterion calculation in steady state.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) + result = evaluator.evaluate_SteadyState(system) slab_tensile_criterion = result.maximal_stress_result.slab_tensile_criterion # Verify it's within valid range - self.assertGreaterEqual(slab_tensile_criterion, 0.0) - self.assertLessEqual(slab_tensile_criterion, 1.0) + assert slab_tensile_criterion >= 0.0 + assert slab_tensile_criterion <= 1.0 - def test_steady_state_stress_arrays_shapes(self): + def test_steady_state_stress_arrays_shapes( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test that stress arrays in maximal stress result have consistent shapes.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) + result = evaluator.evaluate_SteadyState(system) maximal_stress = result.maximal_stress_result # All stress arrays should have the same shape - self.assertEqual( - maximal_stress.principal_stress_kPa.shape, - maximal_stress.principal_stress_norm.shape, - ) - self.assertEqual( - maximal_stress.Sxx_kPa.shape, - maximal_stress.Sxx_norm.shape, - ) - self.assertEqual( - maximal_stress.principal_stress_kPa.shape, - maximal_stress.Sxx_kPa.shape, + assert ( + maximal_stress.principal_stress_kPa.shape + == maximal_stress.principal_stress_norm.shape ) + assert maximal_stress.Sxx_kPa.shape == maximal_stress.Sxx_norm.shape + assert maximal_stress.principal_stress_kPa.shape == maximal_stress.Sxx_kPa.shape # Arrays should be 2D (spatial dimensions) - self.assertEqual(len(maximal_stress.principal_stress_kPa.shape), 2) - self.assertEqual(len(maximal_stress.Sxx_kPa.shape), 2) + assert len(maximal_stress.principal_stress_kPa.shape) == 2 + assert len(maximal_stress.Sxx_kPa.shape) == 2 - def test_steady_state_with_varying_stiffness(self): + def test_steady_state_with_varying_stiffness( + self, evaluator, layers, phi, segments_length + ): """Test steady state evaluation with different weak layer stiffnesses.""" stiffness_values = [(50, 50), (100, 100), (200, 200)] segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] results_list = [] @@ -707,98 +760,105 @@ def test_steady_state_with_varying_stiffness(self): weak_layer = WeakLayer(rho=180, h=10, G_Ic=0.5, G_IIc=0.8, kn=kn, kt=kt) system = SystemModel( model_input=ModelInput( - layers=self.layers, + layers=layers, weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) - self.assertTrue(result.converged) + result = evaluator.evaluate_SteadyState(system) + assert result.converged results_list.append(result) # Verify that all results are valid for result in results_list: - self.assertGreater(result.touchdown_distance, 0) - self.assertGreater(result.energy_release_rate, 0) + assert result.touchdown_distance > 0 + assert result.energy_release_rate > 0 - def test_steady_state_scenario_config_phi_reset(self): + def test_steady_state_scenario_config_phi_reset( + self, evaluator, layers, weak_layer, segments_length + ): """Test that steady state evaluation sets phi to 0.0 internally.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] original_phi = 35.0 system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, scenario_config=ScenarioConfig(phi=original_phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) + result = evaluator.evaluate_SteadyState(system) # The result system should have phi=0 for steady state evaluation - self.assertEqual(result.system.scenario.phi, 0.0) + assert result.system.scenario.phi == 0.0 # The original system should be unchanged - self.assertEqual(system.scenario.phi, original_phi) + assert system.scenario.phi == original_phi - def test_steady_state_touchdown_distance_bounds(self): + def test_steady_state_touchdown_distance_bounds( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test that touchdown distance is within reasonable bounds.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system) + result = evaluator.evaluate_SteadyState(system) # Touchdown distance should be positive - self.assertGreater(result.touchdown_distance, 0) + assert result.touchdown_distance > 0 # Touchdown distance should be less than the cut distance (5e3) # which is the length of the hanging segment in the steady state setup - self.assertLess(result.touchdown_distance, 5e3) + assert result.touchdown_distance < 5e3 - def test_steady_state_mode_forced_correctly(self): + def test_steady_state_mode_forced_correctly( + self, evaluator, layers, weak_layer, phi, segments_length + ): """Test that the forced touchdown mode is correctly applied.""" segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] for mode in ["C_in_contact", "B_point_contact", "A_free_hanging"]: - with self.subTest(mode=mode): - system = SystemModel( - model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, - segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), - ), - config=Config(touchdown=True), - ) - - result = self.evaluator.evaluate_SteadyState(system, mode=mode) - - # Verify the result system has the correct forced mode - self.assertEqual(result.system.slab_touchdown.touchdown_mode, mode) - - def test_steady_state_regression_c_in_contact_values(self): + system = SystemModel( + model_input=ModelInput( + layers=layers, + weak_layer=weak_layer, + segments=segments, + scenario_config=ScenarioConfig(phi=phi), + ), + config=Config(touchdown=True), + ) + + result = evaluator.evaluate_SteadyState(system, mode=mode) + + # Verify the result system has the correct forced mode + assert result.system.slab_touchdown.touchdown_mode == mode + + def test_steady_state_regression_c_in_contact_values( + self, evaluator, layers, weak_layer, phi, segments_length + ): """ Regression test: Check specific numerical values for C_in_contact mode. @@ -806,20 +866,20 @@ def test_steady_state_regression_c_in_contact_values(self): Update these values if intentional changes are made to the calculation. """ segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system, mode="C_in_contact") + result = evaluator.evaluate_SteadyState(system, mode="C_in_contact") expected_touchdown_distance = 1913.1270 expected_err = 5.7857 @@ -859,7 +919,9 @@ def test_steady_state_regression_c_in_contact_values(self): err_msg="Slab tensile criterion changed unexpectedly", ) - def test_steady_state_regression_b_point_contact_values(self): + def test_steady_state_regression_b_point_contact_values( + self, evaluator, layers, weak_layer, phi, segments_length + ): """ Regression test: Check specific numerical values for B_point_contact mode. @@ -867,20 +929,20 @@ def test_steady_state_regression_b_point_contact_values(self): Update these values if intentional changes are made to the calculation. """ segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system, mode="B_point_contact") + result = evaluator.evaluate_SteadyState(system, mode="B_point_contact") expected_touchdown_distance = 2111.1553 expected_err = 5.5184 @@ -905,7 +967,9 @@ def test_steady_state_regression_b_point_contact_values(self): err_msg="Max Sxx norm changed unexpectedly", ) - def test_steady_state_regression_a_free_hanging_values(self): + def test_steady_state_regression_a_free_hanging_values( + self, evaluator, layers, weak_layer, phi, segments_length + ): """ Regression test: Check specific numerical values for A_free_hanging mode. @@ -913,20 +977,20 @@ def test_steady_state_regression_a_free_hanging_values(self): Update these values if intentional changes are made to the calculation. """ segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, + layers=layers, + weak_layer=weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system, mode="A_free_hanging") + result = evaluator.evaluate_SteadyState(system, mode="A_free_hanging") expected_touchdown_distance = 1207.9559 expected_err = 5.6629 @@ -951,7 +1015,9 @@ def test_steady_state_regression_a_free_hanging_values(self): err_msg="Max Sxx norm changed unexpectedly", ) - def test_steady_state_regression_different_weak_layer(self): + def test_steady_state_regression_different_weak_layer( + self, evaluator, layers, phi, segments_length + ): """ Regression test: Check specific numerical values with different weak layer properties. @@ -959,20 +1025,20 @@ def test_steady_state_regression_different_weak_layer(self): """ weak_weak_layer = WeakLayer(rho=150, h=10, G_Ic=0.3, G_IIc=0.6, kn=50, kt=50) segments = [ - Segment(length=self.segments_length, has_foundation=True, m=0), - Segment(length=self.segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), + Segment(length=segments_length, has_foundation=True, m=0), ] system = SystemModel( model_input=ModelInput( - layers=self.layers, + layers=layers, weak_layer=weak_weak_layer, segments=segments, - scenario_config=ScenarioConfig(phi=self.phi), + scenario_config=ScenarioConfig(phi=phi), ), config=Config(touchdown=True), ) - result = self.evaluator.evaluate_SteadyState(system, mode="C_in_contact") + result = evaluator.evaluate_SteadyState(system, mode="C_in_contact") expected_touchdown_distance = 1911.4747 expected_err = 5.0860 @@ -996,7 +1062,3 @@ def test_steady_state_regression_different_weak_layer(self): rtol=1e-4, err_msg="Slab tensile criterion changed unexpectedly for weak layer", ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/analysis/test_slab_tensile_comparisons.py b/tests/analysis/test_slab_tensile_comparisons.py index 4188026..01ef88a 100644 --- a/tests/analysis/test_slab_tensile_comparisons.py +++ b/tests/analysis/test_slab_tensile_comparisons.py @@ -6,7 +6,8 @@ """ from dataclasses import dataclass, field -import unittest + +import pytest from weac.analysis.criteria_evaluator import CriteriaEvaluator from weac.components import ( @@ -166,37 +167,25 @@ def _evaluate_slab_tensile_criterion( return result.maximal_stress_result.slab_tensile_criterion -class TestSlabTensileComparisons(unittest.TestCase): - """Regression checks for slab tensile setup ordering.""" +@pytest.fixture(scope="module") +def evaluator(): + """Shared CriteriaEvaluator for the comparison matrix.""" + return CriteriaEvaluator(CriteriaConfig()) + - @classmethod - def setUpClass(cls): - """Create a shared evaluator for the comparison matrix.""" - cls.evaluator = CriteriaEvaluator(CriteriaConfig()) +class TestSlabTensileComparisons: + """Regression checks for slab tensile setup ordering.""" - def test_slab_tensile_criterion_ordering(self): + @pytest.mark.parametrize( + "case", + COMPARISON_CASES, + ids=[case.name for case in COMPARISON_CASES], + ) + def test_slab_tensile_criterion_ordering(self, evaluator, case): """Each case asserts that setup A exceeds setup B.""" - if not COMPARISON_CASES: - self.skipTest("Populate COMPARISON_CASES A/B setup pairs.") - - for case in COMPARISON_CASES: - with self.subTest(case=case.name): - criterion_a = _evaluate_slab_tensile_criterion( - self.evaluator, case.setup_a - ) - criterion_b = _evaluate_slab_tensile_criterion( - self.evaluator, case.setup_b - ) - # print(f"{case.name}: A={criterion_a:.6f}, B={criterion_b:.6f}") - self.assertGreaterEqual( - criterion_a, - criterion_b, - msg=( - f"{case.name}: expected A >= B, got " - f"A={criterion_a:.6f}, B={criterion_b:.6f}" - ), - ) - - -if __name__ == "__main__": - unittest.main() + criterion_a = _evaluate_slab_tensile_criterion(evaluator, case.setup_a) + criterion_b = _evaluate_slab_tensile_criterion(evaluator, case.setup_b) + assert criterion_a >= criterion_b, ( + f"{case.name}: expected A >= B, got " + f"A={criterion_a:.6f}, B={criterion_b:.6f}" + ) diff --git a/tests/components/test_configs.py b/tests/components/test_configs.py index 321e84c..d94589d 100644 --- a/tests/components/test_configs.py +++ b/tests/components/test_configs.py @@ -5,8 +5,8 @@ """ import json -import unittest +import pytest from pydantic import ValidationError from weac.components import ( @@ -20,7 +20,7 @@ ) -class TestConfig(unittest.TestCase): +class TestConfig: """Test the Config class for runtime configuration.""" def test_config_default_creation(self): @@ -28,7 +28,7 @@ def test_config_default_creation(self): config = Config() # Check default values - self.assertFalse(config.touchdown) + assert config.touchdown is False def test_config_backend_touchdown_compatibility_at_construction(self): """Test that generalized backend + touchdown=True is rejected at construction.""" @@ -38,53 +38,47 @@ def test_config_backend_touchdown_compatibility_at_construction(self): Config(backend="generalized", touchdown=False) # OK # Invalid combination should raise ValidationError - with self.assertRaises(ValidationError) as context: + with pytest.raises( + ValidationError, + match="Slab touchdown is only available for the classic backend", + ): Config(backend="generalized", touchdown=True) - error_msg = str(context.exception) - self.assertIn( - "Slab touchdown is only available for the classic backend", error_msg - ) - def test_config_backend_touchdown_compatibility_via_direct_assignment(self): """Test that validation runs when fields are directly assigned.""" # Start with a valid configuration config = Config(backend="generalized", touchdown=False) - self.assertFalse(config.touchdown) - self.assertEqual(config.backend, "generalized") + assert config.touchdown is False + assert config.backend == "generalized" # Direct assignment of touchdown should trigger validation and fail - with self.assertRaises(ValidationError) as context: + with pytest.raises( + ValidationError, + match="Slab touchdown is only available for the classic backend", + ): config.touchdown = True - error_msg = str(context.exception) - self.assertIn( - "Slab touchdown is only available for the classic backend", error_msg - ) - # Verify config state hasn't changed - self.assertFalse(config.touchdown) - self.assertEqual(config.backend, "generalized") + assert config.touchdown is False + assert config.backend == "generalized" def test_config_backend_touchdown_compatibility_via_backend_assignment(self): """Test that validation runs when backend is changed to incompatible value.""" # Start with touchdown enabled on classic backend config = Config(backend="classic", touchdown=True) - self.assertTrue(config.touchdown) - self.assertEqual(config.backend, "classic") + assert config.touchdown is True + assert config.backend == "classic" # Changing backend to generalized should trigger validation and fail - with self.assertRaises(ValidationError) as context: + with pytest.raises( + ValidationError, + match="Slab touchdown is only available for the classic backend", + ): config.backend = "generalized" - error_msg = str(context.exception) - self.assertIn( - "Slab touchdown is only available for the classic backend", error_msg - ) - # Verify config state hasn't changed - self.assertTrue(config.touchdown) - self.assertEqual(config.backend, "classic") + assert config.touchdown is True + assert config.backend == "classic" def test_config_valid_assignment_transitions(self): """Test that valid field assignments work correctly.""" @@ -94,29 +88,29 @@ def test_config_valid_assignment_transitions(self): config.backend = "classic" config.touchdown = True - self.assertEqual(config.backend, "classic") - self.assertTrue(config.touchdown) + assert config.backend == "classic" + assert config.touchdown is True # Disable touchdown, then switch to generalized - should work config.touchdown = False config.backend = "generalized" - self.assertEqual(config.backend, "generalized") - self.assertFalse(config.touchdown) + assert config.backend == "generalized" + assert config.touchdown is False -class TestScenarioConfig(unittest.TestCase): +class TestScenarioConfig: """Test the ScenarioConfig class.""" def test_scenario_config_defaults(self): """Test ScenarioConfig with default values.""" scenario = ScenarioConfig() - self.assertEqual(scenario.phi, 0) - self.assertEqual(scenario.system_type, "skiers") - self.assertEqual(scenario.cut_length, 0.0) - self.assertEqual(scenario.stiffness_ratio, 1000) - self.assertEqual(scenario.surface_load, 0.0) + assert scenario.phi == 0 + assert scenario.system_type == "skiers" + assert scenario.cut_length == 0.0 + assert scenario.stiffness_ratio == 1000 + assert scenario.surface_load == 0.0 def test_scenario_config_custom_values(self): """Test ScenarioConfig with custom values.""" @@ -128,43 +122,43 @@ def test_scenario_config_custom_values(self): surface_load=0.1, ) - self.assertEqual(scenario.phi, 30.0) - self.assertEqual(scenario.system_type, "skier") - self.assertEqual(scenario.cut_length, 150.0) - self.assertEqual(scenario.stiffness_ratio, 500.0) - self.assertEqual(scenario.surface_load, 0.1) + assert scenario.phi == 30.0 + assert scenario.system_type == "skier" + assert scenario.cut_length == 150.0 + assert scenario.stiffness_ratio == 500.0 + assert scenario.surface_load == 0.1 def test_scenario_config_validation(self): """Test ScenarioConfig validation.""" # Negative crack length - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): ScenarioConfig(cut_length=-10.0) # Invalid stiffness ratio (<= 0) - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): ScenarioConfig(stiffness_ratio=0.0) # Negative surface load - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): ScenarioConfig(surface_load=-5.0) # Invalid system type - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): ScenarioConfig(system_type="invalid_system") -class TestCriteriaConfig(unittest.TestCase): +class TestCriteriaConfig: """Test the CriteriaConfig class.""" def test_criteria_config_defaults(self): """Test CriteriaConfig with default values.""" criteria = CriteriaConfig() - self.assertEqual(criteria.fn, 2.0) - self.assertEqual(criteria.fm, 2.0) - self.assertEqual(criteria.gn, 5.0) - self.assertAlmostEqual(criteria.gm, 1 / 0.45, places=10) - self.assertEqual(criteria.low_density_threshold_kg_m3, 100) + assert criteria.fn == 2.0 + assert criteria.fm == 2.0 + assert criteria.gn == 5.0 + assert criteria.gm == pytest.approx(1 / 0.45, abs=0.5 * 10 ** (-10)) + assert criteria.low_density_threshold_kg_m3 == 100 def test_criteria_config_custom_values(self): """Test CriteriaConfig with custom values.""" @@ -176,139 +170,143 @@ def test_criteria_config_custom_values(self): low_density_threshold_kg_m3=120, ) - self.assertEqual(criteria.fn, 1.5) - self.assertEqual(criteria.fm, 2.0) - self.assertEqual(criteria.gn, 0.8) - self.assertEqual(criteria.gm, 1.2) - self.assertEqual(criteria.low_density_threshold_kg_m3, 120) + assert criteria.fn == 1.5 + assert criteria.fm == 2.0 + assert criteria.gn == 0.8 + assert criteria.gm == 1.2 + assert criteria.low_density_threshold_kg_m3 == 120 def test_criteria_config_validation(self): """Test CriteriaConfig validation.""" # All parameters must be positive - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): CriteriaConfig(fn=0.0) - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): CriteriaConfig(fm=-0.5) - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): CriteriaConfig(gn=-1.0) - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): CriteriaConfig(gm=0.0) - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): CriteriaConfig(low_density_threshold_kg_m3=0.0) -class TestSegment(unittest.TestCase): +class TestSegment: """Test the Segment class.""" def test_segment_creation(self): """Test creating segments with various parameters.""" # Basic segment seg1 = Segment(length=1000.0, has_foundation=True, m=0.0) - self.assertEqual(seg1.length, 1000.0) - self.assertEqual(seg1.has_foundation, True) - self.assertEqual(seg1.m, 0.0) + assert seg1.length == 1000.0 + assert seg1.has_foundation is True + assert seg1.m == 0.0 # Segment with skier load seg2 = Segment(length=2000.0, has_foundation=False, m=75.0) - self.assertEqual(seg2.length, 2000.0) - self.assertEqual(seg2.has_foundation, False) - self.assertEqual(seg2.m, 75.0) + assert seg2.length == 2000.0 + assert seg2.has_foundation is False + assert seg2.m == 75.0 def test_segment_default_mass(self): """Test that segment mass defaults to 0.""" seg = Segment(length=1500.0, has_foundation=True) - self.assertEqual(seg.m, 0.0) + assert seg.m == 0.0 def test_segment_validation(self): """Test segment validation.""" # Negative length - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): Segment(length=-100.0, has_foundation=True) # Negative mass - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): Segment(length=1000.0, has_foundation=True, m=-10.0) -class TestModelInput(unittest.TestCase): - """Test the ModelInput class for complete model validation.""" - - def setUp(self): - """Set up common test data.""" - self.scenario_config = ScenarioConfig(phi=25, system_type="skier") - self.weak_layer = WeakLayer(rho=50, h=30, E=0.25, G_Ic=1) - self.layers = [Layer(rho=200, h=100), Layer(rho=300, h=150)] - self.segments = [ +@pytest.fixture +def model_input_parts(): + """Shared builders for ModelInput tests.""" + return { + "scenario_config": ScenarioConfig(phi=25, system_type="skier"), + "weak_layer": WeakLayer(rho=50, h=30, E=0.25, G_Ic=1), + "layers": [Layer(rho=200, h=100), Layer(rho=300, h=150)], + "segments": [ Segment(length=3000, has_foundation=True, m=70), Segment(length=4000, has_foundation=True, m=0), - ] + ], + } + + +class TestModelInput: + """Test the ModelInput class for complete model validation.""" - def test_model_input_complete(self): + def test_model_input_complete(self, model_input_parts): """Test creating complete ModelInput.""" model = ModelInput( - scenario_config=self.scenario_config, - weak_layer=self.weak_layer, - layers=self.layers, - segments=self.segments, + scenario_config=model_input_parts["scenario_config"], + weak_layer=model_input_parts["weak_layer"], + layers=model_input_parts["layers"], + segments=model_input_parts["segments"], ) - self.assertEqual(model.scenario_config, self.scenario_config) - self.assertEqual(model.weak_layer, self.weak_layer) - self.assertEqual(model.layers, self.layers) - self.assertEqual(model.segments, self.segments) + assert model.scenario_config == model_input_parts["scenario_config"] + assert model.weak_layer == model_input_parts["weak_layer"] + assert model.layers == model_input_parts["layers"] + assert model.segments == model_input_parts["segments"] - def test_model_input_empty_collections(self): + def test_model_input_empty_collections(self, model_input_parts): """Test validation with empty layers or segments.""" # Empty layers list - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): ModelInput( - scenario_config=self.scenario_config, - weak_layer=self.weak_layer, + scenario_config=model_input_parts["scenario_config"], + weak_layer=model_input_parts["weak_layer"], layers=[], - segments=self.segments, + segments=model_input_parts["segments"], ) # Empty segments list - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): ModelInput( - scenario_config=self.scenario_config, - weak_layer=self.weak_layer, - layers=self.layers, + scenario_config=model_input_parts["scenario_config"], + weak_layer=model_input_parts["weak_layer"], + layers=model_input_parts["layers"], segments=[], ) - def test_model_input_json_serialization(self): + def test_model_input_json_serialization(self, model_input_parts): """Test JSON serialization and schema generation.""" model = ModelInput( - scenario_config=self.scenario_config, - weak_layer=self.weak_layer, - layers=self.layers, - segments=self.segments, + scenario_config=model_input_parts["scenario_config"], + weak_layer=model_input_parts["weak_layer"], + layers=model_input_parts["layers"], + segments=model_input_parts["segments"], ) # Test JSON serialization json_str = model.model_dump_json() - self.assertIsInstance(json_str, str) + assert isinstance(json_str, str) # Test that it can be parsed back parsed_data = json.loads(json_str) - self.assertIsInstance(parsed_data, dict) + assert isinstance(parsed_data, dict) # Test schema generation schema = ModelInput.model_json_schema() - self.assertIsInstance(schema, dict) - self.assertIn("properties", schema) - self.assertIn("scenario_config", schema["properties"]) - self.assertIn("weak_layer", schema["properties"]) - self.assertIn("layers", schema["properties"]) - self.assertIn("segments", schema["properties"]) + assert isinstance(schema, dict) + assert "properties" in schema + assert "scenario_config" in schema["properties"] + assert "weak_layer" in schema["properties"] + assert "layers" in schema["properties"] + assert "segments" in schema["properties"] -class TestModelInputPhysicalConsistency(unittest.TestCase): +class TestModelInputPhysicalConsistency: """Test physical consistency checks for ModelInput.""" def test_layer_ordering_makes_sense(self): @@ -325,10 +323,8 @@ def test_layer_ordering_makes_sense(self): # Check that weak layer is less dense than slab layers for layer in layers: - self.assertLess( - weak_layer.rho, - layer.rho, - "Weak layer should typically be less dense than slab layers", + assert weak_layer.rho < layer.rho, ( + "Weak layer should typically be less dense than slab layers" ) def test_segment_length_consistency(self): @@ -342,17 +338,9 @@ def test_segment_length_consistency(self): ] total_length = sum(seg.length for seg in segments) - self.assertGreater(total_length, 0, "Total length should be positive") - self.assertLess( - total_length, 100000, "Total length should be reasonable (< 100m)" - ) + assert total_length > 0, "Total length should be positive" + assert total_length < 100000, "Total length should be reasonable (< 100m)" # Check that at least one segment is supported has_support = any(seg.has_foundation for seg in segments) - self.assertTrue( - has_support, "At least one segment should have foundation support" - ) - - -if __name__ == "__main__": - unittest.main(verbosity=2) + assert has_support, "At least one segment should have foundation support" diff --git a/tests/components/test_layer.py b/tests/components/test_layer.py index c6723fc..a5ce16a 100644 --- a/tests/components/test_layer.py +++ b/tests/components/test_layer.py @@ -4,90 +4,107 @@ Tests validation, automatic property calculations, and edge cases. """ -import unittest - import numpy as np +import pytest from pydantic import ValidationError from weac.components.layer import ( Layer, - WeakLayer, _adam_tensile_strength, _bergfeld_youngs_modulus, _gerling_youngs_modulus, _scapozza_youngs_modulus, _sigrist_tensile_strength, ) -from weac.constants import NU +from weac.components.presets import ( + LESS_WEAK_LAYER, + VERY_WEAK_LAYER, + WEAK_LAYER, +) +from weac.components.weak_layer import WeakLayer, _schottner_fc_dh_youngs_modulus +from weac.constants import CS0, CS1, NU, RHO_ICE -class TestLayerPropertyCalculations(unittest.TestCase): +class TestLayerPropertyCalculations: """Test the layer property calculation functions.""" def test_bergfeld_calculation(self): """Test Bergfeld Young's modulus calculation.""" # Test with standard ice density E = _bergfeld_youngs_modulus(rho=917.0) # Ice density - self.assertGreater(E, 0, "Young's modulus should be positive") - self.assertTrue(np.isscalar(E), "Result should be a scalar") + assert E > 0, "Young's modulus should be positive" + assert np.isscalar(E), "Result should be a scalar" # Test with typical snow densities E_light = _bergfeld_youngs_modulus(rho=100.0) E_heavy = _bergfeld_youngs_modulus(rho=400.0) - self.assertLess(E_light, E_heavy, "Heavier snow should have higher modulus") + assert E_light < E_heavy, "Heavier snow should have higher modulus" def test_scapozza_calculation(self): """Test Scapozza Young's modulus calculation.""" E = _scapozza_youngs_modulus(rho=200.0) - self.assertGreater(E, 0, "Young's modulus should be positive") + assert E > 0, "Young's modulus should be positive" def test_gerling_calculation(self): """Test Gerling Young's modulus calculation.""" E = _gerling_youngs_modulus(rho=250.0) - self.assertGreater(E, 0, "Young's modulus should be positive") - - -class TestTensileStrengthCalculations(unittest.TestCase): + assert E > 0, "Young's modulus should be positive" + + def test_schottner_fc_dh_calculation(self): + """Test Schöttner FC&DH Young's modulus at paper benchmark densities.""" + E_150 = _schottner_fc_dh_youngs_modulus(rho=150.0) + E_250 = _schottner_fc_dh_youngs_modulus(rho=250.0) + expected_150 = CS0 * (150.0 / RHO_ICE) ** CS1 + expected_250 = CS0 * (250.0 / RHO_ICE) ** CS1 + assert E_150 == pytest.approx(expected_150, abs=0.5 * 10 ** (-10)) + assert E_250 == pytest.approx(expected_250, abs=0.5 * 10 ** (-10)) + # Paper window: ~1.5 MPa at 150 kg/m³, ~24 MPa at 250 kg/m³ + assert E_150 == pytest.approx(1.5, abs=0.1) + assert E_250 == pytest.approx(24.0, abs=1.0) + assert E_150 < E_250 + + +class TestTensileStrengthCalculations: """Test tensile strength calculation functions.""" def test_sigrist_calculation_kPa(self): """Test Sigrist tensile strength calculation in kPa.""" # Test with typical snow density ts = _sigrist_tensile_strength(rho=200.0, unit="kPa") - self.assertGreater(ts, 0, "Tensile strength should be positive") - self.assertTrue(np.isscalar(ts), "Result should be a scalar") + assert ts > 0, "Tensile strength should be positive" + assert np.isscalar(ts), "Result should be a scalar" # Test with different densities ts_light = _sigrist_tensile_strength(rho=100.0, unit="kPa") ts_heavy = _sigrist_tensile_strength(rho=400.0, unit="kPa") - self.assertLess(ts_light, ts_heavy, "Heavier snow should have higher strength") + assert ts_light < ts_heavy, "Heavier snow should have higher strength" def test_sigrist_calculation_MPa(self): """Test Sigrist tensile strength calculation in MPa.""" ts_kPa = _sigrist_tensile_strength(rho=200.0, unit="kPa") ts_MPa = _sigrist_tensile_strength(rho=200.0, unit="MPa") - self.assertAlmostEqual( - ts_kPa, ts_MPa * 1000, places=5, msg="Unit conversion should be correct" + assert ts_kPa == pytest.approx(ts_MPa * 1000, abs=0.5 * 10 ** (-5)), ( + "Unit conversion should be correct" ) def test_adam_calculation_kPa(self): """Test Adam tensile strength calculation in kPa.""" # Test with typical snow density ts = _adam_tensile_strength(rho=300.0, unit="kPa") - self.assertGreater(ts, 0, "Tensile strength should be positive") - self.assertTrue(np.isscalar(ts), "Result should be a scalar") + assert ts > 0, "Tensile strength should be positive" + assert np.isscalar(ts), "Result should be a scalar" # Test with different densities ts_light = _adam_tensile_strength(rho=150.0, unit="kPa") ts_heavy = _adam_tensile_strength(rho=450.0, unit="kPa") - self.assertLess(ts_light, ts_heavy, "Heavier snow should have higher strength") + assert ts_light < ts_heavy, "Heavier snow should have higher strength" def test_adam_calculation_MPa(self): """Test Adam tensile strength calculation in MPa.""" ts_kPa = _adam_tensile_strength(rho=300.0, unit="kPa") ts_MPa = _adam_tensile_strength(rho=300.0, unit="MPa") - self.assertAlmostEqual( - ts_kPa, ts_MPa * 1000, places=5, msg="Unit conversion should be correct" + assert ts_kPa == pytest.approx(ts_MPa * 1000, abs=0.5 * 10 ** (-5)), ( + "Unit conversion should be correct" ) def test_sigrist_vs_adam_comparison(self): @@ -97,89 +114,70 @@ def test_sigrist_vs_adam_comparison(self): ts_sigrist = _sigrist_tensile_strength(rho=rho_low, unit="kPa") ts_adam = _adam_tensile_strength(rho=rho_low, unit="kPa") # Both should give positive values - self.assertGreater(ts_sigrist, 0) - self.assertGreater(ts_adam, 0) + assert ts_sigrist > 0 + assert ts_adam > 0 # At high densities rho_high = 400.0 ts_sigrist_high = _sigrist_tensile_strength(rho=rho_high, unit="kPa") ts_adam_high = _adam_tensile_strength(rho=rho_high, unit="kPa") - self.assertGreater(ts_sigrist_high, 0) - self.assertGreater(ts_adam_high, 0) + assert ts_sigrist_high > 0 + assert ts_adam_high > 0 -class TestLayerTensileStrength(unittest.TestCase): +class TestLayerTensileStrength: """Test Layer class tensile strength functionality.""" def test_layer_default_tensile_strength_method(self): """Test that default method is 'hybrid'.""" layer = Layer(rho=200.0, h=100.0) - self.assertEqual( - layer.tensile_strength_method, - "hybrid", - "Default method should be 'hybrid'", - ) - self.assertGreater( - layer.tensile_strength, 0, "Tensile strength should be calculated" + assert layer.tensile_strength_method == "hybrid", ( + "Default method should be 'hybrid'" ) + assert layer.tensile_strength > 0, "Tensile strength should be calculated" def test_layer_sigrist_method(self): """Test Layer with explicit Sigrist method.""" layer = Layer(rho=200.0, h=100.0, tensile_strength_method="sigrist") expected_ts = _sigrist_tensile_strength(rho=200.0, unit="kPa") - self.assertAlmostEqual( - layer.tensile_strength, - expected_ts, - places=5, - msg="Tensile strength should match Sigrist calculation", - ) + assert layer.tensile_strength == pytest.approx( + expected_ts, abs=0.5 * 10 ** (-5) + ), "Tensile strength should match Sigrist calculation" def test_layer_adam_method(self): """Test Layer with explicit Adam method.""" layer = Layer(rho=300.0, h=100.0, tensile_strength_method="adam") expected_ts = _adam_tensile_strength(rho=300.0, unit="kPa") - self.assertAlmostEqual( - layer.tensile_strength, - expected_ts, - places=5, - msg="Tensile strength should match Adam calculation", - ) + assert layer.tensile_strength == pytest.approx( + expected_ts, abs=0.5 * 10 ** (-5) + ), "Tensile strength should match Adam calculation" def test_layer_hybrid_method_low_density(self): """Test hybrid method uses Sigrist for density < 250.""" rho = 200.0 # Below 250 threshold layer = Layer(rho=rho, h=100.0, tensile_strength_method="hybrid") expected_ts = _sigrist_tensile_strength(rho=rho, unit="kPa") - self.assertAlmostEqual( - layer.tensile_strength, - expected_ts, - places=5, - msg="Hybrid should use Sigrist for rho < 250", - ) + assert layer.tensile_strength == pytest.approx( + expected_ts, abs=0.5 * 10 ** (-5) + ), "Hybrid should use Sigrist for rho < 250" def test_layer_hybrid_method_high_density(self): """Test hybrid method uses Adam for density >= 250.""" rho = 300.0 # Above 250 threshold layer = Layer(rho=rho, h=100.0, tensile_strength_method="hybrid") expected_ts = _adam_tensile_strength(rho=rho, unit="kPa") - self.assertAlmostEqual( - layer.tensile_strength, - expected_ts, - places=5, - msg="Hybrid should use Adam for rho >= 250", - ) + assert layer.tensile_strength == pytest.approx( + expected_ts, abs=0.5 * 10 ** (-5) + ), "Hybrid should use Adam for rho >= 250" def test_layer_hybrid_method_at_threshold(self): """Test hybrid method behavior exactly at 250 kg/m³.""" rho = 250.0 # Exactly at threshold layer = Layer(rho=rho, h=100.0, tensile_strength_method="hybrid") expected_ts = _adam_tensile_strength(rho=rho, unit="kPa") - self.assertAlmostEqual( - layer.tensile_strength, - expected_ts, - places=5, - msg="Hybrid should use Adam for rho = 250", - ) + assert layer.tensile_strength == pytest.approx( + expected_ts, abs=0.5 * 10 ** (-5) + ), "Hybrid should use Adam for rho = 250" def test_layer_custom_tensile_strength(self): """Test that custom tensile strength overrides calculation.""" @@ -190,14 +188,12 @@ def test_layer_custom_tensile_strength(self): tensile_strength=custom_ts, tensile_strength_method="sigrist", ) - self.assertEqual( - layer.tensile_strength, - custom_ts, - "Custom tensile strength should override calculation", + assert layer.tensile_strength == custom_ts, ( + "Custom tensile strength should override calculation" ) -class TestTensileStrengthPhysicalConsistency(unittest.TestCase): +class TestTensileStrengthPhysicalConsistency: """Test physical consistency of tensile strength calculations.""" def test_density_strength_relationship(self): @@ -205,10 +201,8 @@ def test_density_strength_relationship(self): layer_light = Layer(rho=150.0, h=100.0) layer_heavy = Layer(rho=350.0, h=100.0) - self.assertLess( - layer_light.tensile_strength, - layer_heavy.tensile_strength, - "Heavier snow should have higher tensile strength", + assert layer_light.tensile_strength < layer_heavy.tensile_strength, ( + "Heavier snow should have higher tensile strength" ) def test_hybrid_continuity_around_threshold(self): @@ -219,18 +213,14 @@ def test_hybrid_continuity_around_threshold(self): layer_above = Layer(rho=251.0, h=100.0, tensile_strength_method="hybrid") # Both should have positive strength - self.assertGreater(layer_below.tensile_strength, 0) - self.assertGreater(layer_above.tensile_strength, 0) + assert layer_below.tensile_strength > 0 + assert layer_above.tensile_strength > 0 # Values should be reasonably close (within an order of magnitude) # This is a loose check since the formulations differ ratio = layer_above.tensile_strength / layer_below.tensile_strength - self.assertLess( - ratio, 10.0, "Strength shouldn't jump by more than 10x at threshold" - ) - self.assertGreater( - ratio, 0.1, "Strength shouldn't drop by more than 10x at threshold" - ) + assert ratio < 10.0, "Strength shouldn't jump by more than 10x at threshold" + assert ratio > 0.1, "Strength shouldn't drop by more than 10x at threshold" def test_all_methods_give_positive_strength(self): """Test that all methods produce positive tensile strength.""" @@ -240,10 +230,8 @@ def test_all_methods_give_positive_strength(self): for rho in rho_values: for method in methods: layer = Layer(rho=rho, h=100.0, tensile_strength_method=method) - self.assertGreater( - layer.tensile_strength, - 0, - f"Method {method} with rho={rho} should give positive strength", + assert layer.tensile_strength > 0, ( + f"Method {method} with rho={rho} should give positive strength" ) def test_tensile_strength_density_monotonicity(self): @@ -258,14 +246,12 @@ def test_tensile_strength_density_monotonicity(self): ] # Check that each strength is greater than the previous for i in range(1, len(strengths)): - self.assertGreater( - strengths[i], - strengths[i - 1], - f"Strength should increase with density for {method} method", + assert strengths[i] > strengths[i - 1], ( + f"Strength should increase with density for {method} method" ) -class TestLayer(unittest.TestCase): +class TestLayer: """Test the Layer class functionality.""" def test_layer_creation_with_required_fields(self): @@ -273,44 +259,44 @@ def test_layer_creation_with_required_fields(self): layer = Layer(rho=200.0, h=100.0) # Check required fields - self.assertEqual(layer.rho, 200.0) - self.assertEqual(layer.h, 100.0) + assert layer.rho == 200.0 + assert layer.h == 100.0 # Check auto-calculated fields - self.assertIsNotNone(layer.E, "Young's modulus should be auto-calculated") - self.assertIsNotNone(layer.G, "Shear modulus should be auto-calculated") - self.assertGreater(layer.E, 0, "Young's modulus should be positive") - self.assertGreater(layer.G, 0, "Shear modulus should be positive") + assert layer.E is not None, "Young's modulus should be auto-calculated" + assert layer.G is not None, "Shear modulus should be auto-calculated" + assert layer.E > 0, "Young's modulus should be positive" + assert layer.G > 0, "Shear modulus should be positive" # Check default Poisson's ratio - self.assertEqual(layer.nu, NU, "Default Poisson's ratio should be 0.25") + assert layer.nu == NU, "Default Poisson's ratio should be 0.25" def test_layer_creation_with_all_fields(self): """Test creating a layer with all fields specified.""" layer = Layer(rho=250.0, h=150.0, nu=0.3, E=50.0, G=20.0) - self.assertEqual(layer.rho, 250.0) - self.assertEqual(layer.h, 150.0) - self.assertEqual(layer.nu, 0.3) - self.assertEqual(layer.E, 50.0, "Specified E should override auto-calculation") - self.assertEqual(layer.G, 20.0, "Specified G should override auto-calculation") + assert layer.rho == 250.0 + assert layer.h == 150.0 + assert layer.nu == 0.3 + assert layer.E == 50.0, "Specified E should override auto-calculation" + assert layer.G == 20.0, "Specified G should override auto-calculation" def test_layer_validation_errors(self): """Test that invalid layer parameters raise ValidationError.""" # Negative density - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): Layer(rho=-100.0, h=100.0) # Zero thickness - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): Layer(rho=200.0, h=0.0) # Invalid Poisson's ratio (>= 0.5) - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): Layer(rho=200.0, h=100.0, nu=0.5) # Negative Young's modulus - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): Layer(rho=200.0, h=100.0, E=-10.0) def test_shear_modulus_calculation(self): @@ -319,33 +305,71 @@ def test_shear_modulus_calculation(self): # G = E / (2 * (1 + nu)) expected_G = 100.0 / (2 * (1 + 0.25)) - self.assertAlmostEqual(layer.G, expected_G, places=5) + assert layer.G == pytest.approx(expected_G, abs=0.5 * 10 ** (-5)) -class TestWeakLayer(unittest.TestCase): +class TestWeakLayer: """Test the WeakLayer class functionality.""" + def test_weak_layer_defaults_schottner(self): + """Bare WeakLayer uses Schöttner FC&DH at rho=150.""" + wl = WeakLayer() + assert wl.rho == 150.0 + assert wl.E_method == "schottner_fc_dh" + expected = CS0 * (150.0 / RHO_ICE) ** CS1 + assert wl.E == pytest.approx(expected, abs=0.5 * 10 ** (-10)) + assert wl.E == pytest.approx(1.55, abs=0.02) + + def test_weak_layer_explicit_E_skips_density_law(self): + """Explicit E > 0 overrides the density law (PlaneStrain keeps E).""" + wl = WeakLayer(rho=200.0, E=5.0) + assert wl.E == pytest.approx(5.0, abs=0.5 * 10 ** (-10)) + + def test_weak_layer_bergfeld_selection(self): + """Explicit E_method=bergfeld uses Bergfeld, not Schottner.""" + wl = WeakLayer(rho=200.0, E_method="bergfeld") + expected = _bergfeld_youngs_modulus(200.0) + assert wl.E == pytest.approx(expected, abs=0.5 * 10 ** (-10)) + assert wl.E != pytest.approx( + _schottner_fc_dh_youngs_modulus(200.0), abs=0.5 * 10 ** (-5) + ) + + def test_weak_layer_presets_density_to_E(self): + """Presets derive E from Schottner at their densities.""" + assert VERY_WEAK_LAYER.rho == 100 + assert WEAK_LAYER.rho == 150 + assert LESS_WEAK_LAYER.rho == 200 + assert VERY_WEAK_LAYER.E == pytest.approx( + _schottner_fc_dh_youngs_modulus(100), abs=0.5 * 10 ** (-10) + ) + assert WEAK_LAYER.E == pytest.approx( + _schottner_fc_dh_youngs_modulus(150), abs=0.5 * 10 ** (-10) + ) + assert LESS_WEAK_LAYER.E == pytest.approx( + _schottner_fc_dh_youngs_modulus(200), abs=0.5 * 10 ** (-10) + ) + def test_weak_layer_creation_minimal(self): """Test creating a weak layer with minimal required fields.""" wl = WeakLayer(rho=50.0, h=10.0) # Check required fields - self.assertEqual(wl.rho, 50.0) - self.assertEqual(wl.h, 10.0) + assert wl.rho == 50.0 + assert wl.h == 10.0 # Check auto-calculated fields - self.assertIsNotNone(wl.E, "Young's modulus should be auto-calculated") - self.assertIsNotNone(wl.G, "Shear modulus should be auto-calculated") - self.assertIsNotNone(wl.kn, "Normal stiffness should be auto-calculated") - self.assertIsNotNone(wl.kt, "Shear stiffness should be auto-calculated") - self.assertGreater(wl.E, 0, "Young's modulus should be positive") - self.assertGreater(wl.G, 0, "Shear modulus should be positive") - self.assertGreater(wl.kn, 0, "Normal stiffness should be positive") - self.assertGreater(wl.kt, 0, "Shear stiffness should be positive") + assert wl.E is not None, "Young's modulus should be auto-calculated" + assert wl.G is not None, "Shear modulus should be auto-calculated" + assert wl.kn is not None, "Normal stiffness should be auto-calculated" + assert wl.kt is not None, "Shear stiffness should be auto-calculated" + assert wl.E > 0, "Young's modulus should be positive" + assert wl.G > 0, "Shear modulus should be positive" + assert wl.kn > 0, "Normal stiffness should be positive" + assert wl.kt > 0, "Shear stiffness should be positive" # Check default fracture properties - self.assertEqual(wl.G_Ic, 0.56) - self.assertEqual(wl.G_IIc, 0.79) + assert wl.G_Ic == 0.56 + assert wl.G_IIc == 0.79 def test_weak_layer_stiffness_calculations(self): """Test weak layer stiffness calculations.""" @@ -354,35 +378,35 @@ def test_weak_layer_stiffness_calculations(self): # kn = E_plane / h = E / (1 - nu²) / h E_plane = 10.0 / (1 - 0.2**2) expected_kn = E_plane / 20.0 - self.assertAlmostEqual(wl.kn, expected_kn, places=5) + assert wl.kn == pytest.approx(expected_kn, abs=0.5 * 10 ** (-5)) # kt = G / h expected_G = 10.0 / (2 * (1 + 0.2)) expected_kt = expected_G / 20.0 - self.assertAlmostEqual(wl.kt, expected_kt, places=5) + assert wl.kt == pytest.approx(expected_kt, abs=0.5 * 10 ** (-5)) def test_weak_layer_custom_stiffnesses(self): """Test weak layer with custom stiffness values.""" wl = WeakLayer(rho=80.0, h=15.0, kn=5.0, kt=3.0) - self.assertEqual(wl.kn, 5.0, "Custom kn should override calculation") - self.assertEqual(wl.kt, 3.0, "Custom kt should override calculation") + assert wl.kn == 5.0, "Custom kn should override calculation" + assert wl.kt == 3.0, "Custom kt should override calculation" def test_weak_layer_fracture_properties(self): """Test weak layer fracture property validation.""" wl = WeakLayer(rho=90.0, h=25.0, G_Ic=1.5, G_IIc=1.8) - self.assertEqual(wl.G_Ic, 1.5) - self.assertEqual(wl.G_IIc, 1.8) + assert wl.G_Ic == 1.5 + assert wl.G_IIc == 1.8 def test_weak_layer_validation_errors(self): """Test weak layer validation errors.""" # Zero thickness - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): WeakLayer(rho=100.0, h=0.0) -class TestLayerPhysicalConsistency(unittest.TestCase): +class TestLayerPhysicalConsistency: """Test physical consistency of layer calculations.""" def test_layer_density_modulus_relationship(self): @@ -390,15 +414,11 @@ def test_layer_density_modulus_relationship(self): layer_light = Layer(rho=150.0, h=100.0) layer_heavy = Layer(rho=350.0, h=100.0) - self.assertLess( - layer_light.E, - layer_heavy.E, - "Heavier snow should have higher Young's modulus", + assert layer_light.E < layer_heavy.E, ( + "Heavier snow should have higher Young's modulus" ) - self.assertLess( - layer_light.G, - layer_heavy.G, - "Heavier snow should have higher shear modulus", + assert layer_light.G < layer_heavy.G, ( + "Heavier snow should have higher shear modulus" ) def test_weak_layer_thickness_stiffness_relationship(self): @@ -406,30 +426,22 @@ def test_weak_layer_thickness_stiffness_relationship(self): wl_thin = WeakLayer(rho=100.0, h=10.0) wl_thick = WeakLayer(rho=100.0, h=30.0) - self.assertGreater( - wl_thin.kn, - wl_thick.kn, - "Thinner weak layer should have higher normal stiffness", + assert wl_thin.kn > wl_thick.kn, ( + "Thinner weak layer should have higher normal stiffness" ) - self.assertGreater( - wl_thin.kt, - wl_thick.kt, - "Thinner weak layer should have higher shear stiffness", + assert wl_thin.kt > wl_thick.kt, ( + "Thinner weak layer should have higher shear stiffness" ) def test_poisson_ratio_bounds(self): """Test Poisson's ratio physical bounds.""" # Test upper bound (must be < 0.5 for positive definite stiffness) - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): Layer(rho=200.0, h=100.0, nu=0.5) - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): Layer(rho=200.0, h=100.0, nu=0.6) # Test lower bound (must be >= 0) - with self.assertRaises(ValidationError): + with pytest.raises(ValidationError): Layer(rho=200.0, h=100.0, nu=-0.1) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ee72678 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +"""Pytest configuration for the WEAC test suite.""" + +from weac.logging_config import setup_logging + +setup_logging(level="WARNING") diff --git a/tests/core/test_eigensystem.py b/tests/core/test_eigensystem.py index d2ccb32..8b4bf3b 100644 --- a/tests/core/test_eigensystem.py +++ b/tests/core/test_eigensystem.py @@ -5,69 +5,76 @@ complementary and particular solutions. """ -import unittest - import numpy as np +import pytest from weac.components import Layer, WeakLayer from weac.core.eigensystem import Eigensystem from weac.core.slab import Slab -class TestEigensystemBasicProperties(unittest.TestCase): - """Test basic eigensystem setup and property calculations.""" +@pytest.fixture +def multi_layer_eigensystem(): + """Eigensystem with a two-layer slab.""" + layers = [Layer(rho=200, h=100), Layer(rho=300, h=150)] + weak_layer = WeakLayer(rho=50, h=20, E=0.5, G_Ic=1.0) + slab = Slab(layers) + return Eigensystem(weak_layer, slab) + + +@pytest.fixture +def single_layer_eigensystem(): + """Eigensystem with a single-layer slab (eigenvalue tests).""" + layers = [Layer(rho=250, h=120)] + weak_layer = WeakLayer(rho=80, h=25, E=0.3) + slab = Slab(layers) + return Eigensystem(weak_layer, slab) - def setUp(self): - """Set up common test data.""" - self.layers = [Layer(rho=200, h=100), Layer(rho=300, h=150)] - self.weak_layer = WeakLayer(rho=50, h=20, E=0.5, G_Ic=1.0) - self.slab = Slab(self.layers) - self.eigensystem = Eigensystem(self.weak_layer, self.slab) - def test_eigensystem_initialization(self): +@pytest.fixture +def solution_eigensystem(): + """Eigensystem for complementary/particular solution tests.""" + layers = [Layer(rho=200, h=100)] + weak_layer = WeakLayer(rho=60, h=15) + slab = Slab(layers) + return Eigensystem(weak_layer, slab) + + +class TestEigensystemBasicProperties: + """Test basic eigensystem setup and property calculations.""" + + def test_eigensystem_initialization(self, multi_layer_eigensystem): """Test that eigensystem initializes correctly.""" - self.assertIsNotNone(self.eigensystem.weak_layer) - self.assertIsNotNone(self.eigensystem.slab) + eigensystem = multi_layer_eigensystem + assert eigensystem.weak_layer is not None + assert eigensystem.slab is not None # Check that eigenvalue calculation was performed - self.assertIsNotNone( - self.eigensystem.ewC, "Complex eigenvalues should be calculated" - ) - self.assertIsNotNone( - self.eigensystem.ewR, "Real eigenvalues should be calculated" - ) - self.assertIsNotNone( - self.eigensystem.evC, "Complex eigenvectors should be calculated" - ) - self.assertIsNotNone( - self.eigensystem.evR, "Real eigenvectors should be calculated" - ) + assert eigensystem.ewC is not None, "Complex eigenvalues should be calculated" + assert eigensystem.ewR is not None, "Real eigenvalues should be calculated" + assert eigensystem.evC is not None, "Complex eigenvectors should be calculated" + assert eigensystem.evR is not None, "Real eigenvectors should be calculated" - def test_laminate_stiffness_parameters(self): + def test_laminate_stiffness_parameters(self, multi_layer_eigensystem): """Test calculation of laminate stiffness parameters.""" + eigensystem = multi_layer_eigensystem # Check that stiffness parameters are positive - self.assertGreater( - self.eigensystem.A11, 0, "Extensional stiffness should be positive" - ) - self.assertGreater( - self.eigensystem.D11, 0, "Bending stiffness should be positive" - ) - self.assertGreater( - self.eigensystem.kA55, 0, "Shear stiffness should be positive" - ) + assert eigensystem.A11 > 0, "Extensional stiffness should be positive" + assert eigensystem.D11 > 0, "Bending stiffness should be positive" + assert eigensystem.kA55 > 0, "Shear stiffness should be positive" # K0 can be negative depending on coupling - self.assertIsInstance(self.eigensystem.K0, float) + assert isinstance(eigensystem.K0, float) - def test_system_matrix_properties(self): + def test_system_matrix_properties(self, multi_layer_eigensystem): """Test properties of the system matrix.""" - K = self.eigensystem.K + K = multi_layer_eigensystem.K # Check matrix dimensions - self.assertEqual(K.shape, (6, 6), "System matrix should be 6x6") + assert K.shape == (6, 6), "System matrix should be 6x6" # Check that it's a real matrix - self.assertTrue(np.all(np.isreal(K)), "System matrix should be real") + assert np.all(np.isreal(K)), "System matrix should be real" # Check specific structure (first row should be [0, 1, 0, 0, 0, 0]) expected_first_row = [0, 1, 0, 0, 0, 0] @@ -94,195 +101,155 @@ def test_system_matrix_properties(self): ) -class TestEigensystemEigenvalueCalculations(unittest.TestCase): +class TestEigensystemEigenvalueCalculations: """Test eigenvalue and eigenvector calculations.""" - def setUp(self): - """Set up test eigensystem.""" - layers = [Layer(rho=250, h=120)] - weak_layer = WeakLayer(rho=80, h=25, E=0.3) - slab = Slab(layers) - self.eigensystem = Eigensystem(weak_layer, slab) - - def test_eigenvalue_classification(self): + def test_eigenvalue_classification(self, single_layer_eigensystem): """Test that eigenvalues are correctly classified.""" + eigensystem = single_layer_eigensystem # Real eigenvalues should be real - self.assertTrue( - np.all(np.isreal(self.eigensystem.ewR)), - "Real eigenvalues should be real numbers", + assert np.all(np.isreal(eigensystem.ewR)), ( + "Real eigenvalues should be real numbers" ) # Complex eigenvalues should have positive imaginary parts - if len(self.eigensystem.ewC) > 0: - self.assertTrue( - np.all(self.eigensystem.ewC.imag > 0), - "Complex eigenvalues should have positive imaginary parts", + if len(eigensystem.ewC) > 0: + assert np.all(eigensystem.ewC.imag > 0), ( + "Complex eigenvalues should have positive imaginary parts" ) - def test_eigenvector_dimensions(self): + def test_eigenvector_dimensions(self, single_layer_eigensystem): """Test that eigenvectors have correct dimensions.""" + eigensystem = single_layer_eigensystem # Real eigenvectors - if len(self.eigensystem.ewR) > 0: - self.assertEqual( - self.eigensystem.evR.shape[0], - 6, - "Real eigenvectors should be 6-dimensional", + if len(eigensystem.ewR) > 0: + assert eigensystem.evR.shape[0] == 6, ( + "Real eigenvectors should be 6-dimensional" ) - self.assertEqual( - self.eigensystem.evR.shape[1], - len(self.eigensystem.ewR), - "Number of real eigenvectors should match number of real eigenvalues", + assert eigensystem.evR.shape[1] == len(eigensystem.ewR), ( + "Number of real eigenvectors should match number of real eigenvalues" ) # Complex eigenvectors - if len(self.eigensystem.ewC) > 0: - self.assertEqual( - self.eigensystem.evC.shape[0], - 6, - "Complex eigenvectors should be 6-dimensional", + if len(eigensystem.ewC) > 0: + assert eigensystem.evC.shape[0] == 6, ( + "Complex eigenvectors should be 6-dimensional" ) - self.assertEqual( - self.eigensystem.evC.shape[1], - len(self.eigensystem.ewC), - "Number of complex eigenvectors should match number of complex eigenvalues", + assert eigensystem.evC.shape[1] == len(eigensystem.ewC), ( + "Number of complex eigenvectors should match number of complex eigenvalues" ) - def test_eigenvalue_shifts(self): + def test_eigenvalue_shifts(self, single_layer_eigensystem): """Test eigenvalue shift arrays.""" + eigensystem = single_layer_eigensystem # Shifts should have same length as eigenvalues - self.assertEqual( - len(self.eigensystem.sR), - len(self.eigensystem.ewR), - "Real shifts should match real eigenvalues", + assert len(eigensystem.sR) == len(eigensystem.ewR), ( + "Real shifts should match real eigenvalues" ) - self.assertEqual( - len(self.eigensystem.sC), - len(self.eigensystem.ewC), - "Complex shifts should match complex eigenvalues", + assert len(eigensystem.sC) == len(eigensystem.ewC), ( + "Complex shifts should match complex eigenvalues" ) # Shifts should be -1 or 0 - self.assertTrue( - np.all(np.isin(self.eigensystem.sR, [-1, 0])), - "Real shifts should be -1 or 0", - ) - self.assertTrue( - np.all(np.isin(self.eigensystem.sC, [-1, 0])), - "Complex shifts should be -1 or 0", + assert np.all(np.isin(eigensystem.sR, [-1, 0])), "Real shifts should be -1 or 0" + assert np.all(np.isin(eigensystem.sC, [-1, 0])), ( + "Complex shifts should be -1 or 0" ) -class TestEigensystemSolutionMethods(unittest.TestCase): +class TestEigensystemSolutionMethods: """Test complementary and particular solution methods.""" - def setUp(self): - """Set up test eigensystem.""" - layers = [Layer(rho=200, h=100)] - weak_layer = WeakLayer(rho=60, h=15) - slab = Slab(layers) - self.eigensystem = Eigensystem(weak_layer, slab) - - def test_complementary_solution_bedded(self): + def test_complementary_solution_bedded(self, solution_eigensystem): """Test complementary solution for bedded segment.""" x = 100.0 # Position length = 1000.0 # Segment length has_foundation = True # Bedded - zh = self.eigensystem.zh(x, length, has_foundation) + zh = solution_eigensystem.zh(x, length, has_foundation) # Should return 6x6 matrix - self.assertEqual( - zh.shape, (6, 6), "Complementary solution should be 6x6 matrix" - ) + assert zh.shape == (6, 6), "Complementary solution should be 6x6 matrix" # Should be real for bedded segments - self.assertTrue( - np.allclose(np.imag(zh), 0.0, atol=1e-12), - "Bedded complementary solution should be (numerically) real", + assert np.allclose(np.imag(zh), 0.0, atol=1e-12), ( + "Bedded complementary solution should be (numerically) real" ) - def test_complementary_solution_free(self): + def test_complementary_solution_free(self, solution_eigensystem): """Test complementary solution for free segment.""" x = 50.0 # Position length = 500.0 # Segment length has_foundation = False # Free - zh = self.eigensystem.zh(x, length, has_foundation) + zh = solution_eigensystem.zh(x, length, has_foundation) # Should return 6x6 matrix - self.assertEqual( - zh.shape, (6, 6), "Complementary solution should be 6x6 matrix" - ) + assert zh.shape == (6, 6), "Complementary solution should be 6x6 matrix" - self.assertTrue( - np.allclose(np.imag(zh), 0.0, atol=1e-12), - "Free complementary solution should be (numerically) real", + assert np.allclose(np.imag(zh), 0.0, atol=1e-12), ( + "Free complementary solution should be (numerically) real" ) - def test_complementary_solution_at_origin(self): + def test_complementary_solution_at_origin(self, solution_eigensystem): """Test complementary solution at x=0.""" - zh_bedded = self.eigensystem.zh(0.0, 1000.0, True) - zh_free = self.eigensystem.zh(0.0, 1000.0, False) + zh_bedded = solution_eigensystem.zh(0.0, 1000.0, True) + zh_free = solution_eigensystem.zh(0.0, 1000.0, False) # At x=0, certain columns should have specific values # For free segments, the polynomial form gives specific patterns - self.assertTrue( - np.isfinite(zh_bedded).all(), "Bedded solution should be finite at origin" - ) - self.assertTrue( - np.isfinite(zh_free).all(), "Free solution should be finite at origin" + assert np.isfinite(zh_bedded).all(), ( + "Bedded solution should be finite at origin" ) + assert np.isfinite(zh_free).all(), "Free solution should be finite at origin" - def test_particular_solution_bedded(self): + def test_particular_solution_bedded(self, solution_eigensystem): """Test particular solution for bedded segment.""" x = 200.0 # Position phi = 30.0 # Inclination has_foundation = True # Bedded qs = 5.0 # Surface load - zp = self.eigensystem.zp(x, phi, has_foundation, qs) + zp = solution_eigensystem.zp(x, phi, has_foundation, qs) # Should return 6x1 vector - self.assertEqual(zp.shape, (6, 1), "Particular solution should be 6x1 vector") + assert zp.shape == (6, 1), "Particular solution should be 6x1 vector" # Should be real - self.assertTrue( - np.allclose(np.imag(zp), 0.0, atol=1e-12), - "Particular solution should be (numerically) real", + assert np.allclose(np.imag(zp), 0.0, atol=1e-12), ( + "Particular solution should be (numerically) real" ) - def test_particular_solution_free(self): + def test_particular_solution_free(self, solution_eigensystem): """Test particular solution for free segment.""" x = 150.0 # Position phi = 25.0 # Inclination has_foundation = False # Free qs = 0.0 # No additional surface load - zp = self.eigensystem.zp(x, phi, has_foundation, qs) + zp = solution_eigensystem.zp(x, phi, has_foundation, qs) # Should be real - self.assertTrue( - np.allclose(np.imag(zp), 0.0, atol=1e-12), - "Particular solution should be (numerically) real", + assert np.allclose(np.imag(zp), 0.0, atol=1e-12), ( + "Particular solution should be (numerically) real" ) - def test_load_vector_calculation(self): + def test_load_vector_calculation(self, solution_eigensystem): """Test system load vector calculation.""" phi = 20.0 # Inclination qs = 10.0 # Surface load - q = self.eigensystem.get_load_vector(phi, qs) + q = solution_eigensystem.get_load_vector(phi, qs) # Should return 6x1 vector - self.assertEqual(q.shape, (6, 1), "Load vector should be 6x1") + assert q.shape == (6, 1), "Load vector should be 6x1" # Should be real - self.assertTrue( - np.allclose(np.imag(q), 0.0, atol=1e-12), - "Load vector should be (numerically) real", + assert np.allclose(np.imag(q), 0.0, atol=1e-12), ( + "Load vector should be (numerically) real" ) -class TestEigensystemPhysicalConsistency(unittest.TestCase): +class TestEigensystemPhysicalConsistency: """Test physical consistency of eigensystem calculations.""" def test_stiffness_scaling_with_properties(self): @@ -299,12 +266,8 @@ def test_stiffness_scaling_with_properties(self): eig2 = Eigensystem(weak_layer, slab2) # Higher Young's modulus should lead to higher stiffnesses - self.assertGreater( - eig2.A11, eig1.A11, "Higher E should increase extensional stiffness" - ) - self.assertGreater( - eig2.D11, eig1.D11, "Higher E should increase bending stiffness" - ) + assert eig2.A11 > eig1.A11, "Higher E should increase extensional stiffness" + assert eig2.D11 > eig1.D11, "Higher E should increase bending stiffness" def test_weak_layer_stiffness_influence(self): """Test that weak layer properties affect system behavior.""" @@ -320,11 +283,9 @@ def test_weak_layer_stiffness_influence(self): eig_stiff = Eigensystem(wl_stiff, slab) # Stiffness values should be different - self.assertNotAlmostEqual( - eig_soft.K[1, 0], - eig_stiff.K[1, 0], - msg="Different weak layer properties should affect system matrix", - ) + assert eig_soft.K[1, 0] != pytest.approx( + eig_stiff.K[1, 0], abs=0.5 * 10 ** (-7) + ), "Different weak layer properties should affect system matrix" def test_inclination_effect_on_loads(self): """Test that inclination affects load vectors correctly.""" @@ -338,9 +299,8 @@ def test_inclination_effect_on_loads(self): q_inclined = eigensystem.get_load_vector(phi=30.0, qs=0.0) # Should be different for non-zero inclination - self.assertFalse( - np.allclose(q_flat, q_inclined), - "Load vectors should differ for different inclinations", + assert not np.allclose(q_flat, q_inclined), ( + "Load vectors should differ for different inclinations" ) def test_complementary_solution_continuity(self): @@ -358,11 +318,6 @@ def test_complementary_solution_continuity(self): zh2 = eigensystem.zh(x2, length, True) # Solutions should be very close for nearby points - self.assertTrue( - np.allclose(zh1, zh2, atol=1e-6), - "Complementary solutions should be continuous", + assert np.allclose(zh1, zh2, atol=1e-6), ( + "Complementary solutions should be continuous" ) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/tests/core/test_field_quantities.py b/tests/core/test_field_quantities.py index 3363a89..0ba88b7 100644 --- a/tests/core/test_field_quantities.py +++ b/tests/core/test_field_quantities.py @@ -5,9 +5,8 @@ and other field quantity computations. """ -import unittest - import numpy as np +import pytest from weac.components import Layer, WeakLayer from weac.core.eigensystem import Eigensystem @@ -15,49 +14,138 @@ from weac.core.slab import Slab -class TestFieldQuantitiesBasic(unittest.TestCase): +@pytest.fixture +def basic_fq(): + """FieldQuantities with a simple solution vector for basic tests.""" + layers = [Layer(rho=200, h=100)] + weak_layer = WeakLayer(rho=50, h=20, E=0.5) + slab = Slab(layers) + eigensystem = Eigensystem(weak_layer, slab) + fq = FieldQuantities(eigensystem) + # [u, u', w, w', psi, psi'] at multiple points + Z = np.array( + [ + [1.0, 2.0, 3.0], # u values at 3 points + [0.1, 0.2, 0.3], # u' values + [0.5, 1.0, 1.5], # w values + [0.05, 0.1, 0.15], # w' values + [0.01, 0.02, 0.03], # psi values + [0.001, 0.002, 0.003], # psi' values + ] + ) + return fq, Z + + +@pytest.fixture +def displacement_fq(): + """FieldQuantities for displacement-at-height tests.""" + layers = [Layer(rho=250, h=120)] + weak_layer = WeakLayer(rho=60, h=25) + slab = Slab(layers) + eigensystem = Eigensystem(weak_layer, slab) + fq = FieldQuantities(eigensystem) + Z = np.array( + [ + [2.0, 4.0], # u values + [0.2, 0.4], # u' values + [1.0, 2.0], # w values + [0.1, 0.2], # w' values + [0.05, 0.1], # psi values + [0.005, 0.01], # psi' values + ] + ) + return fq, Z + + +@pytest.fixture +def stress_fq(): + """FieldQuantities with known elastic properties for stress tests.""" + layers = [Layer(rho=200, h=100, E=50, nu=0.25)] # Known elastic properties + weak_layer = WeakLayer(rho=50, h=20, E=0.5, kn=10.0, kt=5.0) # Known stiffnesses + slab = Slab(layers) + eigensystem = Eigensystem(weak_layer, slab) + fq = FieldQuantities(eigensystem) + Z = np.array( + [ + [1.0, 2.0], # u values + [0.1, 0.2], # u' values + [0.5, 1.0], # w values + [0.05, 0.1], # w' values + [0.01, 0.02], # psi values + [0.001, 0.002], # psi' values + ] + ) + return fq, Z + + +@pytest.fixture +def strain_fq(): + """FieldQuantities for strain tests.""" + layers = [Layer(rho=200, h=100)] + weak_layer = WeakLayer(rho=50, h=20) + slab = Slab(layers) + eigensystem = Eigensystem(weak_layer, slab) + fq = FieldQuantities(eigensystem) + Z = np.array( + [ + [1.0, 2.0], + [0.1, 0.2], + [0.5, 1.0], + [0.05, 0.1], + [0.01, 0.02], + [0.001, 0.002], + ] + ) + return fq, Z + + +@pytest.fixture +def err_fq(): + """FieldQuantities for energy release rate tests.""" + layers = [Layer(rho=200, h=100)] + weak_layer = WeakLayer(rho=50, h=20, kn=10.0, kt=5.0) + slab = Slab(layers) + eigensystem = Eigensystem(weak_layer, slab) + fq = FieldQuantities(eigensystem) + # Single point solution vector (crack tip) + Z_tip = np.array( + [ + [1.0], # u + [0.1], # u' + [0.5], # w + [0.05], # w' + [0.01], # psi + [0.001], # psi' + ] + ) + return fq, Z_tip + + +class TestFieldQuantitiesBasic: """Test basic field quantity calculations.""" - def setUp(self): - """Set up test eigensystem and field quantities.""" - layers = [Layer(rho=200, h=100)] - weak_layer = WeakLayer(rho=50, h=20, E=0.5) - slab = Slab(layers) - eigensystem = Eigensystem(weak_layer, slab) - self.fq = FieldQuantities(eigensystem) - - # Create a simple test solution vector - # [u, u', w, w', psi, psi'] at multiple points - self.Z = np.array( - [ - [1.0, 2.0, 3.0], # u values at 3 points - [0.1, 0.2, 0.3], # u' values - [0.5, 1.0, 1.5], # w values - [0.05, 0.1, 0.15], # w' values - [0.01, 0.02, 0.03], # psi values - [0.001, 0.002, 0.003], # psi' values - ] - ) - - def test_center_line_displacement(self): + def test_center_line_displacement(self, basic_fq): """Test center-line displacement calculation.""" - w_values = self.fq.w(self.Z) + fq, Z = basic_fq + w_values = fq.w(Z) # Should return w values (row 2) in default units (mm) - expected = self.Z[2, :] + expected = Z[2, :] np.testing.assert_array_equal( w_values, expected, err_msg="Center-line displacement should equal w component", ) - def test_center_line_displacement_units(self): + def test_center_line_displacement_units(self, basic_fq): """Test center-line displacement with different units.""" + fq, Z = basic_fq # Test different units - w_mm = self.fq.w(self.Z, unit="mm") - w_m = self.fq.w(self.Z, unit="m") - w_cm = self.fq.w(self.Z, unit="cm") - self.assertRaises(ValueError, self.fq.w, self.Z, unit="inch") + w_mm = fq.w(Z, unit="mm") + w_m = fq.w(Z, unit="m") + w_cm = fq.w(Z, unit="cm") + with pytest.raises(ValueError): + fq.w(Z, unit="inch") # Check unit conversions np.testing.assert_array_almost_equal( @@ -73,23 +161,25 @@ def test_center_line_displacement_units(self): err_msg="Centimeter to mm conversion should be correct", ) - def test_center_line_displacement_derivative(self): + def test_center_line_displacement_derivative(self, basic_fq): """Test center-line displacement derivative.""" - dw_dx = self.fq.dw_dx(self.Z) + fq, Z = basic_fq + dw_dx = fq.dw_dx(Z) # Should return w' values (row 3) - expected = self.Z[3, :] + expected = Z[3, :] np.testing.assert_array_equal( dw_dx, expected, err_msg="Displacement derivative should equal w' component" ) - def test_rotation_calculation(self): + def test_rotation_calculation(self, basic_fq): """Test rotation calculation.""" - psi_rad = self.fq.psi(self.Z, unit="rad") - psi_deg = self.fq.psi(self.Z, unit="deg") + fq, Z = basic_fq + psi_rad = fq.psi(Z, unit="rad") + psi_deg = fq.psi(Z, unit="deg") # Radians should equal psi component - expected_rad = self.Z[4, :] + expected_rad = Z[4, :] np.testing.assert_array_equal( psi_rad, expected_rad, @@ -105,48 +195,30 @@ def test_rotation_calculation(self): err_msg="Rotation conversion to degrees should be correct", ) - def test_rotation_derivative(self): + def test_rotation_derivative(self, basic_fq): """Test rotation derivative calculation.""" - dpsi_dx = self.fq.dpsi_dx(self.Z) + fq, Z = basic_fq + dpsi_dx = fq.dpsi_dx(Z) # Should return psi' values (row 5) - expected = self.Z[5, :] + expected = Z[5, :] np.testing.assert_array_equal( dpsi_dx, expected, err_msg="Rotation derivative should equal psi' component" ) -class TestFieldQuantitiesDisplacements(unittest.TestCase): +class TestFieldQuantitiesDisplacements: """Test displacement calculations at different heights.""" - def setUp(self): - """Set up test system.""" - layers = [Layer(rho=250, h=120)] - weak_layer = WeakLayer(rho=60, h=25) - slab = Slab(layers) - eigensystem = Eigensystem(weak_layer, slab) - self.fq = FieldQuantities(eigensystem) - - # Simple solution vector - self.Z = np.array( - [ - [2.0, 4.0], # u values - [0.2, 0.4], # u' values - [1.0, 2.0], # w values - [0.1, 0.2], # w' values - [0.05, 0.1], # psi values - [0.005, 0.01], # psi' values - ] - ) - - def test_displacement_at_different_heights(self): + def test_displacement_at_different_heights(self, displacement_fq): """Test horizontal displacement at different heights.""" + fq, Z = displacement_fq h0 = 30.0 # Height above centerline - u_values = self.fq.u(self.Z, h0) + u_values = fq.u(Z, h0) # u = u0 + h0 * psi - expected = self.Z[0, :] + h0 * self.Z[4, :] + expected = Z[0, :] + h0 * Z[4, :] np.testing.assert_array_almost_equal( u_values, expected, @@ -154,14 +226,15 @@ def test_displacement_at_different_heights(self): err_msg="Displacement at height should follow u = u0 + h*psi", ) - def test_displacement_derivative_at_height(self): + def test_displacement_derivative_at_height(self, displacement_fq): """Test displacement derivative at different heights.""" + fq, Z = displacement_fq h0 = 40.0 - du_dx = self.fq.du_dx(self.Z, h0) + du_dx = fq.du_dx(Z, h0) # du/dx = u0' + h0 * psi' - expected = self.Z[1, :] + h0 * self.Z[5, :] + expected = Z[1, :] + h0 * Z[5, :] np.testing.assert_array_almost_equal( du_dx, expected, @@ -169,48 +242,28 @@ def test_displacement_derivative_at_height(self): err_msg="Displacement derivative should follow du/dx = u0' + h*psi'", ) - def test_displacement_at_centerline(self): + def test_displacement_at_centerline(self, displacement_fq): """Test that displacement at centerline equals u0.""" - u_centerline = self.fq.u(self.Z, h0=0.0) + fq, Z = displacement_fq + u_centerline = fq.u(Z, h0=0.0) # At centerline (h0=0), u = u0 - expected = self.Z[0, :] + expected = Z[0, :] np.testing.assert_array_equal( u_centerline, expected, err_msg="Displacement at centerline should equal u0" ) -class TestFieldQuantitiesStresses(unittest.TestCase): +class TestFieldQuantitiesStresses: """Test stress and force calculations.""" - def setUp(self): - """Set up test system with known properties.""" - layers = [Layer(rho=200, h=100, E=50, nu=0.25)] # Known elastic properties - weak_layer = WeakLayer( - rho=50, h=20, E=0.5, kn=10.0, kt=5.0 - ) # Known stiffnesses - slab = Slab(layers) - eigensystem = Eigensystem(weak_layer, slab) - self.fq = FieldQuantities(eigensystem) - - # Test solution vector - self.Z = np.array( - [ - [1.0, 2.0], # u values - [0.1, 0.2], # u' values - [0.5, 1.0], # w values - [0.05, 0.1], # w' values - [0.01, 0.02], # psi values - [0.001, 0.002], # psi' values - ] - ) - - def test_axial_force_calculation(self): + def test_axial_force_calculation(self, stress_fq): """Test axial normal force calculation.""" - N = self.fq.N(self.Z) + fq, Z = stress_fq + N = fq.N(Z) # N = A11 * u' + B11 * psi' - expected = self.fq.es.A11 * self.Z[1, :] + self.fq.es.B11 * self.Z[5, :] + expected = fq.es.A11 * Z[1, :] + fq.es.B11 * Z[5, :] np.testing.assert_array_almost_equal( N, expected, @@ -218,12 +271,13 @@ def test_axial_force_calculation(self): err_msg="Axial force should follow N = A11*u' + B11*psi'", ) - def test_bending_moment_calculation(self): + def test_bending_moment_calculation(self, stress_fq): """Test bending moment calculation.""" - M = self.fq.M(self.Z) + fq, Z = stress_fq + M = fq.M(Z) # M = B11 * u' + D11 * psi' - expected = self.fq.es.B11 * self.Z[1, :] + self.fq.es.D11 * self.Z[5, :] + expected = fq.es.B11 * Z[1, :] + fq.es.D11 * Z[5, :] np.testing.assert_array_almost_equal( M, expected, @@ -231,12 +285,13 @@ def test_bending_moment_calculation(self): err_msg="Bending moment should follow M = B11*u' + D11*psi'", ) - def test_shear_force_calculation(self): + def test_shear_force_calculation(self, stress_fq): """Test vertical shear force calculation.""" - V = self.fq.V(self.Z) + fq, Z = stress_fq + V = fq.V(Z) # V = kA55 * (w' + psi) - expected = self.fq.es.kA55 * (self.Z[3, :] + self.Z[4, :]) + expected = fq.es.kA55 * (Z[3, :] + Z[4, :]) np.testing.assert_array_almost_equal( V, expected, @@ -244,13 +299,14 @@ def test_shear_force_calculation(self): err_msg="Shear force should follow V = kA55*(w' + psi)", ) - def test_weak_layer_normal_stress(self): + def test_weak_layer_normal_stress(self, stress_fq): """Test weak layer normal stress calculation.""" - sig_MPa = self.fq.sig(self.Z, unit="MPa") - sig_kPa = self.fq.sig(self.Z, unit="kPa") + fq, Z = stress_fq + sig_MPa = fq.sig(Z, unit="MPa") + sig_kPa = fq.sig(Z, unit="kPa") # sig = -kn * w - expected_MPa = -self.fq.es.weak_layer.kn * self.Z[2, :] + expected_MPa = -fq.es.weak_layer.kn * Z[2, :] np.testing.assert_array_almost_equal( sig_MPa, expected_MPa, @@ -263,16 +319,17 @@ def test_weak_layer_normal_stress(self): sig_kPa, sig_MPa * 1000, decimal=8, err_msg="kPa should be 1000 times MPa" ) - def test_weak_layer_shear_stress(self): + def test_weak_layer_shear_stress(self, stress_fq): """Test weak layer shear stress calculation.""" - tau = self.fq.tau(self.Z, unit="MPa") + fq, Z = stress_fq + tau = fq.tau(Z, unit="MPa") # tau = -kt * (w' * h/2 - u(h=H/2)) - h = self.fq.es.weak_layer.h - H = self.fq.es.slab.H - u_surface = self.fq.u(self.Z, h0=H / 2) + h = fq.es.weak_layer.h + H = fq.es.slab.H + u_surface = fq.u(Z, h0=H / 2) - expected = self.fq.es.weak_layer.kt * (self.Z[3, :] * h / 2 - u_surface) + expected = fq.es.weak_layer.kt * (Z[3, :] * h / 2 - u_surface) np.testing.assert_array_almost_equal( tau, expected, @@ -281,48 +338,31 @@ def test_weak_layer_shear_stress(self): ) -class TestFieldQuantitiesStrains(unittest.TestCase): +class TestFieldQuantitiesStrains: """Test strain calculations.""" - def setUp(self): - """Set up test system.""" - layers = [Layer(rho=200, h=100)] - weak_layer = WeakLayer(rho=50, h=20) - slab = Slab(layers) - eigensystem = Eigensystem(weak_layer, slab) - self.fq = FieldQuantities(eigensystem) - - self.Z = np.array( - [ - [1.0, 2.0], - [0.1, 0.2], - [0.5, 1.0], - [0.05, 0.1], - [0.01, 0.02], - [0.001, 0.002], - ] - ) - - def test_normal_strain_calculation(self): + def test_normal_strain_calculation(self, strain_fq): """Test weak layer normal strain calculation.""" - eps = self.fq.eps(self.Z) + fq, Z = strain_fq + eps = fq.eps(Z) # eps = -w / h - expected = -self.Z[2, :] / self.fq.es.weak_layer.h + expected = -Z[2, :] / fq.es.weak_layer.h np.testing.assert_array_almost_equal( eps, expected, decimal=10, err_msg="Normal strain should follow eps = -w/h" ) - def test_shear_strain_calculation(self): + def test_shear_strain_calculation(self, strain_fq): """Test weak layer shear strain calculation.""" - gamma = self.fq.gamma(self.Z) + fq, Z = strain_fq + gamma = fq.gamma(Z) # gamma = w'/2 - u(h=H/2)/h - h = self.fq.es.weak_layer.h - H = self.fq.es.slab.H - u_surface = self.fq.u(self.Z, h0=H / 2) + h = fq.es.weak_layer.h + H = fq.es.slab.H + u_surface = fq.u(Z, h0=H / 2) - expected = self.Z[3, :] / 2 - u_surface / h + expected = Z[3, :] / 2 - u_surface / h np.testing.assert_array_almost_equal( gamma, expected, @@ -331,36 +371,17 @@ def test_shear_strain_calculation(self): ) -class TestFieldQuantitiesEnergyReleaseRates(unittest.TestCase): +class TestFieldQuantitiesEnergyReleaseRates: """Test energy release rate calculations.""" - def setUp(self): - """Set up test system.""" - layers = [Layer(rho=200, h=100)] - weak_layer = WeakLayer(rho=50, h=20, kn=10.0, kt=5.0) - slab = Slab(layers) - eigensystem = Eigensystem(weak_layer, slab) - self.fq = FieldQuantities(eigensystem) - - # Single point solution vector (crack tip) - self.Z_tip = np.array( - [ - [1.0], # u - [0.1], # u' - [0.5], # w - [0.05], # w' - [0.01], # psi - [0.001], # psi' - ] - ) - - def test_mode_I_energy_release_rate(self): + def test_mode_I_energy_release_rate(self, err_fq): """Test Mode I energy release rate calculation.""" - G_I = self.fq.Gi(self.Z_tip, unit="kJ/m^2") + fq, Z_tip = err_fq + G_I = fq.Gi(Z_tip, unit="kJ/m^2") # G_I = sig^2 / (2 * kn) - sig = self.fq.sig(self.Z_tip, unit="MPa") - expected = sig**2 / (2 * self.fq.es.weak_layer.kn) + sig = fq.sig(Z_tip, unit="MPa") + expected = sig**2 / (2 * fq.es.weak_layer.kn) np.testing.assert_array_almost_equal( G_I, @@ -369,13 +390,14 @@ def test_mode_I_energy_release_rate(self): err_msg="Mode I ERR should follow G_I = sig²/(2*kn)", ) - def test_mode_II_energy_release_rate(self): + def test_mode_II_energy_release_rate(self, err_fq): """Test Mode II energy release rate calculation.""" - G_II = self.fq.Gii(self.Z_tip, unit="kJ/m^2") + fq, Z_tip = err_fq + G_II = fq.Gii(Z_tip, unit="kJ/m^2") # G_II = tau^2 / (2 * kt) - tau = self.fq.tau(self.Z_tip, unit="MPa") - expected = tau**2 / (2 * self.fq.es.weak_layer.kt) + tau = fq.tau(Z_tip, unit="MPa") + expected = tau**2 / (2 * fq.es.weak_layer.kt) np.testing.assert_array_almost_equal( G_II, @@ -384,11 +406,12 @@ def test_mode_II_energy_release_rate(self): err_msg="Mode II ERR should follow G_II = tau²/(2*kt)", ) - def test_energy_release_rate_units(self): + def test_energy_release_rate_units(self, err_fq): """Test energy release rate unit conversions.""" - G_I_kJ = self.fq.Gi(self.Z_tip, unit="kJ/m^2") - G_I_J = self.fq.Gi(self.Z_tip, unit="J/m^2") - G_I_N = self.fq.Gi(self.Z_tip, unit="N/mm") + fq, Z_tip = err_fq + G_I_kJ = fq.Gi(Z_tip, unit="kJ/m^2") + G_I_J = fq.Gi(Z_tip, unit="J/m^2") + G_I_N = fq.Gi(Z_tip, unit="N/mm") # Check unit conversions np.testing.assert_array_almost_equal( @@ -399,7 +422,7 @@ def test_energy_release_rate_units(self): ) -class TestFieldQuantitiesPhysicalConsistency(unittest.TestCase): +class TestFieldQuantitiesPhysicalConsistency: """Test physical consistency of field quantity calculations.""" def test_displacement_continuity(self): @@ -418,8 +441,8 @@ def test_displacement_continuity(self): u2 = fq.u(Z, h2) # Should be very close for nearby heights - self.assertAlmostEqual( - u1[0], u2[0], places=6, msg="Displacement should be continuous" + assert u1[0] == pytest.approx(u2[0], abs=0.5 * 10 ** (-6)), ( + "Displacement should be continuous" ) def test_stress_sign_conventions(self): @@ -434,9 +457,7 @@ def test_stress_sign_conventions(self): Z_positive_w = np.array([[0], [0], [1.0], [0], [0], [0]]) # Positive w sig_pos = fq.sig(Z_positive_w) - self.assertLess( - sig_pos[0], 0, "Positive deflection should give compressive stress" - ) + assert sig_pos[0] < 0, "Positive deflection should give compressive stress" def test_energy_release_rate_positivity(self): """Test that energy release rates are always positive.""" @@ -452,9 +473,5 @@ def test_energy_release_rate_positivity(self): G_I = fq.Gi(Z_nonzero) G_II = fq.Gii(Z_nonzero) - self.assertGreaterEqual(G_I[0], 0, "Mode I ERR should be non-negative") - self.assertGreaterEqual(G_II[0], 0, "Mode II ERR should be non-negative") - - -if __name__ == "__main__": - unittest.main(verbosity=2) + assert G_I[0] >= 0, "Mode I ERR should be non-negative" + assert G_II[0] >= 0, "Mode II ERR should be non-negative" diff --git a/tests/core/test_scenario.py b/tests/core/test_scenario.py index d6292c4..4e2a566 100644 --- a/tests/core/test_scenario.py +++ b/tests/core/test_scenario.py @@ -2,9 +2,10 @@ This module contains tests for the Scenario class. """ -import unittest +from types import SimpleNamespace import numpy as np +import pytest from weac.components import Layer, ScenarioConfig, Segment, WeakLayer from weac.core.scenario import Scenario @@ -12,39 +13,62 @@ from weac.utils.misc import decompose_to_xyz -class TestScenario(unittest.TestCase): - """Test the Scenario class.""" +@pytest.fixture +def scenario_parts(): + """Shared building blocks for Scenario tests.""" + layer = Layer(rho=200, h=100) + slab = Slab([layer]) + weak_layer = WeakLayer(rho=150, h=30) + segments_two = [ + Segment(length=400.0, has_foundation=True, m=75.0), + Segment(length=600.0, has_foundation=True, m=0.0), + ] + cfg = ScenarioConfig( + phi=10.0, system_type="skiers", surface_load=0.2, cut_length=123.0 + ) + return SimpleNamespace( + layer=layer, + slab=slab, + weak_layer=weak_layer, + segments_two=segments_two, + cfg=cfg, + ) - def setUp(self): - # Simple slab with a single layer - self.layer = Layer(rho=200, h=100) - self.slab = Slab([self.layer]) - # Weak layer with defaults (kn derived from properties) - self.weak_layer = WeakLayer(rho=150, h=30) - # Default two segments to test typical case - self.segments_two = [ - Segment(length=400.0, has_foundation=True, m=75.0), - Segment(length=600.0, has_foundation=True, m=0.0), - ] - # Config with non-zero angle and surface load to exercise load decomposition - self.cfg = ScenarioConfig( - phi=10.0, system_type="skiers", surface_load=0.2, cut_length=123.0 - ) - def test_init_sets_core_attributes(self): +class TestScenario: + """Test the Scenario class.""" + + def test_init_sets_core_attributes(self, scenario_parts): """Test that init sets core attributes correctly.""" - s = Scenario(self.cfg, self.segments_two, self.weak_layer, self.slab) - self.assertEqual(s.system_type, self.cfg.system_type) - self.assertAlmostEqual(s.phi, self.cfg.phi) - self.assertAlmostEqual(s.surface_load, self.cfg.surface_load) + s = Scenario( + scenario_parts.cfg, + scenario_parts.segments_two, + scenario_parts.weak_layer, + scenario_parts.slab, + ) + assert s.system_type == scenario_parts.cfg.system_type + assert s.phi == pytest.approx(scenario_parts.cfg.phi, abs=0.5 * 10 ** (-7)) + assert s.surface_load == pytest.approx( + scenario_parts.cfg.surface_load, abs=0.5 * 10 ** (-7) + ) # L is total length - self.assertAlmostEqual(s.L, sum(seg.length for seg in self.segments_two)) + assert s.L == pytest.approx( + sum(seg.length for seg in scenario_parts.segments_two), + abs=0.5 * 10 ** (-7), + ) # cut_length is propagated - self.assertAlmostEqual(s.cut_length, self.cfg.cut_length) + assert s.cut_length == pytest.approx( + scenario_parts.cfg.cut_length, abs=0.5 * 10 ** (-7) + ) - def test_setup_scenario_multiple_segments(self): + def test_setup_scenario_multiple_segments(self, scenario_parts): """Test that setup_scenario sets up correctly for multiple segments.""" - s = Scenario(self.cfg, self.segments_two, self.weak_layer, self.slab) + s = Scenario( + scenario_parts.cfg, + scenario_parts.segments_two, + scenario_parts.weak_layer, + scenario_parts.slab, + ) # li is segment lengths np.testing.assert_allclose(s.li, np.array([400.0, 600.0])) # ki reflects foundation flags @@ -54,76 +78,104 @@ def test_setup_scenario_multiple_segments(self): # cumulative length np.testing.assert_allclose(s.cum_sum_li, np.array([400.0, 1000.0])) # get_segment_idx mapping across domains - self.assertEqual(s.get_segment_idx(0.0), 0) - self.assertEqual(s.get_segment_idx(399.9999), 0) + assert s.get_segment_idx(0.0) == 0 + assert s.get_segment_idx(399.9999) == 0 # exactly on boundary goes to next bin - self.assertEqual(s.get_segment_idx(400.0), 1) - self.assertEqual(s.get_segment_idx(999.9999), 1) + assert s.get_segment_idx(400.0) == 1 + assert s.get_segment_idx(999.9999) == 1 # vectorized np.testing.assert_array_equal( s.get_segment_idx(np.array([0.0, 100.0, 400.0, 500.0, 999.0])), np.array([0, 0, 1, 1, 1]), ) # out of bounds (> L) raises - with self.assertRaisesRegex(ValueError, r"out of bounds|exceeds|beyond"): + with pytest.raises(ValueError, match=r"out of bounds|exceeds|beyond"): s.get_segment_idx(1000.0001) - def test_setup_scenario_single_segment_adds_dummy(self): + def test_setup_scenario_single_segment_adds_dummy(self, scenario_parts): """Test that setup_scenario adds a dummy segment for single segment case.""" segments_one = [Segment(length=750.0, has_foundation=True, m=0.0)] - s = Scenario(self.cfg, segments_one, self.weak_layer, self.slab) + s = Scenario( + scenario_parts.cfg, + segments_one, + scenario_parts.weak_layer, + scenario_parts.slab, + ) # Dummy segment appended - self.assertEqual(len(s.li), 2) - self.assertAlmostEqual(s.li[0], 750.0) - self.assertAlmostEqual(s.li[1], 0.0) - self.assertTrue(bool(s.ki[1])) - self.assertAlmostEqual(s.mi[-1], 0.0) + assert len(s.li) == 2 + assert s.li[0] == pytest.approx(750.0, abs=0.5 * 10 ** (-7)) + assert s.li[1] == pytest.approx(0.0, abs=0.5 * 10 ** (-7)) + assert bool(s.ki[1]) + assert s.mi[-1] == pytest.approx(0.0, abs=0.5 * 10 ** (-7)) # L equals the actual provided length - self.assertAlmostEqual(s.L, 750.0) + assert s.L == pytest.approx(750.0, abs=0.5 * 10 ** (-7)) # get_segment_idx behavior at end - self.assertEqual(s.get_segment_idx(749.9999), 0) + assert s.get_segment_idx(749.9999) == 0 # x == L is allowed and maps to bin 1 - self.assertEqual(s.get_segment_idx(750.0), 1) - with self.assertRaisesRegex(ValueError, r"out of bounds|exceeds|beyond"): + assert s.get_segment_idx(750.0) == 1 + with pytest.raises(ValueError, match=r"out of bounds|exceeds|beyond"): s.get_segment_idx(750.0001) - def test_calc_normal_and_tangential_loads(self): + def test_calc_normal_and_tangential_loads(self, scenario_parts): """Test that calc_normal_and_tangential_loads computes expected loads.""" - s = Scenario(self.cfg, self.segments_two, self.weak_layer, self.slab) + s = Scenario( + scenario_parts.cfg, + scenario_parts.segments_two, + scenario_parts.weak_layer, + scenario_parts.slab, + ) # Expected from decomposition of slab weight and surface load - qwt, _, qwn = decompose_to_xyz(self.slab.qw, self.cfg.phi) - qst, _, qsn = decompose_to_xyz(self.cfg.surface_load, self.cfg.phi) + qwt, _, qwn = decompose_to_xyz(scenario_parts.slab.qw, scenario_parts.cfg.phi) + qst, _, qsn = decompose_to_xyz( + scenario_parts.cfg.surface_load, scenario_parts.cfg.phi + ) np.testing.assert_allclose(s.qz, qwn + qsn, rtol=1e-12, atol=1e-12) np.testing.assert_allclose(s.qx, qwt + qst, rtol=1e-12, atol=1e-12) # Sanity signs: qz positive (into slope), qx negative (downslope) - self.assertGreater(s.qz, 0.0) - self.assertLessEqual(s.qx, 0.0) + assert s.qz > 0.0 + assert s.qx <= 0.0 - def test_calc_crack_height(self): + def test_calc_crack_height(self, scenario_parts): """Test that calc_crack_height computes expected crack height.""" - s = Scenario(self.cfg, self.segments_two, self.weak_layer, self.slab) - expected_crack_h = self.weak_layer.collapse_height - s.qz / self.weak_layer.kn - self.assertTrue(np.isfinite(expected_crack_h)) - self.assertAlmostEqual(s.crack_h, expected_crack_h) + s = Scenario( + scenario_parts.cfg, + scenario_parts.segments_two, + scenario_parts.weak_layer, + scenario_parts.slab, + ) + expected_crack_h = ( + scenario_parts.weak_layer.collapse_height + - s.qz / scenario_parts.weak_layer.kn + ) + assert np.isfinite(expected_crack_h) + assert s.crack_h == pytest.approx(expected_crack_h, abs=0.5 * 10 ** (-7)) - def test_refresh_from_config_updates_attributes( - self, - ): + def test_refresh_from_config_updates_attributes(self, scenario_parts): """Test that refresh_from_config updates attributes.""" - s = Scenario(self.cfg, self.segments_two, self.weak_layer, self.slab) + s = Scenario( + scenario_parts.cfg, + scenario_parts.segments_two, + scenario_parts.weak_layer, + scenario_parts.slab, + ) # Change config values s.scenario_config.phi = 25.0 s.scenario_config.surface_load = 0.2 s.scenario_config.system_type = "pst-" s.refresh_from_config() # Attributes copied from config - self.assertEqual(s.system_type, "pst-") - self.assertAlmostEqual(s.phi, 25.0) - self.assertAlmostEqual(s.surface_load, 0.2) + assert s.system_type == "pst-" + assert s.phi == pytest.approx(25.0, abs=0.5 * 10 ** (-7)) + assert s.surface_load == pytest.approx(0.2, abs=0.5 * 10 ** (-7)) - def test_refresh_recomputes_setup_when_segments_change(self): + def test_refresh_recomputes_setup_when_segments_change(self, scenario_parts): """Test that refresh_from_config recomputes setup when segments change.""" - s = Scenario(self.cfg, self.segments_two, self.weak_layer, self.slab) + s = Scenario( + scenario_parts.cfg, + scenario_parts.segments_two, + scenario_parts.weak_layer, + scenario_parts.slab, + ) # Mutate segments: change lengths and foundation flags new_segments = [ Segment(length=100.0, has_foundation=True, m=0.0), @@ -137,8 +189,4 @@ def test_refresh_recomputes_setup_when_segments_change(self): np.testing.assert_array_equal(s.ki, np.array([True, False, True])) np.testing.assert_allclose(s.mi, np.array([0.0, 0.0])) np.testing.assert_allclose(s.cum_sum_li, np.array([100.0, 300.0, 600.0])) - self.assertAlmostEqual(s.L, 600.0) - - -if __name__ == "__main__": - unittest.main() + assert s.L == pytest.approx(600.0, abs=0.5 * 10 ** (-7)) diff --git a/tests/core/test_slab.py b/tests/core/test_slab.py index d8cbb9a..44c4bcc 100644 --- a/tests/core/test_slab.py +++ b/tests/core/test_slab.py @@ -4,16 +4,15 @@ Tests layer assembly, property calculations, center of gravity, and physical consistency. """ -import unittest - import numpy as np +import pytest from weac.components import Layer from weac.constants import G_MM_S2 from weac.core.slab import Slab -class TestSlabBasicOperations(unittest.TestCase): +class TestSlabBasicOperations: """Test basic slab assembly and property calculations.""" def test_single_layer_slab(self): @@ -22,16 +21,14 @@ def test_single_layer_slab(self): slab = Slab([layer]) # Check basic properties - self.assertEqual(len(slab.layers), 1) - self.assertEqual( - slab.H, 100.0, "Total thickness should equal single layer thickness" - ) - self.assertEqual(slab.hi[0], 100.0) - self.assertEqual(slab.rhoi[0], 250e-12, "Density should be converted to t/mm³") + assert len(slab.layers) == 1 + assert slab.H == 100.0, "Total thickness should equal single layer thickness" + assert slab.hi[0] == 100.0 + assert slab.rhoi[0] == 250e-12, "Density should be converted to t/mm³" # Check coordinate system (z=0 at slab midpoint) - self.assertEqual(slab.zi_mid[0], 0.0, "Single layer midpoint should be at z=0") - self.assertEqual(slab.zi_bottom[0], 50.0, "Bottom should be H/2 below midpoint") + assert slab.zi_mid[0] == 0.0, "Single layer midpoint should be at z=0" + assert slab.zi_bottom[0] == 50.0, "Bottom should be H/2 below midpoint" def test_multi_layer_slab(self): """Test slab with multiple layers.""" @@ -44,7 +41,7 @@ def test_multi_layer_slab(self): # Check total thickness expected_H = 50 + 80 + 70 - self.assertEqual(slab.H, expected_H) + assert slab.H == expected_H # Check layer thicknesses np.testing.assert_array_almost_equal(slab.hi, [50, 80, 70]) @@ -67,7 +64,7 @@ def test_multi_layer_slab(self): np.testing.assert_array_almost_equal(slab.zi_bottom, expected_zi_bottom) -class TestSlabCenterOfGravity(unittest.TestCase): +class TestSlabCenterOfGravity: """Test center of gravity calculations.""" def test_uniform_density_slab(self): @@ -79,11 +76,8 @@ def test_uniform_density_slab(self): slab = Slab(layers) # For uniform density, CoG should be at geometric center (z=0) - self.assertAlmostEqual( - slab.z_cog, - 0.0, - places=5, - msg="Uniform density slab should have CoG at geometric center", + assert slab.z_cog == pytest.approx(0.0, abs=0.5 * 10 ** (-5)), ( + "Uniform density slab should have CoG at geometric center" ) def test_density_gradient_slab(self): @@ -95,9 +89,7 @@ def test_density_gradient_slab(self): slab = Slab(layers) # CoG should shift toward heavier bottom layer (positive z) - self.assertGreater( - slab.z_cog, 0.0, "CoG should shift toward heavier bottom layer" - ) + assert slab.z_cog > 0.0, "CoG should shift toward heavier bottom layer" def test_top_heavy_slab(self): """Test CoG for top-heavy slab.""" @@ -108,10 +100,10 @@ def test_top_heavy_slab(self): slab = Slab(layers) # CoG should shift toward heavier top layer (negative z) - self.assertLess(slab.z_cog, 0.0, "CoG should shift toward heavier top layer") + assert slab.z_cog < 0.0, "CoG should shift toward heavier top layer" -class TestSlabWeightCalculations(unittest.TestCase): +class TestSlabWeightCalculations: """Test weight and load calculations.""" def test_weight_load_calculation(self): @@ -121,7 +113,7 @@ def test_weight_load_calculation(self): # qw = sum(rho * g * h) for all layers expected_qw = 200e-12 * G_MM_S2 * 100 # t/mm³ * mm/s² * mm = t*mm/s²/mm² = N/mm - self.assertAlmostEqual(slab.qw, expected_qw, places=8) + assert slab.qw == pytest.approx(expected_qw, abs=0.5 * 10 ** (-8)) def test_multi_layer_weight(self): """Test weight calculation for multiple layers.""" @@ -134,10 +126,10 @@ def test_multi_layer_weight(self): # Calculate expected total weight per unit length expected_qw = (150 * 60 + 250 * 80 + 350 * 100) * 1e-12 * G_MM_S2 - self.assertAlmostEqual(slab.qw, expected_qw, places=8) + assert slab.qw == pytest.approx(expected_qw, abs=0.5 * 10 ** (-8)) -class TestSlabVerticalCenterOfGravity(unittest.TestCase): +class TestSlabVerticalCenterOfGravity: """Test vertical center of gravity calculations for inclined slabs.""" def test_vertical_cog_flat_surface(self): @@ -148,9 +140,9 @@ def test_vertical_cog_flat_surface(self): x_cog, z_cog, w = slab.calc_vertical_center_of_gravity(phi=0) # For flat surface, should have zero displacement and weight - self.assertEqual(x_cog, 0.0) - self.assertEqual(z_cog, 0.0) - self.assertEqual(w, 0.0) + assert x_cog == 0.0 + assert z_cog == 0.0 + assert w == 0.0 def test_vertical_cog_inclined_surface(self): """Test vertical CoG calculation for inclined surface.""" @@ -163,13 +155,9 @@ def test_vertical_cog_inclined_surface(self): x_cog, z_cog, w = slab.calc_vertical_center_of_gravity(phi=30) # For inclined surface, should have non-zero values - self.assertNotEqual( - x_cog, 0.0, "Horizontal CoG should be non-zero for inclined surface" - ) - self.assertNotEqual( - z_cog, 0.0, "Vertical CoG should be non-zero for inclined surface" - ) - self.assertGreater(w, 0.0, "Weight should be positive") + assert x_cog != 0.0, "Horizontal CoG should be non-zero for inclined surface" + assert z_cog != 0.0, "Vertical CoG should be non-zero for inclined surface" + assert w > 0.0, "Weight should be positive" def test_vertical_cog_steep_inclination(self): """Test vertical CoG for steep inclination.""" @@ -180,19 +168,15 @@ def test_vertical_cog_steep_inclination(self): x_cog_60, _, w_60 = slab.calc_vertical_center_of_gravity(phi=60) # Steeper inclination should result in larger displacements and weights - self.assertGreater( - abs(x_cog_60), - abs(x_cog_30), - "Steeper inclination should increase horizontal displacement", + assert abs(x_cog_60) > abs(x_cog_30), ( + "Steeper inclination should increase horizontal displacement" ) - self.assertGreater( - w_60, - w_30, - "Steeper inclination should increase weight of triangular segment", + assert w_60 > w_30, ( + "Steeper inclination should increase weight of triangular segment" ) -class TestSlabElasticProperties(unittest.TestCase): +class TestSlabElasticProperties: """Test elastic property assembly.""" def test_elastic_property_arrays(self): @@ -218,16 +202,12 @@ def test_automatic_property_calculation(self): slab = Slab(layers) # Properties should be auto-calculated and positive - self.assertGreater( - slab.Ei[0], 0, "Young's modulus should be auto-calculated and positive" - ) - self.assertGreater( - slab.Gi[0], 0, "Shear modulus should be auto-calculated and positive" - ) - self.assertEqual(slab.nui[0], 0.25, "Default Poisson's ratio should be 0.25") + assert slab.Ei[0] > 0, "Young's modulus should be auto-calculated and positive" + assert slab.Gi[0] > 0, "Shear modulus should be auto-calculated and positive" + assert slab.nui[0] == 0.25, "Default Poisson's ratio should be 0.25" -class TestSlabPhysicalConsistency(unittest.TestCase): +class TestSlabPhysicalConsistency: """Test physical consistency of slab calculations.""" def test_coordinate_system_consistency(self): @@ -240,15 +220,15 @@ def test_coordinate_system_consistency(self): slab = Slab(layers) # Total thickness should equal sum of layer thicknesses - self.assertEqual(slab.H, sum(slab.hi)) + assert slab.H == sum(slab.hi) # Bottom of last layer should be at H/2 - self.assertAlmostEqual(slab.zi_bottom[-1], slab.H / 2, places=5) + assert slab.zi_bottom[-1] == pytest.approx(slab.H / 2, abs=0.5 * 10 ** (-5)) # Top of first layer should be at -H/2 # (first layer bottom - first layer thickness) top_of_first = slab.zi_bottom[0] - slab.hi[0] - self.assertAlmostEqual(top_of_first, -slab.H / 2, places=5) + assert top_of_first == pytest.approx(-slab.H / 2, abs=0.5 * 10 ** (-5)) def test_center_of_gravity_bounds(self): """Test that center of gravity is within slab bounds.""" @@ -259,12 +239,8 @@ def test_center_of_gravity_bounds(self): slab = Slab(layers) # CoG should be within slab thickness bounds - self.assertGreaterEqual( - slab.z_cog, -slab.H / 2, "CoG should be within slab (above top)" - ) - self.assertLessEqual( - slab.z_cog, slab.H / 2, "CoG should be within slab (below bottom)" - ) + assert slab.z_cog >= -slab.H / 2, "CoG should be within slab (above top)" + assert slab.z_cog <= slab.H / 2, "CoG should be within slab (below bottom)" def test_mass_conservation(self): """Test that mass calculations are consistent.""" @@ -279,8 +255,4 @@ def test_mass_conservation(self): # Weight per unit length should equal mass per length times gravity expected_weight = total_mass_per_length * G_MM_S2 - self.assertAlmostEqual(slab.qw, expected_weight, places=10) - - -if __name__ == "__main__": - unittest.main(verbosity=2) + assert slab.qw == pytest.approx(expected_weight, abs=0.5 * 10 ** (-10)) diff --git a/tests/core/test_slab_touchdown.py b/tests/core/test_slab_touchdown.py index bd34c93..5ee9cff 100644 --- a/tests/core/test_slab_touchdown.py +++ b/tests/core/test_slab_touchdown.py @@ -2,10 +2,10 @@ This module contains tests for the SlabTouchdown class. """ -import unittest from unittest.mock import patch import numpy as np +import pytest from weac.components import Layer, ScenarioConfig, Segment, WeakLayer from weac.constants import STIFFNESS_COLLAPSE_FACTOR @@ -15,64 +15,58 @@ from weac.core.slab_touchdown import SlabTouchdown -class SlabTouchdownTestBase(unittest.TestCase): - """Base class for SlabTouchdown tests, providing common setup.""" - - def make_base_objects(self): - """Make base objects for testing.""" - layers = [Layer(rho=220, h=120)] - slab = Slab(layers) - weak_layer = WeakLayer(rho=120, h=25) - # Two segments: supported then unsupported, typical PST layout - segments = [ - Segment(length=5e3, has_foundation=True, m=0.0), - Segment(length=200.0, has_foundation=False, m=0.0), - ] - cfg = ScenarioConfig( - phi=10.0, system_type="pst-", cut_length=200.0, surface_load=0.0 - ) - scenario = Scenario(cfg, segments, weak_layer, slab) - eig = Eigensystem(weak_layer, slab) - return scenario, eig - - -class TestSlabTouchdownInitialization(SlabTouchdownTestBase): +@pytest.fixture +def base_objects(): + """Make base objects for SlabTouchdown testing.""" + layers = [Layer(rho=220, h=120)] + slab = Slab(layers) + weak_layer = WeakLayer(rho=120, h=25) + # Two segments: supported then unsupported, typical PST layout + segments = [ + Segment(length=5e3, has_foundation=True, m=0.0), + Segment(length=200.0, has_foundation=False, m=0.0), + ] + cfg = ScenarioConfig( + phi=10.0, system_type="pst-", cut_length=200.0, surface_load=0.0 + ) + scenario = Scenario(cfg, segments, weak_layer, slab) + eig = Eigensystem(weak_layer, slab) + return scenario, eig + + +class TestSlabTouchdownInitialization: """Test the initialization of the SlabTouchdown class.""" - def test_init_sets_flat_config_and_collapsed_eigensystem(self): + def test_init_sets_flat_config_and_collapsed_eigensystem(self, base_objects): """Test the initialization of the SlabTouchdown class.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects with patch.object(SlabTouchdown, "_setup_touchdown_system", return_value=None): td = SlabTouchdown(scenario, eig) # flat_config has phi=0 and preserves other fields - self.assertEqual(td.flat_config.phi, 0.0) - self.assertEqual( - td.flat_config.system_type, scenario.scenario_config.system_type - ) - self.assertEqual(td.flat_config.cut_length, scenario.scenario_config.cut_length) - self.assertEqual( - td.flat_config.surface_load, scenario.scenario_config.surface_load - ) + assert td.flat_config.phi == 0.0 + assert td.flat_config.system_type == scenario.scenario_config.system_type + assert td.flat_config.cut_length == scenario.scenario_config.cut_length + assert td.flat_config.surface_load == scenario.scenario_config.surface_load # collapsed weak layer stiffness scaled - self.assertAlmostEqual( - td.collapsed_weak_layer.kn, + assert td.collapsed_weak_layer.kn == pytest.approx( scenario.weak_layer.kn * STIFFNESS_COLLAPSE_FACTOR, + abs=0.5 * 10 ** (-7), ) - self.assertAlmostEqual( - td.collapsed_weak_layer.kt, + assert td.collapsed_weak_layer.kt == pytest.approx( scenario.weak_layer.kt * STIFFNESS_COLLAPSE_FACTOR, + abs=0.5 * 10 ** (-7), ) # collapsed eigensystem uses collapsed weak layer and same slab - self.assertIs(td.collapsed_eigensystem.weak_layer, td.collapsed_weak_layer) - self.assertIs(td.collapsed_eigensystem.slab, scenario.slab) + assert td.collapsed_eigensystem.weak_layer is td.collapsed_weak_layer + assert td.collapsed_eigensystem.slab is scenario.slab -class TestSlabTouchdownBoundaries(SlabTouchdownTestBase): +class TestSlabTouchdownBoundaries: """Test the calculation of touchdown mode boundaries.""" - def test_calc_l_AB_root_exists_and_within_bounds(self): + def test_calc_l_AB_root_exists_and_within_bounds(self, base_objects): """Test the calculation of touchdown mode boundaries.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects # Avoid heavy setup with patch.object(SlabTouchdown, "_setup_touchdown_system", return_value=None): td = SlabTouchdown(scenario, eig) @@ -83,12 +77,12 @@ def test_calc_l_AB_root_exists_and_within_bounds(self): td.eigensystem.kA55 = 10.0 with patch.object(td, "_substitute_stiffness", return_value=2.0): l_ab = td._calc_l_AB() # pylint: disable=protected-access - self.assertGreater(l_ab, 0.0) - self.assertLess(l_ab, td.scenario.L) + assert l_ab > 0.0 + assert l_ab < td.scenario.L - def test_calc_l_BC_root_exists_and_within_bounds(self): + def test_calc_l_BC_root_exists_and_within_bounds(self, base_objects): """Test the calculation of touchdown mode boundaries.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects with patch.object(SlabTouchdown, "_setup_touchdown_system", return_value=None): td = SlabTouchdown(scenario, eig) # Make bs positive and control substitute stiffness to constants @@ -98,16 +92,16 @@ def test_calc_l_BC_root_exists_and_within_bounds(self): td.eigensystem.kA55 = 10.0 with patch.object(td, "_substitute_stiffness", return_value=3.0): l_bc = td._calc_l_BC() # pylint: disable=protected-access - self.assertGreater(l_bc, 0.0) - self.assertLess(l_bc, td.scenario.L) + assert l_bc > 0.0 + assert l_bc < td.scenario.L -class TestSlabTouchdownModeAndDistance(SlabTouchdownTestBase): +class TestSlabTouchdownModeAndDistance: """Test the calculation of touchdown mode and distance.""" - def test_calc_touchdown_mode_assigns_correct_mode(self): + def test_calc_touchdown_mode_assigns_correct_mode(self, base_objects): """Test the calculation of touchdown mode and distance.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects with patch.object(SlabTouchdown, "_setup_touchdown_system", return_value=None): td = SlabTouchdown(scenario, eig) with ( @@ -118,33 +112,33 @@ def test_calc_touchdown_mode_assigns_correct_mode(self): td.scenario.scenario_config.cut_length = 200.0 td.scenario.cut_length = 200.0 td._calc_touchdown_mode() # pylint: disable=protected-access - self.assertEqual(td.touchdown_mode, "A_free_hanging") + assert td.touchdown_mode == "A_free_hanging" # Mode B: l_AB < cut_length <= l_BC td.scenario.scenario_config.cut_length = 400.0 td.scenario.cut_length = 400.0 td._calc_touchdown_mode() # pylint: disable=protected-access - self.assertEqual(td.touchdown_mode, "B_point_contact") + assert td.touchdown_mode == "B_point_contact" # Mode C: cut_length > l_BC td.scenario.scenario_config.cut_length = 800.0 td.scenario.cut_length = 800.0 td._calc_touchdown_mode() # pylint: disable=protected-access - self.assertEqual(td.touchdown_mode, "C_in_contact") + assert td.touchdown_mode == "C_in_contact" - def test_calc_touchdown_distance_sets_expected_values(self): + def test_calc_touchdown_distance_sets_expected_values(self, base_objects): """Test the calculation of touchdown mode and distance.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects with patch.object(SlabTouchdown, "_setup_touchdown_system", return_value=None): td = SlabTouchdown(scenario, eig) # Mode A/B: equals cut_length td.touchdown_mode = "A_free_hanging" td.scenario.cut_length = 123.0 td._calc_touchdown_distance() # pylint: disable=protected-access - self.assertEqual(td.touchdown_distance, 123.0) + assert td.touchdown_distance == 123.0 td.touchdown_mode = "B_point_contact" td.scenario.cut_length = 321.0 td._calc_touchdown_distance() # pylint: disable=protected-access - self.assertEqual(td.touchdown_distance, 321.0) + assert td.touchdown_distance == 321.0 # Mode C: uses helper methods td.touchdown_mode = "C_in_contact" @@ -153,43 +147,45 @@ def test_calc_touchdown_distance_sets_expected_values(self): patch.object(td, "_calc_collapsed_weak_layer_kR", return_value=222.0), ): td._calc_touchdown_distance() # pylint: disable=protected-access - self.assertEqual(td.touchdown_distance, 111.0) - self.assertEqual(td.collapsed_weak_layer_kR, 222.0) + assert td.touchdown_distance == 111.0 + assert td.collapsed_weak_layer_kR == 222.0 -class TestSlabTouchdownHelpers(SlabTouchdownTestBase): +class TestSlabTouchdownHelpers: """Test helper methods for the SlabTouchdown class.""" - def test_generate_straight_scenario(self): + def test_generate_straight_scenario(self, base_objects): """Test the generation of a straight scenario.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects with patch.object(SlabTouchdown, "_setup_touchdown_system", return_value=None): td = SlabTouchdown(scenario, eig) L = 555.5 straight = td._generate_straight_scenario(L) # pylint: disable=protected-access - self.assertAlmostEqual(straight.L, L) - self.assertEqual(straight.phi, 0.0) + assert straight.L == pytest.approx(L, abs=0.5 * 10 ** (-7)) + assert straight.phi == 0.0 # First segment should be the provided one, dummy appended internally - self.assertGreaterEqual(len(straight.li), 1) - self.assertTrue(bool(straight.ki[0])) + assert len(straight.li) >= 1 + assert bool(straight.ki[0]) - def test_create_collapsed_eigensystem_scales_weak_layer(self): + def test_create_collapsed_eigensystem_scales_weak_layer(self, base_objects): """Test the creation of a collapsed eigensystem.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects with patch.object(SlabTouchdown, "_setup_touchdown_system", return_value=None): td = SlabTouchdown(scenario, eig) # Recreate to test method in isolation collapsed = td._create_collapsed_eigensystem() # pylint: disable=protected-access - self.assertAlmostEqual( - collapsed.weak_layer.kn, scenario.weak_layer.kn * STIFFNESS_COLLAPSE_FACTOR + assert collapsed.weak_layer.kn == pytest.approx( + scenario.weak_layer.kn * STIFFNESS_COLLAPSE_FACTOR, + abs=0.5 * 10 ** (-7), ) - self.assertAlmostEqual( - collapsed.weak_layer.kt, scenario.weak_layer.kt * STIFFNESS_COLLAPSE_FACTOR + assert collapsed.weak_layer.kt == pytest.approx( + scenario.weak_layer.kt * STIFFNESS_COLLAPSE_FACTOR, + abs=0.5 * 10 ** (-7), ) - def test_calc_touchdown_distance_in_mode_C_root_in_range(self): + def test_calc_touchdown_distance_in_mode_C_root_in_range(self, base_objects): """Test the calculation of touchdown mode and distance.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects scenario.scenario_config.cut_length = 300.0 scenario.cut_length = 300.0 with patch.object(SlabTouchdown, "_setup_touchdown_system", return_value=None): @@ -212,24 +208,24 @@ def fake_subst(straight_scenario, es, dof): # pylint: disable=unused-argument with patch.object(td, "_substitute_stiffness", side_effect=fake_subst): d = td._calc_touchdown_distance_in_mode_C() # pylint: disable=protected-access - self.assertGreater(d, 0.0) - self.assertLess(d, scenario.cut_length) + assert d > 0.0 + assert d < scenario.cut_length - def test_calc_collapsed_weak_layer_kR_returns_positive(self): + def test_calc_collapsed_weak_layer_kR_returns_positive(self, base_objects): """Test the calculation of collapsed weak layer stiffness.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects with patch.object(SlabTouchdown, "_setup_touchdown_system", return_value=None): td = SlabTouchdown(scenario, eig) td.touchdown_mode = "A_free_hanging" td.touchdown_distance = 100.0 with patch.object(td, "_substitute_stiffness", return_value=7.5): kR = td._calc_collapsed_weak_layer_kR() # pylint: disable=protected-access - self.assertGreater(kR, 0.0) - self.assertAlmostEqual(kR, 7.5) + assert kR > 0.0 + assert kR == pytest.approx(7.5, abs=0.5 * 10 ** (-7)) - def test_substitute_stiffness_rot_and_trans_are_finite(self): + def test_substitute_stiffness_rot_and_trans_are_finite(self, base_objects): """Test the calculation of substitute stiffness.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects # Avoid running setup (roots) and use method directly with patch.object(SlabTouchdown, "_setup_touchdown_system", return_value=None): td = SlabTouchdown(scenario, eig) @@ -237,14 +233,14 @@ def test_substitute_stiffness_rot_and_trans_are_finite(self): straight = td._generate_straight_scenario(L=400.0) # pylint: disable=protected-access kR = td._substitute_stiffness(straight, td.eigensystem, dof="rot") # pylint: disable=protected-access kN = td._substitute_stiffness(straight, td.eigensystem, dof="trans") # pylint: disable=protected-access - self.assertTrue(np.isfinite(kR)) - self.assertTrue(np.isfinite(kN)) - self.assertGreater(kR, 0.0) - self.assertGreater(kN, 0.0) + assert np.isfinite(kR) + assert np.isfinite(kN) + assert kR > 0.0 + assert kN > 0.0 - def test_setup_touchdown_system_calls_subroutines(self): + def test_setup_touchdown_system_calls_subroutines(self, base_objects): """Test the setup of the touchdown system.""" - scenario, eig = self.make_base_objects() + scenario, eig = base_objects with ( patch.object( SlabTouchdown, "_calc_touchdown_mode", return_value=None @@ -255,9 +251,5 @@ def test_setup_touchdown_system_calls_subroutines(self): ): SlabTouchdown(scenario, eig) # The constructor calls _setup_touchdown_system which should call both - self.assertTrue(m1.called) - self.assertTrue(m2.called) - - -if __name__ == "__main__": - unittest.main(verbosity=2) + assert m1.called + assert m2.called diff --git a/tests/core/test_system_model.py b/tests/core/test_system_model.py index 695252a..4a27499 100644 --- a/tests/core/test_system_model.py +++ b/tests/core/test_system_model.py @@ -2,10 +2,11 @@ This module contains tests for the SystemModel class. """ -import unittest +from types import SimpleNamespace from unittest.mock import MagicMock, patch import numpy as np +import pytest from weac.components import ( Config, @@ -19,27 +20,65 @@ from weac.core.system_model import SystemModel -class TestSystemModelCaching(unittest.TestCase): +@pytest.fixture +def caching_parts(): + """Shared components for SystemModel caching tests.""" + return SimpleNamespace( + config=Config(), + layers=[Layer(rho=200, h=500)], + weak_layer=WeakLayer(rho=150, h=10), + segments=[Segment(length=10000, has_foundation=True, m=0)], + scenario_config=ScenarioConfig(phi=30, system_type="skiers"), + ) + + +@pytest.fixture +def behavior_parts(): + """Shared components for SystemModel behavior tests.""" + return SimpleNamespace( + config=Config(), + layers=[Layer(rho=200, h=500)], + weak_layer=WeakLayer(rho=150, h=10), + segments=[ + Segment(length=10000, has_foundation=True, m=80), + Segment(length=4000, has_foundation=False, m=0), + ], + scenario_config=ScenarioConfig( + phi=10.0, system_type="skiers", cut_length=3000.0 + ), + ) + + +def _build_model( + parts, + touchdown: bool = False, + system_type: SystemType = "skiers", +) -> SystemModel: + """Build a SystemModel from shared behavior parts.""" + config = Config(touchdown=touchdown) + sc = ScenarioConfig(phi=10.0, system_type=system_type, cut_length=3000.0) + model_input = ModelInput( + layers=parts.layers, + weak_layer=parts.weak_layer, + segments=parts.segments, + scenario_config=sc, + ) + return SystemModel(model_input=model_input, config=config) + + +class TestSystemModelCaching: """Test caching mechanisms in the SystemModel.""" - def setUp(self): - """Set up common components for tests.""" - self.config = Config() - self.layers = [Layer(rho=200, h=500)] - self.weak_layer = WeakLayer(rho=150, h=10) - self.segments = [Segment(length=10000, has_foundation=True, m=0)] - self.scenario_config = ScenarioConfig(phi=30, system_type="skiers") - @patch("weac.core.eigensystem.Eigensystem.calc_eigensystem") - def test_eigensystem_calculation_called_once(self, mock_calc): + def test_eigensystem_calculation_called_once(self, mock_calc, caching_parts): """Test that eigensystem calculation is called only once when cached.""" model_input = ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, - segments=self.segments, - scenario_config=self.scenario_config, + layers=caching_parts.layers, + weak_layer=caching_parts.weak_layer, + segments=caching_parts.segments, + scenario_config=caching_parts.scenario_config, ) - system = SystemModel(model_input=model_input, config=self.config) + system = SystemModel(model_input=model_input, config=caching_parts.config) # Access eigensystem multiple times _ = system.eigensystem @@ -47,51 +86,47 @@ def test_eigensystem_calculation_called_once(self, mock_calc): _ = system.eigensystem # calc_eigensystem should only be called once due to caching - self.assertEqual( - mock_calc.call_count, - 1, - "Eigensystem calculation should only be called once", + assert mock_calc.call_count == 1, ( + "Eigensystem calculation should only be called once" ) - def test_eigensystem_caching(self): + def test_eigensystem_caching(self, caching_parts): """Test that eigensystem is cached and reused.""" model_input = ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, - segments=self.segments, - scenario_config=self.scenario_config, + layers=caching_parts.layers, + weak_layer=caching_parts.weak_layer, + segments=caching_parts.segments, + scenario_config=caching_parts.scenario_config, ) - system = SystemModel(model_input=model_input, config=self.config) + system = SystemModel(model_input=model_input, config=caching_parts.config) eigensystem1 = system.eigensystem eigensystem2 = system.eigensystem - self.assertIs( - eigensystem1, eigensystem2, "Cached eigensystem should be the same object" + assert eigensystem1 is eigensystem2, ( + "Cached eigensystem should be the same object" ) - def test_unknown_constants_caching(self): + def test_unknown_constants_caching(self, caching_parts): """Test that unknown constants are cached and reused.""" model_input = ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, - segments=self.segments, - scenario_config=self.scenario_config, + layers=caching_parts.layers, + weak_layer=caching_parts.weak_layer, + segments=caching_parts.segments, + scenario_config=caching_parts.scenario_config, ) - system = SystemModel(model_input=model_input, config=self.config) + system = SystemModel(model_input=model_input, config=caching_parts.config) constants1 = system.unknown_constants constants2 = system.unknown_constants - self.assertIs( - constants1, constants2, "Cached constants should be the same object" - ) + assert constants1 is constants2, "Cached constants should be the same object" - def test_slab_update_invalidates_all_caches(self): + def test_slab_update_invalidates_all_caches(self, caching_parts): """Test that slab updates invalidate both eigensystem and unknown constants.""" model_input = ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, - segments=self.segments, - scenario_config=self.scenario_config, + layers=caching_parts.layers, + weak_layer=caching_parts.weak_layer, + segments=caching_parts.segments, + scenario_config=caching_parts.scenario_config, ) - system = SystemModel(model_input=model_input, config=self.config) + system = SystemModel(model_input=model_input, config=caching_parts.config) eigensystem_before = system.eigensystem constants_before = system.unknown_constants @@ -101,18 +136,18 @@ def test_slab_update_invalidates_all_caches(self): eigensystem_after = system.eigensystem constants_after = system.unknown_constants - self.assertIsNot(eigensystem_before, eigensystem_after) - self.assertIsNot(constants_before, constants_after) + assert eigensystem_before is not eigensystem_after + assert constants_before is not constants_after - def test_weak_layer_update_invalidates_all_caches(self): + def test_weak_layer_update_invalidates_all_caches(self, caching_parts): """Test that weak layer updates invalidate both caches.""" model_input = ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, - segments=self.segments, - scenario_config=self.scenario_config, + layers=caching_parts.layers, + weak_layer=caching_parts.weak_layer, + segments=caching_parts.segments, + scenario_config=caching_parts.scenario_config, ) - system = SystemModel(model_input=model_input, config=self.config) + system = SystemModel(model_input=model_input, config=caching_parts.config) eigensystem_before = system.eigensystem constants_before = system.unknown_constants @@ -122,18 +157,18 @@ def test_weak_layer_update_invalidates_all_caches(self): eigensystem_after = system.eigensystem constants_after = system.unknown_constants - self.assertIsNot(eigensystem_before, eigensystem_after) - self.assertIsNot(constants_before, constants_after) + assert eigensystem_before is not eigensystem_after + assert constants_before is not constants_after - def test_scenario_update_invalidates_constants_only(self): + def test_scenario_update_invalidates_constants_only(self, caching_parts): """Test that scenario updates only invalidate unknown constants, not eigensystem.""" model_input = ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, - segments=self.segments, - scenario_config=self.scenario_config, + layers=caching_parts.layers, + weak_layer=caching_parts.weak_layer, + segments=caching_parts.segments, + scenario_config=caching_parts.scenario_config, ) - system = SystemModel(model_input=model_input, config=self.config) + system = SystemModel(model_input=model_input, config=caching_parts.config) eigensystem_before = system.eigensystem constants_before = system.unknown_constants @@ -145,41 +180,15 @@ def test_scenario_update_invalidates_constants_only(self): eigensystem_after = system.eigensystem constants_after = system.unknown_constants - self.assertIs(eigensystem_before, eigensystem_after) - self.assertIsNot(constants_before, constants_after) + assert eigensystem_before is eigensystem_after + assert constants_before is not constants_after -class TestSystemModelBehavior(unittest.TestCase): +class TestSystemModelBehavior: """Test the behavior of the SystemModel class.""" - def setUp(self): - """Set up the test environment.""" - self.config = Config() - self.layers = [Layer(rho=200, h=500)] - self.weak_layer = WeakLayer(rho=150, h=10) - self.segments = [ - Segment(length=10000, has_foundation=True, m=80), - Segment(length=4000, has_foundation=False, m=0), - ] - self.scenario_config = ScenarioConfig( - phi=10.0, system_type="skiers", cut_length=3000.0 - ) - - def _build_model( - self, touchdown: bool = False, system_type: SystemType = "skiers" - ) -> SystemModel: - config = Config(touchdown=touchdown) - sc = ScenarioConfig(phi=10.0, system_type=system_type, cut_length=3000.0) - model_input = ModelInput( - layers=self.layers, - weak_layer=self.weak_layer, - segments=self.segments, - scenario_config=sc, - ) - return SystemModel(model_input=model_input, config=config) - @patch("weac.core.system_model.SlabTouchdown") - def test_touchdown_updates_segments_for_pst_minus(self, mock_td): + def test_touchdown_updates_segments_for_pst_minus(self, mock_td, behavior_parts): """Test that touchdown updates segments for pst-.""" mock_inst = MagicMock() mock_inst.touchdown_distance = 1234.0 @@ -187,13 +196,13 @@ def test_touchdown_updates_segments_for_pst_minus(self, mock_td): mock_inst.collapsed_weak_layer_kR = 42.0 mock_td.return_value = mock_inst - system = self._build_model(touchdown=True, system_type="pst-") + system = _build_model(behavior_parts, touchdown=True, system_type="pst-") _ = system.slab_touchdown # trigger - self.assertEqual(system.scenario.segments[-1].length, 1234.0) + assert system.scenario.segments[-1].length == 1234.0 @patch("weac.core.system_model.SlabTouchdown") - def test_touchdown_updates_segments_for_minus_pst(self, mock_td): + def test_touchdown_updates_segments_for_minus_pst(self, mock_td, behavior_parts): """Test that touchdown updates segments for -pst.""" mock_inst = MagicMock() mock_inst.touchdown_distance = 2222.0 @@ -201,15 +210,15 @@ def test_touchdown_updates_segments_for_minus_pst(self, mock_td): mock_inst.collapsed_weak_layer_kR = 11.0 mock_td.return_value = mock_inst - system = self._build_model(touchdown=True, system_type="-pst") + system = _build_model(behavior_parts, touchdown=True, system_type="-pst") _ = system.slab_touchdown # trigger - self.assertEqual(system.scenario.segments[0].length, 2222.0) + assert system.scenario.segments[0].length == 2222.0 @patch("weac.core.system_model.UnknownConstantsSolver.solve_for_unknown_constants") @patch("weac.core.system_model.SlabTouchdown") def test_unknown_constants_uses_touchdown_params_when_enabled( - self, mock_td, mock_solve + self, mock_td, mock_solve, behavior_parts ): """Test that unknown constants uses touchdown params when enabled.""" mock_inst = MagicMock() @@ -231,17 +240,19 @@ def solver_side_effect( mock_solve.side_effect = solver_side_effect - system = self._build_model(touchdown=True, system_type="pst-") + system = _build_model(behavior_parts, touchdown=True, system_type="pst-") _ = system.unknown_constants mock_solve.assert_called_once() _, kwargs = mock_solve.call_args - self.assertEqual(kwargs["touchdown_distance"], 1500.0) - self.assertEqual(kwargs["touchdown_mode"], "C_in_contact") - self.assertEqual(kwargs["collapsed_weak_layer_kR"], 7.5) + assert kwargs["touchdown_distance"] == 1500.0 + assert kwargs["touchdown_mode"] == "C_in_contact" + assert kwargs["collapsed_weak_layer_kR"] == 7.5 @patch("weac.core.system_model.UnknownConstantsSolver.solve_for_unknown_constants") - def test_unknown_constants_without_touchdown_passes_none(self, mock_solve): + def test_unknown_constants_without_touchdown_passes_none( + self, mock_solve, behavior_parts + ): """Test that unknown constants without touchdown passes None.""" def solver_side_effect( @@ -253,19 +264,21 @@ def solver_side_effect( collapsed_weak_layer_kR, ): n = len(scenario.segments) - self.assertIsNone(touchdown_distance) - self.assertIsNone(touchdown_mode) - self.assertIsNone(collapsed_weak_layer_kR) + assert touchdown_distance is None + assert touchdown_mode is None + assert collapsed_weak_layer_kR is None return np.zeros((6, n)) mock_solve.side_effect = solver_side_effect - system = self._build_model(touchdown=False, system_type="skiers") + system = _build_model(behavior_parts, touchdown=False, system_type="skiers") _ = system.unknown_constants mock_solve.assert_called_once() @patch("weac.core.system_model.UnknownConstantsSolver.solve_for_unknown_constants") - def test_uncracked_unknown_constants_sets_all_foundation(self, mock_solve): + def test_uncracked_unknown_constants_sets_all_foundation( + self, mock_solve, behavior_parts + ): """Test that uncracked_unknown_constants sets all foundation.""" captured_scenarios = [] @@ -283,18 +296,16 @@ def solver_side_effect( mock_solve.side_effect = solver_side_effect - system = self._build_model(touchdown=False, system_type="skiers") + system = _build_model(behavior_parts, touchdown=False, system_type="skiers") _ = system.uncracked_unknown_constants - self.assertGreater(len(captured_scenarios), 0) - self.assertTrue( - all(seg.has_foundation for seg in captured_scenarios[-1].segments) - ) + assert len(captured_scenarios) > 0 + assert all(seg.has_foundation for seg in captured_scenarios[-1].segments) @patch("weac.core.system_model.SlabTouchdown") @patch("weac.core.system_model.UnknownConstantsSolver.solve_for_unknown_constants") def test_update_scenario_invalidates_touchdown_and_constants( - self, mock_solve, mock_td + self, mock_solve, mock_td, behavior_parts ): """Test that update_scenario invalidates touchdown and constants.""" mock_inst = MagicMock() @@ -316,7 +327,7 @@ def solver_side_effect( mock_solve.side_effect = solver_side_effect - system = self._build_model(touchdown=True, system_type="pst-") + system = _build_model(behavior_parts, touchdown=True, system_type="pst-") _ = system.slab_touchdown first_td_calls = mock_td.call_count _ = system.unknown_constants @@ -330,11 +341,13 @@ def solver_side_effect( _ = system.slab_touchdown _ = system.unknown_constants - self.assertGreater(mock_td.call_count, first_td_calls) - self.assertGreaterEqual(mock_solve.call_count, 2) + assert mock_td.call_count > first_td_calls + assert mock_solve.call_count >= 2 @patch("weac.core.system_model.UnknownConstantsSolver.solve_for_unknown_constants") - def test_toggle_touchdown_switches_solver_arguments(self, mock_solve): + def test_toggle_touchdown_switches_solver_arguments( + self, mock_solve, behavior_parts + ): """Test that toggle_touchdown switches the solver arguments.""" calls = [] @@ -352,7 +365,7 @@ def solver_side_effect( mock_solve.side_effect = solver_side_effect - system = self._build_model(touchdown=False, system_type="skiers") + system = _build_model(behavior_parts, touchdown=False, system_type="skiers") _ = system.unknown_constants # first call without TD with patch("weac.core.system_model.SlabTouchdown") as mock_td: @@ -365,15 +378,15 @@ def solver_side_effect( system.toggle_touchdown(True) _ = system.unknown_constants # second call with TD - self.assertEqual(len(calls), 2) + assert len(calls) == 2 # First without touchdown - self.assertEqual(calls[0], (None, None, None)) + assert calls[0] == (None, None, None) # Second with touchdown - self.assertEqual(calls[1], (900.0, "A_free_hanging", None)) + assert calls[1] == (900.0, "A_free_hanging", None) - def test_z_function_scalar_and_array(self): + def test_z_function_scalar_and_array(self, behavior_parts): """Test the z function with scalar and array inputs.""" - system = self._build_model(touchdown=False, system_type="skiers") + system = _build_model(behavior_parts, touchdown=False, system_type="skiers") # Patch eigensystem methods on the instance to simple deterministic outputs I6 = np.eye(6) @@ -399,7 +412,7 @@ def fake_zp(x, phi, has_foundation, qs): # pylint: disable=unused-argument has_foundation=True, qs=0.0, ) - self.assertEqual(z_scalar.shape, (6, 6)) + assert z_scalar.shape == (6, 6) expected = 2.0 * I6 + np.ones((6, 1)) @ np.ones( (1, 6) ) # Broadcast to (6, 6) @@ -416,8 +429,4 @@ def fake_zp(x, phi, has_foundation, qs): # pylint: disable=unused-argument qs=0.0, ) expected_cols = z_scalar.shape[1] * len(x) - self.assertEqual(z_array.shape, (6, expected_cols)) - - -if __name__ == "__main__": - unittest.main(verbosity=2) + assert z_array.shape == (6, expected_cols) diff --git a/tests/run_tests.py b/tests/run_tests.py deleted file mode 100644 index 786dcfb..0000000 --- a/tests/run_tests.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python -""" -Test runner script for the WEAC package. - -This script discovers and runs all tests in the tests directory. -Provides a pytest-like output with detailed reporting. -""" - -import os -import sys -import unittest - -from weac.logging_config import setup_logging # noqa: E402 - -setup_logging(level="WARNING") -current_dir = os.path.dirname(os.path.abspath(__file__)) -parent_dir = os.path.dirname(current_dir) - - -def run_tests(): - """Discover and run all tests in the tests directory and subdirectories.""" - # Get the directory containing this script - test_dir = os.path.dirname(os.path.abspath(__file__)) - - print(f"Discovering tests in: {test_dir}") - print("Looking for test files matching pattern: test_*.py") - print("Searching recursively in subdirectories...") - print("-" * 60) - - # Discover all tests in the tests directory (recursive by default) - test_suite = unittest.defaultTestLoader.discover( - test_dir, pattern="test_*.py", top_level_dir=parent_dir - ) - - # Count and display discovered tests - test_count = test_suite.countTestCases() - print(f"Found {test_count} test cases") - print("-" * 60) - - # Create a test runner - test_runner = unittest.TextTestRunner(verbosity=2) - - # Run the tests - result = test_runner.run(test_suite) - - # Print summary - print("\n" + "=" * 60) - print(f"Tests run: {result.testsRun}") - print(f"Failures: {len(result.failures)}") - print(f"Errors: {len(result.errors)}") - if result.testsRun > 0: - success_rate = ( - (result.testsRun - len(result.failures) - len(result.errors)) - / result.testsRun - * 100 - ) - print(f"Success rate: {success_rate:.1f}%") - else: - print("No tests were run") - - return result - - -if __name__ == "__main__": - unittest_result = run_tests() - sys.exit(0 if unittest_result.wasSuccessful() else 1) diff --git a/tests/test_comparison_results.py b/tests/test_comparison_results.py index 90a8790..8cee32a 100644 --- a/tests/test_comparison_results.py +++ b/tests/test_comparison_results.py @@ -2,9 +2,8 @@ This module contains tests that compare the results of the old and new WEAC implementations. """ -import unittest - import numpy as np +import pytest from weac.analysis.analyzer import Analyzer from weac.components import ( @@ -21,7 +20,7 @@ ) -class TestIntegrationOldVsNew(unittest.TestCase): +class TestIntegrationOldVsNew: """Integration tests comparing old weac implementation with new weac implementation.""" def test_simple_two_layer_setup(self): @@ -109,47 +108,33 @@ def test_simple_two_layer_setup(self): ) # Compare the WeakLayer attributes - self.assertEqual( - old_state["weak"]["nu"], - new_system.weak_layer.nu, - "Weak layer Poisson's ratio should be the same", + assert old_state["weak"]["nu"] == new_system.weak_layer.nu, ( + "Weak layer Poisson's ratio should be the same" ) - self.assertEqual( - old_state["weak"]["E"], - new_system.weak_layer.E, - "Weak layer Young's modulus should be the same", + assert old_state["weak"]["E"] == new_system.weak_layer.E, ( + "Weak layer Young's modulus should be the same" ) - self.assertEqual( - old_state["t"], - new_system.weak_layer.h, - "Weak layer thickness should be the same", + assert old_state["t"] == new_system.weak_layer.h, ( + "Weak layer thickness should be the same" ) - self.assertEqual( - old_state["kn"], - new_system.weak_layer.kn, - "Weak layer normal stiffness should be the same", + assert old_state["kn"] == new_system.weak_layer.kn, ( + "Weak layer normal stiffness should be the same" ) - self.assertEqual( - old_state["kt"], - new_system.weak_layer.kt, - "Weak layer shear stiffness should be the same", + assert old_state["kt"] == new_system.weak_layer.kt, ( + "Weak layer shear stiffness should be the same" ) # Compare the Slab properties - self.assertEqual( - old_state["h"], new_system.slab.H, "Slab thickness should be the same" - ) - self.assertEqual( - old_state["zs"], - new_system.slab.z_cog, - "Slab center of gravity should be the same", + assert old_state["h"] == new_system.slab.H, "Slab thickness should be the same" + assert old_state["zs"] == new_system.slab.z_cog, ( + "Slab center of gravity should be the same" ) # Compare the Layer properties old_slab = ( np.asarray(old_state["slab"]) if old_state["slab"] is not None else None ) - self.assertIsNotNone(old_slab, "Old slab data should be available") + assert old_slab is not None, "Old slab data should be available" if old_slab is not None: np.testing.assert_array_equal( old_slab[:, 0] * 1e-12, @@ -178,14 +163,12 @@ def test_simple_two_layer_setup(self): ) # Compare all the attributes of the old and new model - self.assertEqual( - old_state["a"], - new_system.scenario.cut_length, - "Cut length should be the same", + assert old_state["a"] == new_system.scenario.cut_length, ( + "Cut length should be the same" ) # Compare the z vectors - self.assertEqual(old_z.shape, new_z.shape, "Z-vector shapes should match") + assert old_z.shape == new_z.shape, "Z-vector shapes should match" np.testing.assert_allclose( old_z, new_z, @@ -361,69 +344,47 @@ def test_simple_two_layer_setup_with_touchdown(self): ) # Compare the WeakLayer attributes - self.assertEqual( - old_state["weak"]["nu"], - new_system.weak_layer.nu, - "Weak layer Poisson's ratio should be the same", + assert old_state["weak"]["nu"] == new_system.weak_layer.nu, ( + "Weak layer Poisson's ratio should be the same" ) - self.assertEqual( - old_state["weak"]["E"], - new_system.weak_layer.E, - "Weak layer Young's modulus should be the same", + assert old_state["weak"]["E"] == new_system.weak_layer.E, ( + "Weak layer Young's modulus should be the same" ) - self.assertEqual( - old_state["t"], - new_system.weak_layer.h, - "Weak layer thickness should be the same", + assert old_state["t"] == new_system.weak_layer.h, ( + "Weak layer thickness should be the same" ) - self.assertEqual( - old_state["kn"], - new_system.weak_layer.kn, - "Weak layer normal stiffness should be the same", + assert old_state["kn"] == new_system.weak_layer.kn, ( + "Weak layer normal stiffness should be the same" ) - self.assertEqual( - old_state["kt"], - new_system.weak_layer.kt, - "Weak layer shear stiffness should be the same", + assert old_state["kt"] == new_system.weak_layer.kt, ( + "Weak layer shear stiffness should be the same" ) # Compare the Slab Touchdown attributes - self.assertEqual( - old_state["touchdown"]["tc"], - new_system.scenario.crack_h, - "Crack height should be the same", + assert old_state["touchdown"]["tc"] == new_system.scenario.crack_h, ( + "Crack height should be the same" ) - self.assertEqual( - old_state["touchdown"]["a1"], - new_system.slab_touchdown.l_AB, - "Transition length A should be the same", + assert old_state["touchdown"]["a1"] == pytest.approx( + new_system.slab_touchdown.l_AB, abs=0.5 * 10 ** (-9) ) - self.assertEqual( - old_state["touchdown"]["a2"], - new_system.slab_touchdown.l_BC, - "Transition length B should be the same", + assert old_state["touchdown"]["a2"] == pytest.approx( + new_system.slab_touchdown.l_BC, abs=0.5 * 10 ** (-9) ) - self.assertEqual( - old_state["touchdown"]["td"], - new_system.slab_touchdown.touchdown_distance, - "Touchdown distance should be the same", + assert old_state["touchdown"]["td"] == pytest.approx( + new_system.slab_touchdown.touchdown_distance, abs=0.5 * 10 ** (-9) ) # Compare the Slab properties - self.assertEqual( - old_state["h"], new_system.slab.H, "Slab thickness should be the same" - ) - self.assertEqual( - old_state["zs"], - new_system.slab.z_cog, - "Slab center of gravity should be the same", + assert old_state["h"] == new_system.slab.H, "Slab thickness should be the same" + assert old_state["zs"] == new_system.slab.z_cog, ( + "Slab center of gravity should be the same" ) # Compare the Layer properties old_slab = ( np.asarray(old_state["slab"]) if old_state["slab"] is not None else None ) - self.assertIsNotNone(old_slab, "Old slab data should be available") + assert old_slab is not None, "Old slab data should be available" if old_slab is not None: np.testing.assert_array_equal( old_slab[:, 0] * 1e-12, @@ -452,18 +413,12 @@ def test_simple_two_layer_setup_with_touchdown(self): ) # Compare all the attributes of the old and new model - self.assertEqual( - old_state["a"], - new_system.scenario.cut_length, - "Cut length should be the same", + assert old_state["a"] == new_system.scenario.cut_length, ( + "Cut length should be the same" ) # --- Compare results --- - self.assertEqual( - old_z.shape, - new_z.shape, - "Result arrays should have the same shape", - ) + assert old_z.shape == new_z.shape, "Result arrays should have the same shape" # Numerical differences lie in the absolute realm of e-12 np.testing.assert_allclose( @@ -554,7 +509,3 @@ def test_simple_two_layer_setup_with_touchdown(self): atol=1e-12, err_msg="Principal slab stress should be very similar", ) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/tests/test_regression_simulation.py b/tests/test_regression_simulation.py index fd2bbc6..73d72aa 100644 --- a/tests/test_regression_simulation.py +++ b/tests/test_regression_simulation.py @@ -2,9 +2,8 @@ This module contains regression tests for the WEAC model. """ -import unittest - import numpy as np +import pytest from weac.analysis import CriteriaEvaluator from weac.components import ( @@ -21,52 +20,52 @@ GT_skier_baseline = np.array( [ [ - -1.3311587133616033e-03, - -1.3311587133987555e-03, - -1.4922878538805329e-02, - -1.4922878538805305e-02, - -1.3316416781406679e-03, - -1.3311587133616033e-03, + -1.5915629333945330e-03, + -1.5915629336245172e-03, + -1.5881063073921575e-02, + -1.5881063073921602e-02, + -1.5926744799202215e-03, + -1.5915629333945330e-03, ], [ - -1.3400532113402682e-27, - -1.9609062333698352e-16, - -8.8088543943750638e-05, - 1.8243392275606253e-05, - 2.5491108889889770e-09, - 1.3113971286963517e-13, + -4.9580332155970320e-26, + -1.1282559163294969e-15, + -8.8727607933936081e-05, + 1.7604328285420965e-05, + 5.4530238965214982e-09, + 5.9050496161149038e-13, ], [ - 1.2028124616334202e-03, - 1.2028124616361854e-03, - 4.2336109897242152e-02, - 4.2336109897242159e-02, - 1.2027765147493792e-03, - 1.2028124616334202e-03, + 1.7566221061926016e-03, + 1.7566221062121309e-03, + 5.2305890327102263e-02, + 5.2305890327102263e-02, + 1.7565277179893842e-03, + 1.7566221061926016e-03, ], [ - 4.4863892018696710e-28, - 1.4594950179586782e-17, - 9.0840725538762377e-04, - -1.0213155501342633e-03, - 1.8972934933226463e-10, - 4.3904509669894562e-14, + 1.6582910707734481e-26, + 9.5807099034229853e-17, + 8.9919447058825388e-04, + -1.0305283349336320e-03, + 4.6304955252766267e-10, + 1.9750353870306439e-13, ], [ 1.0207865877058275e-05, - 1.0207865877358878e-05, - 2.0858241860062231e-04, - 2.0858241860062263e-04, - 1.0211773622223890e-05, + 1.0207865878602401e-05, + 2.1212597559624593e-04, + 2.1212597559624601e-04, + 1.0215328859423427e-05, 1.0207865877058275e-05, ], [ - 9.3082770992463219e-30, - 1.5866005526363208e-18, - 5.7089479049104315e-06, - 1.4556704561361483e-06, - -2.0625263341890901e-11, - -9.1092262290486623e-16, + 2.6286547599000873e-28, + 7.5751701007567834e-18, + 6.4394219643531282e-06, + 2.1861445155788474e-06, + -3.6611891841525330e-11, + -3.1307448147011621e-15, ], ] ) @@ -74,70 +73,70 @@ GT_skiers_baseline = np.array( [ [ - -3.3364140411700502e-03, - -3.3371039610692352e-03, - -1.0211953916849679e-02, - -1.0211953916849772e-02, - -3.7930081429868277e-03, - -1.1362149028450508e-02, - -1.1362149028450560e-02, - -3.3383877478897019e-03, - -3.3364140411700502e-03, + -2.4653852973084063e-03, + -2.4655484885915658e-03, + -8.6351058719333351e-03, + -8.6351058719333403e-03, + -2.7068559840275818e-03, + -9.6646423216041888e-03, + -9.6646423216041888e-03, + -2.4658396871515922e-03, + -2.4653852973084063e-03, ], [ - -8.0289180784556896e-13, - -2.3962146278368322e-09, - -3.5438765390651617e-05, - 4.4357106916068844e-06, - 5.6324248362093287e-07, - -4.1293393719283317e-05, - 5.2268283766852303e-06, - 6.8550347830960343e-09, - 2.2968941139093848e-12, + -5.5742993132157257e-14, + -6.5624814220476531e-10, + -3.5669712207617548e-05, + 4.2047638746413479e-06, + 3.3339662507053073e-07, + -4.1596301998286905e-05, + 4.9239200976819496e-06, + 1.8272574652797735e-09, + 1.5521080181112037e-13, ], [ - 5.3656877671703247e-03, - 5.3657501774765836e-03, - 4.0999377862256728e-02, - 4.0999377862256728e-02, - 5.3516089951323098e-03, - 4.6936976212589937e-02, - 4.6936976212589923e-02, - 5.3655092252078438e-03, - 5.3656877671703247e-03, + 3.5132442123852032e-03, + 3.5132596023747385e-03, + 3.1362176114134076e-02, + 3.1362176114134097e-02, + 3.5054833580758235e-03, + 3.6003234142644044e-02, + 3.6003234142644051e-02, + 3.5132013604950180e-03, + 3.5132442123852032e-03, ], [ - 2.1476913299692529e-13, - 2.1676209355807275e-10, - 3.2479067052872183e-04, - -3.9885538154198533e-04, - 1.4280212737807196e-07, - 3.7892379106187288e-04, - -4.6532993635395239e-04, - 6.2010795683549551e-10, - 6.1440651481267745e-13, + 1.7212354353603082e-14, + 6.1888427880333399e-11, + 3.2999742490243448e-04, + -3.9364862716827253e-04, + 9.1521517730917898e-08, + 3.8499746964858417e-04, + -4.5925625776724132e-04, + 1.7232215222682087e-10, + 4.7926083085385896e-14, ], [ 1.0845857494374418e-05, - 1.0848064160073291e-05, - 9.1766771806106752e-05, - 9.1766771806106942e-05, - 1.2307527822099668e-05, - 1.0526725389866414e-04, - 1.0526725389866431e-04, - 1.0852170272287989e-05, + 1.0846583894195900e-05, + 8.9675432515149128e-05, + 8.9675432515149019e-05, + 1.1920821852005561e-05, + 1.0281925764731007e-04, + 1.0281925764731007e-04, + 1.0847880082191190e-05, 1.0845857494374418e-05, ], [ - 1.1022164968036404e-15, - 7.6641418314537503e-12, - 3.3164846239000650e-06, - 1.7215055806097094e-06, - -1.7298852781935918e-09, - 3.8690662777605046e-06, - 2.0082573939217563e-06, - -2.1925402470511338e-11, - -3.1531951864791889e-15, + 1.5154752883604485e-16, + 2.9211029133588500e-12, + 2.8976674951365774e-06, + 1.3026884518462219e-06, + -1.4769670776969376e-09, + 3.3805303433233482e-06, + 1.5197214594845995e-06, + -8.1335195861421427e-12, + -4.2196897119194863e-16, ], ] ) @@ -145,48 +144,48 @@ GT_pst_without_touchdown = np.array( [ [ - -7.2487996383562396e-03, - -6.0196423568498235e-03, - 2.0773162839138180e00, - 2.0773162839138175e00, - 1.2130315043983948e01, - 1.3485989766738559e01, + -8.4871937355689708e-03, + -7.1030228169780734e-03, + 2.1468411930446529e00, + 2.1468411930446507e00, + 1.2199839953114783e01, + 1.3555514675869393e01, ], [ - -8.4703294725430034e-22, - 5.0708000603491068e-10, - 8.6973240373155250e-03, + 0.0000000000000000e00, + 1.9281153001886413e-09, + 8.6973240373155267e-03, 8.6973240373155267e-03, 2.1039215467303948e-03, 1.7347234759768071e-18, ], [ - 5.2190784110483475e-03, - 2.4392769285311888e-03, - 1.7127974554689163e00, - 1.7127974554689156e00, - 3.1178068254972919e02, - 8.2709909746257256e02, + 6.4429390610499186e-03, + 3.1432512066047066e-03, + 1.9796843832394049e00, + 1.9796843832394051e00, + 3.1544987306063848e02, + 8.3417059155662048e02, ], [ - -3.1911617258468120e-05, - -3.9755915991866683e-11, - 2.9311857533740264e-02, - 2.9311857533740261e-02, - 2.3604562295124668e-01, - 2.6458510067192831e-01, + -3.4064303328982494e-05, + -1.4409831819984820e-10, + 3.1013009325309624e-02, + 3.1013009325309628e-02, + 2.3774677474281602e-01, + 2.6628625246349769e-01, ], [ - 3.1911617258468134e-05, - 1.8113788874495151e-05, - -2.8287378556700056e-02, - -2.8287378556700049e-02, - -2.3553338346272659e-01, - -2.6458510067192831e-01, + 3.4064303328982487e-05, + 1.8112855093008230e-05, + -2.9988530348269416e-02, + -2.9988530348269416e-02, + -2.3723453525429594e-01, + -2.6628625246349769e-01, ], [ - 5.0398620458123951e-24, - -1.2082657686176822e-12, + -4.1030402786211213e-24, + -3.7428407015253508e-12, -1.7819428769682468e-04, -1.7819428769682468e-04, -4.4063073869004441e-05, @@ -249,7 +248,7 @@ ) -class TestRegressionSimulation(unittest.TestCase): +class TestRegressionSimulation: """Regression tests asserting stable outputs for key scenarios.""" def test_skier_baseline(self): @@ -376,8 +375,10 @@ def test_pst_with_touchdown_baseline(self): C = sm.unknown_constants # Touchdown mode and distance baselines - self.assertEqual(td.touchdown_mode, "C_in_contact") - self.assertAlmostEqual(td.touchdown_distance, 1577.2698088929287, places=6) + assert td.touchdown_mode == "C_in_contact" + assert td.touchdown_distance == pytest.approx( + 1577.2698088929287, abs=0.5 * 10 ** (-6) + ) # Scenario segments updated by touchdown length seg_lengths = np.array([seg.length for seg in sm.scenario.segments]) @@ -418,42 +419,46 @@ def test_criteria_evaluator_regressions(self): # find_minimum_force baseline fm = evaluator.find_minimum_force(system=sm, tolerance_stress=0.005) - self.assertTrue(fm.success) - self.assertGreater(fm.critical_skier_weight, 0) + assert fm.success + assert fm.critical_skier_weight > 0 # Baseline values recorded - self.assertAlmostEqual(fm.critical_skier_weight, 68.504569930, places=6) - self.assertAlmostEqual(fm.max_dist_stress, 1.0000189267255666, places=6) - self.assertLess(fm.min_dist_stress, 1.0) + assert fm.critical_skier_weight == pytest.approx( + 75.17870187198098, abs=0.5 * 10 ** (-6) + ) + assert fm.max_dist_stress == pytest.approx( + 1.0000048176337313, abs=0.5 * 10 ** (-6) + ) + assert fm.min_dist_stress < 1.0 # evaluate_SteadyState baseline ss = evaluator.evaluate_SteadyState(system=sm, vertical=False) - self.assertTrue(ss.converged) - self.assertGreater(ss.touchdown_distance, 0) + assert ss.converged + assert ss.touchdown_distance > 0 # Baseline values recorded - self.assertAlmostEqual(ss.touchdown_distance, 1265.551937834, places=6) - np.testing.assert_allclose(ss.energy_release_rate, 2.123992, rtol=1e-6, atol=0) + assert ss.touchdown_distance == pytest.approx( + 1262.7061033873686, abs=0.5 * 10 ** (-6) + ) + np.testing.assert_allclose( + ss.energy_release_rate, 2.110196960094839, rtol=1e-6, atol=0 + ) # evaluate_coupled_criterion baseline cc = evaluator.evaluate_coupled_criterion(system=sm, max_iterations=10) - self.assertIsNotNone(cc) - self.assertIsInstance(cc.critical_skier_weight, float) - self.assertIsInstance(cc.crack_length, float) + assert cc is not None + assert isinstance(cc.critical_skier_weight, float) + assert isinstance(cc.crack_length, float) # Baseline values recorded - self.assertTrue(cc.converged) + assert cc.converged np.testing.assert_allclose( - cc.critical_skier_weight, 183.40853553646807, rtol=1e-2 + cc.critical_skier_weight, 180.87597195071328, rtol=1e-2 ) - np.testing.assert_allclose(cc.crack_length, 119.58600407185531, rtol=1e-2) + np.testing.assert_allclose(cc.crack_length, 118.82324435526425, rtol=1e-2) np.testing.assert_allclose(cc.g_delta, 1.0, rtol=1e-2) np.testing.assert_allclose(cc.dist_ERR_envelope, 0.0, atol=1e-2) # find_minimum_crack_length baseline (returns crack length > 0) crack_len, new_segments = evaluator.find_minimum_crack_length(system=sm) - self.assertGreater(crack_len, 0) - self.assertTrue(all(isinstance(s, Segment) for s in new_segments)) + assert crack_len > 0 + assert all(isinstance(s, Segment) for s in new_segments) # Baseline value recorded - np.testing.assert_allclose(crack_len, 1582.87791111003, rtol=1e-2) - - -if __name__ == "__main__": - unittest.main(verbosity=2) + np.testing.assert_allclose(crack_len, 1564.671141349807, rtol=1e-2) diff --git a/tests/utils/test_json_helpers.py b/tests/utils/test_json_helpers.py index 15ef6bf..e3bb8e2 100644 --- a/tests/utils/test_json_helpers.py +++ b/tests/utils/test_json_helpers.py @@ -1,21 +1,21 @@ """Unit tests for JSON helpers.""" import json -import unittest import numpy as np +import pytest from .json_helpers import json_default -class TestJsonHelpers(unittest.TestCase): +class TestJsonHelpers: """Test the JSON serialization helpers.""" def test_json_default_numpy_array(self): """Verify numpy arrays are serialized to lists.""" data = {"a": np.array([1, 2, 3])} result = json.dumps(data, default=json_default) - self.assertEqual(json.loads(result), {"a": [1, 2, 3]}) + assert json.loads(result) == {"a": [1, 2, 3]} def test_json_default_numpy_scalars(self): """Verify numpy scalar types are serialized to Python primitives.""" @@ -32,7 +32,7 @@ def test_json_default_numpy_scalars(self): "bool_true": True, "bool_false": False, } - self.assertDictEqual(json.loads(result), expected) + assert json.loads(result) == expected def test_json_default_mixed_types(self): """Verify mixed data including numpy and standard types serializes correctly.""" @@ -46,8 +46,8 @@ def test_json_default_mixed_types(self): result = json.dumps(data, default=json_default) # Note: np.float32 may have precision differences, test against its .item() expected_py_float = np.float32(1.23).item() - self.assertAlmostEqual( - json.loads(result)["np_float"], expected_py_float, places=6 + assert json.loads(result)["np_float"] == pytest.approx( + expected_py_float, abs=0.5 * 10 ** (-6) ) # Check the rest of the dictionary loaded_result = json.loads(result) @@ -58,7 +58,7 @@ def test_json_default_mixed_types(self): "py_str": "hello", "py_list": [1, "a", None], } - self.assertDictEqual(loaded_result, expected_dict) + assert loaded_result == expected_dict def test_json_default_unhandled_type(self): """Verify unhandled types are converted to their string representation.""" @@ -71,22 +71,18 @@ def __str__(self): data = {"key": Unserializable()} result = json.dumps(data, default=json_default) - self.assertEqual(json.loads(result), {"key": "UnserializableObject"}) + assert json.loads(result) == {"key": "UnserializableObject"} - def test_various_inputs(self): - """Test a variety of inputs for comprehensive coverage.""" - test_cases = [ + @pytest.mark.parametrize( + "value,expected", + [ (np.int32(-5), "-5"), (np.float64(1e-9), "1e-09"), (np.array([1.0, 2.5]), "[1.0, 2.5]"), (True, "true"), (None, "null"), - ] - - for value, expected in test_cases: - with self.subTest(value=value): - self.assertEqual(json.dumps(value, default=json_default), expected) - - -if __name__ == "__main__": - unittest.main() + ], + ) + def test_various_inputs(self, value, expected): + """Test a variety of inputs for comprehensive coverage.""" + assert json.dumps(value, default=json_default) == expected diff --git a/tests/utils/test_misc.py b/tests/utils/test_misc.py index a64a873..839c6e0 100644 --- a/tests/utils/test_misc.py +++ b/tests/utils/test_misc.py @@ -4,15 +4,14 @@ Tests force decomposition, skier load calculations, and other utility functions. """ -import unittest - import numpy as np +import pytest from weac.constants import G_MM_S2, LSKI_MM from weac.utils.misc import decompose_to_xyz, get_skier_point_load -class TestForceDecomposition(unittest.TestCase): +class TestForceDecomposition: """Test the decompose_to_xyz function.""" def test_flat_surface_decomposition(self): @@ -24,18 +23,8 @@ def test_flat_surface_decomposition(self): fx, _, fz = decompose_to_xyz(f, phi, theta) # On flat surface, normal component equals original force, tangential is zero - self.assertAlmostEqual( - fz, - f, - places=10, - msg="Normal component should equal original force on flat surface", - ) - self.assertAlmostEqual( - fx, - 0.0, - places=10, - msg="Tangential component should be zero on flat surface", - ) + assert fz == pytest.approx(f, abs=0.5 * 10 ** (-10)) + assert fx == pytest.approx(0.0, abs=0.5 * 10 ** (-10)) def test_vertical_surface_decomposition(self): """Test force decomposition on vertical surface (phi=90).""" @@ -46,18 +35,8 @@ def test_vertical_surface_decomposition(self): fx, _, fz = decompose_to_xyz(f, phi, theta) # On vertical surface, normal component is zero, tangential equals original force - self.assertAlmostEqual( - fz, - 0.0, - places=10, - msg="Normal component should be zero on vertical surface", - ) - self.assertAlmostEqual( - fx, - -f, - places=10, - msg="Tangential component should equal negative original force", - ) + assert fz == pytest.approx(0.0, abs=0.5 * 10 ** (-10)) + assert fx == pytest.approx(-f, abs=0.5 * 10 ** (-10)) def test_45_degree_decomposition(self): """Test force decomposition on 45-degree surface.""" @@ -69,22 +48,12 @@ def test_45_degree_decomposition(self): # On 45-degree surface, both components should be equal in magnitude expected_component = f / np.sqrt(2) - self.assertAlmostEqual( - abs(fz), - expected_component, - places=8, - msg="Normal component magnitude should be f/√2 for 45° surface", - ) - self.assertAlmostEqual( - abs(fx), - expected_component, - places=8, - msg="Tangential component magnitude should be f/√2 for 45° surface", - ) + assert abs(fz) == pytest.approx(expected_component, abs=0.5 * 10 ** (-8)) + assert abs(fx) == pytest.approx(expected_component, abs=0.5 * 10 ** (-8)) # Check signs: normal should be positive (into slope), tangential negative (downslope) - self.assertGreater(fz, 0, "Normal component should be positive (into slope)") - self.assertLess(fx, 0, "Tangential component should be negative (downslope)") + assert fz > 0, "Normal component should be positive (into slope)" + assert fx < 0, "Tangential component should be negative (downslope)" def test_30_degree_decomposition(self): """Test force decomposition on 30-degree surface.""" @@ -98,8 +67,8 @@ def test_30_degree_decomposition(self): expected_z = f * np.cos(np.deg2rad(30)) # f * cos(30°) = f * √3/2 expected_x = -f * np.sin(np.deg2rad(30)) # -f * sin(30°) = -f/2 - self.assertAlmostEqual(fz, expected_z, places=10) - self.assertAlmostEqual(fx, expected_x, places=10) + assert fz == pytest.approx(expected_z, abs=0.5 * 10 ** (-10)) + assert fx == pytest.approx(expected_x, abs=0.5 * 10 ** (-10)) def test_negative_angles(self): """Test force decomposition with negative angles.""" @@ -113,8 +82,8 @@ def test_negative_angles(self): # Tangential should be positive (upslope for negative angle) with magnitude f*sin(phi) expected_z = f * np.cos(np.deg2rad(phi)) expected_x = -f * np.sin(np.deg2rad(phi)) - self.assertAlmostEqual(fz, expected_z, places=10) - self.assertAlmostEqual(fx, expected_x, places=10) + assert fz == pytest.approx(expected_z, abs=0.5 * 10 ** (-10)) + assert fx == pytest.approx(expected_x, abs=0.5 * 10 ** (-10)) def test_zero_force(self): """Test force decomposition with zero force.""" @@ -124,8 +93,8 @@ def test_zero_force(self): fx, _, fz = decompose_to_xyz(f, phi, theta) - self.assertEqual(fz, 0.0, "Zero force should give zero normal component") - self.assertEqual(fx, 0.0, "Zero force should give zero tangential component") + assert fz == 0.0, "Zero force should give zero normal component" + assert fx == 0.0, "Zero force should give zero tangential component" def test_energy_conservation(self): """Test that force decomposition conserves energy (magnitude).""" @@ -139,15 +108,12 @@ def test_energy_conservation(self): original_magnitude_squared = f**2 decomposed_magnitude_squared = fx**2 + fy**2 + fz**2 - self.assertAlmostEqual( - original_magnitude_squared, - decomposed_magnitude_squared, - places=10, - msg="Force magnitude should be conserved in decomposition", + assert original_magnitude_squared == pytest.approx( + decomposed_magnitude_squared, abs=0.5 * 10 ** (-10) ) -class TestSkierPointLoad(unittest.TestCase): +class TestSkierPointLoad: """Test the get_skier_point_load function.""" def test_skier_load_calculation(self): @@ -159,9 +125,7 @@ def test_skier_load_calculation(self): # Expected calculation: F = 1e-3 * m * G_MM_S2 / LSKI_MM expected_F = 1e-3 * m * G_MM_S2 / LSKI_MM - self.assertAlmostEqual( - F, expected_F, places=10, msg="Skier load should match expected calculation" - ) + assert F == pytest.approx(expected_F, abs=0.5 * 10 ** (-10)) def test_skier_load_units(self): """Test that skier load has correct units.""" @@ -170,15 +134,15 @@ def test_skier_load_units(self): # Result should be in N/mm (force per unit length) # For typical values, this should be a small positive number - self.assertGreater(F, 0, "Skier load should be positive") - self.assertLess(F, 1, "Skier load should be reasonable magnitude (< 1 N/mm)") + assert F > 0, "Skier load should be positive" + assert F < 1, "Skier load should be reasonable magnitude (< 1 N/mm)" def test_zero_mass_skier(self): """Test skier load calculation with zero mass.""" m = 0.0 F = get_skier_point_load(m) - self.assertEqual(F, 0.0, "Zero mass should give zero load") + assert F == 0.0, "Zero mass should give zero load" def test_heavy_skier(self): """Test skier load calculation with heavy skier.""" @@ -189,13 +153,8 @@ def test_heavy_skier(self): m_light = 60.0 F_light = get_skier_point_load(m_light) - self.assertGreater(F, F_light, "Heavier skier should produce larger load") - self.assertAlmostEqual( - F / F_light, - m / m_light, - places=10, - msg="Load should scale linearly with mass", - ) + assert F > F_light, "Heavier skier should produce larger load" + assert F / F_light == pytest.approx(m / m_light, abs=0.5 * 10 ** (-10)) def test_skier_load_scaling(self): """Test that skier load scales linearly with mass.""" @@ -206,15 +165,10 @@ def test_skier_load_scaling(self): for i in range(1, len(masses)): ratio_mass = masses[i] / masses[0] ratio_load = loads[i] / loads[0] - self.assertAlmostEqual( - ratio_mass, - ratio_load, - places=10, - msg=f"Load should scale linearly: mass ratio {ratio_mass}, load ratio {ratio_load}", - ) + assert ratio_mass == pytest.approx(ratio_load, abs=0.5 * 10 ** (-10)) -class TestUtilityFunctionConsistency(unittest.TestCase): +class TestUtilityFunctionConsistency: """Test consistency and edge cases for utility functions.""" def test_decomposition_symmetry(self): @@ -227,20 +181,10 @@ def test_decomposition_symmetry(self): fx_neg, _, fz_neg = decompose_to_xyz(f, -phi, theta) # Normal components should be equal - self.assertAlmostEqual( - fz_pos, - fz_neg, - places=10, - msg="Normal components should be equal for ±φ", - ) + assert fz_pos == pytest.approx(fz_neg, abs=0.5 * 10 ** (-10)) # Tangential components should be opposite - self.assertAlmostEqual( - fx_pos, - -fx_neg, - places=10, - msg="Tangential components should be opposite for ±φ", - ) + assert fx_pos == pytest.approx(-fx_neg, abs=0.5 * 10 ** (-10)) def test_large_angles(self): """Test force decomposition for large angles.""" @@ -253,8 +197,8 @@ def test_large_angles(self): # At 120°, normal component should be negative (surface leans over) # and tangential component should be negative (large downslope) - self.assertLess(fz, 0, "Normal component should be negative for obtuse angles") - self.assertLess(fx, 0, "Tangential component should be negative") + assert fz < 0, "Normal component should be negative for obtuse angles" + assert fx < 0, "Tangential component should be negative" def test_angle_bounds(self): """Test force decomposition at angle boundaries.""" @@ -263,13 +207,13 @@ def test_angle_bounds(self): # Test at exactly 0° fx, _, fz = decompose_to_xyz(f, 0.0, theta) - self.assertAlmostEqual(fz, f, places=15) - self.assertAlmostEqual(fx, 0.0, places=15) + assert fz == pytest.approx(f, abs=0.5 * 10 ** (-15)) + assert fx == pytest.approx(0.0, abs=0.5 * 10 ** (-15)) # Test at exactly 90° (expect some floating-point precision issues) fx, _, fz = decompose_to_xyz(f, 90.0, theta) - self.assertAlmostEqual(fz, 0.0, places=10) # Reduced precision for 90° case - self.assertAlmostEqual(fx, -f, places=15) + assert fz == pytest.approx(0.0, abs=0.5 * 10 ** (-10)) + assert fx == pytest.approx(-f, abs=0.5 * 10 ** (-15)) def test_force_decomposition_with_arrays(self): """Test that functions work with array inputs (if applicable).""" @@ -279,15 +223,15 @@ def test_force_decomposition_with_arrays(self): # Should work with array input try: loads = get_skier_point_load(masses) - self.assertEqual(len(loads), len(masses), "Should handle array input") + assert len(loads) == len(masses), "Should handle array input" for i, m in enumerate(masses): expected = get_skier_point_load(m) - self.assertAlmostEqual(loads[i], expected, places=10) + assert loads[i] == pytest.approx(expected, abs=0.5 * 10 ** (-10)) except (TypeError, AttributeError) as exc: - self.skipTest(f"get_skier_point_load does not support array inputs: {exc}") + pytest.skip(f"get_skier_point_load does not support array inputs: {exc}") -class TestPhysicalReasonableness(unittest.TestCase): +class TestPhysicalReasonableness: """Test that utility functions produce physically reasonable results.""" def test_typical_skier_loads(self): @@ -299,14 +243,14 @@ def test_typical_skier_loads(self): F = get_skier_point_load(m) # Load should be positive but not huge - self.assertGreater(F, 0, f"Load should be positive for {m} kg skier") - self.assertLess(F, 10, f"Load should be reasonable for {m} kg skier") + assert F > 0, f"Load should be positive for {m} kg skier" + assert F < 10, f"Load should be reasonable for {m} kg skier" # Rough sanity check: load should be on order of mg/length # where length is ski contact length rough_estimate = m * 9.81 / 1000 # Very rough estimate in N/mm - self.assertLess( - F, 10 * rough_estimate, "Load should be reasonable compared to weight" + assert F < 10 * rough_estimate, ( + "Load should be reasonable compared to weight" ) def test_typical_force_decompositions(self): @@ -319,21 +263,9 @@ def test_typical_force_decompositions(self): fx, _, fz = decompose_to_xyz(f, phi, theta) # Both components should be significant but less than original force - self.assertGreater( - abs(fz), 0, f"Normal component should be non-zero at {phi}°" - ) - self.assertGreater( - abs(fx), 0, f"Tangential component should be non-zero at {phi}°" + assert abs(fz) > 0, f"Normal component should be non-zero at {phi}°" + assert abs(fx) > 0, f"Tangential component should be non-zero at {phi}°" + assert abs(fz) < f, f"Normal component should be less than total at {phi}°" + assert abs(fx) < f, ( + f"Tangential component should be less than total at {phi}°" ) - self.assertLess( - abs(fz), f, f"Normal component should be less than total at {phi}°" - ) - self.assertLess( - abs(fx), - f, - f"Tangential component should be less than total at {phi}°", - ) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/tests/utils/test_snowpilot_parser.py b/tests/utils/test_snowpilot_parser.py index d190544..94cc3db 100644 --- a/tests/utils/test_snowpilot_parser.py +++ b/tests/utils/test_snowpilot_parser.py @@ -7,7 +7,8 @@ import math import os -import unittest + +import pytest from weac.components import Layer from weac.utils.snowpilot_parser import ( @@ -16,55 +17,54 @@ ) -class TestSnowPilotParser(unittest.TestCase): - """Test the SnowPilotParser functionality.""" +@pytest.fixture +def materials_dir(): + """Path to test materials in .materials/.""" + return os.path.join(os.path.dirname(os.path.dirname(__file__)), ".materials") - def setUp(self): - """Set up test fixtures with paths to test CAAML files.""" - # Paths to test materials in .materials/ - self.materials_dir = os.path.join( - os.path.dirname(os.path.dirname(__file__)), ".materials" - ) - self.caaml_with_density = os.path.join(self.materials_dir, "test_snowpit1.xml") - self.caaml_without_density = os.path.join( - self.materials_dir, "test_snowpit2.xml" - ) - # Verify test files exist - self.assertTrue( - os.path.exists(self.caaml_with_density), - f"Test file not found: {self.caaml_with_density}", - ) - self.assertTrue( - os.path.exists(self.caaml_without_density), - f"Test file not found: {self.caaml_without_density}", - ) +@pytest.fixture +def caaml_with_density(materials_dir): + """CAAML file that contains density measurements.""" + path = os.path.join(materials_dir, "test_snowpit1.xml") + assert os.path.exists(path), f"Test file not found: {path}" + return path + + +@pytest.fixture +def caaml_without_density(materials_dir): + """CAAML file that lacks density measurements.""" + path = os.path.join(materials_dir, "test_snowpit2.xml") + assert os.path.exists(path), f"Test file not found: {path}" + return path + + +class TestSnowPilotParser: + """Test the SnowPilotParser functionality.""" - def test_parse_caaml_with_density_measurements(self): + def test_parse_caaml_with_density_measurements(self, caaml_with_density): """Test parsing CAAML file that contains density measurements.""" - parser = SnowPilotParser(self.caaml_with_density) + parser = SnowPilotParser(caaml_with_density) layers, density_methods = parser.extract_layers() # Should have extracted layers - self.assertGreater(len(layers), 0, "Should extract layers from CAAML") - self.assertGreater( - density_methods.count("density_obs"), - 0, - "Should use measured density for some layers", + assert len(layers) > 0, "Should extract layers from CAAML" + assert density_methods.count("density_obs") > 0, ( + "Should use measured density for some layers" ) - def test_parse_caaml_without_density_measurements(self): + def test_parse_caaml_without_density_measurements(self, caaml_without_density): """Test parsing CAAML file that lacks density measurements.""" - parser = SnowPilotParser(self.caaml_without_density) + parser = SnowPilotParser(caaml_without_density) layers, density_methods = parser.extract_layers() # Should have extracted layers - self.assertGreater(len(layers), 0, "Should extract layers from CAAML") - self.assertEqual(density_methods.count("geldsetzer"), len(layers)) + assert len(layers) > 0, "Should extract layers from CAAML" + assert density_methods.count("geldsetzer") == len(layers) - def test_density_extraction_logic(self): + def test_density_extraction_logic(self, caaml_with_density): """Test the density extraction logic with overlapping measurements.""" - parser = SnowPilotParser(self.caaml_with_density) + parser = SnowPilotParser(caaml_with_density) # Get density layers for testing sp_density_layers = [ @@ -79,104 +79,88 @@ def test_density_extraction_logic(self): density = parser.get_density_for_layer_range( 20, 60, sp_density_layers ) # 2-6cm in mm - self.assertIsNotNone(density, "Should find density for overlapping layer") - self.assertIsInstance(density, float, "Density should be a float") - self.assertGreater(density, 0, "Density should be positive") + assert density is not None, "Should find density for overlapping layer" + assert isinstance(density, float), "Density should be a float" + assert density > 0, "Density should be positive" # Test case 2: Layer with no overlap # Test a layer well beyond the density measurements density_no_overlap = parser.get_density_for_layer_range( 1000, 1100, sp_density_layers ) # 100-110cm - self.assertIsNone( - density_no_overlap, "Should return None for non-overlapping layer" + assert density_no_overlap is None, ( + "Should return None for non-overlapping layer" ) - def test_layer_properties_validation(self): + def test_layer_properties_validation(self, caaml_with_density): """Test that extracted layers have valid properties.""" - parser = SnowPilotParser(self.caaml_with_density) + parser = SnowPilotParser(caaml_with_density) layers, _ = parser.extract_layers() for i, layer in enumerate(layers): - with self.subTest(layer_index=i): - # Validate layer properties - self.assertIsInstance( - layer, Layer, f"Layer {i} should be Layer instance" - ) - self.assertGreater( - layer.rho, 0, f"Layer {i} density should be positive" - ) - self.assertGreater( - layer.h, 0, f"Layer {i} thickness should be positive" - ) - self.assertLessEqual( - layer.rho, - 1000, - f"Layer {i} density should be reasonable (<= 1000 kg/m³)", - ) - - def test_error_handling_missing_data(self): + # Validate layer properties + assert isinstance(layer, Layer), f"Layer {i} should be Layer instance" + assert layer.rho > 0, f"Layer {i} density should be positive" + assert layer.h > 0, f"Layer {i} thickness should be positive" + assert layer.rho <= 1000, ( + f"Layer {i} density should be reasonable (<= 1000 kg/m³)" + ) + + def test_error_handling_missing_data(self, caaml_without_density): """Test error handling for missing required data.""" # This would require creating a malformed CAAML file or mocking # For now, test that parser handles empty density layers gracefully - parser = SnowPilotParser(self.caaml_without_density) + parser = SnowPilotParser(caaml_without_density) # Test with empty density layers list result = parser.get_density_for_layer_range(0, 100, []) - self.assertIsNone(result, "Should return None for empty density layers") + assert result is None, "Should return None for empty density layers" - def test_pit_slope_angle_from_caaml(self): + def test_pit_slope_angle_from_caaml(self, caaml_with_density): """Location validSlopeAngle is exposed when present.""" - parser = SnowPilotParser(self.caaml_with_density) - self.assertAlmostEqual(parser.pit_slope_angle_deg() or 0.0, 33.0) + parser = SnowPilotParser(caaml_with_density) + assert (parser.pit_slope_angle_deg() or 0.0) == pytest.approx( + 33.0, abs=0.5 * 10 ** (-7) + ) def test_slope_normal_le_plumb_depth(self): """Slope-normal depth is less than or equal to plumb-line depth.""" d_v = 100.0 # arbitrary plumb thickness [mm] - self.assertEqual(vertical_to_slope_normal_depth_scale(0.0), 1.0) - self.assertEqual(d_v * vertical_to_slope_normal_depth_scale(0.0), d_v) + assert vertical_to_slope_normal_depth_scale(0.0) == 1.0 + assert d_v * vertical_to_slope_normal_depth_scale(0.0) == d_v for phi in (5.0, 15.0, 33.0, 45.0, 60.0, 75.0): scale = vertical_to_slope_normal_depth_scale(phi) d_n = d_v * scale - self.assertLessEqual( - scale, - 1.0, - msg="scale should be <= 1 for tilted slopes", - ) - self.assertLessEqual( - d_n, - d_v, - msg="slope-normal thickness should be <= plumb thickness", - ) + assert scale <= 1.0, "scale should be <= 1 for tilted slopes" + assert d_n <= d_v, "slope-normal thickness should be <= plumb thickness" - def test_slope_normal_thickness_scaling(self): + def test_slope_normal_thickness_scaling(self, caaml_with_density): """Non-zero phi scales vertical thickness to slope-normal (cos(phi)).""" - parser = SnowPilotParser(self.caaml_with_density) + parser = SnowPilotParser(caaml_with_density) layers_0, _ = parser.extract_layers(0.0) phi = 60.0 scale = math.cos(math.radians(phi)) layers_sloped, _ = parser.extract_layers(phi) - self.assertEqual(len(layers_0), len(layers_sloped)) - for i, (a, b) in enumerate(zip(layers_0, layers_sloped)): - with self.subTest(layer_index=i): - self.assertAlmostEqual(b.h, a.h * scale, places=5) + assert len(layers_0) == len(layers_sloped) + for a, b in zip(layers_0, layers_sloped): + assert b.h == pytest.approx(a.h * scale, abs=0.5 * 10 ** (-5)) - def test_unit_conversion(self): + def test_unit_conversion(self, caaml_with_density): """Test that different units are converted correctly.""" - parser = SnowPilotParser(self.caaml_with_density) + parser = SnowPilotParser(caaml_with_density) layers, _ = parser.extract_layers() # All thicknesses should be in mm (converted from cm in CAAML) for layer in layers: # Thicknesses should be reasonable for mm units (> 1mm, < 2000mm typically) - self.assertGreater(layer.h, 0.1, "Layer thickness should be > 0.1mm") - self.assertLess( - layer.h, 5000, "Layer thickness should be < 5000mm (reasonable limit)" + assert layer.h > 0.1, "Layer thickness should be > 0.1mm" + assert layer.h < 5000, ( + "Layer thickness should be < 5000mm (reasonable limit)" ) - def test_density_weighted_average(self): + def test_density_weighted_average(self, caaml_with_density): """Test that overlapping density measurements are weighted correctly.""" - parser = SnowPilotParser(self.caaml_with_density) + parser = SnowPilotParser(caaml_with_density) # Get density layers sp_density_layers = [ @@ -195,18 +179,7 @@ def test_density_weighted_average(self): ) # 0-25cm in mm if density is not None: # May be None if no overlap logic issue - self.assertIsInstance(density, float, "Weighted density should be float") - self.assertGreater(density, 0, "Weighted density should be positive") + assert isinstance(density, float), "Weighted density should be float" + assert density > 0, "Weighted density should be positive" # Should be close to 20 since most measurements are 20 kg/m³ - self.assertAlmostEqual( - density, 20, delta=5, msg="Weighted average should be close to 20 kg/m³" - ) - - -if __name__ == "__main__": - # Set up logging to see debug info during tests - import logging - - logging.basicConfig(level=logging.INFO) - - unittest.main() + assert density == pytest.approx(20, abs=5) diff --git a/uv.lock b/uv.lock index 9c130b2..5dc2802 100644 --- a/uv.lock +++ b/uv.lock @@ -716,6 +716,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "ipykernel" version = "7.1.0" @@ -1730,6 +1739,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "prometheus-client" version = "0.23.1" @@ -1960,6 +1978,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2806,6 +2840,7 @@ dev = [ { name = "nest-asyncio" }, { name = "pycodestyle" }, { name = "pylint" }, + { name = "pytest" }, { name = "pyzmq" }, { name = "ruff" }, { name = "tornado" }, @@ -2857,6 +2892,7 @@ requires-dist = [ { name = "pycodestyle", marker = "extra == 'dev'", specifier = ">=2.11.1" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pylint", marker = "extra == 'dev'", specifier = ">=3.2.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "pyzmq", marker = "extra == 'dev'", specifier = ">=27.0.1" }, { name = "pyzmq", marker = "extra == 'interactive'", specifier = ">=27.0.1" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },