Skip to content
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

QuantityKinds #1967

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open

QuantityKinds #1967

wants to merge 12 commits into from

Conversation

andrewgsavage
Copy link
Collaborator

@andrewgsavage andrewgsavage commented Apr 10, 2024

  • Closes # (insert issue number)
  • Executed pre-commit run --all-files with no errors
  • The change is fully covered by automated unit tests
  • Documented in docs/ as appropriate
  • Added an entry to the CHANGES file

Releveant issues: #1388 #551 #676 #1073

@andrewgsavage
Copy link
Collaborator Author

andrewgsavage commented Apr 10, 2024

I got this behaviour working as I wanted:

from pint import UnitRegistry
ureg = UnitRegistry()
Q_ = ureg.Quantity
moment_arm = Q_(1, "m").to_kind("[moment_arm]")
force = Q_(1, "lbf").to_kind("[force]")
# to_kind converts to the preferred_unit of the kind
assert force.units == ureg.N

# both force and moment_arm have kind defined.
# Torque is defined in default_en:
# [torque] = [force] * [moment_arm]
# the result is a quantity with kind [torque]
torque = force * moment_arm
assert torque.units == ureg.N * ureg.m
assert torque.kind == "[torque]"

# Energy is defined in default_en:
# [energy] = [force] * [length] = J
distance = Q_(1, "m").to_kind("[length]")
energy = force * distance
assert energy.kind == "[energy]"
assert energy.units == ureg.J

# Torque is not energy so cannot be added
with pytest.raises(ValueError):
    energy + torque

Here I've used an attribute Quantity.kind. Another option is to create a subclass QuantityKind that inherits from Quantity.

One constraint is the multiplication of kind_a * kind_b must be unique, ie you cannot have[energy] = [force] * [distance] and
[torque] = [force] * [distance]
You can have multiple definitions that result in a quantity, [energy] = [force] * [distance] = [power] * [time], but needs implementing.
I think formulas resulting from 3 quantitykinds are possible, ie [energy] = [mass] * [specific_heat_capacity] * [delta_temperature]

I imagine there's a better way to do the lookup when multiplying.

What other examples would be good to test?
Shoud it be possible to add a quantity with kind defined and one without kind defined?

@andrewgsavage
Copy link
Collaborator Author

andrewgsavage commented Apr 12, 2024

Will need a KindContainer like UnitContainer to hold kinds to allow definitions in terms of 3 parameters, eg
[specific_heat_capacity] = [energy] / [temperature] / [mass]

Another thought is the order of operations could matter eg if specific energy is defined,
[specific_energy] = [energy] / [mass]

energy = Q_(1, "J").to_kind("[energy]")
mass = Q_(1, "kg").to_kind("[mass]")
temperature = Q_(1, "K").to_kind("[temperature]")

shc = (energy / mass) / temperature)
shc.kind
[specific_energy] / [temperature]

Would need to define shc as
[specific_heat_capacity] = [energy] / [temperature] / [mass] = [specific_energy] / [temperature]

@andrewgsavage
Copy link
Collaborator Author

Added a Kind object similar to Unit. I should make it handle mathematical operations, ie Kind("[force]") * Kind("[distance]") = Kind("[torque] ") and then use could multiplying/adding Kind in Quantity.__mul__ and other operators to determine output kinds.

I am thinking a QuantityKind would be worth adding, mainly so isinstance can be used rather than hasattr(obj, "_kinds"). That would be needed for downstream libraries that shouldn't be using private attributes.

@andrewgsavage
Copy link
Collaborator Author

functionality to address #676

force = ureg.Kind("[force]")

# Valid units based on dimensionality
force.compatible_units()
frozenset({<Unit('atomic_unit_of_force')>,
           <Unit('dyne')>,
           <Unit('force_gram')>,
           <Unit('newton')>,
           <Unit('force_kilogram')>,
           <Unit('force_metric_ton')>})
