feat: adding support for units

With `pint` as the unit package, the user can now define Quantities as
attributes of their DataService class. This will be rendered as a float
in the frontend application with the unit as an additional field
appended to the form.
This commit is contained in:
Mose Müller 2023-08-07 14:03:59 +02:00
parent 90869f07a7
commit eed309590e
17 changed files with 243 additions and 23 deletions

View File

@ -14,6 +14,7 @@ type AttributeType =
| 'bool'
| 'float'
| 'int'
| 'Quantity'
| 'list'
| 'method'
| 'DataService'
@ -61,6 +62,19 @@ export const GenericComponent = React.memo(
isInstantUpdate={isInstantUpdate}
/>
);
} else if (attribute.type === 'Quantity') {
return (
<NumberComponent
name={name}
type="float"
parent_path={parentPath}
docString={attribute.doc}
readOnly={attribute.readonly}
value={Number(attribute.value['magnitude'])}
unit={attribute.value['unit']}
isInstantUpdate={isInstantUpdate}
/>
);
} else if (attribute.type === 'NumberSlider') {
return (
<SliderComponent

View File

@ -14,6 +14,7 @@ interface NumberComponentProps {
readOnly: boolean;
docString: string;
isInstantUpdate: boolean;
unit?: string;
}
// TODO: highlight the digit that is being changed by setting both selectionStart and
@ -100,7 +101,7 @@ const handleDeleteKey = (
};
export const NumberComponent = React.memo((props: NumberComponentProps) => {
const { name, parent_path, readOnly, docString, isInstantUpdate } = props;
const { name, parent_path, readOnly, docString, isInstantUpdate, unit } = props;
const renderCount = useRef(0);
// Create a state for the cursor position
const [cursorPosition, setCursorPosition] = useState(null);
@ -242,6 +243,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
onBlur={handleBlur}
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
/>
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
</InputGroup>
{!readOnly && (
<div className="d-flex flex-column">

30
poetry.lock generated
View File

@ -104,7 +104,6 @@ packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
[package.extras]
colorama = ["colorama (>=0.4.3)"]
@ -551,6 +550,30 @@ files = [
{file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"},
]
[[package]]
name = "pint"
version = "0.22"
description = "Physical quantities module"
optional = false
python-versions = ">=3.9"
files = [
{file = "Pint-0.22-py3-none-any.whl", hash = "sha256:6e2b3c5c2b4d9b516608bc860a417a39d66eb99c958f36540cf931d2c2e9f80f"},
{file = "Pint-0.22.tar.gz", hash = "sha256:2d139f6abbcf3016cad7d3cec05707fe908ac4f99cf59aedfd6ee667b7a64433"},
]
[package.dependencies]
typing-extensions = "*"
[package.extras]
babel = ["babel (<=2.8)"]
dask = ["dask"]
mip = ["mip (>=1.13)"]
numpy = ["numpy (>=1.19.5)"]
pandas = ["pint-pandas (>=0.3)"]
test = ["pytest", "pytest-cov", "pytest-mpl", "pytest-subtests"]
uncertainties = ["uncertainties (>=3.1.6)"]
xarray = ["xarray"]
[[package]]
name = "platformdirs"
version = "3.10.0"
@ -969,7 +992,6 @@ files = [
[package.dependencies]
anyio = ">=3.4.0,<5"
typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""}
[package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"]
@ -1149,5 +1171,5 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
content-hash = "408c82ee6f4c1b35f33294edb6e86764560dc5140f04cd5ff438e617981555e1"
python-versions = "^3.10"
content-hash = "083c3e13b4c314c5012daa87c94566a985631385629a3ff695ffcfc22efa0f09"

View File

@ -17,6 +17,7 @@ toml = "^0.10.2"
python-socketio = "^5.8.0"
websockets = "^11.0.3"
confz = "^2.0.0"
pint = "^0.22"
[tool.poetry.group.dev.dependencies]
types-toml = "^0.10.8.6"

View File

@ -8,6 +8,7 @@ from typing import Any, Optional, cast, get_type_hints
import rpyc
from loguru import logger
import pydase.units as u
from pydase.utils.helpers import (
convert_arguments_to_hinted_types,
generate_paths_from_DataService_dict,
@ -62,6 +63,9 @@ class DataService(rpyc.Service, AbstractDataService):
if isinstance(current_value, float) and isinstance(__value, int):
__value = float(__value)
if isinstance(current_value, u.Quantity):
__value = u.convert_to_quantity(__value, str(current_value.u))
super().__setattr__(__name, __value)
if self.__dict__.get("_initialised") and not __name == "_initialised":
@ -263,7 +267,9 @@ class DataService(rpyc.Service, AbstractDataService):
prop: property = getattr(self.__class__, key)
result[key] = {
"type": type(value).__name__,
"value": value,
"value": value
if not isinstance(value, u.Quantity)
else {"magnitude": value.m, "unit": str(value.u)},
"readonly": prop.fset is None,
"doc": inspect.getdoc(prop),
}
@ -279,7 +285,9 @@ class DataService(rpyc.Service, AbstractDataService):
else:
result[key] = {
"type": type(value).__name__,
"value": value,
"value": value
if not isinstance(value, u.Quantity)
else {"magnitude": value.m, "unit": str(value.u)},
"readonly": False,
}

View File

@ -1,13 +1,13 @@
{
"files": {
"main.css": "/static/css/main.d5ec2545.css",
"main.js": "/static/js/main.b51434ec.js",
"main.js": "/static/js/main.09e2d82a.js",
"index.html": "/index.html",
"main.d5ec2545.css.map": "/static/css/main.d5ec2545.css.map",
"main.b51434ec.js.map": "/static/js/main.b51434ec.js.map"
"main.09e2d82a.js.map": "/static/js/main.09e2d82a.js.map"
},
"entrypoints": [
"static/css/main.d5ec2545.css",
"static/js/main.b51434ec.js"
"static/js/main.09e2d82a.js"
]
}

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.b51434ec.js"></script><link href="/static/css/main.d5ec2545.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.09e2d82a.js"></script><link href="/static/css/main.d5ec2545.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -15,6 +15,7 @@ from rpyc import (
from rpyc import ThreadedServer
from uvicorn.server import HANDLED_SIGNALS
import pydase.units as u
from pydase import DataService
from pydase.version import __version__
@ -295,6 +296,12 @@ class Server:
# > File "/usr/lib64/python3.11/json/encoder.py", line 180, in default
# > raise TypeError(f'Object of type {o.__class__.__name__} '
# > TypeError: Object of type list is not JSON serializable
notify_value = value
if isinstance(value, Enum):
notify_value = value.name
if isinstance(value, u.Quantity):
notify_value = {"magnitude": value.m, "unit": str(value.u)}
async def notify() -> None:
try:
await self._wapi.sio.emit( # type: ignore
@ -303,11 +310,7 @@ class Server:
"data": {
"parent_path": parent_path,
"name": name,
"value": value.name
if isinstance(
value, Enum
) # enums are not JSON serializable
else value,
"value": notify_value,
}
},
)

55
src/pydase/units.py Normal file
View File

@ -0,0 +1,55 @@
from typing import TypedDict
import pint
units: pint.UnitRegistry = pint.UnitRegistry()
units.default_format = "~P" # pretty and short format
Quantity = pint.Quantity
Unit = units.Unit
class QuantityDict(TypedDict):
magnitude: int | float
unit: str
def convert_to_quantity(
value: QuantityDict | float | int | Quantity, unit: str = ""
) -> Quantity:
"""
Convert a given value into a pint.Quantity object with the specified unit.
Args:
value (QuantityDict | float | int | Quantity):
The value to be converted into a Quantity object.
- If value is a float or int, it will be directly converted to the specified
unit.
- If value is a dict, it must have keys 'magnitude' and 'unit' to represent
the value and unit.
- If value is a Quantity object, it will remain unchanged.\n
unit (str, optional): The target unit for conversion. If empty and value is not
a Quantity object, it will assume a unitless quantity.
Returns:
Quantity: The converted value as a pint.Quantity object with the specified unit.
Examples:
>>> convert_to_quantity(5, 'm')
<Quantity(5.0, 'meters')>
>>> convert_to_quantity({'magnitude': 10, 'unit': 'mV'})
<Quantity(10.0, 'millivolt')>
>>> convert_to_quantity(10.0 * u.units.V)
<Quantity(10.0, 'volt')>
Notes:
- If unit is not provided and value is a float or int, the resulting Quantity will be unitless.
"""
if isinstance(value, int | float):
quantity = float(value) * Unit(unit)
elif isinstance(value, dict):
quantity = float(value["magnitude"]) * Unit(value["unit"])
else:
quantity = value
return quantity # type: ignore

View File

@ -106,7 +106,7 @@ def generate_paths_from_DataService_dict(
# ignoring methods
continue
new_path = f"{parent_path}.{key}" if parent_path else key
if isinstance(value["value"], dict):
if isinstance(value["value"], dict) and value["type"] != "Quantity":
paths.extend(generate_paths_from_DataService_dict(value["value"], new_path)) # type: ignore
elif isinstance(value["value"], list):
for index, item in enumerate(value["value"]):
@ -124,7 +124,7 @@ def generate_paths_from_DataService_dict(
return paths
STANDARD_TYPES = ("int", "float", "bool", "str", "Enum", "NoneType")
STANDARD_TYPES = ("int", "float", "bool", "str", "Enum", "NoneType", "Quantity")
def get_nested_value_by_path_and_key(data: dict, path: str, key: str = "value") -> Any:

View File

@ -14,7 +14,7 @@ def warn_if_instance_class_does_not_inherit_from_DataService(__value: object) ->
"_abc",
]
and base_class_name not in ["DataService", "list", "Enum"]
and type(__value).__name__ not in ["CallbackManager", "TaskManager"]
and type(__value).__name__ not in ["CallbackManager", "TaskManager", "Quantity"]
):
logger.warning(
f"Warning: Class {type(__value).__name__} does not inherit from DataService."

115
tests/test_units.py Normal file
View File

@ -0,0 +1,115 @@
from typing import Any
from pytest import CaptureFixture
import pydase.units as u
from pydase.data_service.data_service import DataService
def test_DataService_setattr(capsys: CaptureFixture) -> None:
class ServiceClass(DataService):
voltage = 1.0 * u.units.V
_current: u.Quantity = 1.0 * u.units.mA
@property
def current(self) -> u.Quantity:
return self._current
@current.setter
def current(self, value: Any) -> None:
self._current = value
service = ServiceClass()
# You can just set floats to the Quantity objects. The DataService __setattr__ will
# automatically convert this
service.voltage = 10.0 # type: ignore
service.current = 1.5
assert service.voltage == 10.0 * u.units.V # type: ignore
assert service.current == 1.5 * u.units.mA
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.voltage = 10.0 V",
"ServiceClass.current = 1.5 mA",
]
)
actual_output = sorted(captured.out.strip().split("\n")) # type: ignore
assert actual_output == expected_output
service.voltage = 12.0 * u.units.V # type: ignore
service.current = 1.51 * u.units.A
assert service.voltage == 12.0 * u.units.V # type: ignore
assert service.current == 1.51 * u.units.A
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.voltage = 12.0 V",
"ServiceClass.current = 1.51 A",
]
)
actual_output = sorted(captured.out.strip().split("\n")) # type: ignore
assert actual_output == expected_output
def test_convert_to_quantity() -> None:
assert u.convert_to_quantity(1.0, unit="V") == 1.0 * u.units.V
assert u.convert_to_quantity(1, unit="mV") == 1.0 * u.units.mV
assert u.convert_to_quantity({"magnitude": 12, "unit": "kV"}) == 12.0 * u.units.kV
assert u.convert_to_quantity(1.0 * u.units.mV) == 1.0 * u.units.mV
def test_update_DataService_attribute(capsys: CaptureFixture) -> None:
class ServiceClass(DataService):
voltage = 1.0 * u.units.V
_current: u.Quantity = 1.0 * u.units.mA
@property
def current(self) -> u.Quantity:
return self._current
@current.setter
def current(self, value: Any) -> None:
self._current = value
service = ServiceClass()
service.update_DataService_attribute(
path_list=[], attr_name="voltage", value=1.0 * u.units.mV
)
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.voltage = 1.0 mV",
]
)
actual_output = sorted(captured.out.strip().split("\n")) # type: ignore
assert actual_output == expected_output
service.update_DataService_attribute(path_list=[], attr_name="voltage", value=2)
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.voltage = 2.0 mV",
]
)
actual_output = sorted(captured.out.strip().split("\n")) # type: ignore
assert actual_output == expected_output
service.update_DataService_attribute(
path_list=[], attr_name="voltage", value={"magnitude": 123, "unit": "kV"}
)
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.voltage = 123.0 kV",
]
)
actual_output = sorted(captured.out.strip().split("\n")) # type: ignore
assert actual_output == expected_output