| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
|
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.
|
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Summary
QTable converts any Column that has a unit into a Quantity, and Quantity defaults to dtype=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 with datatype="long" and unit="NA" reads into Table as exact int64 and into QTable as a wrong float, with no warning (#17963).
This PR adds an optional QTable keyword argument and attribute preserve_quantity_dtype. When it is true the Quantity is built with dtype=None, which keeps whatever dtype the input column had. The default behavior is unchanged.
Addresses #17963. This is the table side only — the io.votable behavior of treating unit="NA" or unit="" as a real unit, which is what routes these ID columns into Quantity in the first place, is left for a separate change.
This supersedes #12505. I believe that using a proper TableAttribute that 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.py and the tests.
Details
Click to expandAdd a QTable.preserve_quantity_dtype table attribute that keeps the column dtype when converting to Quantity
1. Where the dtype is lost
QTable._convert_col_for_table() is the single place where a Column with a unit becomes a Quantity:
Quantity.__new__ has dtype=np.inexact, so int64 in gives float64 out. Nothing else in the chain is lossy — the ECSV/FITS/VOTable readers all hand QTable an exact integer column, and Table keeps it exact. Passing dtype=None instead makes Quantity keep the input dtype.
2. Implementation
All of it is in astropy/table/table.py.
The attribute. preserve_quantity_dtype = TableAttribute() on QTable, so the value lives in meta['__attributes__'] and travels with the table through slicing, copying, pickling and ECSV.
It is declared with the default None rather than default=False on purpose. MetaAttribute.__get__ copies any non-None default into meta['__attributes__'] the first time the attribute is read, so TableAttribute(default=False) would give every QTable a meta of {'__attributes__': {'preserve_quantity_dtype': False}} and write that block into every ECSV file. With the None default the getter short-circuits and meta stays empty until the attribute is explicitly set. This is the same reason Table._hidden_columns defaults to None.
The conversion.
Passing the kwarg conditionally rather than dtype=np.inexact in the false branch keeps the default path from hardcoding a default that belongs to Quantity.
One reorder in Table.__init__. Table attributes were applied after init_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. The self.meta assignment and the kwarg loop now run before init_func():
Setting meta first 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 only self.meta references between __init__ and _new_from_slice are in __getstate__ and _new_from_slice itself. Verified against table, io.ascii, io.misc, io.votable, units, utils and nddata, whose MetaAttribute/meta handling would be the first thing to break.
3. Testing
TestPreserveQuantityDtype in astropy/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, meta left 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 in meta, and a float32 column being left alone rather than upcast.
Also checked by hand:
astropy/table, io/ascii, io/misc, io/votable, units, utils, nddata: 11775 passed, 200 skipped, 29 xfailed. docs/table and docs/io doctests: 35 passed, 6 skipped.
4. Known limitations
The attribute governs _convert_col_for_table(), so it only applies to columns that reach QTable as a Column with a unit. Two read paths bypass it, both documented in the new docs note:
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 is QTable(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() calls Quantity with exactly the arguments it did before, QTable.preserve_quantity_dtype reads back as None, and meta is untouched. The Table.__init__ reorder is not observable — attribute precedence, meta identity under copy=False, and deepcopy semantics under copy=True are all unchanged.
A docs/changes/table/20247.feature.rst fragment is included, and docs/table/mixin_columns.rst gains a "Quantity and Integer Columns" section replacing the .. attention:: block that previously documented the float cast as unavoidable.