Skip to content

Commit 12f08cd

Browse files
committed
Add unit-aware Param() field and with_units()/to_example() to meshly (#24)
* Add unit-aware parameter support with Param() and update README for installation instructions * Bump version to 3.5.0-alpha in pyproject.toml
1 parent 4e1ee2c commit 12f08cd

7 files changed

Lines changed: 588 additions & 4 deletions

File tree

python/README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ A Python library for efficient 3D mesh serialization using [meshoptimizer](https
66

77
```bash
88
pip install meshly
9+
10+
# With unit conversion support (pint)
11+
pip install meshly[units]
912
```
1013

1114
## Features
@@ -15,6 +18,7 @@ pip install meshly
1518
- **`Packable`**: Base class for automatic numpy/JAX array serialization to zip files
1619
- **`Mesh`**: 3D mesh representation extending Packable with meshoptimizer encoding. Use factory methods: `from_triangles()`, `from_polygons()`, `create()`
1720
- **`ArrayUtils`**: Utility class for extracting/reconstructing individual arrays
21+
- **`Param`**: Unit-aware parameter field for Pydantic models (drop-in replacement for `Field`)
1822
- **`PackableStore`**: File-based store for persistent storage with deduplication
1923
- **`LazyModel`**: Lazy proxy that defers asset loading until field access
2024
- **`Resource`**: Binary data reference that serializes by content checksum
@@ -28,6 +32,13 @@ pip install meshly
2832
- **`IndexSequence`**: Optimized encoding for mesh indices (1D array)
2933
- **`InlineArray`**: Array serialized as inline JSON list (no binary compression)
3034

35+
### Unit-Aware Parameters
36+
37+
- **`Param()`**: Drop-in replacement for `pydantic.Field()` that adds `units`, `shape`, and `example` metadata to the JSON schema
38+
- **`ParamInfo`**: `FieldInfo` subclass backing `Param()` — carries units/shape/example through Pydantic's schema generation
39+
- **`Packable.to_example()`**: Class method that builds an instance from `Param()` example/default values
40+
- **`Packable.with_units()`**: Returns a clone with numeric/array fields converted to `pint.Quantity` objects
41+
3142
### Key Capabilities
3243

3344
- Automatic encoding/decoding of numpy array attributes via `Array`, `IndexSequence`, `InlineArray` type annotations
@@ -184,6 +195,54 @@ class OptimizedMesh(Packable):
184195

185196
> **Note:** All dtypes are supported. Arrays with non-4-byte dtypes (e.g., `float16`, `int8`, `uint8`) are automatically padded to 4-byte alignment during encoding and unpadded during decoding (meshoptimizer requirement). For best performance, prefer 4-byte aligned dtypes like `float32`, `int32`, or `float64`.
186197
198+
### Unit-Aware Parameters with Param()
199+
200+
`Param()` is a drop-in replacement for `pydantic.Field()` that adds units, shape, and example metadata to the JSON schema. It works on any Pydantic `BaseModel` or `Packable` field, including `InlineArray`:
201+
202+
```python
203+
from meshly import Packable, Param, InlineArray
204+
205+
class Simulation(Packable):
206+
velocity: InlineArray = Param(units="m/s", example=[30.0, 0, 0], shape=(3,),
207+
description="Flow velocity vector [vx, vy, vz]")
208+
temperature: float = Param(300.0, units="K", description="Fluid temperature")
209+
pressure: float = Param(101325.0, units="Pa", description="Outlet pressure")
210+
name: str = Param("default", units="dimensionless", description="Simulation name")
211+
212+
# Units appear in the JSON schema
213+
schema = Simulation.model_json_schema()
214+
print(schema["properties"]["temperature"])
215+
# {'default': 300.0, 'description': 'Fluid temperature', 'title': 'Temperature',
216+
# 'type': 'number', 'units': 'K'}
217+
218+
# Create from example values
219+
sim = Simulation.to_example()
220+
print(sim.velocity) # [30. 0. 0.]
221+
print(sim.temperature) # 300.0
222+
223+
# Convert to pint Quantities (requires `pip install meshly[units]`)
224+
sim_units = sim.with_units()
225+
print(sim_units.velocity) # [30.0 0.0 0.0] meter / second
226+
print(sim_units.velocity.to("km/h")) # [108.0 0.0 0.0] kilometer / hour
227+
print(sim_units.temperature.to("degC")) # 26.85 degree_Celsius
228+
229+
# Convert to SI base units
230+
sim_base = sim.with_units(base_units=True)
231+
print(sim_base.pressure) # 101325.0 kilogram / meter / second ** 2
232+
```
233+
234+
`Param()` requires either a default value or an `example`:
235+
```python
236+
# With default
237+
velocity: float = Param(10.0, units="m/s")
238+
239+
# With example (no default, field is required)
240+
velocity: float = Param(units="m/s", example=10.0)
241+
242+
# Error: neither default nor example
243+
velocity: float = Param(units="m/s") # ValueError!
244+
```
245+
187246
### Dict of Pydantic BaseModel Objects
188247

189248
You can also use dictionaries containing Pydantic `BaseModel` instances with numpy arrays:
@@ -645,6 +704,34 @@ class MyData(Packable):
645704
color: InlineArray # Small arrays as inline JSON (no $ref)
646705
```
647706

707+
### Param
708+
709+
```python
710+
def Param(
711+
default: Any = ...,
712+
*,
713+
units: str, # Required: unit string (e.g., "m/s", "Pa", "dimensionless")
714+
shape: tuple[int, ...] = None, # Optional: expected array shape
715+
example: Any = None, # Optional: example value for to_example()
716+
description: str = None, # Optional: field description
717+
# ... all other pydantic.Field kwargs supported (gt, ge, lt, le, etc.)
718+
) -> ParamInfo
719+
```
720+
721+
### ParamInfo
722+
723+
```python
724+
class ParamInfo(FieldInfo):
725+
"""FieldInfo subclass that adds units, shape, and example to the JSON schema.
726+
727+
Works on any Pydantic BaseModel. When used with InlineArray, the units
728+
are preserved in the JSON schema output via json_schema_extra.
729+
"""
730+
units: str
731+
shape: tuple[int, ...] | None
732+
example: Any
733+
```
734+
648735
### ArrayUtils
649736

650737
```python
@@ -702,6 +789,11 @@ class Packable(BaseModel):
702789
# Array conversion
703790
def convert_to(self, array_type: ArrayType) -> T
704791

792+
# Param-aware helpers
793+
@classmethod
794+
def to_example(cls) -> T # Build instance from Param() example/default values
795+
def with_units(self, base_units: bool = False) -> T # Clone with pint Quantities (requires pint)
796+
705797
# Extract/Encode (instance methods)
706798
def extract(self) -> ExtractedPackable # Cached for efficiency
707799
def encode(self) -> bytes # Calls extract() internally
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# Unit-Aware Parameters with `Param()`\n",
8+
"\n",
9+
"Demonstrates `Param()`, `to_example()`, `with_units()`, and automatic pint Quantity stripping using a single comprehensive model."
10+
]
11+
},
12+
{
13+
"cell_type": "code",
14+
"execution_count": 1,
15+
"metadata": {},
16+
"outputs": [],
17+
"source": [
18+
"from meshly import Packable, Param, InlineArray, Array\n",
19+
"from pint import UnitRegistry\n",
20+
"import numpy as np\n",
21+
"\n",
22+
"ureg = UnitRegistry()\n",
23+
"\n",
24+
"class Simulation(Packable):\n",
25+
" \"\"\"Covers all field types: float, InlineArray, Array, nested Packable.\"\"\"\n",
26+
" velocity: InlineArray = Param(units=\"m/s\", example=[30.0, 0, 0], shape=(3,), description=\"Inlet velocity\")\n",
27+
" temperature: float = Param(300.0, units=\"K\", description=\"Fluid temperature\")\n",
28+
" pressure: float = Param(101325.0, units=\"Pa\", description=\"Static pressure\")\n",
29+
" displacements: Array = Param(units=\"m\", example=np.zeros((4, 3), dtype=np.float32), shape=(None, 3), description=\"Nodal displacements\")\n",
30+
" label: str = Param(\"default\", units=\"dimensionless\", description=\"Run label\")"
31+
]
32+
},
33+
{
34+
"cell_type": "code",
35+
"execution_count": 2,
36+
"metadata": {},
37+
"outputs": [
38+
{
39+
"name": "stdout",
40+
"output_type": "stream",
41+
"text": [
42+
"velocity: units=m/s\n",
43+
"temperature: units=K\n",
44+
"pressure: units=Pa\n",
45+
"displacements: units=m\n"
46+
]
47+
}
48+
],
49+
"source": [
50+
"import json\n",
51+
"\n",
52+
"# Units appear in the JSON schema (this is what MCP tools / UIs see)\n",
53+
"schema = Simulation.model_json_schema()\n",
54+
"for name in [\"velocity\", \"temperature\", \"pressure\", \"displacements\"]:\n",
55+
" props = schema[\"properties\"][name]\n",
56+
" print(f\"{name}: units={props.get('units')}\")"
57+
]
58+
},
59+
{
60+
"cell_type": "code",
61+
"execution_count": 3,
62+
"metadata": {},
63+
"outputs": [
64+
{
65+
"name": "stdout",
66+
"output_type": "stream",
67+
"text": [
68+
"velocity: [30. 0. 0.]\n",
69+
"temperature: 300.0\n",
70+
"displacements: (4, 3)\n"
71+
]
72+
}
73+
],
74+
"source": [
75+
"# to_example() builds an instance from Param example/default values\n",
76+
"sim = Simulation.to_example()\n",
77+
"print(f\"velocity: {sim.velocity}\")\n",
78+
"print(f\"temperature: {sim.temperature}\")\n",
79+
"print(f\"displacements: {sim.displacements.shape}\")"
80+
]
81+
},
82+
{
83+
"cell_type": "code",
84+
"execution_count": 4,
85+
"metadata": {},
86+
"outputs": [
87+
{
88+
"name": "stdout",
89+
"output_type": "stream",
90+
"text": [
91+
"velocity: [30.0 0.0 0.0] meter / second\n",
92+
"to km/h: [108.0 0.0 0.0] kilometer / hour\n",
93+
"temperature: 300.0 kelvin\n",
94+
"to Celsius: 26.850000000000023 degree_Celsius\n",
95+
"pressure: 1.0 standard_atmosphere\n",
96+
"displace mm: [[0.0 0.0 0.0] [0.0 0.0 0.0] [0.0 0.0 0.0] [0.0 0.0 0.0]] millimeter\n"
97+
]
98+
}
99+
],
100+
"source": [
101+
"# with_units() converts fields to pint Quantities\n",
102+
"su = sim.with_units()\n",
103+
"print(f\"velocity: {su.velocity}\")\n",
104+
"print(f\"to km/h: {su.velocity.to('km/h')}\")\n",
105+
"print(f\"temperature: {su.temperature}\")\n",
106+
"print(f\"to Celsius: {su.temperature.to('degC')}\")\n",
107+
"print(f\"pressure: {su.pressure.to('atm')}\")\n",
108+
"print(f\"displace mm: {su.displacements.to('mm')}\")"
109+
]
110+
},
111+
{
112+
"cell_type": "code",
113+
"execution_count": 5,
114+
"metadata": {},
115+
"outputs": [
116+
{
117+
"name": "stdout",
118+
"output_type": "stream",
119+
"text": [
120+
"velocity (m/s): [30. 0. 0.] (ndarray)\n",
121+
"temperature (K): 300.0 (float)\n",
122+
"pressure (Pa): 101325.0 (float)\n",
123+
"displacements (m): [[0.001 0. 0. ]] (ndarray)\n"
124+
]
125+
}
126+
],
127+
"source": [
128+
"# Pass pint Quantities directly — auto-converted to declared units, stored as plain values\n",
129+
"sim2 = Simulation(\n",
130+
" velocity=ureg.Quantity([108.0, 0, 0], \"km/h\"),\n",
131+
" temperature=ureg.Quantity(26.85, \"degC\"),\n",
132+
" pressure=ureg.Quantity(1.0, \"atm\"),\n",
133+
" displacements=ureg.Quantity(np.array([[1, 0, 0]], dtype=np.float32), \"mm\"),\n",
134+
")\n",
135+
"print(f\"velocity (m/s): {sim2.velocity} ({type(sim2.velocity).__name__})\")\n",
136+
"print(f\"temperature (K): {sim2.temperature} ({type(sim2.temperature).__name__})\")\n",
137+
"print(f\"pressure (Pa): {sim2.pressure} ({type(sim2.pressure).__name__})\")\n",
138+
"print(f\"displacements (m): {sim2.displacements} ({type(sim2.displacements).__name__})\")"
139+
]
140+
},
141+
{
142+
"cell_type": "code",
143+
"execution_count": 6,
144+
"metadata": {},
145+
"outputs": [
146+
{
147+
"name": "stdout",
148+
"output_type": "stream",
149+
"text": [
150+
"Expected error: Field 'value' on NoUnits received a pint Quantity with units 'meter / second', but no Param(units=...) is defined for this field. Either add Param(units=...) or pass a plain value.\n",
151+
"dimensionless OK: 42.0\n"
152+
]
153+
}
154+
],
155+
"source": [
156+
"# Error: passing units to a field without Param raises TypeError\n",
157+
"class NoUnits(Packable):\n",
158+
" value: float = 0.0\n",
159+
"\n",
160+
"try:\n",
161+
" NoUnits(value=ureg.Quantity(42.0, \"m/s\"))\n",
162+
"except TypeError as e:\n",
163+
" print(f\"Expected error: {e}\")\n",
164+
"\n",
165+
"# Dimensionless quantities on non-Param fields are fine\n",
166+
"m = NoUnits(value=ureg.Quantity(42.0, \"dimensionless\"))\n",
167+
"print(f\"dimensionless OK: {m.value}\")"
168+
]
169+
}
170+
],
171+
"metadata": {
172+
"kernelspec": {
173+
"display_name": "Python 3",
174+
"language": "python",
175+
"name": "python3"
176+
},
177+
"language_info": {
178+
"name": "python",
179+
"version": "3.12.2"
180+
}
181+
},
182+
"nbformat": 4,
183+
"nbformat_minor": 4
184+
}

python/meshly/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
IndexSequence,
2424
InlineArray,
2525
)
26+
from meshly.param import Param, ParamInfo
2627
from meshly.cell_types import (
2728
CellType,
2829
CellTypeUtils,
@@ -53,6 +54,9 @@
5354
# Mesh classes
5455
"Mesh",
5556
"TMesh",
57+
# Parameter metadata
58+
"Param",
59+
"ParamInfo",
5660
# Array types and utilities
5761
"Array",
5862
"InlineArray",

python/meshly/array.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ def __eq__(self, other):
8686

8787

8888
class _InlineArrayAnnotation:
89-
"""Pydantic annotation for arrays serialized as inline JSON lists."""
89+
"""Pydantic annotation for arrays serialized as inline JSON lists.
90+
91+
When the field is defined with Param(units=...), the units/shape/description
92+
are preserved in the JSON schema output via json_schema_extra on the FieldInfo.
93+
"""
9094

9195
def __get_pydantic_core_schema__(
9296
self, source_type: Any, handler: GetCoreSchemaHandler

0 commit comments

Comments
 (0)