Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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 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'
56 changes: 56 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,23 @@ def default(self, o):
if isinstance(o, AttributeView):
return dict(o)

# Handle units and uncertainties
if units_support and hasattr(o, 'magnitude') and hasattr(o, 'units'):
# This is a pint.Quantity
return {
'__pint_quantity__': True,
'magnitude': o.magnitude,
'units': str(o.units)
}

if uncertainties_support and hasattr(o, 'nominal_value') and hasattr(o, 'std_dev'):
# This is an uncertainties.UFloat
return {
'__uncertainties_ufloat__': True,
'nominal_value': o.nominal_value,
'std_dev': o.std_dev
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot this encoding/decoding behavior is not very clean. Instead please add a decoder & encoder for the supported types (pint.Quantity and ufloat) in the units.py module, and here only check if isinstance(o, pint.Quantity (or ufloat) and then call the right encoder. Both these encoders should behave like the rest of the data serialization framework, i.e. they should return {"dtype": ..., "data":...} dictionaries, instead of this custom dict that has __pint_quantity__ and __uncertainties_ufloat__. The dtype could be the type of the decoders themselves, or of a proxy type. We don't have proxy type encoding/decoding support yet, so this would be a first

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored to clean COMPAS-style encoding/decoding pattern as requested:

Added proper encoder/decoder classes in units.py:

  • PintQuantityEncoder for pint.Quantity objects
  • UncertaintiesUFloatEncoder for uncertainties.UFloat objects

DataEncoder now uses isinstance() checks and calls appropriate encoders

Encoders return standard {"dtype": "compas.units/...", "data": {...}} dictionaries instead of custom markers

Leverages existing COMPAS dtype resolution - no custom decoding logic needed

The implementation now follows the established COMPAS serialization framework patterns consistently. See commit e531988.

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


Expand Down Expand Up @@ -232,6 +265,29 @@ def object_hook(self, o):
A (reconstructed), deserialized object.

"""
# Handle pint quantities
if o.get('__pint_quantity__'):
if units_support:
# Import units registry from compas.units
try:
from compas.units import units as compas_units
return compas_units.ureg.Quantity(o['magnitude'], o['units'])
except ImportError:
# Fallback: create a basic pint registry
ureg = pint.UnitRegistry()
return ureg.Quantity(o['magnitude'], o['units'])
else:
# Graceful degradation - return just the magnitude
return o['magnitude']

# Handle uncertainties
if o.get('__uncertainties_ufloat__'):
if uncertainties_support:
return uncertainties.ufloat(o['nominal_value'], o['std_dev'])
else:
# Graceful degradation - return just the nominal value
return o['nominal_value']

if "dtype" not in o:
return o

Expand Down
197 changes: 197 additions & 0 deletions src/compas/units.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""
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

from typing import Union

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

# 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
NumericType = Union[float, int]
if UNITS_AVAILABLE:
NumericType = Union[NumericType, pint.Quantity]
if UNCERTAINTIES_AVAILABLE:
NumericType = Union[NumericType, uncertainties.UFloat]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typing module is not available in Python 2.7 (ironpython), so, make this conditional or use doc type hints, since this is only for documentation purposes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed typing import for Python 2.7/IronPython compatibility:

  • Added conditional import: try: from typing import Union except ImportError: pass
  • Wrapped Union usage in try/except with fallback to documentation comment
  • Verified compatibility when typing module is not available

Commit: 64ec754



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