diff --git a/poetry.lock b/poetry.lock index f8d21e5b..50e2bf8b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1172,6 +1172,24 @@ python-dateutil = ">=2.7" [package.extras] dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7)"] +[[package]] +name = "mpmath" +version = "1.3.0" +description = "Python library for arbitrary-precision floating-point arithmetic" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, + {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, +] + +[package.extras] +develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] +docs = ["sphinx"] +gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""] +tests = ["pytest (>=4.6)"] + [[package]] name = "msgpack" version = "1.1.2" @@ -2317,6 +2335,24 @@ files = [ {file = "smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c"}, ] +[[package]] +name = "sympy" +version = "1.14.0" +description = "Computer algebra system (CAS) in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"}, + {file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"}, +] + +[package.dependencies] +mpmath = ">=1.1.0,<1.4" + +[package.extras] +dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] + [[package]] name = "tabulate" version = "0.9.0" @@ -2488,6 +2524,23 @@ files = [ {file = "tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98"}, ] +[[package]] +name = "unyt" +version = "3.1.0" +description = "A package for handling numpy arrays with units" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "unyt-3.1.0-py3-none-any.whl", hash = "sha256:6ff9efe694a1c13f5b07ccb4c6b30a0e282d5e4989947f0ef5aadf4ad7be3f69"}, + {file = "unyt-3.1.0.tar.gz", hash = "sha256:771582a87f1e521c9b62f7ca269b1965c0f77b479f7a063a51e8cef8b4cae51d"}, +] + +[package.dependencies] +numpy = ">=1.21.3,<3.0" +packaging = ">=20.9" +sympy = ">=1.9.0" + [[package]] name = "urllib3" version = "2.6.3" @@ -2512,4 +2565,4 @@ plot = ["matplotlib", "plotly"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.15" -content-hash = "ac08fbf5d9a233743b7f3e4035c700c30db4a9b73efbf502047082cf575210e8" +content-hash = "56c850f5ad4d927f7b6bb852189b4a557550484b13513811d3d5efe1885f8a24" diff --git a/pyproject.toml b/pyproject.toml index 54041d85..7ecb6164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dependencies = [ "pyyaml (>=6.0.2,<7.0.0)", "flatdict (==4.0.0)", "pytest (>=9.0.3,<10.0.0)", + "unyt (>=3.1.0,<4.0.0)", ] [project.urls] diff --git a/simvue/run.py b/simvue/run.py index a160fa75..60a92a8c 100644 --- a/simvue/run.py +++ b/simvue/run.py @@ -17,6 +17,8 @@ import humanfriendly import datetime import os +from unyt import unyt_quantity +from unyt.exceptions import UnitParseError import pydantic import re @@ -183,6 +185,8 @@ def __init__( self._executor = Executor(self) self._dispatcher: DispatcherBaseClass | None = None + self._meta_cache: dict[str, typing.Any] = {} + self._folder: Folder | None = None self._term_color: bool = True self._grids: dict[str, str] = {} @@ -1680,6 +1684,12 @@ def log_metrics( ) ``` """ + + # If there are any metric units to be uploaded do so now + if _units := self._meta_cache.get("metrics"): + self.update_metadata({"simvue": {"metrics": _units}}) + del self._meta_cache["metrics"] + # TODO: When metrics and grids are combined into a single entity # this can be removed. For now need to separate tensor based metrics # from regular @@ -2586,3 +2596,40 @@ def log_alert( _alert.commit() return True + + @prettify_pydantic + @pydantic.validate_call + def set_metric_units( + self, + metric_name: MetricKeyString, + *, + units: str, + mks_unit: str | None = None, + mks_conversion: float | None = None, + ) -> None: + """Define units for metrics. + + Parameters + ---------- + metric_name : str + name of metric to assign units to + units : str + unit symbol + label : str | None, optional + alternative longer name for units + """ + self._meta_cache.setdefault("metrics", {}) + + try: + _unit_obj = unyt_quantity.from_string(units) + self._meta_cache["metrics"][metric_name] = { + "units": units, + "mks_conversion": mks_conversion or float(_unit_obj.in_mks().value), + "mks_units": mks_unit or f"{_unit_obj.in_mks().units}", + } + except UnitParseError: + self._meta_cache["metrics"][metric_name] = { + "units": units, + "mks_conversion": mks_conversion, + "mks_units": mks_unit, + } diff --git a/tests/functional/test_run_class.py b/tests/functional/test_run_class.py index 0057a93b..aee52d33 100644 --- a/tests/functional/test_run_class.py +++ b/tests/functional/test_run_class.py @@ -1724,3 +1724,18 @@ def test_no_alert_dupes_different_run(alert_type: typing.Literal["user", "events else: MetricsRangeAlert(identifier=created_id).delete() + +@pytest.mark.run +@pytest.mark.online +def test_set_metric_units(create_plain_run: tuple[sv_run.Run, dict]) -> None: + run, _ = create_plain_run + run.set_metric_units("x", units="ft") + run.set_metric_units("y", units="m") + run.set_metric_units("z", units="Foobars", mks_conversion=3.542, mks_unit="m") + run.log_metrics({"x": 10, "y": 3, "z": 22}) + + _metadata = RunObject(run.id).metadata + assert (_metric_data := _metadata.get("simvue", {}).get("metrics")) + assert _metric_data["x"] == {"units": "ft", "mks_units": "m", "mks_conversion": 0.3048} + assert _metric_data["y"] == {"units": "m", "mks_units": "m", "mks_conversion": 1} + assert _metric_data["z"] == {"units": "Foobars", "mks_units": "m", "mks_conversion": 3.542}