Skip to content

Commit 742caab

Browse files
feat: experimental affine units implementation (#168)
--------- Co-authored-by: Ruben Gonzalez <[email protected]>
1 parent 56a5c67 commit 742caab

File tree

5 files changed

+217
-2
lines changed

5 files changed

+217
-2
lines changed

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ This can greatly improve both runtime performance, by avoiding type instabilitie
1818
- [Usage](#usage)
1919
- [Constants](#constants)
2020
- [Symbolic Units](#symbolic-units)
21+
- [Custom Units](#custom-units)
22+
- [Affine Units](#affine-units)
2123
- [Arrays](#arrays)
2224
- [Unitful](#unitful)
2325
- [Types](#types)
24-
- [Vectors](#vectors)
2526

2627
## Performance
2728

@@ -284,6 +285,18 @@ julia> 3us"V" |> us"OneFiveV"
284285
2.0 OneFiveV
285286
```
286287

288+
#### Affine Units
289+
290+
You can also use "*affine*" units such as Celsius or Fahrenheit,
291+
using the `ua"..."` string macro:
292+
293+
```julia
294+
julia> room_temp = 22ua"degC"
295+
295.15 K
296+
297+
julia> freezing = 32ua"degF"
298+
273.15 K
299+
```
287300

288301
### Arrays
289302

docs/src/units.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,32 @@ You can define custom units with the `@register_unit` macro:
4141
```@docs
4242
@register_unit
4343
```
44+
45+
## Affine Units
46+
47+
DynamicQuantities also supports affine units like Celsius and Fahrenheit through the `AffineUnit{R}` type and the `ua` string macro.
48+
For example,
49+
50+
```julia
51+
# Define temperature in Celsius
52+
room_temp = 22ua"degC" # 295.15 K
53+
54+
# Define temperature in Fahrenheit
55+
freezing = 32ua"degF" # 273.15 K
56+
57+
# Can take differences normally, as these are now regular Quantities:
58+
room_temp - freezing
59+
# 22 K
60+
```
61+
62+
Note there are some subtleties about working with these:
63+
64+
```@docs
65+
@ua_str
66+
aff_uparse
67+
```
68+
69+
Currently, the only supported affine units are:
70+
71+
- `°C` or `degC` - Degrees Celsius
72+
- `°F` or `degF` - Degrees Fahrenheit

src/DynamicQuantities.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export QuantityArray
1010
export DimensionError
1111
export ustrip, dimension, uexpand, uconvert, ustripexpand
1212
export ulength, umass, utime, ucurrent, utemperature, uluminosity, uamount
13-
export uparse, @u_str, sym_uparse, @us_str, @register_unit
13+
export uparse, @u_str, sym_uparse, @us_str, @register_unit, aff_uparse, @ua_str
1414

1515
# Deprecated:
1616
export expand_units
@@ -29,6 +29,7 @@ using DispatchDoctor: @stable
2929
include("constants.jl")
3030
include("uparse.jl")
3131
include("symbolic_dimensions.jl")
32+
include("affine_dimensions.jl")
3233
include("complex.jl")
3334
include("register_units.jl")
3435
include("disambiguities.jl")

src/affine_dimensions.jl

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""
2+
AffineUnit{R}
3+
4+
A simple struct for representing affine units like Celsius and Fahrenheit.
5+
This is not part of the AbstractDimensions hierarchy.
6+
7+
AffineUnit only supports scalar multiplication in the form `number * unit` (e.g., `22ua"degC"`),
8+
which immediately converts it to a regular `Quantity{Float64,Dimensions{R}}`. Other operations
9+
like `unit * number`, division, addition, or subtraction with AffineUnit are not supported.
10+
11+
!!! warning "Non-associative multiplication"
12+
Multiplication with AffineUnit is non-associative due to the auto-conversion property.
13+
For example, `(2 * 3) * ua"degC"` ≠ `2 * (3 * ua"degC")` because when a number multiplies an AffineUnit,
14+
it immediately converts to a regular Quantity with the affine transformation applied.
15+
16+
!!! warning
17+
This is an experimental feature and may change in the future.
18+
"""
19+
struct AffineUnit{R}
20+
scale::Float64
21+
offset::Float64
22+
basedim::Dimensions{R}
23+
name::Symbol
24+
end
25+
26+
Base.show(io::IO, unit::AffineUnit) = print(io, unit.name)
27+
28+
# This immediately converts to regular Dimensions
29+
function Base.:*(value::Number, unit::AffineUnit)
30+
# Apply the affine transformation: value * scale + offset
31+
new_value = value * unit.scale + unit.offset
32+
# Always use Float64 for temperature conversions to avoid precision issues
33+
return Quantity(new_value, unit.basedim)
34+
end
35+
36+
# Error messages for unsupported operations - defined using a loop
37+
for op in [:*, :/, :+, :-], (first, second) in [(:AffineUnit, :Number), (:Number, :AffineUnit)]
38+
39+
# Skip the already defined value * unit case
40+
op == :* && first == :Number && second == :AffineUnit && continue
41+
42+
@eval function Base.$op(a::$first, b::$second)
43+
throw(ArgumentError("Affine units only support scalar multiplication in the form 'number * unit', e.g., 22 * ua\"degC\", which will immediately convert it to a regular `Quantity{Float64,Dimensions{R}}`. Other operations are not supported."))
44+
end
45+
end
46+
47+
# Module for affine unit parsing
48+
module AffineUnits
49+
import ..AffineUnit
50+
import ..Dimensions
51+
import ..DEFAULT_DIM_BASE_TYPE
52+
import ..Quantity
53+
54+
# Define Celsius and Fahrenheit units inside the module
55+
const °C = AffineUnit(1.0, 273.15, Dimensions{DEFAULT_DIM_BASE_TYPE}(temperature=1), :°C)
56+
const degC = °C
57+
const °F = AffineUnit(5/9, 459.67 * 5/9, Dimensions{DEFAULT_DIM_BASE_TYPE}(temperature=1), :°F)
58+
const degF = °F
59+
60+
const AFFINE_UNIT_SYMBOLS = [:°C, :degC, :°F, :degF]
61+
62+
function map_to_scope(ex::Expr)
63+
if ex.head != :call
64+
throw(ArgumentError("Unexpected expression: $ex. Only `:call` is expected."))
65+
end
66+
ex.args[2:end] = map(map_to_scope, ex.args[2:end])
67+
return ex
68+
end
69+
70+
function map_to_scope(sym::Symbol)
71+
if !(sym in AFFINE_UNIT_SYMBOLS)
72+
throw(ArgumentError("Symbol $sym not found in affine units. Only °C/degC and °F/degF are supported."))
73+
end
74+
if sym in (:°C, :degC)
75+
return °C
76+
else # if sym in (:°F, :degF)
77+
return °F
78+
end
79+
end
80+
81+
# For literals and other expressions
82+
map_to_scope(ex) = ex
83+
end
84+
85+
"""
86+
ua"unit"
87+
88+
Parse a string containing an affine unit expression.
89+
Currently only supports °C (or degC) and °F (or degF).
90+
91+
For example:
92+
93+
```julia
94+
room_temp = 22ua"degC" # The multiplication returns a Quantity
95+
```
96+
97+
!!! warning
98+
This is an experimental feature and may change in the future.
99+
"""
100+
macro ua_str(s)
101+
ex = AffineUnits.map_to_scope(Meta.parse(s))
102+
return esc(ex)
103+
end
104+
105+
"""
106+
aff_uparse(s::AbstractString)
107+
108+
Parse a string into an affine unit (°C/degC, °F/degF). Function equivalent of `ua"unit"`.
109+
110+
!!! warning
111+
This is an experimental feature and may change in the future.
112+
"""
113+
function aff_uparse(s::AbstractString)
114+
ex = AffineUnits.map_to_scope(Meta.parse(s))
115+
return eval(ex)::AffineUnit{DEFAULT_DIM_BASE_TYPE}
116+
end

test/unittests.jl

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ using DynamicQuantities: GenericQuantity, with_type_parameters, constructorof
66
using DynamicQuantities: promote_quantity_on_quantity, promote_quantity_on_value
77
using DynamicQuantities: UNIT_VALUES, UNIT_MAPPING, UNIT_SYMBOLS, ALL_MAPPING, ALL_SYMBOLS, ALL_VALUES
88
using DynamicQuantities.SymbolicUnits: SYMBOLIC_UNIT_VALUES
9+
using DynamicQuantities: AffineUnit, AffineUnits
910
using DynamicQuantities: map_dimensions
1011
using DynamicQuantities: _register_unit
1112
using Ratios: SimpleRatio
@@ -2000,6 +2001,61 @@ end
20002001
@test QuantityArray([km, km]) |> uconvert(us"m") != [km, km]
20012002
end
20022003

2004+
2005+
@testset "Tests of AffineDimensions" begin
2006+
# Test basic unit creation
2007+
°C = ua"°C"
2008+
°F = ua"°F"
2009+
2010+
# Test unit identity
2011+
@test °C isa AffineUnit
2012+
2013+
# Test basic properties
2014+
@test °C.basedim.temperature == 1
2015+
@test °C.basedim.length == 0
2016+
2017+
# Test unit equivalence
2018+
@test ua"°C" == ua"degC"
2019+
@test ua"°F" == ua"degF"
2020+
2021+
# Test conversion to regular dimensions via multiplication
2022+
@test 0 * °C 273.15u"K"
2023+
@test 100 * °C 373.15u"K"
2024+
@test 32 * °F 273.15u"K"
2025+
2026+
# Test temperature equivalence
2027+
@test 0ua"degC" 32ua"degF"
2028+
@test -40ua"degC" -40ua"degF"
2029+
2030+
# Can do multiplication inside
2031+
@test ua"22degC" isa Quantity
2032+
@test ua"22degC" == 22ua"degC"
2033+
2034+
# Test unsupported operations - verify the error message
2035+
@test_throws "Affine units only support scalar multiplication in the form 'number * unit'" °C * 2
2036+
2037+
# Test AffineUnits module functionality
2038+
@test AffineUnits.°C === °C
2039+
@test AffineUnits.degC === °C
2040+
@test AffineUnits.°F === °F
2041+
@test AffineUnits.degF === °F
2042+
2043+
# Test parsing of non-:call expression
2044+
@test_throws "Unexpected expression" AffineUnits.map_to_scope(:(let x=1; x; end))
2045+
2046+
# Test aff_uparse function
2047+
@test aff_uparse("°C") === ua"°C"
2048+
@test aff_uparse("degC") === ua"degC"
2049+
@test aff_uparse("°F") === ua"°F"
2050+
@test aff_uparse("degF") === ua"degF"
2051+
@test_throws ArgumentError aff_uparse("K")
2052+
2053+
# Test show function for AffineUnit
2054+
@test sprint(show, °C) == "°C"
2055+
2056+
@test sprint(show, °F) == "°F"
2057+
end
2058+
20032059
@testset "Test div" begin
20042060
for Q in (RealQuantity, Quantity, GenericQuantity)
20052061
x = Q{Int}(10, length=1)

0 commit comments

Comments
 (0)