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

Fix VOTable integer columns with units being cast to float in QTable by taldcroft · Pull Request #20248 · astropy/astropy · GitHub

Fix VOTable integer columns with units being cast to float in QTable - #20248

Open
taldcroft wants to merge 3 commits into
astropy:mainfrom
taldcroft:votable-preserve-quantity-dtype
Open

Fix VOTable integer columns with units being cast to float in QTable#20248
taldcroft wants to merge 3 commits into
astropy:mainfrom
taldcroft:votable-preserve-quantity-dtype

Conversation

Copy link
Copy Markdown
Member

Summary

A VOTable FIELD declares its datatype explicitly, but reading one into a QTable threw that away for any integer column that also carried a unit: the column was converted to Quantity, which casts to float64, silently changing every value above 2**53. Object identifiers are routinely that large, so the reported Euclid object_id column came back with wrong values and no warning, while the same file read into a plain Table was exact.

>>> QTable.read("euclid.vot")["object_id"][0]
<MaskedQuantity 2.74110056e+18 NA>       # main: not the value in the file

>>> QTable.read("euclid.vot")["object_id"][0]
<MaskedQuantity 2741100559643251862 NA>  # this branch: exact

TableElement.to_table() now sets the QTable preserve_quantity_dtype attribute added in #20247, so the declared datatype survives the conversion to Quantity. A long column with unit="ct" becomes an integer Quantity in counts rather than a float one, which is the behavior discussed in the issue.

Fixes #17963.

Based on #20247 — the first commit here is that PR, so this one should be merged after it. Only the second commit is new.

Backwards compatibility

Reading a VOTable into a QTable changes the dtype of integer columns that have a unit, from float64 to the declared integer type. That is the bug being fixed, and it is the only value-level change: everything else, including plain Table reads, is untouched. Code that assumed a float column there will now see an integer one — an integer Quantity supports the same operations, but true division and in-place float assignment behave as they do for any integer array.

The other visible change is the extra __attributes__ entry in the meta of every table produced by to_table(), as described above.

AI disclosure

This PR is AI-generated using Claude Opus 5. I have examined the code and fully understand the changes in tree.py and the tests.

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

Details

Click to expand

Set the preserve_quantity_dtype table attribute when converting a VOTable to an astropy table

1. Where the cast happens

io.votable itself was never lossy. TableElement.to_table() returns a Table whose columns hold the exact integer values from the file. The cast happens afterwards, in QTable._convert_col_for_table(), which turns any Column with a unit into a Quantity — and Quantity.__new__ defaults to dtype=np.inexact. Since the reader is registered for Table, the unified I/O layer produces the QTable itself, with out = cls(out, copy=False) in table/connect.py, well after the reader has returned.

That is why the fix cannot be a change to the values or dtypes the reader produces — they are already right. What the reader has to do is tell the eventual QTable conversion to leave the dtype alone, and the only channel that survives from the reader to that conversion is the table meta.

2. Implementation

Four lines in TableElement.to_table(), in astropy/io/votable/tree.py:

+# The FIELD ``datatype`` attribute is authoritative, so an integer column that
+# also has a unit must not be silently cast to float when this table is
+# converted to a `~astropy.table.QTable`. This sets the QTable
+# ``preserve_quantity_dtype`` table attribute, which is stored in the meta.
+meta["__attributes__"] = {"preserve_quantity_dtype": True}
+
 table = Table(self.array, names=names, meta=meta)

preserve_quantity_dtype is the TableAttribute added in #20247; like every TableAttribute its value lives in meta['__attributes__'], which is documented behavior of TableAttribute rather than an internal detail being poked at.

This goes in to_table() rather than in connect.py::read_table_votable() so that it also covers code that goes through the votable API directly:

t = parse(fileobj).get_first_table().to_table()
QTable(t)["object_id"].dtype     # int64

That is the path pyvo and astroquery use — DALResultsTable.to_qtable() in the issue report — so fixing only the unified-I/O entry point would have left the reporter's actual call broken.

3. Testing

Two tests in astropy/io/votable/tests/test_table.py, built on a VOTABLE_INT_WITH_UNIT sample modeled on the file in the issue: object_id is datatype="long" unit="NA" with a VALUES null, counts is datatype="long" unit="ct", ra is datatype="double" unit="deg".

  • test_qtable_preserves_int_datatype — object_id and counts are int64 and ra is float64; the object_id values compare equal to the exact Python ints; counts keeps u.count; the attribute reads back as True; and a plain Table read of the same bytes is unchanged, since it was never affected.
  • test_qtable_preserves_int_datatype_round_trip — writing the QTable back to VOTable and re-reading keeps int64 and the exact values.

Two things worth knowing if you touch these tests: reading unit="NA" emits nothing, but writing it back does emit W50: Invalid unit string 'NA', so only the write is wrapped in pytest.warns. And unit="count" is not valid VOUnit — it parses to UnrecognizedUnit, so the sample uses ct.

Also checked by hand against the file from the issue:

case result
long + unit="NA" int64 MaskedQuantity, exact, unit NA
long + unit="ct" int64 MaskedQuantity in counts
double + unit="deg" float64, unchanged
parse().get_first_table().to_table() then QTable(...) int64
VOTable write then read int64, values exact
plain Table.read unchanged in every case

astropy/io/votable, table, io/ascii, io/misc, units, utils, plus docs/table and docs/io doctests: 10522 passed, 206 skipped, 29 xfailed.

4. Trade-offs and what is not addressed

Every table from to_table() now carries meta['__attributes__'] = {'preserve_quantity_dtype': True}, whether or not it has an integer column with a unit. This is visible in t.meta and propagates to formats that preserve meta — an ECSV written from a VOTable-read table gains a __attributes__: {preserve_quantity_dtype: true} line. Writing back to VOTable drops it, since TableElement.from_table() reads only the ID/name/ref/ucd/utype/description keys.

The alternative is to set it only when a column actually is an integer with a unit, which keeps meta clean in the common case. I went with the unconditional form because the guarantee comes from the format — VOTable always declares datatype — rather than from the contents of a particular file. Easy to change if reviewers prefer the narrower version.

Not addressed here, from the issue discussion:

  • unit="NA" still parses to an UnrecognizedUnit and the column still becomes a Quantity. The suggestion of skipping the Quantity conversion entirely for UnrecognizedUnit columns is a table change with wider consequences, and is independent of the dtype question — with this PR the values are correct either way.
  • No warning is emitted when a unit string fails to parse on read. That behavior is unchanged.

taldcroft added this to the v8.1.0 milestone Aug 17, 2026
taldcroft added Bug API change PRs and issues that change an existing API, possibly requiring a deprecation period labels Aug 17, 2026

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.

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

Labels

API change PRs and issues that change an existing API, possibly requiring a deprecation period Bug Docs io.votable table

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: QTable converts int columns to floats

1 participant


Back | FazBrowse Home | New Git URL