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 @@
+
-
@@ -84,18 +84,19 @@ WEAC implements closed-form analytical models for the [mechanical analysis of dr
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
[](https://atom.io)
[](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
+
[](https://github.com/2phi/weac/actions/workflows/release.yml)
[](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" },