Skip to content

Commit cf8cef8

Browse files
authored
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 cf8cef8

6 files changed

Lines changed: 349 additions & 3 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

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

python/meshly/packable.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,97 @@ def load(
664664
extracted = store.load_extracted(key)
665665
return cls.reconstruct(extracted, assets=store.load_asset, array_type=array_type, is_lazy=is_lazy)
666666

667+
# -------------------------------------------------------------------------
668+
# Param-aware helpers
669+
# -------------------------------------------------------------------------
670+
671+
@classmethod
672+
def to_example(cls) -> "Packable":
673+
"""Create an instance using example values from Param() fields.
674+
675+
For each Param field, uses example if defined, else falls back to default.
676+
Handles both native ParamInfo fields and InlineArray fields where
677+
Pydantic converts ParamInfo to plain FieldInfo.
678+
"""
679+
from meshly.param import ParamInfo
680+
681+
example_data: dict[str, Any] = {}
682+
for field_name, field_info in cls.model_fields.items():
683+
is_param = isinstance(field_info, ParamInfo)
684+
has_units_extra = (
685+
isinstance(field_info.json_schema_extra, dict)
686+
and "units" in field_info.json_schema_extra
687+
)
688+
if not is_param and not has_units_extra:
689+
continue
690+
691+
# Try example from ParamInfo, then FieldInfo.examples, then default
692+
example = None
693+
if is_param and field_info.example is not None:
694+
example = field_info.example
695+
elif field_info.examples and len(field_info.examples) > 0:
696+
example = field_info.examples[0]
697+
698+
if example is not None:
699+
example_data[field_name] = example
700+
elif field_info.default is not None and field_info.default is not ...:
701+
example_data[field_name] = field_info.default
702+
elif field_info.default_factory is not None:
703+
example_data[field_name] = field_info.default_factory()
704+
else:
705+
raise ValueError(
706+
f"Parameter '{field_name}' has no example or default value. "
707+
f"Provide example=... or default=... in Param()."
708+
)
709+
return cls(**example_data)
710+
711+
def with_units(self, base_units: bool = False) -> "Packable":
712+
"""Clone with numeric/array Param fields converted to pint Quantities.
713+
714+
Reads units from either ParamInfo (for fields defined with Param()) or
715+
json_schema_extra (for InlineArray fields where Pydantic converts
716+
ParamInfo to plain FieldInfo but preserves the extra dict).
717+
718+
Args:
719+
base_units: If True, convert to SI base units.
720+
"""
721+
try:
722+
from pint import UnitRegistry
723+
ureg = UnitRegistry()
724+
except ImportError:
725+
raise ImportError("pint is required for with_units(). Install with: pip install pint")
726+
727+
from meshly.param import ParamInfo
728+
729+
cloned = self.model_copy()
730+
for field_name, field_info in self.model_fields.items():
731+
# Get units from ParamInfo or json_schema_extra
732+
units: str | None = None
733+
if isinstance(field_info, ParamInfo):
734+
units = field_info.units
735+
elif isinstance(field_info.json_schema_extra, dict):
736+
units = field_info.json_schema_extra.get("units")
737+
738+
value = getattr(self, field_name)
739+
740+
# Recurse into nested Packable fields
741+
if isinstance(value, Packable):
742+
object.__setattr__(cloned, field_name, value.with_units(base_units=base_units))
743+
continue
744+
745+
if not units or units == "dimensionless":
746+
continue
747+
748+
try:
749+
quantity = ureg.Quantity(value, units)
750+
if base_units:
751+
quantity = quantity.to_base_units()
752+
object.__setattr__(cloned, field_name, quantity)
753+
except Exception:
754+
pass
755+
756+
return cloned
757+
667758
def __reduce__(self):
668759
"""Support for pickle serialization using standard dict approach."""
669760
return (

0 commit comments

Comments
 (0)