mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-06-03 20:30:40 +02:00
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:
parent
90869f07a7
commit
eed309590e
@ -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
|
||||
|
@ -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
30
poetry.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
]
|
||||
}
|
@ -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>
|
3
src/pydase/frontend/static/js/main.09e2d82a.js
Normal file
3
src/pydase/frontend/static/js/main.09e2d82a.js
Normal file
File diff suppressed because one or more lines are too long
1
src/pydase/frontend/static/js/main.09e2d82a.js.map
Normal file
1
src/pydase/frontend/static/js/main.09e2d82a.js.map
Normal file
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
@ -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
55
src/pydase/units.py
Normal 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
|
@ -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:
|
||||
|
@ -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
115
tests/test_units.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user