Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ A Python library for efficient 3D mesh serialization using [meshoptimizer](https

```bash
pip install meshly

# With unit conversion support (pint)
pip install meshly[units]
```

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

### Unit-Aware Parameters

- **`Param()`**: Drop-in replacement for `pydantic.Field()` that adds `units`, `shape`, and `example` metadata to the JSON schema
- **`ParamInfo`**: `FieldInfo` subclass backing `Param()` — carries units/shape/example through Pydantic's schema generation
- **`Packable.to_example()`**: Class method that builds an instance from `Param()` example/default values
- **`Packable.with_units()`**: Returns a clone with numeric/array fields converted to `pint.Quantity` objects

### Key Capabilities

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

> **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`.

### Unit-Aware Parameters with Param()

`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`:

```python
from meshly import Packable, Param, InlineArray

class Simulation(Packable):
velocity: InlineArray = Param(units="m/s", example=[30.0, 0, 0], shape=(3,),
description="Flow velocity vector [vx, vy, vz]")
temperature: float = Param(300.0, units="K", description="Fluid temperature")
pressure: float = Param(101325.0, units="Pa", description="Outlet pressure")
name: str = Param("default", units="dimensionless", description="Simulation name")

# Units appear in the JSON schema
schema = Simulation.model_json_schema()
print(schema["properties"]["temperature"])
# {'default': 300.0, 'description': 'Fluid temperature', 'title': 'Temperature',
# 'type': 'number', 'units': 'K'}

# Create from example values
sim = Simulation.to_example()
print(sim.velocity) # [30. 0. 0.]
print(sim.temperature) # 300.0

# Convert to pint Quantities (requires `pip install meshly[units]`)
sim_units = sim.with_units()
print(sim_units.velocity) # [30.0 0.0 0.0] meter / second
print(sim_units.velocity.to("km/h")) # [108.0 0.0 0.0] kilometer / hour
print(sim_units.temperature.to("degC")) # 26.85 degree_Celsius

# Convert to SI base units
sim_base = sim.with_units(base_units=True)
print(sim_base.pressure) # 101325.0 kilogram / meter / second ** 2
```

`Param()` requires either a default value or an `example`:
```python
# With default
velocity: float = Param(10.0, units="m/s")

# With example (no default, field is required)
velocity: float = Param(units="m/s", example=10.0)

# Error: neither default nor example
velocity: float = Param(units="m/s") # ValueError!
```

### Dict of Pydantic BaseModel Objects

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

### Param

```python
def Param(
default: Any = ...,
*,
units: str, # Required: unit string (e.g., "m/s", "Pa", "dimensionless")
shape: tuple[int, ...] = None, # Optional: expected array shape
example: Any = None, # Optional: example value for to_example()
description: str = None, # Optional: field description
# ... all other pydantic.Field kwargs supported (gt, ge, lt, le, etc.)
) -> ParamInfo
```

### ParamInfo

```python
class ParamInfo(FieldInfo):
"""FieldInfo subclass that adds units, shape, and example to the JSON schema.

Works on any Pydantic BaseModel. When used with InlineArray, the units
are preserved in the JSON schema output via json_schema_extra.
"""
units: str
shape: tuple[int, ...] | None
example: Any
```

### ArrayUtils

```python
Expand Down Expand Up @@ -702,6 +789,11 @@ class Packable(BaseModel):
# Array conversion
def convert_to(self, array_type: ArrayType) -> T

# Param-aware helpers
@classmethod
def to_example(cls) -> T # Build instance from Param() example/default values
def with_units(self, base_units: bool = False) -> T # Clone with pint Quantities (requires pint)

# Extract/Encode (instance methods)
def extract(self) -> ExtractedPackable # Cached for efficiency
def encode(self) -> bytes # Calls extract() internally
Expand Down
4 changes: 4 additions & 0 deletions python/meshly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
IndexSequence,
InlineArray,
)
from meshly.param import Param, ParamInfo
from meshly.cell_types import (
CellType,
CellTypeUtils,
Expand Down Expand Up @@ -53,6 +54,9 @@
# Mesh classes
"Mesh",
"TMesh",
# Parameter metadata
"Param",
"ParamInfo",
# Array types and utilities
"Array",
"InlineArray",
Expand Down
6 changes: 5 additions & 1 deletion python/meshly/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ def __eq__(self, other):


class _InlineArrayAnnotation:
"""Pydantic annotation for arrays serialized as inline JSON lists."""
"""Pydantic annotation for arrays serialized as inline JSON lists.

When the field is defined with Param(units=...), the units/shape/description
are preserved in the JSON schema output via json_schema_extra on the FieldInfo.
"""

def __get_pydantic_core_schema__(
self, source_type: Any, handler: GetCoreSchemaHandler
Expand Down
91 changes: 91 additions & 0 deletions python/meshly/packable.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,97 @@ def load(
extracted = store.load_extracted(key)
return cls.reconstruct(extracted, assets=store.load_asset, array_type=array_type, is_lazy=is_lazy)

# -------------------------------------------------------------------------
# Param-aware helpers
# -------------------------------------------------------------------------

@classmethod
def to_example(cls) -> "Packable":
"""Create an instance using example values from Param() fields.

For each Param field, uses example if defined, else falls back to default.
Handles both native ParamInfo fields and InlineArray fields where
Pydantic converts ParamInfo to plain FieldInfo.
"""
from meshly.param import ParamInfo

example_data: dict[str, Any] = {}
for field_name, field_info in cls.model_fields.items():
is_param = isinstance(field_info, ParamInfo)
has_units_extra = (
isinstance(field_info.json_schema_extra, dict)
and "units" in field_info.json_schema_extra
)
if not is_param and not has_units_extra:
continue

# Try example from ParamInfo, then FieldInfo.examples, then default
example = None
if is_param and field_info.example is not None:
example = field_info.example
elif field_info.examples and len(field_info.examples) > 0:
example = field_info.examples[0]

if example is not None:
example_data[field_name] = example
elif field_info.default is not None and field_info.default is not ...:
example_data[field_name] = field_info.default
elif field_info.default_factory is not None:
example_data[field_name] = field_info.default_factory()
else:
raise ValueError(
f"Parameter '{field_name}' has no example or default value. "
f"Provide example=... or default=... in Param()."
)
return cls(**example_data)

def with_units(self, base_units: bool = False) -> "Packable":
"""Clone with numeric/array Param fields converted to pint Quantities.

Reads units from either ParamInfo (for fields defined with Param()) or
json_schema_extra (for InlineArray fields where Pydantic converts
ParamInfo to plain FieldInfo but preserves the extra dict).

Args:
base_units: If True, convert to SI base units.
"""
try:
from pint import UnitRegistry
ureg = UnitRegistry()
except ImportError:
raise ImportError("pint is required for with_units(). Install with: pip install pint")

from meshly.param import ParamInfo

cloned = self.model_copy()
for field_name, field_info in self.model_fields.items():
# Get units from ParamInfo or json_schema_extra
units: str | None = None
if isinstance(field_info, ParamInfo):
units = field_info.units
elif isinstance(field_info.json_schema_extra, dict):
units = field_info.json_schema_extra.get("units")

value = getattr(self, field_name)

# Recurse into nested Packable fields
if isinstance(value, Packable):
object.__setattr__(cloned, field_name, value.with_units(base_units=base_units))
continue

if not units or units == "dimensionless":
continue

try:
quantity = ureg.Quantity(value, units)
if base_units:
quantity = quantity.to_base_units()
object.__setattr__(cloned, field_name, quantity)
except Exception:
pass

return cloned

def __reduce__(self):
"""Support for pickle serialization using standard dict approach."""
return (
Expand Down
Loading
Loading