diff --git a/README.md b/README.md index 3dec7312..5046f824 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/src/units.md b/docs/src/units.md index e85d4a8b..3ac1f9dd 100644 --- a/docs/src/units.md +++ b/docs/src/units.md @@ -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 diff --git a/src/DynamicQuantities.jl b/src/DynamicQuantities.jl index c7fca81c..530482ca 100644 --- a/src/DynamicQuantities.jl +++ b/src/DynamicQuantities.jl @@ -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 @@ -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") diff --git a/src/affine_dimensions.jl b/src/affine_dimensions.jl new file mode 100644 index 00000000..2138f33b --- /dev/null +++ b/src/affine_dimensions.jl @@ -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 diff --git a/test/unittests.jl b/test/unittests.jl index eac84641..7d371642 100644 --- a/test/unittests.jl +++ b/test/unittests.jl @@ -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 @@ -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)