diff --git a/README.md b/README.md index 3dec7312..f0a1fe0f 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,52 @@ julia> 3us"V" |> us"OneFiveV" 2.0 OneFiveV ``` +### Affine units +Units that have an offset (such as °C = K + 273.15) are an unfortunate fact of life. `AffineDimensions` seeks to extend DynamicQuantities.jl to reduce dependence on Unitful.jl, and enable handling/converting such units in a flexible, type-stable manner. You can access these units through the `ua"..."` string macro: +``` +t = ua"degC" +t = ua"°C" +t = ua"°F" +``` +Because `AffineDimensions` are more general than `SymbolicDimensions`, units available `SymbolicDimensions` are also available in `AffineDimensions`, allowing you to have something that can handle affine and non-affine quantities in a type-stable manner +``` +p = ua"kPa" +``` + +#### Custom affine units +To access units from the affine unit registry, the string macro `ua"..."` can be used. This macro will always return quantities with AffineDimensions, even if a non-affine unit is called (it will simply have an offset of 0). Because AffineDimensions are a generalization of SymbolicDimensions, the affine unit registry will mirror the symbolic unit registry. +``` +@register_unit psi 6.89476us"kPa" +ua"psi" +>> 1.0 psi +``` +However, strictly affine units cannot belong to the symbolic registry, so a different macro must be used on an AffineDimension (or quantity thereof) +``` +@register_affine_unit psig AffineDimensions(offset=u"Constants.atm", basedim=u"psi") #Gauge pressure implies atmospheric offset +ua"psig" +>> 1.0 psig +us"psig" +>> ERROR: LoadError: ArgumentError: Symbol psig not found in `Units` or `Constants`. +``` +Affine unit parsing can also be done outside of a macro using `aff_uparse(str::AbstractString)` +``` +aff_uparse("°C") +>> 1.0 °C +``` +#### Operations on affine quantities +In Unitful.jl, multiplication of affine quantities is not supported for affine dimensions: +``` +using Unitful +u"R"*0u"°C" +>> ERROR: AffineError: an invalid operation was attempted with affine units: °C +``` +This behaviour is mimicked in DynamicQuantities: +``` +using DynamicQuantities +u"Constants.R"*(0ua"°C") +>> AssertionError: AffineDimensions °C has a non-zero offset, implicit conversion is not allowed due to ambiguity. Use uexpand(x) to explicitly convert +``` +In general, it's best to treat quantities with AffineDimensions as placeholders and use `uexpand(q)` or `uconvert(units, q)` as soon as possible. The main objective of AffineDimesnions is to provide you with convenient, type-stable tools to do this conversion before applying mathematical operations. ### Arrays diff --git a/docs/make.jl b/docs/make.jl index 96120d1f..14035dd5 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -46,6 +46,7 @@ makedocs(; "Units" => "units.md", "Constants" => "constants.md", "Symbolic Units" => "symbolic_units.md", + "Affine Units" => "affine_units.md", "Types" => "types.md", ], warnonly = [:missing_docs] diff --git a/docs/src/affine_units.md b/docs/src/affine_units.md new file mode 100644 index 00000000..4813f23f --- /dev/null +++ b/docs/src/affine_units.md @@ -0,0 +1,53 @@ +# Affine Dimensions +Units that have an offset (such as °C = K + 273.15) are an unfortunate fact of life: they are used extensively but often result in ambiguous mathematical operations (many other packages, such as Unitful.jl only support limited operations for affine dimensions). `AffineDimensions` seeks to extend DynamicQuantities.jl to reduce dependence on Unitful.jl, and enable handling/converting such units in a flexible, type-stable manner. + +`AffineDimensions` are a generalization of `Dimensions` and `SymbolicDimensions`. While SymbolicDimensions essentially add a scale to Dimensions, AffineDimensions will add both a scale and an offset. Verious constructors can be used to construct `AffineDimensions` from other dimensions. +``` +kelvin = AffineDimensions(basedim=u"K") #Assumes a scale of 1 and offset 0 +rankine = AffineDimensions(scale=5/9, offset=0.0, basedim=dimension(u"K")) #Rankine is a scaled version of Kelvin, offset is assumed to be of units 'basedim' +fahrenheit = AffineDimensions(scale=1.0, offset=Quantity(459.67, rankine), basedim=rankine) #Its best to make offset a `Quantity` to be explicit +celsius = AffineDimensions(scale=9/5, offset=Quantity(32.0, rankine), basedim=fahrenheit) #When AffineDimensiosn are used, offset starts with basedim's offset +``` +## Registration and parsing +To access units from the affine unit registry, the string macro `ua"..."` can be used. This macro will always return quantities with AffineDimensions, even if a non-affine unit is called (it will simply have an offset of 0). Because AffineDimensions are a generalization of SymbolicDimensions, the affine unit registry will mirror the symbolic unit registry. +``` +@register_unit psi 6.89476us"kPa" +u"psi" +>> 6894.76 m⁻¹ kg s⁻² +us"psi" +>> 1.0 psi +ua"psi" +>> 1.0 psi +``` +However, strictly affine units cannot belong to the symbolic registry, so a different macro must be used on an AffineDimension (or quantity thereof) +``` +@register_affine_unit psig AffineDimensions(offset=u"Constants.atm", basedim=u"psi") #Gauge pressure implies atmospheric offset +ua"psig" +>> 1.0 psig +us"psig" +>> ERROR: LoadError: ArgumentError: Symbol psig not found in `Units` or `Constants`. +``` +Affine unit parsing can also be done outside of a macro using `aff_uparse(str::AbstractString)` +``` +aff_uparse("°C") +>> 1.0 °C +``` +```@docs +@ua_str +@register_affine_unit +aff_uparse +``` +## Operations +In Unitful.jl, multiplication of affine quantities is not supported for affine dimensions: +``` +using Unitful +u"R"*0u"°C" +>> ERROR: AffineError: an invalid operation was attempted with affine units: °C +``` +This behaviour is mimicked in DynamicQuantities: +``` +using DynamicQuantities +u"Constants.R"*(0ua"°C") +>> AssertionError: AffineDimensions °C has a non-zero offset, implicit conversion is not allowed due to ambiguity. Use uexpand(x) to explicitly convert +``` +In general, it's best to treat quantities with AffineDimensions as placeholders and use `uexpand(q)` or `uconvert(units, q)` as soon as possible. The main objective of AffineDimesnions is to provide you with convenient, type-stable tools to do this conversion before applying mathematical operations. \ No newline at end of file diff --git a/src/DynamicQuantities.jl b/src/DynamicQuantities.jl index 4e2911f2..6614525d 100644 --- a/src/DynamicQuantities.jl +++ b/src/DynamicQuantities.jl @@ -6,11 +6,12 @@ export Quantity, GenericQuantity, RealQuantity export FixedRational export AbstractDimensions, Dimensions, NoDims export AbstractSymbolicDimensions, SymbolicDimensions, SymbolicDimensionsSingleton +export AbstractAffineDimensions, AffineDimensions export QuantityArray export DimensionError export ustrip, dimension export ulength, umass, utime, ucurrent, utemperature, uluminosity, uamount -export uparse, @u_str, sym_uparse, @us_str, uexpand, uconvert, @register_unit +export uparse, @u_str, sym_uparse, @us_str, aff_uparse, @ua_str, uexpand, uconvert, @register_unit, @register_affine_unit # Deprecated: export expand_units @@ -29,9 +30,11 @@ 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") + include("deprecated.jl") end diff --git a/src/affine_dimensions.jl b/src/affine_dimensions.jl new file mode 100644 index 00000000..590f2595 --- /dev/null +++ b/src/affine_dimensions.jl @@ -0,0 +1,442 @@ + +const AbstractQuantityOrArray{T,D} = Union{UnionAbstractQuantity{T,D}, QuantityArray{T,<:Any,D}} + +abstract type AbstractAffineDimensions{R} <: AbstractDimensions{R} end + +const AffineOrSymbolicDimensions{R} = Union{AbstractAffineDimensions{R}, AbstractSymbolicDimensions{R}} + +""" + AffineDimensions{R}(scale::Float64, offset:Float64, basedim::Dimensions{R}, symbol::Symbol=nothing) + +AffineDimensions adds a scale and offset to Dimensions{R} allowing the expression of affine transformations of units (for example °C) +The offset parameter is in SI units (i.e. having the dimension of basedim) +""" +@kwdef struct AffineDimensions{R} <: AbstractAffineDimensions{R} + scale::Float64 = 1.0 + offset::Float64 = 0.0 + basedim::Dimensions{R} + symbol::Symbol = :nothing +end + +# Inferring the type parameter R +AffineDimensions(s, o, dims::AbstractDimensions{R}, sym::Symbol=:nothing) where {R} = AffineDimensions{R}(s, o, dims, sym) +AffineDimensions(s, o, q::UnionAbstractQuantity{<:Any,<:AbstractDimensions{R}}, sym::Symbol=:nothing) where {R} = AffineDimensions{R}(s, o, q, sym) +AffineDimensions(d::Dimensions{R}) where R = AffineDimensions{R}(scale=1.0, offset=0.0, basedim=d, symbol=:nothing) + +# Affine dimensions from other affine dimensions +function AffineDimensions{R}(s::Real, o::Real, dims::AbstractAffineDimensions, sym::Symbol=:nothing) where {R} + new_s = s*scale(dims) + new_o = offset(dims) + o*scale(dims) #Scale of o is assumed to be scale of base dimensions + return AffineDimensions{R}(new_s, new_o, basedim(dims), sym) +end + + +function AffineDimensions{R}(s::Real, o::UnionAbstractQuantity, dims::AbstractAffineDimensions, sym::Symbol=:nothing) where {R} + new_s = s*scale(dims) + new_o = offset(dims) + ustrip(siunits(o)) #Offset is always in SI units + return AffineDimensions{R}(new_s, new_o, basedim(dims), sym) +end + +function AffineDimensions{R}(s::Real, o::UnionAbstractQuantity, dims::Dimensions, sym::Symbol=:nothing) where {R} + return AffineDimensions{R}(s, ustrip(siunits(o)), dims, sym) +end + +# Affine dimensions from quantities +function AffineDimensions{R}(s::Real, o::UnionAbstractQuantity, q::UnionAbstractQuantity, sym::Symbol=:nothing) where {R} + q0 = siunits(0*q) #Origin point in SI units + oΔ = siunits(o) - siunits(0*o) #Offset is a difference in affine units + dimension(q0) == dimension(oΔ) || throw(DimensionError(o, q)) #Check the units and give an informative error + + #Obtain SI units of the scale and offset + o_si = oΔ + q0 #Total offset is origin plus the offset + q_si = siunits(q) - q0 #The scaling quantity must remove the origin + + #Call the SI quantity constructor + return AffineDimensions{R}(s, o_si, q_si, sym) +end + +# Base case when everyting is convrted to si units (offset is assumed to be in SI units) +function AffineDimensions{R}(s::Real, o::UnionAbstractQuantity{<:Any,<:Dimensions}, q::UnionAbstractQuantity{<:Any,<:Dimensions}, sym::Symbol=:nothing) where {R} + dimension(o) == dimension(q) || throw(DimensionError(o, q)) + o_val = ustrip(o) + q_val = ustrip(q) + return AffineDimensions{R}(s*q_val, o_val, dimension(q), sym) +end + +# If a quantity is used only for the dimension, the offset is assumed to be in the same scale as the quantity +function AffineDimensions{R}(s::Real, o::Real, q::Q, sym::Symbol=:nothing) where {R, Q<:UnionAbstractQuantity} + return AffineDimensions{R}(s, o*q, q, sym) +end + + +scale(d::AffineDimensions) = d.scale +offset(d::AffineDimensions) = d.offset +basedim(d::AffineDimensions) = d.basedim + +with_type_parameters(::Type{<:AffineDimensions}, ::Type{R}) where {R} = AffineDimensions{R} +constructorof(::Type{AffineDimensions}) = AffineDimensions{DEFAULT_DIM_BASE_TYPE} +constructorof(::Type{AffineDimensions{R}}) where {R} = AffineDimensions{R} + +function Base.show(io::IO, d::AbstractAffineDimensions) + addsign = ifelse(offset(d)<0, "-" , "+") + + if d.symbol != :nothing + print(io, d.symbol) + elseif isone(scale(d)) & iszero(offset(d)) + print(io, basedim(d)) + elseif iszero(offset(d)) + print(io, "(", scale(d), " ", basedim(d),")") + elseif isone(scale(d)) + print(io, "(", addsign, abs(offset(d)), basedim(d), ")") + else + print(io, "(", scale(d), addsign, abs(offset(d)), " ", basedim(d),")") + end +end + +assert_no_offset(d::AffineDimensions) = iszero(offset(d)) || throw(AssertionError("AffineDimensions $(d) has a non-zero offset, implicit conversion is not allowed due to ambiguity. Use uexpand(x) to explicitly convert")) +siunits(q::UnionAbstractQuantity{<:Any,<:Dimensions}) = q +siunits(q::UnionAbstractQuantity{<:Any,<:AbstractSymbolicDimensions}) = uexpand(q) +function siunits(q::Q) where {T,R,D<:AbstractAffineDimensions{R},Q<:UnionAbstractQuantity{T,D}} + return force_convert(with_type_parameters(Q, T, Dimensions{R}), q) +end +siunits(q::QuantityArray) = siunits.(q) + + +""" + uexpand(q::Q) where {T,R,D<:AbstractAffineDimensions{R},Q<:UnionAbstractQuantity{T,D}} + +Expand the affine units in a quantity to their base SI form. In other words, this converts a quantity with AbstractAffineDimensions +to one with Dimensions. The opposite of this function is uconvert, for converting to specific symbolic units, or, e.g., +convert(Quantity{<:Any,<:AbstractSymbolicDimensions}, q), for assuming SI units as the output symbols. +""" +uexpand(q::UnionAbstractQuantity{<:Any,<:AbstractAffineDimensions}) = siunits(q) + + +""" + affine_quantity(q::UnionAbstractQuantity) + +Converts a quantity to its nearest affine quantity representation (with scale=1.0 and offset=0.0) +""" +function affine_quantity(q::Q) where {T,R,D<:AbstractDimensions{R},Q<:UnionAbstractQuantity{T,D}} + q_si = siunits(q) + dims = AffineDimensions{R}(scale=1.0, offset=0.0, basedim=dimension(q_si)) + q_val = convert(T, ustrip(q_si)) + return constructorof(Q)(q_val, dims) +end + +""" + affine_unit(q::UnionAbstractQuantity, symbol::Symbol=:nothing) + +Converts a quantity to its nearest affine unit (with scale=ustrip(q) and offset=0.0) +""" +function affine_unit(q::Q, symbol::Symbol=:nothing) where {T,R,D<:AbstractDimensions{R},Q<:UnionAbstractQuantity{T,D}} + q_si = siunits(q) + dims = AffineDimensions{R}(scale=ustrip(q_si), offset=0.0, basedim=dimension(q_si), symbol=symbol) + return constructorof(Q)(one(T), dims) +end + +for (type, _, _) in ABSTRACT_QUANTITY_TYPES + @eval begin + function Base.convert(::Type{Q}, q::UnionAbstractQuantity{<:Any,<:Dimensions}) where {T,Q<:$type{T,AffineDimensions}} + return convert(with_type_parameters(Q, T, AffineDimensions{DEFAULT_DIM_BASE_TYPE}), q) + end + + function Base.convert(::Type{Q}, q::UnionAbstractQuantity{<:Any,<:Dimensions}) where {T,R,Q<:$type{T,AffineDimensions{R}}} + dims = AffineDimensions{R}(scale=1, offset=0, basedim=dimension(q)) + return constructorof(Q)(convert(T, ustrip(q)), dims) + end + + # Forced (explicit) conversions will not error if offset is non-zero + function force_convert(::Type{Q}, q::UnionAbstractQuantity{<:Any,<:AbstractAffineDimensions}) where {T,D<:Dimensions,Q<:$type{T,D}} + d = dimension(q) + v = ustrip(q)*scale(d) + offset(d) + return constructorof(Q)(convert(T, v), basedim(d)) + end + + # Implicit conversions will fail if the offset it non-zero (to prevent silently picking ambiguous operations) + function Base.convert(::Type{Q}, q::UnionAbstractQuantity{<:Any,<:AbstractAffineDimensions}) where {T,D<:Dimensions,Q<:$type{T,D}} + assert_no_offset(dimension(q)) + return force_convert(Q, q) + end + end +end + +function Base.promote_rule(::Type{AffineDimensions{R1}}, ::Type{AffineDimensions{R2}}) where {R1,R2} + return AffineDimensions{promote_type(R1,R2)} +end +function Base.promote_rule(::Type{AffineDimensions{R1}}, ::Type{Dimensions{R2}}) where {R1,R2} + return Dimensions{promote_type(R1,R2)} +end +function Base.promote_rule(::Type{Dimensions{R1}}, ::Type{AffineDimensions{R2}}) where {R1,R2} + return Dimensions{promote_type(R1,R2)} +end +function Base.promote_rule(::Type{SymbolicDimensions{R1}}, ::Type{AffineDimensions{R2}}) where {R1,R2} + return Dimensions{promote_type(R1,R2)} +end +function Base.promote_rule(::Type{AffineDimensions{R1}}, ::Type{SymbolicDimensions{R2}}) where {R1,R2} + return Dimensions{promote_type(R1,R2)} +end + + +# Conversions for Dimensions |> AffineDimenions +""" + uconvert(qout::UnionAbstractQuantity{<:Any, <:AbstractAffineDimensions}, q::UnionAbstractQuantity{<:Any, <:Dimensions}) + +Convert a quantity `q` with base SI units to the affine units of `qout`, for `q` and `qout` with compatible units. +You can also use `|>` as a shorthand for `uconvert` +""" +function uconvert(qout::UnionAbstractQuantity{<:Any,<:AffineDimensions}, q::UnionAbstractQuantity{<:Any,<:Dimensions}) + @assert isone(ustrip(qout)) "You passed a quantity with a non-unit value to uconvert." + dout = dimension(qout) + dimension(q) == basedim(dout) || throw(DimensionError(q, qout)) + vout = (ustrip(q) - offset(dout))/scale(dout) + return new_quantity(typeof(q), vout, dout) +end + +function uconvert(qout::UnionAbstractQuantity{<:Any,<:AffineDimensions}, q::QuantityArray{<:Any,<:Any,<:Dimensions}) + @assert isone(ustrip(qout)) "You passed a quantity with a non-unit value to uconvert." + dout = dimension(qout) + dimension(q) == basedim(dout) || throw(DimensionError(q, qout)) + vout = (ustrip(q) .- offset(dout))./scale(dout) + return QuantityArray(vout, dout, quantity_type(q)) +end + +function uconvert(qout::UnionAbstractQuantity{<:Any,<:AbstractSymbolicDimensions}, qin::AbstractQuantityOrArray{<:Any,<:AbstractAffineDimensions}) + uconvert(qout, siunits(qin)) +end + +function uconvert(qout::UnionAbstractQuantity{<:Any,<:AbstractAffineDimensions}, qin::AbstractQuantityOrArray{<:Any,<:AbstractSymbolicDimensions}) + uconvert(qout, siunits(qin)) +end + +function uconvert(qout::UnionAbstractQuantity{<:Any,<:AbstractAffineDimensions}, qin::AbstractQuantityOrArray{<:Any,<:AbstractAffineDimensions}) + uconvert(qout, siunits(qin)) +end + +function map_dimensions(op::typeof(+), args::AffineDimensions...) + assert_no_offset.(args) + return AffineDimensions( + scale=*(scale.(args)...), + offset=zero(Float64), + basedim=map_dimensions(op, basedim.(args)...) + ) +end + +function map_dimensions(op::typeof(-), args::AffineDimensions...) + assert_no_offset.(args) + return AffineDimensions( + scale=/(scale.(args)...), + offset=zero(Float64), + basedim=map_dimensions(op, basedim.(args)...) + ) +end + +#This is required because /(x::Number) results in an error, so it needs to be cased out to inv +function map_dimensions(op::typeof(-), d::AffineDimensions) + assert_no_offset(d) + return AffineDimensions( + scale=inv(scale(d)), + offset=zero(Float64), + basedim=map_dimensions(op, basedim(d)) + ) +end + +function map_dimensions(fix1::Base.Fix1{typeof(*)}, l::AffineDimensions{R}) where {R} + assert_no_offset(l) + return AffineDimensions( + scale=scale(l)^fix1.x, + offset=zero(Float64), + basedim=map_dimensions(fix1, basedim(l)) + ) +end + + +# This function works like uexpand but will throw an error if the offset is 0 +function _no_offset_expand(q::Q) where {T,R,D<:AbstractAffineDimensions{R},Q<:UnionAbstractQuantity{T,D}} + return convert(with_type_parameters(Q, T, Dimensions{R}), q) +end + +# Addition will return Quantity{T, Dimensions} +Base.:+(q1::UnionAbstractQuantity{<:Any,<:AffineDimensions}, q2::UnionAbstractQuantity{<:Any,<:AffineDimensions}) = _no_offset_expand(q1) + _no_offset_expand(q2) + +# Subtraction will return Quantity{T, Dimensions}, in special cases, differences between offsetted AffineDimensions is allowed as offsets cancel out +function Base.:-(q1::UnionAbstractQuantity{<:Any,<:AffineDimensions}, q2::UnionAbstractQuantity{<:Any,<:AffineDimensions}) + if dimension(q1) == dimension(q2) + return siunits(q1) - siunits(q2) + else + return _no_offset_expand(q1) - _no_offset_expand(q2) + end +end + +for op in (:(==), :(≈)) + @eval Base.$op(q1::UnionAbstractQuantity{<:Any,<:AffineDimensions}, q2::UnionAbstractQuantity{<:Any,<:AffineDimensions}) = $op(siunits(q1), siunits(q2)) + @eval Base.$op(q1::UnionAbstractQuantity{<:Any,<:AffineDimensions}, q2::UnionAbstractQuantity{<:Any,<:AbstractDimensions}) = $op(siunits(q1), siunits(q2)) + @eval Base.$op(q1::UnionAbstractQuantity{<:Any,<:AbstractDimensions}, q2::UnionAbstractQuantity{<:Any,<:AffineDimensions}) = $op(siunits(q1), siunits(q2)) +end + +Base.:(==)(d1::AffineDimensions, d2::AffineDimensions) = (d1.scale==d2.scale) & (d1.offset==d2.offset) & (d1.basedim == d2.basedim) + +# Units are stored using SymbolicDimensionsSingleton +const DEFAULT_AFFINE_QUANTITY_TYPE = with_type_parameters(DEFAULT_QUANTITY_TYPE, DEFAULT_VALUE_TYPE, AffineDimensions{DEFAULT_DIM_BASE_TYPE}) + +module AffineUnits + + using DispatchDoctor: @unstable + + import ..affine_unit + import ..scale + import ..offset + import ..basedim + import ..dimension + import ..ustrip + import ..constructorof + import ..DEFAULT_AFFINE_QUANTITY_TYPE + import ..DEFAULT_DIM_TYPE + import ..DEFAULT_VALUE_TYPE + import ..Units: UNIT_SYMBOLS, UNIT_VALUES + import ..Constants: CONSTANT_SYMBOLS, CONSTANT_VALUES + import ..Constants + import ..Quantity + import ..INDEX_TYPE + import ..AbstractDimensions + import ..AffineDimensions + import ..UnionAbstractQuantity + + import ..DEFAULT_DIM_BASE_TYPE + import ..WriteOnceReadMany + import ..SymbolicUnits.as_quantity + + const AFFINE_UNIT_SYMBOLS = WriteOnceReadMany([UNIT_SYMBOLS...]) + const AFFINE_UNIT_VALUES = WriteOnceReadMany(affine_unit.([UNIT_VALUES...], [UNIT_SYMBOLS...])) + const AFFINE_UNIT_MAPPING = WriteOnceReadMany(Dict(s => INDEX_TYPE(i) for (i, s) in enumerate(AFFINE_UNIT_SYMBOLS))) + + # Used for registering units in current module + function update_external_affine_unit(name::Symbol, q::UnionAbstractQuantity{<:Any,<:AffineDimensions{R}}) where {R} + ind = get(AFFINE_UNIT_MAPPING, name, INDEX_TYPE(0)) + if !iszero(ind) + @warn "unit $(name) already exists, skipping" + return nothing + end + + # Extract original dimensions + dims = dimension(q) + + # Add "name" to the symbol to make it display + d_sym = AffineDimensions{DEFAULT_DIM_BASE_TYPE}( + scale=scale(dims), + offset=offset(dims), + basedim=basedim(dims), + symbol=(dims.symbol == :nothing) ? name : dims.symbol + ) + + # Reconstruct the quantity with the new name + q_sym = constructorof(DEFAULT_AFFINE_QUANTITY_TYPE)(ustrip(q), d_sym) + + push!(AFFINE_UNIT_SYMBOLS, name) + push!(AFFINE_UNIT_VALUES, q_sym) + AFFINE_UNIT_MAPPING[name] = lastindex(AFFINE_UNIT_SYMBOLS) + return nothing + end + + update_external_affine_unit(name::Symbol, q::UnionAbstractQuantity) = update_external_affine_unit(name, affine_unit(q)) + update_external_affine_unit(name::Symbol, d::AbstractDimensions) = update_external_affine_unit(name, Quantity(DEFAULT_VALUE_TYPE(1.0), d)) + function update_external_affine_unit(d::AffineDimensions) + d.symbol != :nothing || error("Cannot register affine dimension if symbol is :nothing") + return update_external_affine_unit(d.symbol, d) + end + + """ + aff_uparse(s::AbstractString) + + Parse a string containing an expression of units and return the + corresponding `Quantity` object with `Float64` value. + However, unlike the regular `u"..."` macro, this macro uses + `AffineDimensions` for the dimension type, which can represent a greater + number of units, but much more limited functionality with calculations. + For example, `aff_uparse("km/s^2")` would be parsed to + `Quantity(1.0, AffineDimensions(scale=1000.0, offset=0.0, basedim=Dimensions(length=1, time=-2)))`. + """ + function aff_uparse(s::AbstractString) + ex = map_to_scope(Meta.parse(s)) + ex = :($as_quantity($ex)) + return eval(ex)::DEFAULT_AFFINE_QUANTITY_TYPE + end + + as_quantity(q::DEFAULT_AFFINE_QUANTITY_TYPE) = q + + """ + ua"[unit expression]" + + Parse a string containing an expression of units and return the + corresponding `Quantity` object with `Float64` value. + However, unlike the regular `u"..."` macro, this macro uses + `AffineDimensions` for the dimension type, which can represent a greater + number of units, but much more limited functionality with calculations. + For example, `ua"km/s^2"` would be parsed to + `Quantity(1.0, AffineDimensions(scale=1000.0, offset=0.0, basedim=Dimensions(length=1, time=-2)))`. + """ + macro ua_str(s) + ex = map_to_scope(Meta.parse(s)) + ex = :($(as_quantity)($ex)) + return esc(ex) + end + + @unstable function map_to_scope(ex::Expr) + if ex.head != :call + throw(ArgumentError("Unexpected expression: $ex. Only `:call` is expected.")) + end + if ex.head == :call + ex.args[2:end] = map(map_to_scope, ex.args[2:end]) + return ex + end + end + + function map_to_scope(sym::Symbol) + sym in AFFINE_UNIT_SYMBOLS || throw(ArgumentError("Symbol $sym not found in `AffineUnits`.")) + return lookup_unit(sym) + end + + function map_to_scope(ex) + return ex + end + + function lookup_unit(ex::Symbol) + i = findfirst(==(ex), AFFINE_UNIT_SYMBOLS)::Int + return AFFINE_UNIT_VALUES[i] + end + + #Register Celsius and Fahrenheit (the most commonly used affine units) + begin + K = Quantity(1.0, temperature=1) + °C = Quantity(1.0, AffineDimensions(scale=1.0, offset=273.15*K, basedim=K, symbol=:°C)) + °F = Quantity(1.0, AffineDimensions(scale=5/9, offset=(-160/9)°C, basedim=°C, symbol=:°F)) + update_external_affine_unit(dimension(°C)) + update_external_affine_unit(:degC, dimension(°C)) + update_external_affine_unit(dimension(°F)) + update_external_affine_unit(:degF, dimension(°F)) + end + +end + + + +import .AffineUnits: aff_uparse, update_external_affine_unit + +""" + ua"[unit expression]" + +Parse a string containing an expression of units and return the +corresponding `Quantity` object with `Float64` value. +However, unlike the regular `u"..."` macro, this macro uses +`AffineDimensions` for the dimension type, which can represent a greater +number of units, but supports a much smaller set of operations. It is +adviced to convert AffineDimensions to regular are symbolic dimensions +as soon as possible. +For example, `ua"km/s^2"` would be parsed to +`Quantity(1.0, AffineDimensions(scale=1000.0, offset=0.0, basedim=Dimensions(length=1, time=-2)))`. +""" +macro ua_str(s) + ex = AffineUnits.map_to_scope(Meta.parse(s)) + ex = :($(AffineUnits.as_quantity)($ex)) + return esc(ex) +end diff --git a/src/register_units.jl b/src/register_units.jl index 680ff13f..2f016113 100644 --- a/src/register_units.jl +++ b/src/register_units.jl @@ -12,9 +12,17 @@ function update_all_values(name_symbol, unit) ALL_MAPPING[name_symbol] = i UNIT_MAPPING[name_symbol] = i update_external_symbolic_unit_value(name_symbol) + update_external_affine_unit(name_symbol, unit) end end +function update_affine_values(name_symbol, unit) + lock(UNIT_UPDATE_LOCK) do + update_external_affine_unit(name_symbol, unit) + end +end + + """ @register_unit symbol value @@ -70,3 +78,41 @@ function _register_unit(name::Symbol, value) ) return reg_expr end + +""" + @register_affine_unit symbol value + +Register a new unit under the given symbol in the AFFINE UNIT REGISTRY ONLY. + +All units registered with @register_unit will automatically be registered in the affine units registry +``` +@register_unit psi 6.89476us"kPa" +u"psi" +>> 6894.76 m⁻¹ kg s⁻² +us"psi" +>> 1.0 psi +ua"psi" +>> 1.0 psi +``` +However, strictly affine units cannot belong to the symbolic registry, so a different macro must be used on an AffineDimension (or quantity thereof) +``` +@register_affine_unit psig AffineDimensions(offset=u"Constants.atm", basedim=u"psi") #Gauge pressure implies atmospheric offset +ua"psig" +>> 1.0 psig +us"psig" +>> ERROR: LoadError: ArgumentError: Symbol psig not found in `Units` or `Constants`. +``` +""" +macro register_affine_unit(name, expr) + return esc(_register_affine_unit(name, expr)) +end + +function _register_affine_unit(name, expr) + name_symbol = Meta.quot(name) + index = get(AffineUnits.AFFINE_UNIT_MAPPING, name, INDEX_TYPE(0)) + if !iszero(index) + unit = AffineUnits.AFFINE_UNIT_VALUES[index] + error("Unit `$name` is already defined as `$unit`") + end + return :($update_affine_values($name_symbol, $expr)) +end \ No newline at end of file diff --git a/test/unittests.jl b/test/unittests.jl index 30d7d18e..fddb3b17 100644 --- a/test/unittests.jl +++ b/test/unittests.jl @@ -6,8 +6,9 @@ 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.AffineUnits: AFFINE_UNIT_SYMBOLS, AFFINE_UNIT_MAPPING, AFFINE_UNIT_VALUES using DynamicQuantities: map_dimensions -using DynamicQuantities: _register_unit +using DynamicQuantities: _register_unit, _register_affine_unit using Ratios: SimpleRatio using SaferIntegers: SafeInt16 using StaticArrays: SArray, MArray @@ -1991,6 +1992,109 @@ end @test QuantityArray([km, km]) |> uconvert(us"m") != [km, km] end + +@testset "Tests of AffineDimensions" begin + °C = ua"°C" + °F = ua"°F" + mps = ua"m/s" + + @test aff_uparse("m/(s^2.5)") == ua"m/(s^2.5)" + @test_throws ArgumentError aff_uparse("s[1]") + @test_throws ArgumentError aff_uparse("pounds_per_hour") + @test °C isa Quantity{T,AffineDimensions{R}} where {T,R} + @test dimension(°C) isa AffineDimensions + @test dimension(°C) isa AbstractAffineDimensions + + @test DynamicQuantities.basedim(dimension(°C)).temperature == 1 + @test DynamicQuantities.basedim(dimension(°C)).length == 0 + + @test inv(mps) == us"s/m" + @test inv(mps) == u"s/m" + @test mps^2 == u"m^2/s^2" + + @test °C == ua"degC" + @test °F == ua"degF" + @test dimension(°C) == dimension(ua"degC") + @test (°C - ua"degC") == 0.0u"K" + + # Constructors + @test with_type_parameters(AffineDimensions, Float64) == AffineDimensions{Float64} + @test constructorof(AffineDimensions) == AffineDimensions{DynamicQuantities.DEFAULT_DIM_BASE_TYPE} + @test constructorof(AffineDimensions{Float64}) == AffineDimensions{Float64} + @test Quantity(1.0, AffineDimensions(dimension(u"K"))) == u"K" + @test AffineDimensions(scale=1, offset=0, basedim=dimension(u"K")) == AffineDimensions(basedim=dimension(u"K")) + @test AffineDimensions(scale=1, offset=0, basedim=u"K") == AffineDimensions(basedim=dimension(ua"K")) + @test AffineDimensions(scale=1.0, offset=273.15u"K", basedim=dimension(u"K")) == AffineDimensions(basedim=ua"°C") + + kelvin = AffineDimensions(basedim=u"K") + @test Quantity(1.0, kelvin) == u"K" + + rankine = AffineDimensions(scale=5/9, offset=0.0, basedim=dimension(u"K")) + @test Quantity(1.0, rankine) == (5/9)u"K" + + fahrenheit = AffineDimensions(scale=1.0, offset=Quantity(459.67, rankine), basedim=rankine) + @test Quantity(1.0, fahrenheit) ≈ °F + + celsius = AffineDimensions(scale=9/5, offset=Quantity(32.0, rankine), basedim=°F) + @test Quantity(1.0, celsius) ≈ °C + + # Round-trip sanity checks + @test -40°C ≈ -40°F + @test Quantity(-40.0, celsius) ≈ Quantity(-40.0, fahrenheit) + + # Test promotion explicitly for coverage: + @test promote_type(AffineDimensions{Int16}, AffineDimensions{Int32}) === AffineDimensions{Int32} + @test promote_type(Dimensions{Int16}, AffineDimensions{Int32}) === Dimensions{Int32} + @test promote_type(AffineDimensions{Int16}, Dimensions{Int32}) === Dimensions{Int32} + @test promote_type(SymbolicDimensions{Int16}, AffineDimensions{Int32}) === Dimensions{Int32} + @test promote_type(AffineDimensions{Int16}, SymbolicDimensions{Int32}) === Dimensions{Int32} + + # Type conversions + @test convert(Quantity{Float64, AffineDimensions}, u"kg") isa Quantity{Float64, AffineDimensions{DynamicQuantities.DEFAULT_DIM_BASE_TYPE}} + @test convert(Quantity{Float64, AffineDimensions{Float64}}, u"kg") isa Quantity{Float64, AffineDimensions{Float64}} + @test convert(Quantity{Float64, Dimensions}, ua"kg") isa Quantity{Float64, Dimensions{DynamicQuantities.DEFAULT_DIM_BASE_TYPE}} + + # Test uncovered operations + @test (2.0ua"m")^2 == (2.0u"m")^2 + @test dimension(ua"m")^Int32(2) == dimension(ua"m^2") + @test 2.0u"m" + 2.0ua"m" === 4.0u"m" + @test 2.0ua"m" + 2.0ua"m" === 4.0u"m" + @test 2.0u"m" - 2.0ua"m" === 0.0u"m" + @test 2.0ua"m" - 2.0ua"cm" === 1.98u"m" + @test 5.0°C - 4.0°C === 1.0u"K" + @test 2.0u"K" ≈ 2.0ua"K" + @test 2.0ua"K" ≈ 2.0ua"K" + @test 2.0ua"K" ≈ 2.0u"K" + @test_throws AssertionError (2ua"°C")^2 + @test uexpand(2ua"°C") == 275.15u"K" + + # Test conversions + @test °C |> us"K" isa Quantity{<:Real, <:SymbolicDimensions} + @test 0°C |> us"K" == 273.15us"K" + @test us"K" |> °C isa Quantity{<:Real, <:AffineDimensions} + @test 0us"K" |> °C == -273.15°C + @test °C |> °F isa Quantity{<:Real, <:AffineDimensions} + @test 0°C |> °F == 32°F + + @test QuantityArray([0,1]°C) |> uconvert(°F) isa QuantityArray{T, <:Any, AffineDimensions{R}} where {T,R} + @test DynamicQuantities.affine_quantity(us"kPa") == u"kPa" + + # Test display against errors + celsius = AffineDimensions(offset=273.15, basedim=u"K") + psi = AffineDimensions(basedim=6.89476us"kPa") + io = IOBuffer() + @test isnothing(show(io, (dimension(°F), dimension(ua"K"), psi, celsius, fahrenheit))) + + # Test updating affine units + @test_warn "unit °C already exists, skipping" DynamicQuantities.update_external_affine_unit(:°C, °C) + @test_warn "unit °C already exists, skipping" DynamicQuantities.update_external_affine_unit(:°C, dimension(°C)) + @test_warn "unit °C already exists, skipping" DynamicQuantities.update_external_affine_unit(dimension(°C)) + @test_throws "Cannot register affine dimension if symbol is :nothing" DynamicQuantities.update_external_affine_unit(celsius) + +end + + + @testset "Test div" begin for Q in (RealQuantity, Quantity, GenericQuantity) x = Q{Int}(10, length=1) @@ -2065,8 +2169,10 @@ end # test block. map_count_before_registering = length(UNIT_MAPPING) all_map_count_before_registering = length(ALL_MAPPING) +affine_count_before_registering = length(AFFINE_UNIT_MAPPING) skipped_register_unit = false +#Registering Symbolic Units if :MyV ∉ UNIT_SYMBOLS # (In case we run this script twice) @eval @register_unit MyV u"V" else @@ -2078,8 +2184,25 @@ end if :MySV2 ∉ UNIT_SYMBOLS @eval @register_unit MySV2 us"km/h" end +if :psi ∉ UNIT_SYMBOLS + @eval @register_unit psi 6.89476us"kPa" +end + +#Registering Affine Units +if :psig ∉ AFFINE_UNIT_SYMBOLS #This example is in the documentation so it better work + @eval @register_affine_unit psig AffineDimensions(offset=u"Constants.atm", basedim=u"psi") +else + skipped_register_unit = true +end +if :My°C ∉ AFFINE_UNIT_SYMBOLS # (In case we run this script twice) + @eval @register_affine_unit My°C ua"°C" +end +if :My°C2 ∉ AFFINE_UNIT_SYMBOLS + @eval @register_affine_unit My°C2 dimension(ua"°C") +end @test_throws "Unit `m` is already defined as `1.0 m`" esc(_register_unit(:m, u"s")) +@test_throws "Unit `°C` is already defined as `1.0 °C`" esc(_register_affine_unit(:°C, ua"°C")) # Constants as well: @test_throws "Unit `Ryd` is already defined" esc(_register_unit(:Ryd, u"Constants.Ryd")) @@ -2088,25 +2211,51 @@ end MyV = u"MyV" MySV = u"MySV" MySV2 = u"MySV2" + My°C = ua"My°C" + My°C2 = ua"My°C2" + psi = u"psi" + psig = ua"psig" @test MyV === u"V" @test MyV == us"V" @test MySV == us"V" @test MySV2 == us"km/h" + @test MySV == ua"V" + @test MySV2 == ua"km/h" + @test psi == ua"psi" + @test psi == u"psi" + @test psig == ua"psig" + @test 0*psig == u"Constants.atm" + @test My°C == ua"My°C" + @test My°C == uexpand(ua"My°C") + @test My°C2 == ua"My°C2" + @test My°C2 == uexpand(ua"My°C2") if !skipped_register_unit - @test length(UNIT_MAPPING) == map_count_before_registering + 3 - @test length(ALL_MAPPING) == all_map_count_before_registering + 3 + @test length(UNIT_MAPPING) == map_count_before_registering + 4 + @test length(ALL_MAPPING) == all_map_count_before_registering + 4 + @test length(AFFINE_UNIT_MAPPING) == affine_count_before_registering + 7 end for my_unit in (MySV, MyV) @test my_unit in UNIT_VALUES @test my_unit in ALL_VALUES @test my_unit in SYMBOLIC_UNIT_VALUES + @test my_unit in AFFINE_UNIT_VALUES #Non-affine units should also be registered end + for my_unit in (:MySV, :MyV) @test my_unit in UNIT_SYMBOLS @test my_unit in ALL_SYMBOLS + @test my_unit in AFFINE_UNIT_SYMBOLS #Non-affine units should also be registered + end + + for my_unit in (My°C, My°C2) #Affine units should only show up in the affine unit registry + @test my_unit in AFFINE_UNIT_VALUES + end + + for my_unit in (:My°C, :My°C2) #Affine units should only show up in the affine unit registry + @test my_unit in AFFINE_UNIT_SYMBOLS end end