diff --git a/CHANGELOG.md b/CHANGELOG.md index bc70187..7b01f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.1] - 2026-03-25 + ### Added +- Affine conversion support in `EdgeDef` (#215) + - `EdgeDef` gains `offset: float = 0.0` field for affine conversions (e.g., temperature scales) + - `materialize()` uses `AffineMap(factor, offset)` when offset is non-zero, `LinearMap(factor)` otherwise + - `load_package()` reads optional `offset` field from TOML `[[edges]]` definitions + - Backward compatible: existing packages and edge definitions are unaffected - Domain-Specific Bases documentation (`docs/guides/domain-bases/`) - Explains how to resolve SI dimensional degeneracies with extended bases - Radiation Dosimetry: Gy vs Sv vs Gy(RBE) vs effective dose @@ -498,7 +505,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial commit -[Unreleased]: https://github.com/withtwoemms/ucon/compare/0.10.0...HEAD +[Unreleased]: https://github.com/withtwoemms/ucon/compare/0.10.1...HEAD +[0.10.1]: https://github.com/withtwoemms/ucon/compare/0.10.0...0.10.1 [0.10.0]: https://github.com/withtwoemms/ucon/compare/0.9.4...0.10.0 [0.9.4]: https://github.com/withtwoemms/ucon/compare/0.9.3...0.9.4 [0.9.3]: https://github.com/withtwoemms/ucon/compare/0.9.2...0.9.3 diff --git a/docs/external/ucon-tools b/docs/external/ucon-tools index ed67aac..8c05d37 160000 --- a/docs/external/ucon-tools +++ b/docs/external/ucon-tools @@ -1 +1 @@ -Subproject commit ed67aac169d589121eb5a117779cf35aaf814cca +Subproject commit 8c05d37254887c0312889d1dd06c6cd7882f0b70 diff --git a/tests/ucon/test_packages.py b/tests/ucon/test_packages.py index c7d021f..bf94652 100644 --- a/tests/ucon/test_packages.py +++ b/tests/ucon/test_packages.py @@ -89,6 +89,118 @@ def test_edge_def_unknown_src(self): self.assertIn('nonexistent', str(ctx.exception)) +class TestEdgeDefAffine(unittest.TestCase): + """Test EdgeDef affine (offset) support.""" + + def test_edge_def_creation_with_offset(self): + """EdgeDef stores offset field.""" + edge_def = EdgeDef(src='celsius', dst='kelvin', factor=1.0, offset=273.15) + self.assertEqual(edge_def.offset, 273.15) + + def test_edge_def_default_offset_zero(self): + """EdgeDef defaults offset to 0.0 for backward compatibility.""" + edge_def = EdgeDef(src='meter', dst='foot', factor=3.28084) + self.assertEqual(edge_def.offset, 0.0) + + def test_edge_def_materialize_affine(self): + """EdgeDef.materialize() uses AffineMap when offset is non-zero.""" + from ucon.maps import AffineMap + + pkg = UnitPackage( + name='test_affine', + units=(UnitDef(name='rankine', dimension='temperature', aliases=('Ra',)),), + edges=(EdgeDef(src='rankine', dst='kelvin', factor=5/9, offset=0.0),), + ) + + # Use a non-zero offset edge + graph = get_default_graph().with_package(pkg) + + # Now add an affine edge manually via EdgeDef + edge_def = EdgeDef(src='celsius', dst='kelvin', factor=1.0, offset=273.15) + edge_def.materialize(graph) + + # Verify the map type on the graph edge + with using_graph(graph): + celsius = get_unit_by_name('celsius') + kelvin = get_unit_by_name('kelvin') + m = graph.convert(src=celsius, dst=kelvin) + self.assertIsInstance(m, AffineMap) + # 0°C → 273.15 K + self.assertAlmostEqual(m(0), 273.15, places=2) + # 100°C → 373.15 K + self.assertAlmostEqual(m(100), 373.15, places=2) + + def test_load_package_with_affine_edge(self): + """load_package() reads offset field from TOML.""" + toml_content = ''' +name = "affine_test" +version = "1.0.0" + +[[units]] +name = "custom_temp" +dimension = "temperature" +aliases = ["ct"] + +[[edges]] +src = "custom_temp" +dst = "kelvin" +factor = 1.0 +offset = 100.0 +''' + with tempfile.NamedTemporaryFile( + mode='w', suffix='.toml', delete=False + ) as f: + f.write(toml_content) + f.flush() + path = Path(f.name) + + try: + pkg = load_package(path) + self.assertEqual(len(pkg.edges), 1) + self.assertEqual(pkg.edges[0].offset, 100.0) + finally: + path.unlink() + + def test_with_package_affine_conversion(self): + """End-to-end: load package with affine edge, convert through graph.""" + toml_content = ''' +name = "affine_e2e" +version = "1.0.0" + +[[units]] +name = "custom_temp" +dimension = "temperature" +aliases = ["ct"] + +[[edges]] +src = "custom_temp" +dst = "kelvin" +factor = 1.0 +offset = 100.0 +''' + with tempfile.NamedTemporaryFile( + mode='w', suffix='.toml', delete=False + ) as f: + f.write(toml_content) + f.flush() + path = Path(f.name) + + try: + pkg = load_package(path) + graph = get_default_graph().with_package(pkg) + + with using_graph(graph): + ct = get_unit_by_name('ct') + kelvin = get_unit_by_name('kelvin') + m = graph.convert(src=ct, dst=kelvin) + # 0 ct → 100.0 K (factor=1.0, offset=100.0) + self.assertAlmostEqual(m(0), 100.0, places=2) + # 50 ct → 150.0 K + self.assertAlmostEqual(m(50), 150.0, places=2) + finally: + path.unlink() + + class TestUnitPackage(unittest.TestCase): """Test UnitPackage dataclass.""" diff --git a/ucon/packages.py b/ucon/packages.py index 1e7e4aa..3401eea 100644 --- a/ucon/packages.py +++ b/ucon/packages.py @@ -120,11 +120,15 @@ class EdgeDef: dst : str Destination unit name or composite expression. factor : float - LinearMap multiplier (dst = factor * src). + Multiplier (dst = factor * src + offset). + offset : float + Additive offset for affine conversions (default 0.0). + When non-zero, an AffineMap is used instead of LinearMap. """ src: str dst: str factor: float + offset: float = 0.0 def materialize(self, graph: 'ConversionGraph'): """Resolve units and add edge to graph. @@ -142,7 +146,7 @@ def materialize(self, graph: 'ConversionGraph'): """ from ucon import get_unit_by_name from ucon.graph import using_graph - from ucon.maps import LinearMap + from ucon.maps import AffineMap, LinearMap from ucon.units import UnknownUnitError # Resolve units within graph context @@ -161,7 +165,10 @@ def materialize(self, graph: 'ConversionGraph'): f"Cannot resolve destination unit '{self.dst}' in edge" ) - graph.add_edge(src=src_unit, dst=dst_unit, map=LinearMap(self.factor)) + if self.offset != 0.0: + graph.add_edge(src=src_unit, dst=dst_unit, map=AffineMap(self.factor, self.offset)) + else: + graph.add_edge(src=src_unit, dst=dst_unit, map=LinearMap(self.factor)) @dataclass(frozen=True) @@ -252,6 +259,7 @@ def load_package(path: str | Path) -> UnitPackage: src=e["src"], dst=e["dst"], factor=float(e["factor"]), + offset=float(e.get("offset", 0.0)), ) for e in data.get("edges", []) )