# Kinds that when multiplied together with the specified exponents give ['force']
force.kind_relations()
{<UnitsContainer({'[area]': 1.0, '[pressure]': 1.0})>,
 <UnitsContainer({'[energy]': 1.0, '[length]': -1.0})>,
 <UnitsContainer({'[gaussian_charge]': 1.0, '[gaussian_magnetic_field]': 1.0})>,
 <UnitsContainer({'[acceleration]': 1.0, '[mass]': 1.0})>,
 <UnitsContainer({'[moment_arm]': -1.0, '[torque]': 1.0})>}
newton = ureg.N
# Kinds that have the same dimenionality as the unit
newton.compatible_kinds()
{'[force]'}
joule = ureg.J
# A better example, (only force is defined with dimensions of kg m s-2)
joule.compatible_kinds()
{'[energy]', '[torque]'}

@dalito
Copy link
Contributor

dalito commented May 14, 2024

@andrewgsavage Adding support for qunatity kinds would be great. Thanks a lot for starting this.

Above you wrote

Here I've used an attribute Quantity.kind. Another option is to create a subclass QuantityKind that inherits from Quantity.

Then you added the QuantityKind class instead of going the attribute-route. What was the reason for this decision?
Having just one Quantity class instead of two (Quantity and QuantityKind`) would be easier to learn/use.

The name QuantityKind was confusing me initially because it is not a class for a quantity kind but for a quantity with kind support.

I was expecting that QuantityKind instances support quantity methods, but when I tried I got

In [1]: from pint import UnitRegistry
   ...: ureg = UnitRegistry()
   ...: Q_ = ureg.Quantity
   ...: K_ = ureg.Kind
In [3]: l = Q_(2, "m").to_kind("[length]")
In [4]: l
Out[4]: <QuantityKind(2, meter, [length])>
In [5]: l.to("foot")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 l.to("foot")

File ~\MyProg_local\gh\pint\pint\facets\plain\quantity.py:538, in PlainQuantity.to(self, other, *contexts, **ctx_kwargs)
    534 other = to_units_container(other, self._REGISTRY)
    536 magnitude = self._convert_magnitude_not_inplace(other, *contexts, **ctx_kwargs)
--> 538 return self.__class__(magnitude, other)

File ~\MyProg_local\gh\pint\pint\facets\kind\objects.py:85, in QuantityKind.__new__(cls, value, kinds, units)
     79     raise TypeError(
     80         "kinds must be of type str, KindKind or "
     81         "UnitsContainer; not {}.".format(type(kinds))
     82     )
     84 if units is None:
---> 85     kk = inst._REGISTRY.Kind(kinds)
     86     if kk.preferred_unit:
     87         units = kk.preferred_unit

File ~\MyProg_local\gh\pint\pint\facets\kind\objects.py:211, in KindKind.__init__(self, kinds)
    209     self._kinds = self._REGISTRY.parse_kinds(kinds)._kinds
    210 else:
--> 211     raise TypeError(
    212         "kinds must be of type str, UnitsContainer; not {}.".format(type(kinds))
    213     )

TypeError: kinds must be of type str, UnitsContainer; not <class 'pint.util.UnitsContainer'>.

Other examples that I tried worked fine. Playing with this confirmed how useful quantity kinds are for correct handling of quantities in even more cases.

@andrewgsavage
Copy link
Collaborator Author

Here I've used an attribute Quantity.kind. Another option is to create a subclass QuantityKind that inherits from Quantity.

Then you added the QuantityKind class instead of going the attribute-route. What was the reason for this decision? Having just one Quantity class instead of two (Quantity and QuantityKind`) would be easier to learn/use.

There were a lot of hasattr(obj, "kind") checks which didn't feel as 'correct' as isinstance(obj, QuantityKind). I saw the Measurement object in the uncertainty facet and moved the kind related code to its own facet. This is nicer as all the kind related code is obvious and in smaller files. I think code for __new__ is simplier when QuantityKind its own class. I added Quantity.to_kind for this reason - a way to initialise without modifying __new__

I am liking the seperationso far; Quantity only provides units/dimensionality checking. QuantityKind provides both units/dimensionality and kind checking. You can switch between the two as needed, when methods are implemented. (Although would this actually be needed? Ideally you would be using QuantityKinds only.) Having two classes makes it more obvious as to whether you have objects with kind support or not.

