From 032290ec418806df914d683715f6aa22dc013ab2 Mon Sep 17 00:00:00 2001 From: Afshawn Lotfi Date: Fri, 17 Apr 2026 07:24:03 +0000 Subject: [PATCH 1/2] Add unit-aware parameter support with Param() and update README for installation instructions --- python/README.md | 92 +++++++++++++++++++++++ python/meshly/__init__.py | 4 + python/meshly/array.py | 6 +- python/meshly/packable.py | 91 ++++++++++++++++++++++ python/meshly/param.py | 154 ++++++++++++++++++++++++++++++++++++++ python/pyproject.toml | 3 +- 6 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 python/meshly/param.py diff --git a/python/README.md b/python/README.md index 02df6d9..7a34870 100644 --- a/python/README.md +++ b/python/README.md @@ -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 @@ -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 @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/python/meshly/__init__.py b/python/meshly/__init__.py index 5adabaf..23eccc5 100644 --- a/python/meshly/__init__.py +++ b/python/meshly/__init__.py @@ -23,6 +23,7 @@ IndexSequence, InlineArray, ) +from meshly.param import Param, ParamInfo from meshly.cell_types import ( CellType, CellTypeUtils, @@ -53,6 +54,9 @@ # Mesh classes "Mesh", "TMesh", + # Parameter metadata + "Param", + "ParamInfo", # Array types and utilities "Array", "InlineArray", diff --git a/python/meshly/array.py b/python/meshly/array.py index 3b97a3a..3ca06fa 100644 --- a/python/meshly/array.py +++ b/python/meshly/array.py @@ -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 diff --git a/python/meshly/packable.py b/python/meshly/packable.py index 942e72d..4e94ac7 100644 --- a/python/meshly/packable.py +++ b/python/meshly/packable.py @@ -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 ( diff --git a/python/meshly/param.py b/python/meshly/param.py new file mode 100644 index 0000000..d4b552e --- /dev/null +++ b/python/meshly/param.py @@ -0,0 +1,154 @@ +"""Unit-aware parameter field for Pydantic models. + +Provides Param(), a drop-in replacement for pydantic.Field() that adds +units, shape, and example metadata to the JSON schema. Works on any +Pydantic BaseModel, including fields typed as InlineArray. +""" + +from typing import Any, Callable + +from pydantic.fields import FieldInfo + + +class ParamInfo(FieldInfo): # type: ignore[final] + """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. + """ + + units: str + shape: tuple[int, ...] | None + example: Any + + def __init__( + self, + default: Any = ..., + *, + units: str, + shape: tuple[int, ...] | None = None, + example: Any = None, + default_factory: Callable[[], Any] | None = None, + alias: str | None = None, + alias_priority: int | None = None, + validation_alias: str | None = None, + serialization_alias: str | None = None, + title: str | None = None, + description: str | None = None, + exclude: bool = False, + deprecated: str | bool | None = None, + json_schema_extra: dict[str, Any] | Callable[[dict[str, Any]], None] | None = None, + frozen: bool | None = None, + validate_default: bool | None = None, + repr: bool = True, + init: bool | None = None, + init_var: bool | None = None, + kw_only: bool | None = None, + pattern: str | None = None, + strict: bool | None = None, + gt: float | None = None, + ge: float | None = None, + lt: float | None = None, + le: float | None = None, + multiple_of: float | None = None, + min_length: int | None = None, + max_length: int | None = None, + ): + has_default = default is not ... or default_factory is not None + has_example = example is not None + if not has_default and not has_example: + raise ValueError( + "Param() requires either a default value or an example. " + "Use Param(default_value, units=...) or Param(units=..., example=...)." + ) + + self.units = units + self.shape = shape + self.example = example + + examples: list[Any] | None = [example] if example is not None else None + + extra: dict[str, Any] = {"units": units} + if shape is not None: + extra["shape"] = shape + if json_schema_extra is not None and isinstance(json_schema_extra, dict): + extra.update(json_schema_extra) + + super().__init__( + default=default, + default_factory=default_factory, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + examples=examples, + exclude=exclude, + deprecated=deprecated, + json_schema_extra=extra, + frozen=frozen, + validate_default=validate_default, + repr=repr, + init=init, + init_var=init_var, + kw_only=kw_only, + pattern=pattern, + strict=strict, + gt=gt, + ge=ge, + lt=lt, + le=le, + multiple_of=multiple_of, + min_length=min_length, + max_length=max_length, + ) + + +def Param( + default: Any = ..., + *, + units: str, + shape: tuple[int, ...] | None = None, + example: Any = None, + default_factory: Callable[[], Any] | None = None, + alias: str | None = None, + alias_priority: int | None = None, + validation_alias: str | None = None, + serialization_alias: str | None = None, + title: str | None = None, + description: str | None = None, + exclude: bool = False, + deprecated: str | bool | None = None, + json_schema_extra: dict[str, Any] | Callable[[dict[str, Any]], None] | None = None, + frozen: bool | None = None, + validate_default: bool | None = None, + repr: bool = True, + init: bool | None = None, + init_var: bool | None = None, + kw_only: bool | None = None, + pattern: str | None = None, + strict: bool | None = None, + gt: float | None = None, + ge: float | None = None, + lt: float | None = None, + le: float | None = None, + multiple_of: float | None = None, + min_length: int | None = None, + max_length: int | None = None, +) -> Any: + """Define a parameter with units and optional constraints. + + Drop-in replacement for pydantic.Field that adds units, shape, and + example metadata to the JSON schema. Works on any Pydantic BaseModel. + """ + return ParamInfo( + default=default, units=units, shape=shape, example=example, + default_factory=default_factory, alias=alias, alias_priority=alias_priority, + validation_alias=validation_alias, serialization_alias=serialization_alias, + title=title, description=description, exclude=exclude, deprecated=deprecated, + json_schema_extra=json_schema_extra, frozen=frozen, validate_default=validate_default, + repr=repr, init=init, init_var=init_var, kw_only=kw_only, pattern=pattern, + strict=strict, gt=gt, ge=ge, lt=lt, le=le, multiple_of=multiple_of, + min_length=min_length, max_length=max_length, + ) diff --git a/python/pyproject.toml b/python/pyproject.toml index a58fac6..9d63d60 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -40,7 +40,8 @@ dependencies = [ [project.optional-dependencies] jax = ["jax", "jaxlib"] -dev = ["pytest>=7.0.0", "pytest-cov>=4.0.0", "pyvista>=0.40.0"] +units = ["pint>=0.20"] +dev = ["pytest>=7.0.0", "pytest-cov>=4.0.0", "pyvista>=0.40.0", "pint>=0.20"] [project.urls] Homepage = "https://github.com/OpenOrion/meshly" From a5c859172946e5154b6cd3b6ecf6c846f6ccd6e2 Mon Sep 17 00:00:00 2001 From: Afshawn Lotfi Date: Fri, 17 Apr 2026 07:26:43 +0000 Subject: [PATCH 2/2] Bump version to 3.5.0-alpha in pyproject.toml --- python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 9d63d60..aaa0918 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "meshly" -version = "3.4.0-alpha" +version = "3.5.0-alpha" description = "High-level abstractions and utilities for working with meshoptimizer" readme = "README.md" license = {text = "MIT"}