Repository navigation
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.
|
|
@taldcroft - before I review, a question about best approach: I dislike integer The most targeted approach would seem to be to simply fix Or, since that would affect round-tripping, we can still create an Or, one could be a bit stricter, and refuse to convert for any unrecognized unit (with maybe an option to force conversion). Though at that point, I'd maybe tend to also not convert columns with exact |
|
@mhvk - I think here we really need to avoid the temptation to guess, for instance guessing that "NA" means Not Applicable. Any kind of guessing is bound to lead to surprise for some user (esp. non-English speaker). The most straightforward and consistent principle to apply rigorously is round tripping. Here is an example with ECSV but the same applies for FITS, VOtable, HDF5 (any format that supports rich metadata). The ECSV file above is an unambiguous requirements document of what the output table should look like. Wrong dtype! The UnitsWarning is appropriate as a heads-up that astropy is surprised. Nevertheless the output QTable should exactly match what the ECSV file specified. |
If the VOtable standard formally defines "NA" as the unit being "not applicable", then dropping the unit upstream in |
Since astropy provides a convenient way to create quantities with unrecognized units, we should support round-tripping them by default. The existing warning seems sufficient to me. |
|
At a more fundamental level, I find this behavior inconsistent and surprising: This would almost argue for making |
|
OK, agreed that really there are two separate concerns, one generally about integer dtype with presumably mostly a unit where that makes sense (like your Indeed, I was getting convinced that perhaps the default should be to keep dtype consistent, until I tried, while with auto-conversion to float it succeeds, which brings back my confidence that quantities should really be float by default. (Of course, out-of-place is always OK) Anyway, in the absence of a clear best way, your option of giving choice does seem best. I'll try to have a look at the actual PR later. |
|
Numpy has the same behavior, so users really should not be surprised. Indeed, in-place auto-conversion would be the real surprise: And Quantity does the right casting to float when expected: |
|
BTW, I'm not saying that I was only talking at the QTable level with making |
|
Yes, maybe we can think about whether there would be some path towards keeping read-in dtypes as they are. This might be more inside the |
This comment was marked as resolved.
This comment was marked as resolved.
|
@mhvk - the actual code changes are quite simple... 2 new lines, 1 changed line and 1 moved line (thanks to TableAttribute!) .Of course there is everything else that goes with it, but I think the conceptual discussion is the hard part here. |
mhvk
left a comment
There was a problem hiding this comment.
Now had a look. I think overall this is good, but wonder a bit about the API, and have some suggestions for the docs.
| def get_table(self): | ||
| return Table( | ||
| [ | ||
| Column(self.VALS, name="a", unit="m"), |
There was a problem hiding this comment.
Stupid little thing, but maybe use "ct" as a unit here?
| ============================ | ||
|
|
||
| When a column of ``int`` ``dtype`` has a defined unit and is converted to | ||
| `~astropy.units.Quantity`, its ``dtype`` is converted to `float` by default. This |
There was a problem hiding this comment.
I'd like to write this a bit more as being something that generally is a good idea, but that there are exceptions. E.g.,
When a column of ``int`` ``dtype`` has a defined unit and is converted to
|Quantity|, its ``dtype`` is converted to `float` by default, as is
generally the case for |Quantity|. This makes sense for most units, for
which discrete values would be strange, but there are exceptions, with
common ones being VOtable or FITS data tables that have integer count
data or source IDs with, arguably illogically, defined units.
| situation is common when reading VOtable or FITS data tables that have integer count | ||
| data or source IDs with defined units. | ||
|
|
||
| You can change this behavior and preserve the original ``dtype`` by providing |
There was a problem hiding this comment.
It may be good to give also a work-around that does not treat all columns the same way. E.g.,
A work-around is to read the table as a |Table| first and remove unwanted units
before converting to a |QTable|.
Alternatively, you can change the default behavior to be to preserve ...
| # Quantity subclasses identified in the unit (such as u.mag()). | ||
| q_cls = Masked(Quantity) if isinstance(col, MaskedColumn) else Quantity | ||
| # Quantity casts integer data to float unless dtype=None is supplied. | ||
| kwargs = {"dtype": None} if self.preserve_quantity_dtype else {} |
There was a problem hiding this comment.
I don't mind the API too much, but I wonder if it would make sense instead to define a table attribute [default_]quantity_dtype=np.inexact which can be set to None, i.e., which exactly does what the dtype argument of Quantity itself does.
One advantage is that here it can just get passed on directly, another perhaps that the docstring can just refer to Quantity directly for details.
It might allow for expansion, e.g., allowing it to be a dict of column names or even a callable (though the API of that would probably be that it replaces q_cls...)
There was a problem hiding this comment.
I did think about this and consider your API before going to what is in this PR. I'll start with a practical problem, which is that np.inexact is an object that cannot currently be serialized to YAML by astropy.
RepresenterError: ('cannot represent an object', <class 'numpy.inexact'>)
Even if it worked, my preference is to keep this simple and readable. Setting default_quantity_dtype=None to change the behavior is confusing because None is commonly the default already. If I looked at that code I wouldn't know what it was doing. It wasn't until I started working on this PR that I learned about the Quantity dtype default.
I can't think of any compelling use case for the allowed flexibility to set the Quantity dtype default to something other than None. E.g. I don't think QTable(..., default_quantity_type=np.float32) is something users would ever do at the Table level. Going further with an idea of allowing it to be a dict becomes complicated and messy, e.g. you need to manage this attribute if table columns are renamed, etc.
There was a problem hiding this comment.
I'm certainly open to better ideas for the name. preserve_quantity_dtype is a little misleading because the quantity dtype is already preserved if you pass in a Quantity. In reality the action here is preserving the dtype of integer-type Column objects with units that are converted to Quantity. I don't know how to say that succinctly.
There was a problem hiding this comment.
Yes, makes sense. The use of np.inexact was a bit of a hack in Quantity.__new__, and setting it to None is indeed rather confusing.
For another name, is quantity_preserves_dtypebetter? Or maybe revert logic and havequantity_prefers_float=True(i.e.,Falseto turn off)? Ordtypes_from_input`?
|
@mhvk - will do on the other comments, thanks. |
|
@mhvk - Here is a completely different idea for consideration. It seems cleaner to me and also brings a new possibility for Quantity. Add a new configuration variable for units named (e.g.)
With this you could do: Then QTable init would do something like With that default change, I think that no changes would be required to any of the I/O layers like ECSV or FITS, but I haven't thought it through entirely. The change in this PR will require adding the new argument to QTable.read() which is not so nice. |
|
Yes, I think giving the user broader choice of what For the present PR, I think you meant that in the "default" case, you allow conversion, but if it is "never" or "always", you just follow what the user asked (i.e., the logic should read a bit different from what you had, |
|
Closed in favor of #20273 |
Summary
QTableconverts anyColumnthat has a unit into aQuantity, andQuantitydefaults todtype=np.inexact. An integer column with a unit is therefore always cast to float64, which silently changes values above 2**53. This hits on source identifiers, which routinely exceed that: a VOTable withdatatype="long"andunit="NA"reads intoTableas exact int64 and intoQTableas a wrong float, with no warning (#17963).This PR adds an optional
QTablekeyword argument and attributepreserve_quantity_dtype. When it is true theQuantityis built withdtype=None, which keeps whatever dtype the input column had. The default behavior is unchanged.Fixes #17963. This is the
tableside only — theio.votablebehavior of treatingunit="NA"orunit=""as a real unit, which is what routes these ID columns intoQuantityin the first place, is left for a separate change.This supersedes and thus closes #12505. I believe that using a proper
TableAttributethat survives copy/pickle/slice/serialize is the right approach.AI disclosure
Much of this PR is AI-generated using Claude Opus 5, but I did make substantive changes to the initial outputs. I have examined the code and fully understand the changes in
table.pyand the tests.Details
Click to expand
Add a
QTable.preserve_quantity_dtypetable attribute that keeps the column dtype when converting toQuantity1. Where the dtype is lost
QTable._convert_col_for_table()is the single place where aColumnwith a unit becomes aQuantity:Quantity.__new__hasdtype=np.inexact, so int64 in gives float64 out. Nothing else in the chain is lossy — the ECSV/FITS/VOTable readers all handQTablean exact integer column, andTablekeeps it exact. Passingdtype=Noneinstead makesQuantitykeep the input dtype.2. Implementation
All of it is in
astropy/table/table.py.The attribute.
preserve_quantity_dtype = TableAttribute()onQTable, so the value lives inmeta['__attributes__']and travels with the table through slicing, copying, pickling and ECSV.It is declared with the default
Nonerather thandefault=Falseon purpose.MetaAttribute.__get__copies any non-Nonedefault intometa['__attributes__']the first time the attribute is read, soTableAttribute(default=False)would give everyQTableametaof{'__attributes__': {'preserve_quantity_dtype': False}}and write that block into every ECSV file. With theNonedefault the getter short-circuits andmetastays empty until the attribute is explicitly set. This is the same reasonTable._hidden_columnsdefaults toNone.The conversion.
Passing the kwarg conditionally rather than
dtype=np.inexactin the false branch keeps the default path from hardcoding a default that belongs toQuantity.One reorder in
Table.__init__. Table attributes were applied afterinit_func(), which is what builds and converts the columns. An attribute supplied as an init kwarg could therefore never affect the conversion —QTable(data, preserve_quantity_dtype=True)would have cast the columns to float and only then recorded the attribute. Theself.metaassignment and the kwarg loop now run beforeinit_func():Setting
metafirst is what also makes the attribute work when it arrives in the input meta rather than as a kwarg, which is the ECSV read path. Precedence is unchanged: the kwarg loop still runs last and still wins over the meta value.This reorder is safe because no init function reads or writes
self.meta— the onlyself.metareferences between__init__and_new_from_sliceare in__getstate__and_new_from_sliceitself. Verified againsttable,io.ascii,io.misc,io.votable,units,utilsandnddata, whoseMetaAttribute/metahandling would be the first thing to break.3. Testing
TestPreserveQuantityDtypeinastropy/table/tests/test_table.py, six tests using[2741100559643251862, 2733456478647137226]— real Euclid-style IDs, neither of which survives a float64 round trip. It covers the default (dtype cast to float,metaleft empty), the init kwarg, setting the attribute on an existing table before adding a column, persistence through slice/copy/QTable(t)/QTable(Table(t)), kwarg precedence over a value already inmeta, and a float32 column being left alone rather than upcast.Also checked by hand:
MaskedQuantity, int64, mask intactu.mag()unit withsubok=True<Magnitude [1, 2] mag>, int64QTable(...)meta == {}, no__attributes__blockcopy=Falsewith user metaqt.meta isthe caller's dict, as before{'x': 1, '__attributes__': {...}}, neither clobbers the otherTable(plain column + unit, attribute in meta)QTable.read()gives exact int64astropy/table,io/ascii,io/misc,io/votable,units,utils,nddata: 11775 passed, 200 skipped, 29 xfailed.docs/tableanddocs/iodoctests: 35 passed, 6 skipped.4. Known limitations
The attribute governs
_convert_col_for_table(), so it only applies to columns that reachQTableas aColumnwith a unit. Two read paths bypass it, both documented in the new docs note:Quantitymixin column — whatQTable.write()produces for ECSV and parquet — is rebuilt bytable/serialize.pythroughQuantityInfo._construct_from_dict(), which callsQuantity(value, unit)with the default float cast. The attribute round-trips correctly but has no effect on those columns. This is a pre-existing bug in its own right (any int64Quantityloses its dtype through ECSV, with or without this PR) and the fix belongs inastropy.units, so it is not included here.__attributes__is a dict andHeaderrefuses it with "Attribute__attributes__of type <class 'dict'> cannot be added to FITS Header - skipping". The int64 data itself is written correctly (TFORM = 'K'); only the attribute is lost.QTable.read(..., preserve_quantity_dtype=True)is also not supported — the unified I/O layer passes unknown kwargs to the reader, not to the table class. The working idiom for now isQTable(Table.read(...), preserve_quantity_dtype=True).5. Backwards compatibility
No behavior changes unless the new attribute is explicitly set. With it unset,
_convert_col_for_table()callsQuantitywith exactly the arguments it did before,QTable.preserve_quantity_dtypereads back asNone, andmetais untouched. TheTable.__init__reorder is not observable — attribute precedence, meta identity undercopy=False, and deepcopy semantics undercopy=Trueare all unchanged.A
docs/changes/table/20247.feature.rstfragment is included, anddocs/table/mixin_columns.rstgains a "Quantity and Integer Columns" section replacing the.. attention::block that previously documented the float cast as unavoidable.