Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

* Added support for `.stp` file extension in addition to `.step` for `RhinoBrep.from_step()` and `RhinoBrep.to_step()` methods.
* Added optional support for units and uncertainties (via `pint.Quantity` and `uncertainties.UFloat`) including data serialization/deserialization support. Support is built around gradual typing, where unit-aware inputs produce unit-aware outputs.
* Added `compas.units.UnitRegistry` for managing physical units with graceful degradation.

### Changed

Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@
jsonschema
networkx >= 3.0
numpy >= 1.15.4
pint >= 0.20
scipy >= 1.1
uncertainties >= 3.1
watchdog; sys_platform != 'emscripten'
27 changes: 27 additions & 0 deletions src/compas/data/encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@
except (ImportError, SyntaxError):
numpy_support = False

# Check for units and uncertainties support
units_support = False
uncertainties_support = False

try:
import pint
units_support = True
except ImportError:
pint = None

try:
import uncertainties
uncertainties_support = True
except ImportError:
uncertainties = None


def cls_from_dtype(dtype, inheritance=None): # type: (...) -> Type[Data]
"""Get the class object corresponding to a COMPAS data type specification.
Expand Down Expand Up @@ -178,6 +194,17 @@ def default(self, o):
if isinstance(o, AttributeView):
return dict(o)

# Handle units and uncertainties using proper encoders
if units_support and pint and isinstance(o, pint.Quantity):
# Use the proper encoder from units module
from compas.units import PintQuantityEncoder
return PintQuantityEncoder.__jsondump__(o)

if uncertainties_support and uncertainties and isinstance(o, uncertainties.UFloat):
# Use the proper encoder from units module
from compas.units import UncertaintiesUFloatEncoder
return UncertaintiesUFloatEncoder.__jsondump__(o)

return super(DataEncoder, self).default(o)


Expand Down
307 changes: 307 additions & 0 deletions src/compas/units.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
"""
Unit and uncertainty support for COMPAS.

This module provides optional support for physical units and measurement uncertainties
throughout the COMPAS framework. The implementation follows a gradual typing approach
where unit-aware inputs produce unit-aware outputs, but plain numeric inputs continue
to work as before.
"""

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

try:
from typing import Union
except ImportError:
pass

__all__ = ['UnitRegistry', 'units', 'NumericType', 'UNITS_AVAILABLE', 'UNCERTAINTIES_AVAILABLE', 'PintQuantityEncoder', 'UncertaintiesUFloatEncoder']

# Check for optional dependencies
try:
import pint
UNITS_AVAILABLE = True
except ImportError:
UNITS_AVAILABLE = False
pint = None

try:
import uncertainties
UNCERTAINTIES_AVAILABLE = True
except ImportError:
UNCERTAINTIES_AVAILABLE = False
uncertainties = None

# Define numeric type union
try:
NumericType = Union[float, int]
if UNITS_AVAILABLE:
NumericType = Union[NumericType, pint.Quantity]
if UNCERTAINTIES_AVAILABLE:
NumericType = Union[NumericType, uncertainties.UFloat]
except NameError:
# typing.Union not available, just use documentation comment
NumericType = float # Union[float, int, pint.Quantity, uncertainties.UFloat] when available


class PintQuantityEncoder:
"""Encoder/decoder for pint.Quantity objects following COMPAS data serialization patterns."""

@staticmethod
def __jsondump__(obj):
"""Serialize a pint.Quantity to COMPAS JSON format.

Parameters
----------
obj : pint.Quantity
The quantity to serialize.

Returns
-------
dict
Dictionary with dtype and data keys.
"""
return {
'dtype': 'compas.units/PintQuantityEncoder',
'data': {
'magnitude': obj.magnitude,
'units': str(obj.units)
}
}

@staticmethod
def __from_data__(data):
"""Reconstruct a pint.Quantity from serialized data.

Parameters
----------
data : dict
The serialized data containing magnitude and units.

Returns
-------
pint.Quantity or float
The reconstructed quantity, or magnitude if pint not available.
"""
if UNITS_AVAILABLE:
# Import units registry from this module
return units.ureg.Quantity(data['magnitude'], data['units'])
else:
# Graceful degradation - return just the magnitude
return data['magnitude']

@staticmethod
def __jsonload__(data, guid=None, name=None):
"""Load method for COMPAS JSON deserialization."""
return PintQuantityEncoder.__from_data__(data)


class UncertaintiesUFloatEncoder:
"""Encoder/decoder for uncertainties.UFloat objects following COMPAS data serialization patterns."""

@staticmethod
def __jsondump__(obj):
"""Serialize an uncertainties.UFloat to COMPAS JSON format.

