Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -498,7 +505,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial commit

<!-- Links -->
[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
Expand Down
112 changes: 112 additions & 0 deletions tests/ucon/test_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
14 changes: 11 additions & 3 deletions ucon/packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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", [])
)
Expand Down
Loading