From e666cef0b9bfb3f78c5eb1bcee08965f71d6f2b8 Mon Sep 17 00:00:00 2001
From: Florian Zill <florian.zill@ufz.de>
Date: Tue, 18 Jul 2023 13:44:57 +0000
Subject: [PATCH] docu for propertylib

---
 docs/_static/ogstools.css                     |  4 +
 docs/conf.py                                  |  7 ++
 docs/examples/howto_propertylib/README.rst    |  6 ++
 .../howto_propertylib/plot_propertylib.py     | 77 +++++++++++++++++++
 ogstools/meshplotlib/core.py                  | 35 ++++++---
 .../physics/nuclearwasteheat/nuclearwaste.py  |  5 +-
 ogstools/propertylib/__init__.py              | 17 ++--
 ogstools/propertylib/_uncoupled.py            | 50 ++++--------
 ogstools/propertylib/defaults.py              | 29 +++++++
 ogstools/propertylib/property.py              | 42 +++++++---
 ogstools/propertylib/property_collection.py   | 35 ++++++---
 ogstools/propertylib/vector2scalar.py         |  8 +-
 pyproject.toml                                |  2 +
 tests/test_nuclearwasteheat.py                | 16 ++--
 tests/test_propertylib.py                     | 32 ++++----
 15 files changed, 263 insertions(+), 102 deletions(-)
 create mode 100644 docs/examples/howto_propertylib/README.rst
 create mode 100644 docs/examples/howto_propertylib/plot_propertylib.py
 create mode 100644 ogstools/propertylib/defaults.py

