FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Add new QTable kwarg and attribute preserve_quantity_dtype by taldcroft · Pull Request #20247 · astropy/astropy · GitHub

Add new QTable kwarg and attribute preserve_quantity_dtype - #20247

Open
taldcroft wants to merge 2 commits into
astropy:mainfrom
taldcroft:table-preserve-quantity-dtype
Open

Add new QTable kwarg and attribute preserve_quantity_dtype#20247
taldcroft wants to merge 2 commits into
astropy:mainfrom
taldcroft:table-preserve-quantity-dtype

Conversation

taldcroft commented Aug 17, 2026
edited
Loading

Copy link
Copy Markdown
Member

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).

>>> t = Table({"source_id": Column([2741100559643251862], unit="")})
>>> QTable(t)["source_id"][0]
<Quantity 2.74110056e+18>                # main: value is off by 6

>>> QTable(t, preserve_quantity_dtype=True)["source_id"][0]
<Quantity 2741100559643251862>           # this branch: exact

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.

  • I certify that I am human and take responsibility for the code and interactions with reviewers.

Details

Click to expand

Add 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:

qcol = q_cls(col.data, col.unit, copy=None, subok=True)

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.

 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 {}
 try:
-    qcol = q_cls(col.data, col.unit, copy=None, subok=True)
+    qcol = q_cls(col.data, col.unit, copy=None, subok=True, **kwargs)

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():

 self._check_names_dtype(names, dtype, n_cols)

-# Finally do the real initialization
-init_func(data, names, dtype, n_cols, copy)
-
 # Set table meta.  If copy=True then deepcopy meta otherwise use the
-# user-supplied meta directly.
+# user-supplied meta directly.  This is done before the real initialization
+# since a TableAttribute stored in meta can change how the columns get
+# converted (e.g. the QTable ``preserve_quantity_dtype`` attribute).
 if meta is not None:
     self.meta = deepcopy(meta) if copy else meta

 # Update meta with TableAttributes supplied as kwargs in Table init.
 # This takes precedence over previously-defined meta.
 if meta_table_attrs:
     for attr, value in meta_table_attrs.items():
         setattr(self, attr, value)

+# Finally do the real initialization
+init_func(data, names, dtype, n_cols, copy)

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:

case result
masked int column with a unit MaskedQuantity, int64, mask intact
u.mag() unit with subok=True <Magnitude [1, 2] mag>, int64
pickle round trip attribute and int64 preserved
default QTable(...) meta == {}, no __attributes__ block
copy=False with user meta qt.meta is the caller's dict, as before
user meta plus the kwarg {'x': 1, '__attributes__': {...}}, neither clobbers the other
ECSV written from a Table (plain column + unit, attribute in meta) QTable.read() gives exact int64

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:

  • A column written as a serialized Quantity mixin column — what QTable.write() produces for ECSV and parquet — is rebuilt by table/serialize.py through QuantityInfo._construct_from_dict(), which calls Quantity(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 int64 Quantity loses its dtype through ECSV, with or without this PR) and the fix belongs in astropy.units, so it is not included here.
  • FITS drops the attribute entirely: __attributes__ is a dict and Header refuses 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 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.

Copy link
Copy Markdown
Contributor

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.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

taldcroft requested a review from mhvk August 17, 2026 19:09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant


Back | FazBrowse Home | New Git URL