Parameters
----------
obj : uncertainties.UFloat
The uncertain value to serialize.

Returns
-------
dict
Dictionary with dtype and data keys.
"""
return {
'dtype': 'compas.units/UncertaintiesUFloatEncoder',
'data': {
'nominal_value': obj.nominal_value,
'std_dev': obj.std_dev
}
}

@staticmethod
def __from_data__(data):
"""Reconstruct an uncertainties.UFloat from serialized data.

Parameters
----------
data : dict
The serialized data containing nominal_value and std_dev.

Returns
-------
uncertainties.UFloat or float
The reconstructed uncertain value, or nominal value if uncertainties not available.
"""
if UNCERTAINTIES_AVAILABLE:
return uncertainties.ufloat(data['nominal_value'], data['std_dev'])
else:
# Graceful degradation - return just the nominal value
return data['nominal_value']

@staticmethod
def __jsonload__(data, guid=None, name=None):
"""Load method for COMPAS JSON deserialization."""
return UncertaintiesUFloatEncoder.__from_data__(data)


class UnitRegistry:
"""Global unit registry for COMPAS.

This class provides a centralized way to create and manage units throughout
the COMPAS framework. It gracefully handles the case where pint is not available.

Examples
--------
>>> from compas.units import units
>>> length = units.Quantity(1.0, 'meter') # Returns 1.0 if pint not available
>>> area = units.Quantity(2.5, 'square_meter')
"""

def __init__(self):
if UNITS_AVAILABLE:
self.ureg = pint.UnitRegistry()
# Use built-in units - no need to redefine basic units
# The registry already has meter, millimeter, etc.
else:
self.ureg = None

def Quantity(self, value, unit=None):
"""Create a quantity with units if available, otherwise return plain value.

Parameters
----------
value : float
The numeric value.
unit : str, optional
The unit string. If None or if pint is not available, returns plain value.

Returns
-------
pint.Quantity or float
A quantity with units if pint is available, otherwise the plain value.
"""
if UNITS_AVAILABLE and unit and self.ureg:
return self.ureg.Quantity(value, unit)
return value

def Unit(self, unit_string):
"""Get a unit object if available.

Parameters
----------
unit_string : str
The unit string (e.g., 'meter', 'mm', 'inch').

Returns
-------
pint.Unit or None
A unit object if pint is available, otherwise None.
"""
if UNITS_AVAILABLE and self.ureg:
return self.ureg.Unit(unit_string)
return None

@property
def meter(self):
"""Meter unit for convenience."""
return self.Unit('m')

@property
def millimeter(self):
"""Millimeter unit for convenience."""
return self.Unit('mm')

@property
def centimeter(self):
"""Centimeter unit for convenience."""
return self.Unit('cm')


def ensure_numeric(value):
"""Ensure a value is numeric, preserving units and uncertainties if present.

Parameters
----------
value : any
Input value that should be numeric.

Returns
-------
NumericType
A numeric value, preserving units/uncertainties if present.
"""
# Check for pint Quantity
if hasattr(value, 'magnitude') and hasattr(value, 'units'):
return value

# Check for uncertainties UFloat
if hasattr(value, 'nominal_value') and hasattr(value, 'std_dev'):
return value

# Convert to float for plain values
return float(value)


def get_magnitude(value):
"""Get the magnitude of a value, handling units and uncertainties.

Parameters
----------
value : NumericType
A numeric value that may have units or uncertainties.

Returns
-------
float
The magnitude/nominal value without units.
"""
# Handle pint Quantity
if hasattr(value, 'magnitude'):
return float(value.magnitude)

# Handle uncertainties UFloat
if hasattr(value, 'nominal_value'):
return float(value.nominal_value)

# Plain numeric value
return float(value)


def has_units(value):
"""Check if a value has units.

Parameters
----------
value : any
Value to check for units.

Returns
-------
bool
True if the value has units, False otherwise.
"""
return hasattr(value, 'magnitude') and hasattr(value, 'units')


def has_uncertainty(value):
"""Check if a value has uncertainty.

Parameters
----------
value : any
Value to check for uncertainty.

Returns
-------
bool
True if the value has uncertainty, False otherwise.
"""
return hasattr(value, 'nominal_value') and hasattr(value, 'std_dev')


# Global registry instance
units = UnitRegistry()
Loading
Loading