The name QuantityKind was confusing me initially because it is not a class for a quantity kind but for a quantity with kind support.

I was expecting that QuantityKind instances support quantity methods, but when I tried I got

In [1]: from pint import UnitRegistry
   ...: ureg = UnitRegistry()
   ...: Q_ = ureg.Quantity
   ...: K_ = ureg.Kind
In [3]: l = Q_(2, "m").to_kind("[length]")
In [4]: l
Out[4]: <QuantityKind(2, meter, [length])>
In [5]: l.to("foot")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 l.to("foot")

File ~\MyProg_local\gh\pint\pint\facets\plain\quantity.py:538, in PlainQuantity.to(self, other, *contexts, **ctx_kwargs)
    534 other = to_units_container(other, self._REGISTRY)
    536 magnitude = self._convert_magnitude_not_inplace(other, *contexts, **ctx_kwargs)
--> 538 return self.__class__(magnitude, other)

File ~\MyProg_local\gh\pint\pint\facets\kind\objects.py:85, in QuantityKind.__new__(cls, value, kinds, units)
     79     raise TypeError(
     80         "kinds must be of type str, KindKind or "
     81         "UnitsContainer; not {}.".format(type(kinds))
     82     )
     84 if units is None:
---> 85     kk = inst._REGISTRY.Kind(kinds)
     86     if kk.preferred_unit:
     87         units = kk.preferred_unit

File ~\MyProg_local\gh\pint\pint\facets\kind\objects.py:211, in KindKind.__init__(self, kinds)
    209     self._kinds = self._REGISTRY.parse_kinds(kinds)._kinds
    210 else:
--> 211     raise TypeError(
    212         "kinds must be of type str, UnitsContainer; not {}.".format(type(kinds))
    213     )

TypeError: kinds must be of type str, UnitsContainer; not <class 'pint.util.UnitsContainer'>.

Other examples that I tried worked fine. Playing with this confirmed how useful quantity kinds are for correct handling of quantities in even more cases.

Ah I hadn't realised that to isn't working. Anything that uses self.__class__ won't work either. I would expect that to return a <QuantityKind ... ft, [length]>. I haven't had to convert units as I've been setting preferred units in defintions, which feels very nice!

@andrewgsavage
Copy link
Collaborator Author

andrewgsavage commented May 14, 2024

I found a c++ library thats being written with kinds.

It has some good documentation on how it's dealing with kinds:
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2024/p3045r0.html#systems-of-quantities

And also points, vectors, tensors ... which they are calling character
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2024/p3045r0.html#operations-in-the-affine-space

interestingly they are using a single class for quantity/quantitykind. They are also not automatically converting force*moment_arm into torque, but are allowing the result to be compared with torque, if I'm reading it correctly.

@dalito
Copy link
Contributor

dalito commented May 16, 2024

Thanks for the great reference! It is the best in-depth text I ever read. They also mentioned the lack of kind handling in pint.

@dalito
Copy link
Contributor

dalito commented May 16, 2024

Wouldn't it be an option to initialize Quantities directly with their kind?

So instead of converting

moment_arm = Q_(1, "m").to_kind("[moment_arm]")

initialize directly as

moment_arm = Q_(1, "m", "[moment_arm]")

This would mean adding a new argument kinds: Optional[KindLike] = None. What disadvantage would this API have? It seems simpler but may have other implications e.g. for performance.

@andrewgsavage
Copy link
Collaborator Author

You can do
moment_arm = QK_(1, "[moment_arm]","m")
Or (assume the qk is initiated with the preferred unit for the kind) (this shortcut makes the ordering mag, kind, unit while I think mag, unit, kind is more intuitive)
`moment_arm = QK_(1, "[moment_arm]")

Wha lt would you expect the result of a quantity* quantitykind to be? Should it error, or drop the kind?

I saw that too. The thought that some units can only be of one kind was not obvious to me

@andrewgsavage
Copy link
Collaborator Author

andrewgsavage commented May 16, 2024 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants