Skip to content

Simple affine units implementation #168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 86 commits into from
Mar 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
3f37480
Basic arithmatic between AffineUnits and other units
Deduction42 Jan 29, 2025
93d065c
Successful exponentiation
Deduction42 Jan 29, 2025
24f7966
Better constructors
Deduction42 Jan 29, 2025
be23440
added affine units parsing
Deduction42 Jan 29, 2025
ebf0fd5
Modified settings
Deduction42 Jan 30, 2025
b611801
Updated plan for unit display
RGonTheNoble Jan 30, 2025
8613bc1
@register_unit now populates AffineUnits
RGonTheNoble Jan 30, 2025
4d07529
Completed unit registration strategy
RGonTheNoble Jan 30, 2025
e811c1c
Better macro documentation
RGonTheNoble Jan 30, 2025
e2eb10f
Fixed ambiguity issues, all unit tests pass
Deduction42 Jan 31, 2025
bcf26df
More robust constructors
Deduction42 Jan 31, 2025
d3c0f6c
More informative dimension mismatch errors
Deduction42 Jan 31, 2025
270ce0e
Fixed some unit conversion bugs
Deduction42 Jan 31, 2025
1565b51
Overloaded approx and equals for AffineQuantities
Deduction42 Jan 31, 2025
0e8b6d8
First ound of unit tests
Deduction42 Feb 3, 2025
0eab0f0
Very strong affine_dimensions.jl coverage
Deduction42 Feb 4, 2025
70cff63
100% coverage of affine_dimensions.jl
Deduction42 Feb 4, 2025
c94ffc9
Renamed AffineUnitsParse to AffineUnits (for convention)
Deduction42 Feb 4, 2025
24bfebd
register_units now 100% covered
Deduction42 Feb 4, 2025
bb6a70f
Added documentation
Deduction42 Feb 4, 2025
39ac03a
Added docu for @register_affine_unit
Deduction42 Feb 4, 2025
782c1bf
Fixed indentation issue
Deduction42 Feb 4, 2025
e8197bd
Fixed documenter typo
Deduction42 Feb 4, 2025
fa1709b
Delete lcov.info
Deduction42 Feb 4, 2025
c1c4045
Added "psig" to affine unit tests, added warning tests
Deduction42 Feb 4, 2025
b9c1328
Fixed a counting issue in tests
Deduction42 Feb 4, 2025
be7df25
Registered degC and degF, tested them
Deduction42 Feb 5, 2025
ed6e0ab
AffineDimension equality ignores symbolic name
Deduction42 Feb 10, 2025
b3484d0
Update README.md
Deduction42 Feb 18, 2025
3934787
Update README.md
Deduction42 Feb 18, 2025
513d7f3
Changed order of inclusion for affine_dimensions.jl
Deduction42 Feb 18, 2025
9e261a1
Style changes and moved operators to map_dimensions
Deduction42 Feb 19, 2025
a26d653
Improvements to unit display
Deduction42 Feb 19, 2025
a970379
Added fallback map_dimensions with testing, style changes
Deduction42 Feb 19, 2025
e76f52e
Changed "begin" block to "let" for scoping
Deduction42 Feb 19, 2025
defde7b
Registered AffineUnits constants, modified "constructorof"
Deduction42 Mar 8, 2025
5183bc6
Removed @kwdef macro
Deduction42 Mar 8, 2025
5099ad9
Merge branch 'main' into main
Deduction42 Mar 8, 2025
4f5d757
Bump version
Deduction42 Mar 8, 2025
fe37122
eliminated siunits(), renamed scale(), offset(), basedim()
Deduction42 Mar 11, 2025
9d460f0
Corrected "constructorof" behaviour
Deduction42 Mar 11, 2025
ce96ea2
Merge branch 'main' into main
Deduction42 Mar 11, 2025
a4ae557
Docs simplification, uscale etc renaming, removed generic map_dimensi…
Deduction42 Mar 11, 2025
0e3ef3c
Modified headers
Deduction42 Mar 11, 2025
832df27
refactor: reduce complexity of affine dimensions
MilesCranmer Mar 11, 2025
ab21637
refactor: remove `affine_quantity`
MilesCranmer Mar 11, 2025
a305a35
refactor: more cleanup of affine dimensions
MilesCranmer Mar 11, 2025
6f88c9e
refactor: more cleanup of affine dimensions
MilesCranmer Mar 11, 2025
0b27482
refactor: more cleanup of affine dimensions
MilesCranmer Mar 11, 2025
a9c4f68
refactor: more cleanup of affine dimensions
MilesCranmer Mar 11, 2025
5281f53
refactor: tweak error message
MilesCranmer Mar 11, 2025
3769ce6
refactor: remove fancy printing for affine dimensions
MilesCranmer Mar 11, 2025
d8b7546
fix: error from refactor
MilesCranmer Mar 11, 2025
c553c95
refactor: further cleanup
MilesCranmer Mar 11, 2025
8874df0
fix: refactoring error
MilesCranmer Mar 11, 2025
9efdf5c
refactor: condense
MilesCranmer Mar 11, 2025
2fd1e32
fix: ambiguity
MilesCranmer Mar 11, 2025
0178d59
refactor: further cleanup
MilesCranmer Mar 11, 2025
762e411
docs: shorten mention of affine units
MilesCranmer Mar 11, 2025
5547fea
docs: reduce verbosity by pointing to `@register_unit`
MilesCranmer Mar 11, 2025
677d79f
refactor: small cleanup
MilesCranmer Mar 11, 2025
97804ce
Re-registering different units now throws an error, does not warn if …
Deduction42 Mar 13, 2025
31c8bfe
refactor: further cleanup
MilesCranmer Mar 13, 2025
76ce32c
Merge branch 'main' of https://github.com/deduction42/dynamicquantiti…
MilesCranmer Mar 13, 2025
212d2ed
Lumped "mod" behaviour with "+" in a metaprogramming loop
Deduction42 Mar 13, 2025
5f33445
Merge branch 'main' of https://github.com/Deduction42/DynamicQuantiti…
Deduction42 Mar 13, 2025
bbee66b
Removed minus special case, added mod.
Deduction42 Mar 13, 2025
59fc5bf
Removed minus special case, included mod
Deduction42 Mar 13, 2025
0a74898
Better units display for ua_str macro
Deduction42 Mar 13, 2025
1138d0f
Broke `ustrip` for safety, patched up the pices
Deduction42 Mar 14, 2025
da84df7
Merged previous refactoring
Deduction42 Mar 15, 2025
e36a4ca
Fixed some missing imports
Deduction42 Mar 15, 2025
c82000a
refactor!: greatly simplify affine units
MilesCranmer Mar 16, 2025
05d80bb
docs: clean up
MilesCranmer Mar 16, 2025
b843c94
fix: macro hygeine
MilesCranmer Mar 16, 2025
48f3066
Merge branch 'main' into simple-affine-2
MilesCranmer Mar 16, 2025
e0fc66c
make affine dimensions more user friendly
MilesCranmer Mar 16, 2025
70e7941
improve docstring
MilesCranmer Mar 16, 2025
f3201de
clean docstring
MilesCranmer Mar 16, 2025
c5469ce
clean up docs
MilesCranmer Mar 16, 2025
0422fa8
clean up changes
MilesCranmer Mar 16, 2025
cf109cf
improve coverage
MilesCranmer Mar 16, 2025
ccde9d9
missing import
MilesCranmer Mar 16, 2025
07060e5
fix test
MilesCranmer Mar 16, 2025
e1e1143
cleanup unittest
MilesCranmer Mar 16, 2025
9c0f1ab
better docstrings
MilesCranmer Mar 16, 2025
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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ This can greatly improve both runtime performance, by avoiding type instabilitie
- [Usage](#usage)
- [Constants](#constants)
- [Symbolic Units](#symbolic-units)
- [Custom Units](#custom-units)
- [Affine Units](#affine-units)
- [Arrays](#arrays)
- [Unitful](#unitful)
- [Types](#types)
- [Vectors](#vectors)

## Performance

Expand Down Expand Up @@ -284,6 +285,18 @@ julia> 3us"V" |> us"OneFiveV"
2.0 OneFiveV
```

#### Affine Units

You can also use "*affine*" units such as Celsius or Fahrenheit,
using the `ua"..."` string macro:

```julia
julia> room_temp = 22ua"degC"
295.15 K

julia> freezing = 32ua"degF"
273.15 K
```

### Arrays

Expand Down
29 changes: 29 additions & 0 deletions docs/src/units.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,32 @@ You can define custom units with the `@register_unit` macro:
```@docs
@register_unit
```

## Affine Units

DynamicQuantities also supports affine units like Celsius and Fahrenheit through the `AffineUnit{R}` type and the `ua` string macro.
For example,

```julia
# Define temperature in Celsius
room_temp = 22ua"degC" # 295.15 K

# Define temperature in Fahrenheit
freezing = 32ua"degF" # 273.15 K

# Can take differences normally, as these are now regular Quantities:
room_temp - freezing
# 22 K
```

Note there are some subtleties about working with these:

```@docs
@ua_str
aff_uparse
```

Currently, the only supported affine units are:

- `°C` or `degC` - Degrees Celsius
- `°F` or `degF` - Degrees Fahrenheit
3 changes: 2 additions & 1 deletion src/DynamicQuantities.jl
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export QuantityArray
export DimensionError
export ustrip, dimension, uexpand, uconvert, ustripexpand
export ulength, umass, utime, ucurrent, utemperature, uluminosity, uamount
export uparse, @u_str, sym_uparse, @us_str, @register_unit
export uparse, @u_str, sym_uparse, @us_str, @register_unit, aff_uparse, @ua_str

# Deprecated:
export expand_units
Expand All @@ -29,6 +29,7 @@ using DispatchDoctor: @stable
include("constants.jl")
include("uparse.jl")
include("symbolic_dimensions.jl")
include("affine_dimensions.jl")
include("complex.jl")
include("register_units.jl")
include("disambiguities.jl")
Expand Down
116 changes: 116 additions & 0 deletions src/affine_dimensions.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""
AffineUnit{R}

A simple struct for representing affine units like Celsius and Fahrenheit.
This is not part of the AbstractDimensions hierarchy.

AffineUnit only supports scalar multiplication in the form `number * unit` (e.g., `22ua"degC"`),
which immediately converts it to a regular `Quantity{Float64,Dimensions{R}}`. Other operations
like `unit * number`, division, addition, or subtraction with AffineUnit are not supported.

!!! warning "Non-associative multiplication"
Multiplication with AffineUnit is non-associative due to the auto-conversion property.
For example, `(2 * 3) * ua"degC"` ≠ `2 * (3 * ua"degC")` because when a number multiplies an AffineUnit,
it immediately converts to a regular Quantity with the affine transformation applied.

!!! warning
This is an experimental feature and may change in the future.
"""
struct AffineUnit{R}
scale::Float64
offset::Float64
basedim::Dimensions{R}
name::Symbol
end

Base.show(io::IO, unit::AffineUnit) = print(io, unit.name)

# This immediately converts to regular Dimensions
function Base.:*(value::Number, unit::AffineUnit)
# Apply the affine transformation: value * scale + offset
new_value = value * unit.scale + unit.offset
# Always use Float64 for temperature conversions to avoid precision issues
return Quantity(new_value, unit.basedim)
end

# Error messages for unsupported operations - defined using a loop
for op in [:*, :/, :+, :-], (first, second) in [(:AffineUnit, :Number), (:Number, :AffineUnit)]

# Skip the already defined value * unit case
op == :* && first == :Number && second == :AffineUnit && continue

@eval function Base.$op(a::$first, b::$second)
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."))
end
end

# Module for affine unit parsing
module AffineUnits
import ..AffineUnit
import ..Dimensions
import ..DEFAULT_DIM_BASE_TYPE
import ..Quantity

# Define Celsius and Fahrenheit units inside the module
const °C = AffineUnit(1.0, 273.15, Dimensions{DEFAULT_DIM_BASE_TYPE}(temperature=1), :°C)
const degC = °C
const °F = AffineUnit(5/9, 459.67 * 5/9, Dimensions{DEFAULT_DIM_BASE_TYPE}(temperature=1), :°F)
const degF = °F

const AFFINE_UNIT_SYMBOLS = [:°C, :degC, :°F, :degF]

function map_to_scope(ex::Expr)
if ex.head != :call
throw(ArgumentError("Unexpected expression: $ex. Only `:call` is expected."))
end
ex.args[2:end] = map(map_to_scope, ex.args[2:end])
return ex
end

function map_to_scope(sym::Symbol)
if !(sym in AFFINE_UNIT_SYMBOLS)
throw(ArgumentError("Symbol $sym not found in affine units. Only °C/degC and °F/degF are supported."))
end
if sym in (:°C, :degC)
return °C
else # if sym in (:°F, :degF)
return °F
end
end

# For literals and other expressions
map_to_scope(ex) = ex
end

"""
ua"unit"

Parse a string containing an affine unit expression.
Currently only supports °C (or degC) and °F (or degF).

For example:

```julia
room_temp = 22ua"degC" # The multiplication returns a Quantity
```

!!! warning
This is an experimental feature and may change in the future.
"""
macro ua_str(s)
ex = AffineUnits.map_to_scope(Meta.parse(s))
return esc(ex)
end

"""
aff_uparse(s::AbstractString)

Parse a string into an affine unit (°C/degC, °F/degF). Function equivalent of `ua"unit"`.

!!! warning
This is an experimental feature and may change in the future.
"""
function aff_uparse(s::AbstractString)
ex = AffineUnits.map_to_scope(Meta.parse(s))
return eval(ex)::AffineUnit{DEFAULT_DIM_BASE_TYPE}
end
56 changes: 56 additions & 0 deletions test/unittests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ using DynamicQuantities: GenericQuantity, with_type_parameters, constructorof
using DynamicQuantities: promote_quantity_on_quantity, promote_quantity_on_value
using DynamicQuantities: UNIT_VALUES, UNIT_MAPPING, UNIT_SYMBOLS, ALL_MAPPING, ALL_SYMBOLS, ALL_VALUES
using DynamicQuantities.SymbolicUnits: SYMBOLIC_UNIT_VALUES
using DynamicQuantities: AffineUnit, AffineUnits
using DynamicQuantities: map_dimensions
using DynamicQuantities: _register_unit
using Ratios: SimpleRatio
Expand Down Expand Up @@ -2000,6 +2001,61 @@ end
@test QuantityArray([km, km]) |> uconvert(us"m") != [km, km]
end


@testset "Tests of AffineDimensions" begin
# Test basic unit creation
°C = ua"°C"
°F = ua"°F"

# Test unit identity
@test °C isa AffineUnit

# Test basic properties
@test °C.basedim.temperature == 1
@test °C.basedim.length == 0

# Test unit equivalence
@test ua"°C" == ua"degC"
@test ua"°F" == ua"degF"

# Test conversion to regular dimensions via multiplication
@test 0 * °C ≈ 273.15u"K"
@test 100 * °C ≈ 373.15u"K"
@test 32 * °F ≈ 273.15u"K"

# Test temperature equivalence
@test 0ua"degC" ≈ 32ua"degF"
@test -40ua"degC" ≈ -40ua"degF"

# Can do multiplication inside
@test ua"22degC" isa Quantity
@test ua"22degC" == 22ua"degC"

# Test unsupported operations - verify the error message
@test_throws "Affine units only support scalar multiplication in the form 'number * unit'" °C * 2

# Test AffineUnits module functionality
@test AffineUnits.°C === °C
@test AffineUnits.degC === °C
@test AffineUnits.°F === °F
@test AffineUnits.degF === °F

# Test parsing of non-:call expression
@test_throws "Unexpected expression" AffineUnits.map_to_scope(:(let x=1; x; end))

# Test aff_uparse function
@test aff_uparse("°C") === ua"°C"
@test aff_uparse("degC") === ua"degC"
@test aff_uparse("°F") === ua"°F"
@test aff_uparse("degF") === ua"degF"
@test_throws ArgumentError aff_uparse("K")

# Test show function for AffineUnit
@test sprint(show, °C) == "°C"

@test sprint(show, °F) == "°F"
end

@testset "Test div" begin
for Q in (RealQuantity, Quantity, GenericQuantity)
x = Q{Int}(10, length=1)
Expand Down
Loading