diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e5db937c41..c116cab87a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/requirements.txt b/requirements.txt index 11f9a7736384..c41c754e16b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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' diff --git a/src/compas/data/encoders.py b/src/compas/data/encoders.py index c77bcdca26ce..cb5bffff2c5a 100644 --- a/src/compas/data/encoders.py +++ b/src/compas/data/encoders.py @@ -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. @@ -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) diff --git a/src/compas/units.py b/src/compas/units.py new file mode 100644 index 000000000000..3be502b714a7 --- /dev/null +++ b/src/compas/units.py @@ -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() \ No newline at end of file diff --git a/tests/compas/test_units.py b/tests/compas/test_units.py new file mode 100644 index 000000000000..109b9b25bf01 --- /dev/null +++ b/tests/compas/test_units.py @@ -0,0 +1,199 @@ +""" +Test suite for units and uncertainties support in COMPAS. + +This test suite validates the units functionality and ensures +backward compatibility is maintained. +""" + +import pytest +import json +import compas +from compas.units import units, UNITS_AVAILABLE, UNCERTAINTIES_AVAILABLE +from compas.data.encoders import DataEncoder, DataDecoder + + +class TestUnitsModule: + """Test the units module functionality.""" + + def test_units_availability(self): + """Test that units are detected correctly.""" + assert isinstance(UNITS_AVAILABLE, bool) + assert isinstance(UNCERTAINTIES_AVAILABLE, bool) + + def test_quantity_creation(self): + """Test quantity creation.""" + result = units.Quantity(1.0, 'meter') + # Should work regardless of pint availability + assert result is not None + + def test_unit_registry_properties(self): + """Test unit registry properties.""" + # These should not raise errors regardless of availability + meter = units.meter + mm = units.millimeter + cm = units.centimeter + + # Properties should be consistent + assert (meter is None) == (not UNITS_AVAILABLE) + assert (mm is None) == (not UNITS_AVAILABLE) + assert (cm is None) == (not UNITS_AVAILABLE) + + +class TestUnitsWithPint: + """Test units functionality when pint is available.""" + + def test_unit_conversions(self): + """Test basic unit conversions work correctly.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + meter = units.Quantity(1.0, 'meter') + millimeter = units.Quantity(1000.0, 'millimeter') + + # They should be equivalent + assert meter.to('millimeter').magnitude == pytest.approx(1000.0) + assert millimeter.to('meter').magnitude == pytest.approx(1.0) + + def test_serialization_with_units(self): + """Test JSON serialization of units.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + # Create a quantity + length = units.Quantity(5.0, 'meter') + + # Serialize + json_str = json.dumps(length, cls=DataEncoder) + assert 'compas.units/PintQuantityEncoder' in json_str + + # Deserialize + reconstructed = json.loads(json_str, cls=DataDecoder) + + # Should be equivalent + assert hasattr(reconstructed, 'magnitude') + assert reconstructed.magnitude == 5.0 + assert str(reconstructed.units) == 'meter' + + def test_mixed_data_with_units(self): + """Test serialization of mixed data containing units.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + # Create mixed data + mixed_data = { + 'plain_value': 42.0, + 'length': units.Quantity(10.0, 'meter'), + 'width': units.Quantity(5.0, 'meter'), + 'nested': { + 'height': units.Quantity(3.0, 'meter') + } + } + + # Serialize + json_str = json.dumps(mixed_data, cls=DataEncoder) + assert 'compas.units/PintQuantityEncoder' in json_str + + # Deserialize + reconstructed = json.loads(json_str, cls=DataDecoder) + + # Check all values + assert reconstructed['plain_value'] == 42.0 + assert hasattr(reconstructed['length'], 'magnitude') + assert reconstructed['length'].magnitude == 10.0 + assert hasattr(reconstructed['width'], 'magnitude') + assert reconstructed['width'].magnitude == 5.0 + assert hasattr(reconstructed['nested']['height'], 'magnitude') + assert reconstructed['nested']['height'].magnitude == 3.0 + + +class TestUncertaintiesWithUncertainties: + """Test uncertainties functionality when uncertainties is available.""" + + def test_uncertainty_creation(self): + """Test uncertainty creation.""" + if compas.IPY or not UNCERTAINTIES_AVAILABLE: + return # Skip on IronPython or when uncertainties not available + + import uncertainties + + val = uncertainties.ufloat(1.0, 0.1) + assert val.nominal_value == 1.0 + assert val.std_dev == 0.1 + + def test_serialization_with_uncertainties(self): + """Test JSON serialization of uncertainties.""" + if compas.IPY or not UNCERTAINTIES_AVAILABLE: + return # Skip on IronPython or when uncertainties not available + + import uncertainties + + # Create an uncertain value + value = uncertainties.ufloat(3.14, 0.01) + + # Serialize + json_str = json.dumps(value, cls=DataEncoder) + assert 'compas.units/UncertaintiesUFloatEncoder' in json_str + + # Deserialize + reconstructed = json.loads(json_str, cls=DataDecoder) + + # Should be equivalent + assert hasattr(reconstructed, 'nominal_value') + assert reconstructed.nominal_value == 3.14 + assert reconstructed.std_dev == 0.01 + + +class TestBackwardCompatibility: + """Test that existing functionality still works.""" + + def test_regular_data_serialization(self): + """Test that plain objects serialize correctly.""" + test_data = {'x': 1.0, 'y': 2.0, 'z': 3.0} + + # Should serialize and deserialize normally + json_str = json.dumps(test_data, cls=DataEncoder) + reconstructed = json.loads(json_str, cls=DataDecoder) + + assert reconstructed == test_data + + +class TestGracefulDegradation: + """Test graceful degradation when dependencies are not available.""" + + def test_units_disabled(self, monkeypatch): + """Test behavior when units are artificially disabled.""" + # Monkey patch to simulate missing pint + monkeypatch.setattr('compas.units.UNITS_AVAILABLE', False) + monkeypatch.setattr('compas.units.pint', None) + + # Create mock COMPAS-style object that looks like our encoder output + mock_quantity = { + 'dtype': 'compas.units/PintQuantityEncoder', + 'data': {'magnitude': 2.5, 'units': 'meter'} + } + + # Serialize and deserialize + json_str = json.dumps(mock_quantity) + reconstructed = json.loads(json_str, cls=DataDecoder) + + # Should fallback to magnitude only + assert reconstructed == 2.5 + + def test_uncertainties_disabled(self, monkeypatch): + """Test behavior when uncertainties are artificially disabled.""" + # Monkey patch to simulate missing uncertainties + monkeypatch.setattr('compas.units.UNCERTAINTIES_AVAILABLE', False) + monkeypatch.setattr('compas.units.uncertainties', None) + + # Create mock COMPAS-style object that looks like our encoder output + mock_ufloat = { + 'dtype': 'compas.units/UncertaintiesUFloatEncoder', + 'data': {'nominal_value': 1.23, 'std_dev': 0.05} + } + + # Serialize and deserialize + json_str = json.dumps(mock_ufloat) + reconstructed = json.loads(json_str, cls=DataDecoder) + + # Should fallback to nominal value only + assert reconstructed == 1.23 \ No newline at end of file diff --git a/tests/compas/test_units_geometry.py b/tests/compas/test_units_geometry.py new file mode 100644 index 000000000000..9de3a9a0eee6 --- /dev/null +++ b/tests/compas/test_units_geometry.py @@ -0,0 +1,356 @@ +# -*- coding: utf-8 -*- +""" +Test suite for units integration with COMPAS geometry objects. + +This test suite validates how units work with geometry functions +and demonstrates the integration points for units in COMPAS. +""" + +import pytest +import json +import math +import compas +from compas.units import units, UNITS_AVAILABLE, UNCERTAINTIES_AVAILABLE +from compas.data.encoders import DataEncoder, DataDecoder +from compas.geometry import Point, Vector, Frame, distance_point_point +from compas.datastructures import Mesh + + +class TestUnitsWithGeometryFunctions: + """Test how units work with geometry functions.""" + + def test_distance_with_units(self): + """Test distance calculation with unit-aware coordinates.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + # Create points with unit coordinates as lists + p1 = [1.0 * units.meter, 2.0 * units.meter, 3.0 * units.meter] + p2 = [4.0 * units.meter, 5.0 * units.meter, 6.0 * units.meter] + + # Convert to plain coordinates for geometry functions (current limitation) + p1_plain = [coord.magnitude for coord in p1] + p2_plain = [coord.magnitude for coord in p2] + + # Distance function works with plain coordinates + distance = distance_point_point(p1_plain, p2_plain) + + # We can then add units back to the result + distance_with_units = distance * units.meter + assert distance_with_units.magnitude == pytest.approx(5.196, abs=1e-3) + assert 'meter' in str(distance_with_units.units) + + def test_mixed_units_conversion(self): + """Test distance with mixed units.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + # Different units should be automatically converted + p1 = [1.0 * units.meter, 0.0 * units.meter, 0.0 * units.meter] + p2 = [1000.0 * units.millimeter, 0.0 * units.millimeter, 0.0 * units.millimeter] + + # Convert units to same base and extract magnitudes + p1_plain = [coord.to('meter').magnitude for coord in p1] + p2_plain = [coord.to('meter').magnitude for coord in p2] + + distance = distance_point_point(p1_plain, p2_plain) + # Should be zero distance (same point in different units) + assert distance == pytest.approx(0.0, abs=1e-10) + + def test_units_serialization_in_geometry_data(self): + """Test serialization of data structures containing units.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + # Create mixed data that might be used in geometry contexts + geometry_data = { + 'coordinates': [1.0 * units.meter, 2.0 * units.meter, 3.0 * units.meter], + 'distance': 5.0 * units.meter, + 'area': 10.0 * units.Quantity(1.0, 'meter^2'), + 'plain_value': 42.0 + } + + # Should serialize correctly + json_str = json.dumps(geometry_data, cls=DataEncoder) + assert 'compas.units/PintQuantityEncoder' in json_str + + # Should deserialize correctly + reconstructed = json.loads(json_str, cls=DataDecoder) + + assert reconstructed['plain_value'] == 42.0 + assert hasattr(reconstructed['distance'], 'magnitude') + assert reconstructed['distance'].magnitude == 5.0 + + +class TestGeometryObjectsSerialization: + """Test geometry objects serialization when they contain unit data.""" + + def test_point_serialization_integration(self): + """Test Point serialization in unit-aware workflows.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + # Points are created with plain coordinates (current behavior) + p = Point(1.0, 2.0, 3.0) + + # But point data can be enhanced with units in workflows + point_data = { + 'geometry': p, + 'units': 'meter', + 'precision': 0.001 * units.meter + } + + # Should serialize correctly + json_str = json.dumps(point_data, cls=DataEncoder) + reconstructed = json.loads(json_str, cls=DataDecoder) + + assert isinstance(reconstructed['geometry'], Point) + assert reconstructed['units'] == 'meter' + assert hasattr(reconstructed['precision'], 'magnitude') + assert reconstructed['precision'].magnitude == 0.001 + + def test_vector_workflow_with_units(self): + """Test Vector in unit-aware workflows.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + # Vectors are created with plain coordinates + v = Vector(1.0, 2.0, 3.0) + + # But can be part of unit-aware data + vector_data = { + 'direction': v, + 'magnitude': 5.0 * units.meter, + 'force': 100.0 * units.Quantity(1.0, 'newton') + } + + # Should serialize correctly + json_str = json.dumps(vector_data, cls=DataEncoder) + reconstructed = json.loads(json_str, cls=DataDecoder) + + assert isinstance(reconstructed['direction'], Vector) + assert hasattr(reconstructed['magnitude'], 'magnitude') + assert reconstructed['magnitude'].magnitude == 5.0 + + def test_frame_with_unit_context(self): + """Test Frame in unit-aware context.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + frame = Frame([1.0, 2.0, 3.0]) + + # Frame can be part of unit-aware design data + design_data = { + 'coordinate_frame': frame, + 'scale': 1.0 * units.meter, + 'tolerance': 0.01 * units.meter + } + + # Should serialize correctly + json_str = json.dumps(design_data, cls=DataEncoder) + reconstructed = json.loads(json_str, cls=DataDecoder) + + assert isinstance(reconstructed['coordinate_frame'], Frame) + assert hasattr(reconstructed['scale'], 'magnitude') + assert reconstructed['scale'].magnitude == 1.0 + + +class TestMeshWithUnitsWorkflow: + """Test Mesh in unit-aware workflows.""" + + def test_mesh_with_unit_attributes(self): + """Test Mesh with unit-aware custom attributes.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + # Create simple mesh + vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + faces = [[0, 1, 2]] + mesh = Mesh.from_vertices_and_faces(vertices, faces) + + # Add unit-aware attributes to the mesh itself + mesh.attributes['units'] = 'meter' + mesh.attributes['scale_factor'] = 1.0 * units.meter + mesh.attributes['material_thickness'] = 0.1 * units.meter + mesh.attributes['area'] = 0.5 * units.Quantity(1.0, 'meter^2') + + # Should serialize correctly + json_str = json.dumps(mesh, cls=DataEncoder) + reconstructed = json.loads(json_str, cls=DataDecoder) + + assert isinstance(reconstructed, Mesh) + assert reconstructed.attributes['units'] == 'meter' + assert hasattr(reconstructed.attributes['scale_factor'], 'magnitude') + assert reconstructed.attributes['scale_factor'].magnitude == 1.0 + assert hasattr(reconstructed.attributes['material_thickness'], 'magnitude') + assert reconstructed.attributes['material_thickness'].magnitude == 0.1 + + def test_mesh_processing_workflow(self): + """Test mesh processing workflow with unit-aware attributes.""" + if compas.IPY or not UNITS_AVAILABLE: + return # Skip on IronPython or when pint not available + + # Create mesh + mesh = Mesh() + + # Add vertices with unit-aware vertex attributes + v1 = mesh.add_vertex(x=0.0, y=0.0, z=0.0) + v2 = mesh.add_vertex(x=1.0, y=0.0, z=0.0) + v3 = mesh.add_vertex(x=0.0, y=1.0, z=0.0) + + # Add face + face = mesh.add_face([v1, v2, v3]) + + # Add unit-aware attributes to vertices + mesh.vertex_attribute(v1, 'load', 10.0 * units.Quantity(1.0, 'newton')) + mesh.vertex_attribute(v2, 'load', 15.0 * units.Quantity(1.0, 'newton')) + mesh.vertex_attribute(v3, 'load', 12.0 * units.Quantity(1.0, 'newton')) + + # Add unit-aware attributes to edges + for edge in mesh.edges(): + mesh.edge_attribute(edge, 'length', 1.0 * units.meter) + + # Add unit-aware attributes to faces + mesh.face_attribute(face, 'area', 0.5 * units.Quantity(1.0, 'meter^2')) + + # Should serialize correctly + json_str = json.dumps(mesh, cls=DataEncoder) + reconstructed = json.loads(json_str, cls=DataDecoder) + + assert isinstance(reconstructed, Mesh) + assert len(list(reconstructed.vertices())) == 3 + + # Check vertex attributes + for vertex in reconstructed.vertices(): + load = reconstructed.vertex_attribute(vertex, 'load') + assert hasattr(load, 'magnitude') + assert load.magnitude in [10.0, 15.0, 12.0] + + # Check edge attributes + for edge in reconstructed.edges(): + length = reconstructed.edge_attribute(edge, 'length') + assert hasattr(length, 'magnitude') + assert length.magnitude == 1.0 + + # Check face attributes + for face in reconstructed.faces(): + area = reconstructed.face_attribute(face, 'area') + assert hasattr(area, 'magnitude') + assert area.magnitude == 0.5 + + +class TestGeometryWithUncertainties: + """Test geometry objects with measurement uncertainties.""" + + def test_measurement_data_with_uncertainties(self): + """Test geometry data with measurement uncertainties.""" + if compas.IPY or not UNCERTAINTIES_AVAILABLE: + return # Skip on IronPython or when uncertainties not available + + import uncertainties as unc + + # Survey/measurement data with uncertainties + measurement_data = { + 'point': Point(1.0, 2.0, 3.0), # Plain geometry + 'measured_coordinates': [ + unc.ufloat(1.0, 0.01), # x ± 0.01 + unc.ufloat(2.0, 0.01), # y ± 0.01 + unc.ufloat(3.0, 0.02) # z ± 0.02 + ], + 'measurement_error': unc.ufloat(0.05, 0.01) # Total error ± uncertainty + } + + # Should serialize correctly + json_str = json.dumps(measurement_data, cls=DataEncoder) + assert 'compas.units/UncertaintiesUFloatEncoder' in json_str + + # Should deserialize correctly + reconstructed = json.loads(json_str, cls=DataDecoder) + + assert isinstance(reconstructed['point'], Point) + assert len(reconstructed['measured_coordinates']) == 3 + assert all(hasattr(coord, 'nominal_value') for coord in reconstructed['measured_coordinates']) + assert all(hasattr(coord, 'std_dev') for coord in reconstructed['measured_coordinates']) + + +class TestGeometryBackwardCompatibility: + """Test that geometry objects work normally without units.""" + + def test_point_backward_compatibility(self): + """Test Point works normally with plain floats.""" + p1 = Point(1.0, 2.0, 3.0) + p2 = Point(4.0, 5.0, 6.0) + + # Should work as before + assert p1.x == 1.0 + assert p1.y == 2.0 + assert p1.z == 3.0 + + # Arithmetic should work + result = p1 + p2 + assert result.x == 5.0 + assert result.y == 7.0 + assert result.z == 9.0 + + # Distance calculation should work + distance = distance_point_point(p1, p2) + assert distance == pytest.approx(5.196, abs=1e-3) + + def test_vector_backward_compatibility(self): + """Test Vector works normally with plain floats.""" + v1 = Vector(1.0, 0.0, 0.0) + v2 = Vector(0.0, 1.0, 0.0) + + # Should work as before + assert v1.length == 1.0 + assert v1.dot(v2) == 0.0 + + cross = v1.cross(v2) + assert cross.z == 1.0 + + def test_frame_backward_compatibility(self): + """Test Frame works normally with plain coordinates.""" + frame = Frame([1.0, 2.0, 3.0]) + + # Should work as before + assert frame.point.x == 1.0 + assert frame.point.y == 2.0 + assert frame.point.z == 3.0 + + def test_mesh_backward_compatibility(self): + """Test Mesh works normally with plain coordinates.""" + vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + faces = [[0, 1, 2]] + + mesh = Mesh.from_vertices_and_faces(vertices, faces) + + # Should work as before + assert len(list(mesh.vertices())) == 3 + assert len(list(mesh.faces())) == 1 + + # Coordinates should be plain floats + coords = mesh.vertex_coordinates(0) + assert coords == [0.0, 0.0, 0.0] + + def test_serialization_backward_compatibility(self): + """Test that regular geometry serialization still works.""" + # Test with various geometry objects + point = Point(1.0, 2.0, 3.0) + vector = Vector(1.0, 2.0, 3.0) + frame = Frame([0.0, 0.0, 0.0]) + + geometry_collection = { + 'point': point, + 'vector': vector, + 'frame': frame, + 'plain_data': [1.0, 2.0, 3.0] + } + + # Should serialize and deserialize normally + json_str = json.dumps(geometry_collection, cls=DataEncoder) + reconstructed = json.loads(json_str, cls=DataDecoder) + + assert isinstance(reconstructed['point'], Point) + assert isinstance(reconstructed['vector'], Vector) + assert isinstance(reconstructed['frame'], Frame) + assert reconstructed['plain_data'] == [1.0, 2.0, 3.0] \ No newline at end of file