ENH: support np.add, subtract, linspace for logarithmic units#19853
ENH: support np.add, subtract, linspace for logarithmic units#19853mhvk wants to merge 3 commits into
Conversation
|
Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.
|
eb4c16a to
35891bf
Compare
35891bf to
7f81e0f
Compare
pllim
left a comment
There was a problem hiding this comment.
I am wary when it comes to doing math in the magnitude systems... Just some clarification questions below. Thanks!
| lq = np.arange(1, 11.0) << u.mag("m/cm") | ||
| lq2 = u.Magnitude(2.0) | ||
| got = lq / lq2 | ||
| exp = lq.to(u.mag) / lq2.to(u.mag) |
There was a problem hiding this comment.
This hurts my head lol. What does it mean physically when someone divides two magnitudes?
There was a problem hiding this comment.
It doesn't mean much, but we've always had quantities where this is allowed: (10*u.mag) / (2*u.mag) == 5 and to match this before we already allowed (10*u.mag()) / (2*u.mag()) == 5. This PR extends this to also work for (5*u.mag("cm/m")) / (2*u.mag) == 5*u.one.
Now I totally agree neither of these seem all that logical, but if I wrote instead 10*u.mag == 5 * (2 * u.mag), it feels more reasonable. But it all boils down to whether one feels that mag is enough of a unit that in division one can cancel...
Note that I would do hope this will not be used, but the multiplication version is needed to get linspace to work...
| lq2 = u.Magnitude(5.0, 2 * u.mag) | ||
| q1 = u.Quantity(lq1) | ||
| q2 = u.Quantity(lq2) | ||
| exp = func(lq1, lq2) |
There was a problem hiding this comment.
This concerns me a bit. How do we know both magnitudes are of the same zeropoints? Subtracting mags should be same as dividing linear flux, but I don't see that sort of checks are done in this test.
There was a problem hiding this comment.
This only works for dimensionless (but possibly scaled) physical units. Otherwise, you cannot even make the Magnitude into a Quantity:
In [2]: u.Quantity(u.Magnitude(5.0, u.mag))
Out[2]: <Quantity 5. mag>
In [3]: u.Quantity(u.Magnitude(5.0*u.m, u.mag))
UnitTypeError: Quantity instances require normal units, not <class 'astropy.units.function.logarithmic.MagUnit'> instances.
Aside: your question brings up a bug: the following should fail too:
In [4]: u.Quantity(u.Magnitude(5.0*u.m, 2*u.mag))
Out[4]: <Quantity -0.87371251 2 mag(m)>
(does not fail on current master either, sigh)
There was a problem hiding this comment.
Note that I rather deal with the above crazy example separately... I raised #19867 to remind us...
| assert res.unit == u.mag() | ||
|
|
||
| def test_linspace(self): | ||
| res = np.linspace(0 * u.dex("AA"), 2 * u.dex("cm"), 11) |
There was a problem hiding this comment.
What does linear space mean in log units?
There was a problem hiding this comment.
This gives
In [5]: np.linspace(0 * u.dex("AA"), 2 * u.dex("cm"), 11)
Out[5]: <Dex [ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10.] dex(Angstrom)>
which is sort-of nice to be able to do. In physical units it is logarithmic:
In [6]: np.linspace(0 * u.dex("AA"), 2 * u.dex("cm"), 11).physical
Out[6]:
<Quantity [1.e+00, 1.e+01, 1.e+02, 1.e+03, 1.e+04, 1.e+05, 1.e+06, 1.e+07,
1.e+08, 1.e+09, 1.e+10] Angstrom>
|
@pllim - the whole thing was really weird to think through. To get the linspace example to work, we need the following to work: So, you see that The thing tricky to see is Now multiplication with a number means multiplying the value and raising the physical unit to the corresponding power, so this should become a list like As written, this is not possible to represent as a single After which the result is less surprising... |
|
p.s. Possibly I should add the above as an example to the documentation... |
neutrinoceros
left a comment
There was a problem hiding this comment.
I'm also not very comfortable with log units arithmetics. I'll hang in there !
I got a couple questions as a warm up.
| # as pure Quantity output? | ||
| if method == "__call__" and (op := ADD_SUBTRACT_UFUNCS.get(function)): | ||
| # Get output, if any. | ||
| out = kwargs.get("out", [None])[0] |
There was a problem hiding this comment.
I don't get why only the first element from out is used. Can you explain ?
There was a problem hiding this comment.
__array_ufunc__ always passes out as a tuple with nout elements. In our case, we know there is only one output. I'll add a comment.
| except TypeError: | ||
| # Operation on units failed: can happen if out got us here, but | ||
| # the inputs were not LogQuantity (so had no function units). | ||
| assert out is self |
There was a problem hiding this comment.
I don't get this assertion. Why is this expected ?
There was a problem hiding this comment.
I put the assert here because the logic is not obvious: as the comment states, the unit operation can only lead to a TypeError if neither input unit was a LogQuantity (or, more specifically, neither unit supported addition or subtraction, which is only supported by LogUnit). But in that case, why are we in LogQuantity.__array_ufunc__? If not an input, the only option then is that out is a LogQuantity.
Does this help? Do you have a suggestion for how to make the comment clearer?
p.s. Note that the unit subtraction/addition can perhaps fail, but that would give a UnitsValueError, not a TypeError or subclass.
There was a problem hiding this comment.
if not an input, the only option then is that out is a
LogQuantity.
this does help a ton. ANd just to double check: do I understand correctly that __array_func__ is tried on every input as well as out with unchanged arguments (henc, out is self) ?
There was a problem hiding this comment.
I think adding this explanation as an inline comment could make a life-saving difference for maintainability. I don't have a better suggestion unfortunately.
There was a problem hiding this comment.
I added at least an example...
There was a problem hiding this comment.
Sorry, I pushed before reading your replies. In the particular example of np.add(q1, q2, out=lq), we go directly to LogQuantity.__array_ufunc__ since LogQuantity is a subclass of Quantity (this follows the python logic for operators).
| if result.unit != new_unit.function_unit: | ||
| # Ensure correct unit, as can be needed when the first | ||
| # argument is not a LogQuantity. | ||
| result <<= new_unit.function_unit |
There was a problem hiding this comment.
This looks symptomatic of a deeper problem, or maybe I'm just misunderstanding something, but I would not expect the result could not already have the correct unit at this point. Why is this allowed ?
There was a problem hiding this comment.
The comment gave the clue, though not clear without an example.... I now have
# Ensure correct unit, as can be needed when the first
# argument is not a LogQuantity, as in 1*u.dex - 10*u.mag().
For this, the call above, where the physical unit has been removed, will do 1*u.dex - 10 * u.mag, which gives -3 dex, but the unit we want is u.mag(), so we need to convert back to mag, 7.5 mag.
But I now realize I could just calculate converters, instead of letting Quantity.__array_ufunc__ take care, which is faster and probably clearer. So, let me do that...
There was a problem hiding this comment.
sounds good. Please let me know when you're ready for another review !
This works by supporting np.add and np.subtract via the python operators and not falling back to regular Quantity unless truly needed.
7f81e0f to
b119b9b
Compare
With this, also allow output of addition or subtraction of regular quantities into logarithmic quantities, as well as the reverse (if the units allow it, of course). In the process, use the new implementation for the python operators (as is normally done for ndarray subclasses, but was not implemented yet here).
b119b9b to
6603b02
Compare
As the title states, but note that for support to be possible, it was necessary to relax assumptions made previously about when a Magnitude is equivalent to a quantity with units of
mag: before, this was the case if the physical unit was dimensionless, but now this is extended to whenever the physical unit is equivalent to dimensionless. I think in practice this is fine. EDIT: note that this also means it is a bit of an API change, now labelled as such!This partially addresses #12548. As such, I guess one could call it a bugfix, but I think the impact goes beyond that, and there doesn't seem a pressing need.