diff --git a/docs/_static/ogstools.css b/docs/_static/ogstools.css
index 5d2f68352..5c4c77d06 100644
--- a/docs/_static/ogstools.css
+++ b/docs/_static/ogstools.css
@@ -5,3 +5,7 @@ nav.bd-links li>a {
 .navbar-brand img {
   height: 50px;
 }
+
+table.dataframe {
+  width: auto;
+}
diff --git a/docs/conf.py b/docs/conf.py
index c3f7575a4..21fe64c52 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -21,6 +21,7 @@ version = release = ogstools.__version__
 extensions = [
     "sphinx.ext.autodoc",
     "sphinx.ext.autodoc.typehints",
+    "sphinx.ext.viewcode",
     "sphinxarg.ext",
     "sphinxcontrib.programoutput",
     "myst_nb",
@@ -84,6 +85,12 @@ sphinx_gallery_conf = {
     "download_all_examples": False,
 }
 
+autoclass_content = "both"
+autodoc_class_signature = "separated"
+autodoc_member_order = "bysource"
+autodoc_preserve_defaults = True
+autodoc_typehints = "description"
+
 
 # Suppress sphinx warning for multiple documents generated by sphinx-gallery
 # May break myst-nb?
diff --git a/docs/examples/howto_propertylib/README.rst b/docs/examples/howto_propertylib/README.rst
new file mode 100644
index 000000000..ef958cb86
--- /dev/null
+++ b/docs/examples/howto_propertylib/README.rst
@@ -0,0 +1,6 @@
+Features of propertylib
+=======================
+
+.. sectionauthor:: Florian Zill (Helmholtz Centre for Environmental Research GmbH - UFZ)
+
+The following jupyter notebook shows some example usage of the propertylib submodule.
diff --git a/docs/examples/howto_propertylib/plot_propertylib.py b/docs/examples/howto_propertylib/plot_propertylib.py
new file mode 100644
index 000000000..038f41e43
--- /dev/null
+++ b/docs/examples/howto_propertylib/plot_propertylib.py
@@ -0,0 +1,77 @@
+"""
+Features of propertylib
+=====================================
+
+.. sectionauthor:: Florian Zill (Helmholtz Centre for Environmental Research GmbH - UFZ)
+
+``propertylib`` provides a common interface for other modules to structure
+reading, conversion and output of mesh data.
+"""
+
+# %%
+
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+
+import ogstools.propertylib as ptl
+
+# %%
+# There are some predefined default properties:
+
+tab = pd.DataFrame(ptl.defaults.all_properties).set_index("output_name")
+tab["type"] = [type(p).__name__ for p in ptl.defaults.all_properties]
+tab.drop(["tag", "func"], axis=1).sort_values(["mask", "data_name"])
+
+# %%
+# You can access properties either form the entire collection or from a subset
+# which only contains properties available to a specific OGS process.
+# Calling a property converts the argument from data_unit to output_unit and
+# applies a function if specified.
+
+print(ptl.defaults.temperature(273.15))  # access from the entire collection
+print(ptl.M.strain(0.01))  # access from Mechanics collection
+
+# %%
+# Available processes are:
+
+print([type(p).__name__ for p in ptl.processes])
+
+# %%
+# VectorProperties and MatrixProperties contain other Properties which represent
+# the result of an applied function on itself. Components can be accessed with
+# brackets. VectorProperties should be of length 2 or 3 corresponding to the
+# dimension. MatrixProperties likewise should be of length 4 [xx, yy, zz, xy]
+# or 6 [xx, yy, zz, xy, yz, xz].
+
+# %%
+# Element 1 (counting from 0) of a 3D displacement vector:
+
+print(ptl.M.displacement[1]([0.01, 0.02, 0.03]))
+
+# %%
+# Magnitude of a 2D displacement vector from:
+
+print(ptl.M.displacement.magnitude([0.03, 0.04]))
+
+# %%
+# Log of Magnitude of a 2D velocity vector from the Hydraulics collection:
+print(ptl.H.velocity.log_magnitude(np.sqrt([50, 50])))
+
+# %%
+# Magnitude and trace of a 3D strain matrix:
+eps = np.array([1, 3, 9, 1, 2, 2]) * 1e-2
+print(ptl.M.strain.magnitude(eps))
+print(ptl.M.strain.trace(eps))
+
+# %%
+# You can change the attributes of the defaults.
+# For example for temperature from the Thermal Collection from the default
+# output_unit °C to °F:
+
+temp = np.linspace(273.15, 373.15, 10)
+fig, axs = plt.subplots(2)
+axs[0].plot(ptl.T.temperature(temp), color="r")
+ptl.defaults.temperature.output_unit = "°F"
+axs[1].plot(ptl.T.temperature(temp), color="b")
+fig.show()
diff --git a/ogstools/meshplotlib/core.py b/ogstools/meshplotlib/core.py
index 2f65c3291..f6a26753d 100644
--- a/ogstools/meshplotlib/core.py
+++ b/ogstools/meshplotlib/core.py
@@ -13,7 +13,8 @@ from matplotlib import patches as mpatches
 from matplotlib import pyplot as plt
 from matplotlib import ticker as mticker
 
-from ogstools.propertylib import ScalarProperty as Scalar
+from ogstools.propertylib import MatrixProperty as Matrix
+from ogstools.propertylib import Property
 from ogstools.propertylib import VectorProperty as Vector
 
 from . import image_tools, setup
@@ -21,8 +22,6 @@ from . import plot_features as pf
 from .levels import get_levels
 from .mesh import Mesh
 
-Property = Union[Scalar, Vector]
-
 
 def xin_cell_data(mesh: pv.UnstructuredGrid, property: Property) -> bool:
     """Determine if the property is exclusive in cell data."""
@@ -43,7 +42,7 @@ def get_data(mesh: pv.UnstructuredGrid, property: Property) -> pv.DataSet:
 
 
 def get_cmap_norm(
-    levels: np.ndarray, property: Scalar, cell_data: bool
+    levels: np.ndarray, property: Property, cell_data: bool
 ) -> tuple[mcolors.Colormap, mcolors.Normalize]:
     """Construct a discrete colormap and norm for the property field."""
     vmin, vmax = (levels[0], levels[-1])
@@ -99,7 +98,11 @@ def plot_isometric(
 
     get_data(mesh, property).active_scalars_name = property.data_name
     # data = get_data(mesh, property)[property.data_name]
-    _p_val = property.magnitude if isinstance(property, Vector) else property
+    _p_val = (
+        property.magnitude
+        if isinstance(property, (Vector, Matrix))
+        else property
+    )
 
     data = get_data(mesh, property)[property.data_name]
     get_data(mesh, property)[property.data_name] = _p_val.values(data)
@@ -129,7 +132,7 @@ def plot_isometric(
 
 def add_colorbar(
     fig: mfigure.Figure,
-    property: Scalar,
+    property: Property,
     cell_data: bool,
     cmap: mcolors.Colormap,
     norm: mcolors.Normalize,
@@ -227,14 +230,18 @@ def subplot(
 
     # get projection
     mean_normal = np.abs(np.mean(mesh.extract_surface().cell_normals, axis=0))
-    projection = np.argmax(mean_normal)
+    projection = int(np.argmax(mean_normal))
     x_id, y_id = np.delete([0, 1, 2], projection)
 
     # faces contains a padding indicating number of points per face which gets
     # removed with this reshaping and slicing to get the array of tri's
     x, y = setup.length.values(surf_tri.points.T[[x_id, y_id]])
     tri = surf_tri.faces.reshape((-1, 4))[:, 1:]
-    _property = property.magnitude if isinstance(property, Vector) else property
+    _property = (
+        property.magnitude
+        if isinstance(property, (Vector, Matrix))
+        else property
+    )
     values = _property.values(get_data(surf_tri, property)[property.data_name])
     p_min, p_max = np.nanmin(values), np.nanmax(values)
 
@@ -281,7 +288,7 @@ def subplot(
         sec_id = np.argmax(np.delete(mean_normal, projection))
         sec_labels = []
         for tick in ax.get_xticks():
-            origin = mesh.center
+            origin = np.array(mesh.center)
             origin[sec_id] = min(
                 max(tick, mesh.bounds[2 * sec_id] + 1e-6),
                 mesh.bounds[2 * sec_id + 1] - 1e-6,
@@ -301,7 +308,7 @@ def subplot(
 
 
 def plot(
-    meshes: Union[np.ndarray[Mesh], Mesh], property: Property
+    meshes: Union[list[Mesh], np.ndarray], property: Property
 ) -> mfigure.Figure:
     """
     Plot the property field of meshes with default settings.
@@ -330,9 +337,13 @@ def plot(
         figsize=figsize,
     )
     fig.patch.set_alpha(1)
-    axs: np.ndarray[plt.Axes] = np.reshape(_axs, [len(meshes), len(meshes[0])])
+    axs: np.ndarray = np.reshape(_axs, [len(meshes), len(meshes[0])])
 
-    _p_val = property.magnitude if isinstance(property, Vector) else property
+    _p_val = (
+        property.magnitude
+        if isinstance(property, (Vector, Matrix))
+        else property
+    )
     p_min, p_max, n_values = np.inf, -np.inf, 0
     for mesh in np.ravel(meshes):
         if get_data(mesh, property) is None:
diff --git a/ogstools/physics/nuclearwasteheat/nuclearwaste.py b/ogstools/physics/nuclearwasteheat/nuclearwaste.py
index 60f5b663f..ae74ad974 100644
--- a/ogstools/physics/nuclearwasteheat/nuclearwaste.py
+++ b/ogstools/physics/nuclearwasteheat/nuclearwaste.py
@@ -71,7 +71,7 @@ class Repository:
     "Waste inventory of the repository."
 
     @property
-    def time_deposit(self) -> Union[float, np.ndarray]:
+    def time_deposit(self) -> Union[float, list[float]]:
         "Deposition time for each nuclear waste type."
         if len(self.waste) == 1:
             return Q_(self.waste[0].time_deposit, units.time).magnitude
@@ -94,5 +94,6 @@ class Repository:
         """
 
         return np.sum(
-            [nw.heat(t, baseline, ncl_id) for nw in self.waste], axis=0
+            np.array([nw.heat(t, baseline, ncl_id) for nw in self.waste]),
+            axis=0,
         )
diff --git a/ogstools/propertylib/__init__.py b/ogstools/propertylib/__init__.py
index 6bdb2b634..f4e1d6b52 100644
--- a/ogstools/propertylib/__init__.py
+++ b/ogstools/propertylib/__init__.py
@@ -1,10 +1,9 @@
 # Author: Florian Zill (Helmholtz Centre for Environmental Research GmbH - UFZ)
 """Define easy-to-access Property classes and PropertyCollection instances."""
 
-from . import _coupled, _uncoupled, property_collection
-from .property import MatrixProperty, ScalarProperty, VectorProperty
-
-material_id = property_collection.PropertyCollection().material_id
+from . import _coupled, _uncoupled, defaults
+from .property import MatrixProperty, Property, ScalarProperty, VectorProperty
+from .property_collection import PropertyCollection
 
 T = _uncoupled.T()
 H = _uncoupled.H()
@@ -15,5 +14,13 @@ HM = _coupled.HM()
 TM = _coupled.TM()
 THM = _coupled.THM()
 
+processes: list[PropertyCollection] = [T, H, M, TH, HM, TM, THM]
+
 
-__all__ = ["ScalarProperty", "VectorProperty", "MatrixProperty"]
+__all__ = [
+    "defaults",
+    "Property",
+    "ScalarProperty",
+    "VectorProperty",
+    "MatrixProperty",
+]
diff --git a/ogstools/propertylib/_uncoupled.py b/ogstools/propertylib/_uncoupled.py
index 6fa21739a..1ca9ab35c 100644
--- a/ogstools/propertylib/_uncoupled.py
+++ b/ogstools/propertylib/_uncoupled.py
@@ -7,9 +7,9 @@ processes.
 
 from dataclasses import dataclass
 
+from . import defaults
 from .property import MatrixProperty, ScalarProperty, VectorProperty
 from .property_collection import PropertyCollection
-from .vector2scalar import effective_pressure, qp_ratio, von_mises
 
 
 @dataclass(init=False)
@@ -22,12 +22,8 @@ class T(PropertyCollection):
     def __init__(self):
         """Initialize the PropertyCollection with default attributes."""
         super().__init__()
-        self.temperature = ScalarProperty(
-            "temperature", "K", "°C", "temperature", "temperature_active"
-        )
-        self.heatflowrate = ScalarProperty(
-            "HeatFlowRate", "", "", "HeatFlowRate", "temperature_active"
-        )
+        self.temperature = defaults.temperature
+        self.heatflowrate = defaults.heatflowrate
 
 
 @dataclass(init=False)
@@ -41,15 +37,9 @@ class H(PropertyCollection):
     def __init__(self):
         """Initialize the PropertyCollection with default attributes."""
         super().__init__()
-        self.pressure = ScalarProperty(
-            "pressure", "Pa", "MPa", "pore pressure", "pressure_active"
-        )
-        self.velocity = VectorProperty(
-            "velocity", "m/s", "mm/d", "darcy velocity", "pressure_active"
-        )
-        self.massflowrate = ScalarProperty(
-            "MassFlowRate", "", "", "MassFlowRate", "pressure_active"
-        )
+        self.pressure = defaults.pressure
+        self.velocity = defaults.velocity
+        self.massflowrate = defaults.massflowrate
 
 
 @dataclass(init=False)
@@ -67,24 +57,10 @@ class M(PropertyCollection):
     def __init__(self):
         """Initialize the PropertyCollection with default attributes."""
         super().__init__()
-        self.displacement = VectorProperty(
-            "displacement", "m", "mm", "displacement", "displacement_active"
-        )
-        self.strain = MatrixProperty(
-            "epsilon", "", "percent", "strain", "displacement_active"
-        )
-        self.stress = MatrixProperty(
-            "sigma", "Pa", "MPa", "stress", "displacement_active"
-        )
-        self.von_mises_stress = self.stress.replace(
-            output_name="von Mises stress", func=von_mises
-        )
-        self.effective_pressure = self.stress.replace(
-            output_name="effective pressure", func=effective_pressure
-        )
-        self.qp_ratio = self.stress.replace(
-            output_name="QP ratio", output_unit="percent", func=qp_ratio
-        )
-        self.nodal_forces = VectorProperty(
-            "NodalForces", "", "", "NodalForces", "displacement_active"
-        )
+        self.displacement = defaults.displacement
+        self.strain = defaults.strain
+        self.stress = defaults.stress
+        self.von_mises_stress = defaults.von_mises_stress
+        self.effective_pressure = defaults.effective_pressure
+        self.qp_ratio = defaults.qp_ratio
+        self.nodal_forces = defaults.nodal_forces
diff --git a/ogstools/propertylib/defaults.py b/ogstools/propertylib/defaults.py
new file mode 100644
index 000000000..365a5554a
--- /dev/null
+++ b/ogstools/propertylib/defaults.py
@@ -0,0 +1,29 @@
+# flake8: noqa: E501
+"Predefined properties."
+
+from . import vector2scalar as v2s
+from .property import MatrixProperty as Matrix
+from .property import Property, TagType
+from .property import ScalarProperty as Scalar
+from .property import VectorProperty as Vector
+
+T_mask = "temperature_active"
+H_mask = "pressure_active"
+M_mask = "displacement_active"
+
+# fmt: off
+displacement = Vector("displacement", "m", "mm", mask=M_mask)
+effective_pressure = Scalar("sigma", "Pa", "MPa", "effective pressure", M_mask, v2s.effective_pressure, TagType.unit_dim_const)
+heatflowrate = Scalar("HeatFlowRate", mask=T_mask)
+massflowrate = Scalar("MassFlowRate", mask=H_mask)
+nodal_forces = Vector("NodalForces", mask=M_mask)
+pressure = Scalar("pressure", "Pa", "MPa", "pore pressure", H_mask)
+qp_ratio = Scalar("sigma", "Pa", "percent", "QP ratio", M_mask, v2s.qp_ratio)
+strain = Matrix("epsilon", "", "percent", "strain", M_mask)
+stress = Matrix("sigma", "Pa", "MPa", "stress", M_mask)
+temperature = Scalar("temperature", "K", "°C", mask=T_mask)
+velocity = Vector("velocity", "m/s", "m/s", "darcy velocity", H_mask)
+von_mises_stress = Scalar("sigma", "Pa", "MPa", "von Mises stress", M_mask, v2s.von_mises, TagType.unit_dim_const)
+# fmt: on
+
+all_properties = [v for v in locals().values() if isinstance(v, Property)]
diff --git a/ogstools/propertylib/property.py b/ogstools/propertylib/property.py
index 921b73d6a..1aa0e512f 100644
--- a/ogstools/propertylib/property.py
+++ b/ogstools/propertylib/property.py
@@ -7,7 +7,7 @@ via pint.
 
 from dataclasses import dataclass, replace
 from enum import Enum
-from typing import Callable, Literal, Union
+from typing import Any, Callable, Literal, Union
 
 import numpy as np
 from pint import UnitRegistry
@@ -24,27 +24,39 @@ u_reg.setup_matplotlib(True)
 
 
 class TagType(Enum):
+    """Enum for property tag types."""
+
     mask = "mask"
     component = "component"
     unit_dim_const = "unit_dim_const"
 
 
 @dataclass
-class ScalarProperty:
-    "Represent a scalar property of a dataset."
+class Property:
+    """Represent a property of a dataset."""
 
     data_name: str
+    """The name of the property data in the dataset."""
     data_unit: str = ""
+    """The unit of the property data in the dataset."""
     output_unit: str = ""
+    """The output unit of the property."""
     output_name: str = ""
+    """The output name of the property."""
     mask: str = ""
-    func: Callable[
-        [Union[float, np.ndarray, PlainQuantity]],
-        Union[float, np.ndarray, PlainQuantity],
+    """The name of the mask data in the dataset."""
+    func: Union[
+        Callable[
+            [Union[float, np.ndarray, PlainQuantity]],
+            Union[float, np.ndarray, PlainQuantity],
+        ],
+        Callable[[Any], Any],
     ] = identity
+    """The function to be applied on the data."""
     tag: Union[
         TagType, Literal["mask", "component", "unit_dim_const"], None
     ] = None
+    """A tag to signify special meanings of the property."""
 
     def __post_init__(self):
         if not self.output_name:
@@ -58,14 +70,14 @@ class ScalarProperty:
 
     def replace(self, **changes):
         """
-        Create a new ScalarProperty object with modified attributes.
+        Create a new Property object with modified attributes.
 
         Be aware that there is no type check safety here. So make sure, the new
         attributes and values are correct.
 
         :param changes: Attributes to be changed.
 
-        :returns: A copy of the ScalarProperty with changed attributes.
+        :returns: A copy of the Property with changed attributes.
         """
         return replace(self, **changes)
 
@@ -122,11 +134,19 @@ class ScalarProperty:
         return self.tag == TagType.mask
 
     def get_mask(self):
-        return ScalarProperty(data_name=self.mask, tag=TagType.mask)
+        """
+        :returns: A property representing this properties mask.
+        """
+        return Property(data_name=self.mask, tag=TagType.mask)
+
+
+@dataclass
+class ScalarProperty(Property):
+    "Represent a scalar property of a dataset."
 
 
 @dataclass
-class VectorProperty(ScalarProperty):
+class VectorProperty(Property):
     """Represent a vector property of a dataset.
 
     Vector properties should contain either 2 (2D) or 3 (3D) components.
@@ -175,7 +195,7 @@ class VectorProperty(ScalarProperty):
 
 
 @dataclass
-class MatrixProperty(ScalarProperty):
+class MatrixProperty(Property):
     """Represent a matrix property of a dataset.
 
     Matrix properties should contain either 4 (2D) or 6 (3D) components.
diff --git a/ogstools/propertylib/property_collection.py b/ogstools/propertylib/property_collection.py
index a8888f1c6..3ef8dd673 100644
--- a/ogstools/propertylib/property_collection.py
+++ b/ogstools/propertylib/property_collection.py
@@ -5,9 +5,10 @@ classes to group the corresponding properties.
 """
 
 from dataclasses import dataclass
-from typing import Union
+from typing import Literal
+from typing import Optional as Opt
 
-from .property import MatrixProperty, ScalarProperty, VectorProperty
+from .property import MatrixProperty, Property, ScalarProperty, VectorProperty
 
 
 @dataclass(init=False)
@@ -24,12 +25,26 @@ class PropertyCollection:
         """Initialize the PropertyCollection with default attributes."""
         self.material_id = ScalarProperty("MaterialIDs")
 
-    def get_properties(
-        self,
-    ) -> list[Union[ScalarProperty, VectorProperty, MatrixProperty]]:
+    def get_properties(self, dim: Opt[Literal[2, 3]] = None) -> list[Property]:
         """Return all scalar-, vector- or matrix properties."""
-        return [
-            v
-            for v in self.__dict__.values()
-            if isinstance(v, (ScalarProperty, VectorProperty, MatrixProperty))
-        ]
+        props = []
+
+        for v in self.__dict__.values():
+            if not isinstance(v, Property):
+                continue
+            props += [v]
+            if isinstance(v, VectorProperty) and dim in [2, 3]:
+                props += [v[i] for i in range(dim)]
+            if isinstance(v, MatrixProperty) and dim in [2, 3]:
+                props += [v.trace]
+                props += [v[i] for i in range(dim * 2)]
+        return props
+
+    def find_property(
+        self, output_name: str, dim: Opt[Literal[2, 3]] = None
+    ) -> Opt[Property]:
+        """Return predefined property with given output_name."""
+        for prop in self.get_properties(dim):
+            if prop.output_name == output_name:
+                return prop
+        return None
diff --git a/ogstools/propertylib/vector2scalar.py b/ogstools/propertylib/vector2scalar.py
index 485b55c0d..d68e7d5c1 100644
--- a/ogstools/propertylib/vector2scalar.py
+++ b/ogstools/propertylib/vector2scalar.py
@@ -9,7 +9,7 @@ def trace(vals: np.ndarray) -> np.ndarray:
     """
     Calculate the trace of each vector in the input array.
 
-    :param values: The input array of vectors.
+    :param vals: The input array of vectors.
 
     :returns: The trace values of the vectors.
     """
@@ -20,7 +20,7 @@ def effective_pressure(vals: np.ndarray) -> np.ndarray:
     """
     Calculate the effective pressure based on the input array.
 
-    :param values: The input array.
+    :param vals: The input array.
 
     :returns: The effective pressure values.
     """
@@ -31,7 +31,7 @@ def von_mises(vals: np.ndarray) -> np.ndarray:
     """
     Calculate the von Mises stress based on the input array.
 
-    :param values: The input array.
+    :param vals: The input array.
 
     :returns: The von Mises stress values.
     """
@@ -45,7 +45,7 @@ def qp_ratio(vals: np.ndarray) -> np.ndarray:
     """
     Calculate the QP ratio (von Mises stress / effective pressure).
 
-    :param values: The input array.
+    :param vals: The input array.
 
     :returns: The QP ratios.
     """
diff --git a/pyproject.toml b/pyproject.toml
index 13e6a902a..a5500718a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,7 @@ dependencies = [
   "scipy>=1.10.1",
   "Pint>=0.22",
   "Pillow>=9.5.0",
+  "pandas>=2.0.3"
 ]
 
 [project.urls]
@@ -110,6 +111,7 @@ extend-ignore = [
   # RUF005 should be disabled when using numpy, see
   # https://github.com/charliermarsh/ruff/issues/2142:
   "RUF005",
+  "PT009",  # can use unittest-assertion
 ]
 target-version = "py39"
 typing-modules = ["mypackage._compat.typing"]
diff --git a/tests/test_nuclearwasteheat.py b/tests/test_nuclearwasteheat.py
index cec4a657c..83c47843a 100644
--- a/tests/test_nuclearwasteheat.py
+++ b/tests/test_nuclearwasteheat.py
@@ -14,12 +14,16 @@ class NuclearWasteHeatTest(unittest.TestCase):
         """Test heat evaluation for different argument combinations."""
         for model in nuclear.waste_types:
             for i in range(len(model.nuclide_powers)):
-                assert model.heat(0.0, baseline=True, ncl_id=i)
-            assert model.heat(0.0, baseline=True)
-            assert model.heat(0.0)
-        assert nuclear.repo_2020.heat(0.0)
-        assert np.all(
-            nuclear.repo_2020_conservative.heat(np.geomspace(1, 1e6, num=10))
+                self.assertGreater(model.heat(0.0, baseline=True, ncl_id=i), 0)
+            self.assertGreater(model.heat(0.0, baseline=True), 0)
+            self.assertGreater(model.heat(0.0), 0)
+        self.assertGreater(nuclear.repo_2020.heat(0.0), 0)
+        self.assertTrue(
+            np.all(
+                nuclear.repo_2020_conservative.heat(
+                    np.geomspace(1, 1e6, num=10)
+                )
+            )
         )
 
 
diff --git a/tests/test_propertylib.py b/tests/test_propertylib.py
index ac3599bf7..3794708cb 100644
--- a/tests/test_propertylib.py
+++ b/tests/test_propertylib.py
@@ -5,7 +5,7 @@ import unittest
 import numpy as np
 from pint.facets.plain import PlainQuantity
 
-from ogstools.propertylib import HM, TH, THM, TM, H, M, T, property_collection
+from ogstools.propertylib import HM, TH, THM, TM, H, M, PropertyCollection, T
 from ogstools.propertylib.property import ScalarProperty, u_reg
 
 Q_ = u_reg.Quantity
@@ -93,34 +93,36 @@ class PhysicalPropertyTest(unittest.TestCase):
 
     def test_simple(self):
         """Test cast functionality."""
-        assert T.temperature(273.15) == Q_(0, "°C")
-        assert M.displacement[0]([1, 2, 3]) == Q_(1, "m")
-        assert M.displacement([1, 2, 3])[1] == Q_(2, "m")
+        self.assertEqual(T.temperature(273.15), Q_(0, "°C"))
+        self.assertEqual(M.displacement[0]([1, 2, 3]), Q_(1, "m"))
+        self.assertEqual(M.displacement([1, 2, 3])[1], Q_(2, "m"))
 
     def test_values(self):
         """Test values functionality."""
-        assert T.temperature.values(273.15) == 0.0
+        self.assertEqual(T.temperature.values(273.15), 0.0)
 
     def test_units(self):
         """Test get_output_unit functionality."""
-        assert T.temperature.get_output_unit() == "°C"
-        assert H.pressure.get_output_unit() == "MPa"
-        assert M.strain.get_output_unit() == "%"
+        self.assertEqual(T.temperature.get_output_unit(), "°C")
+        self.assertEqual(H.pressure.get_output_unit(), "MPa")
+        self.assertEqual(M.strain.get_output_unit(), "%")
 
     def test_mask(self):
         """Test get_output_unit functionality."""
-        assert ScalarProperty("pressure_active").is_component
+        self.assertTrue(ScalarProperty("pressure_active").is_component)
 
     def test_processes(self):
         """Test process attributes."""
 
-        def data_name_set(process: property_collection.PropertyCollection):
+        def data_name_set(process: PropertyCollection):
             return {p.data_name for p in process.get_properties()}
 
-        assert data_name_set(TH) == data_name_set(T) | data_name_set(H)
-        assert data_name_set(HM) == data_name_set(H) | data_name_set(M)
-        assert data_name_set(TM) == data_name_set(T) | data_name_set(M)
-        assert data_name_set(THM) == data_name_set(TH) | data_name_set(M)
+        self.assertEqual(data_name_set(TH), data_name_set(T) | data_name_set(H))
+        self.assertEqual(data_name_set(HM), data_name_set(H) | data_name_set(M))
+        self.assertEqual(data_name_set(TM), data_name_set(T) | data_name_set(M))
+        self.assertEqual(
+            data_name_set(THM), data_name_set(TH) | data_name_set(M)
+        )
 
     def test_copy_ctor(self):
         """Test process attributes."""
@@ -134,7 +136,7 @@ class PhysicalPropertyTest(unittest.TestCase):
             func=M.strain.func,
         )
 
-        assert M.strain == strain_copy
+        self.assertEqual(M.strain, strain_copy)
 
 
 if __name__ == "__main__":
-- 
GitLab