diff --git a/.github/workflows/CFF-test.yml b/.github/workflows/CFF-test.yml index a79eb2fa805e..3e370fa76af4 100644 --- a/.github/workflows/CFF-test.yml +++ b/.github/workflows/CFF-test.yml @@ -1,6 +1,9 @@ name: Checking CITATION.cff on: + workflow_dispatch: + paths: + - "CITATION.cff" push: paths: - "CITATION.cff" @@ -19,7 +22,7 @@ jobs: cffconvert: runs-on: ubuntu-latest steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false - uses: citation-file-format/cffconvert-github-action@4cf11baa70a673bfdf9dad0acc7ee33b3f4b6084 # 2.0.0 diff --git a/.github/workflows/check_changelog.yml b/.github/workflows/check_changelog.yml index c4b0ebdaa5b8..54553ffef3f3 100644 --- a/.github/workflows/check_changelog.yml +++ b/.github/workflows/check_changelog.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'astropy/astropy' steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false - uses: scientific-python/action-towncrier-changelog@f9c7df9a9f8b55cb0c12d94d14b281e9bcd101c0 # v2.0.0 diff --git a/.github/workflows/ci_benchmark.yml b/.github/workflows/ci_benchmark.yml index a1af748d1809..2743b0b173ff 100644 --- a/.github/workflows/ci_benchmark.yml +++ b/.github/workflows/ci_benchmark.yml @@ -26,7 +26,7 @@ jobs: CCACHE_MAXSIZE: 400M steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/ci_cron_daily.yml b/.github/workflows/ci_cron_daily.yml index 73fde8cd9cc6..7d42affb1420 100644 --- a/.github/workflows/ci_cron_daily.yml +++ b/.github/workflows/ci_cron_daily.yml @@ -61,7 +61,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/ci_cron_monthly.yml b/.github/workflows/ci_cron_monthly.yml index 9a0c20445172..a0d2bfa351d7 100644 --- a/.github/workflows/ci_cron_monthly.yml +++ b/.github/workflows/ci_cron_monthly.yml @@ -59,11 +59,11 @@ jobs: - arch: s390x steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false fetch-depth: 0 - - uses: uraimo/run-on-arch-action@d94c13912ea685de38fccc1109385b83fd79427d # v3.0.1 + - uses: uraimo/run-on-arch-action@f9b26e3a1a408d5fd530d20c17b9f3f4428ff8d9 # v3.1.0 name: Run tests id: build with: @@ -104,4 +104,4 @@ jobs: pip install -U --no-build-isolation pyerfa pip install -v --no-build-isolation -e .[test] pip list - python3 -m pytest --pyargs astropy -m "not hypothesis" + python3 -m pytest --strict-markers --pyargs astropy -m "not hypothesis" diff --git a/.github/workflows/ci_cron_weekly.yml b/.github/workflows/ci_cron_weekly.yml index c607c6b6d12a..d42dddf5e5cf 100644 --- a/.github/workflows/ci_cron_weekly.yml +++ b/.github/workflows/ci_cron_weekly.yml @@ -70,10 +70,13 @@ jobs: python: '3.13' toxenv: py313-test-devinfra - - name: Python 3.13t (free-threading) with recommended dependencies + - name: Python 3.14t (free-threading) with recommended dependencies os: ubuntu-latest - python: '3.13t' - toxenv: py313t-test-recdeps + python: '3.14t' + toxenv: py314t-test-recdeps + PYTHON_GIL: 0 + # https://github.com/astropy/astropy/issues/19205 + PYTHON_CONTEXT_AWARE_WARNINGS: 0 - name: Documentation link check os: ubuntu-latest @@ -83,7 +86,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false fetch-depth: 0 @@ -103,7 +106,9 @@ jobs: run: python -m pip install --upgrade tox - name: Run tests run: tox ${{ matrix.toxargs}} -e ${{ matrix.toxenv}} -- ${{ matrix.toxposargs}} - + env: + PYTHON_GIL: ${{ matrix.PYTHON_GIL }} + PYTHON_CONTEXT_AWARE_WARNINGS: ${{ matrix.PYTHON_CONTEXT_AWARE_WARNINGS }} tests_more_architectures: @@ -135,11 +140,11 @@ jobs: - arch: armv7 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false fetch-depth: 0 - - uses: uraimo/run-on-arch-action@d94c13912ea685de38fccc1109385b83fd79427d # v3.0.1 + - uses: uraimo/run-on-arch-action@f9b26e3a1a408d5fd530d20c17b9f3f4428ff8d9 # v3.1.0 name: Run tests id: build with: @@ -185,4 +190,4 @@ jobs: pip install -U --no-build-isolation pyerfa ASTROPY_USE_SYSTEM_ALL=1 pip install -v --no-build-isolation -e .[test] pip list - python3 -m pytest --pyargs astropy -m "not hypothesis" + python3 -m pytest --strict-markers --pyargs astropy -m "not hypothesis" diff --git a/.github/workflows/ci_workflows.yml b/.github/workflows/ci_workflows.yml index 7195ed129484..3af2b4ab9109 100644 --- a/.github/workflows/ci_workflows.yml +++ b/.github/workflows/ci_workflows.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: push: branches: - main @@ -145,7 +146,7 @@ jobs: upload_to_pypi: false upload_to_anaconda: false test_extras: test - test_command: pytest -p no:warnings --astropy-header -m "not hypothesis" -k "not test_data_out_of_range and not test_set_locale and not TestQuantityTyping" --pyargs astropy + test_command: pytest -Wdefault --astropy-header -m "not hypothesis" -k "not test_data_out_of_range and not test_set_locale and not TestQuantityTyping" --strict-markers --pyargs astropy targets: | - cp311-manylinux_x86_64 @@ -165,7 +166,7 @@ jobs: name: Python 3.11 (build only, partial) with limited API (${{ matrix.os }}) needs: [initial_checks] steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d4c5c3add314..17678e6fd4d4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -12,6 +12,7 @@ name: "CodeQL" on: + workflow_dispatch: schedule: # run every Wednesday at 6am UTC - cron: '0 6 * * 3' @@ -38,7 +39,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/open_actions.yml b/.github/workflows/open_actions.yml index a64743ce0389..1733dac6807e 100644 --- a/.github/workflows/open_actions.yml +++ b/.github/workflows/open_actions.yml @@ -44,53 +44,6 @@ jobs: - [ ] 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.` }) - - name: Greet new contributors - uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - issue-message: > - Welcome to Astropy 👋 and thank you for your first issue! - - - A project member will respond to you as soon as possible; in - the meantime, please double-check the [guidelines for submitting - issues](https://github.com/astropy/astropy/blob/main/CONTRIBUTING.md#reporting-issues) - and make sure you've provided the requested details. - - - GitHub issues in the Astropy repository are used to track bug - reports and feature requests; If your issue poses a question about - how to use Astropy, please instead raise your question in the - [Astropy Discourse user - forum](https://community.openastronomy.org/c/astropy/8) and close - this issue. - - - If you feel that this issue has not been responded to in a timely - manner, please send a message directly to the [development - mailing list](http://groups.google.com/group/astropy-dev). If - the issue is urgent or sensitive in nature (e.g., a security - vulnerability) please send an e-mail directly to the private e-mail - feedback@astropy.org. - pr-message: > - Welcome to Astropy 👋 and congratulations on your first pull - request! 🎉 - - - A project member will respond to you as soon as possible; in the - meantime, please have a look over the [Checklist for Contributed - Code](https://github.com/astropy/astropy/blob/main/CONTRIBUTING.md#checklist-for-contributed-code) - and make sure you've addressed as many of the questions there as - possible. - - - If you feel that this pull request has not been responded to in a - timely manner, please send a message directly to the - [development mailing - list](http://groups.google.com/group/astropy-dev). If the issue is - urgent or sensitive in nature (e.g., a security vulnerability) - please send an e-mail directly to the private e-mail - feedback@astropy.org. - name: 'Comment Draft PR' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 if: github.event.pull_request.draft == true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b9ee5bca8197..d04f275fc07a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,12 +21,12 @@ concurrency: cancel-in-progress: true jobs: - build_and_publish: - # This does the actual wheel building and publishing as part of the cron job + build: + # This does the actual wheel building as part of the cron job # or if triggered manually via the workflow dispatch, or for a tag. permissions: contents: none - uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@d9b81a07e789d1b1921ab5fe33de2ddcbbd02c3f # v2.3.0 + uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@a138926f6e4f9667d1306c24f24f5bdcaa01fbab # v2.5.0 if: | github.repository == 'astropy/astropy' && ( startsWith(github.ref, 'refs/tags/v') || @@ -36,20 +36,14 @@ jobs: ) with: - # We upload to PyPI for all tags starting with v but not ones ending in .dev - upload_to_pypi: ${{ startsWith(github.ref, 'refs/tags/v') && !endsWith(github.ref, '.dev') && (github.event_name == 'push') }} + # We use trusted publishing so the upload is handled in a separate job below + upload_to_pypi: false + save_artifacts: true upload_to_anaconda: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} anaconda_user: astropy anaconda_package: astropy anaconda_keep_n_latest: 10 - - # For nightly wheels as well as when building with the 'Build all wheels' label, we disable - # the build isolation and explicitly install the latest developer version of numpy as well as - # the latest stable versions of all other build-time dependencies. - # KEEP IN SYNC WITH [build-system] from pyproject.toml env: | - CIBW_BEFORE_BUILD: '${{ ((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Build all wheels')) && 'pip install -U --pre --extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple setuptools>=77.0.0 setuptools_scm cython numpy>=0.0.dev0 extension-helpers pyerfa') || '' }}' - CIBW_BUILD_FRONTEND: '${{ ((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Build all wheels')) && 'build; args: --no-isolation') || 'build' }}' EXTENSION_HELPERS_PY_LIMITED_API: 'cp311' test_extras: test @@ -57,7 +51,7 @@ jobs: # currently fails, see https://github.com/astropy/astropy/issues/10409 # We also exclude test_set_locale as it sometimes relies on the correct locale # packages being installed, which it isn't always. - test_command: pytest -p no:warnings --astropy-header -m "not hypothesis" -k "not test_data_out_of_range and not test_set_locale and not TestQuantityTyping" --pyargs astropy + test_command: pytest -Wdefault --astropy-header -m "not hypothesis" -k "not test_data_out_of_range and not test_set_locale and not TestQuantityTyping" --strict-markers --pyargs astropy targets: | # Linux wheels - cp3*-manylinux_x86_64 @@ -78,5 +72,23 @@ jobs: - cp3*-win_arm64 secrets: - pypi_token: ${{ secrets.pypi_token }} anaconda_token: ${{ secrets.anaconda_token }} + + upload: + # Upload to PyPI using trusted publishing for all tags starting with v but not ones ending in .dev + if: startsWith(github.ref, 'refs/tags/v') && !endsWith(github.ref, '.dev') && github.event_name == 'push' + name: Upload release to PyPI + runs-on: ubuntu-latest + needs: [build] + environment: pypi + permissions: + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + merge-multiple: true + pattern: dist-* + path: dist + - name: Upload to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.github/workflows/update_astropy_iers_data_pin.yml b/.github/workflows/update_astropy_iers_data_pin.yml index 18a25b1a2a03..76afed341c60 100644 --- a/.github/workflows/update_astropy_iers_data_pin.yml +++ b/.github/workflows/update_astropy_iers_data_pin.yml @@ -5,8 +5,6 @@ name: Auto-update astropy-iers-data minimum version on: - schedule: - - cron: '0 0 2 * *' # Monthly workflow_dispatch: permissions: @@ -23,7 +21,7 @@ jobs: if: github.repository == 'astropy/astropy' steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false - name: Set up Python @@ -43,16 +41,17 @@ jobs: git commit -m "Update minimum required version of astropy-iers-data" fi - name: Create Pull Request - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 with: + base: ${{ github.ref_name }} branch: update-astropy-iers-data-pin branch-suffix: timestamp delete-branch: true - labels: no-changelog-entry-needed, utils.iers + labels: no-changelog-entry-needed, utils.iers, skip-basebranch-check title: Update minimum required version of astropy-iers-data body: | This is an automated update of the minimum version of astropy-iers-data package. - :pray: Please apply backport labels to any active backport branches for v6.0.x or later. :pray: + One pull request per active branch would be opened separately. Please apply proper milestone to each of them. :warning: Please close and re-open this pull request to trigger the CI. :warning: diff --git a/.mailmap b/.mailmap index f60bffd33dd4..49cea876467c 100644 --- a/.mailmap +++ b/.mailmap @@ -6,12 +6,13 @@ Adam Ginsburg Adele Plunkett Adrian Price-Whelan Adrian Price-Whelan -Akshat Dixit Akshat1Nar +Akshat Dixit Albert Y. Shih Aleh Khvalko Aleksi Suutarinen Alex Conley Alex Conley +Alex Fox <156043000+AlexFoxOSU@users.noreply.github.com> Alex Hagen Alex Rudy Alexander Bakanov @@ -41,6 +42,7 @@ Axel Donath Benjamin Alan Weaver Benjamin Alan Weaver +Ben Green <120208759+ScatteredComet@users.noreply.github.com> Benjamin Roulston Benjamin Winkel Bhavya Khandelwal @@ -49,7 +51,7 @@ Brett Graham Brett Morris Brett Morris Brett Morris -Brett Morris Brett M. Morris +Brett Morris Brian Soto Brian Svoboda Brigitta Sipőcz @@ -68,6 +70,7 @@ Christian Clauss Christoph Gohlke Christopher Bonnett Clara Brasseur +Clara Brasseur Clément Robert Clément Robert Craig Jones @@ -85,8 +88,7 @@ Daniel Datsev Daniel Datsev Daniel Giles Daniel Lenz -Daniel Ryan Dan Ryan -Daniel Ryan DanRyanIrish +Daniel Ryan Daria Cara Daria Cara <36781821+daria-cara@users.noreply.github.com> David Collom @@ -97,11 +99,12 @@ David Grant <33813984+DavoGrant@ David Kirkby David Pérez-Suárez David Shupe -Deen-Dot Deen-dot -Deen-Dot Deen-dot <80238871+Deen-dot@users.noreply.github.com> +Deen-Dot +Deen-Dot <80238871+Deen-dot@users.noreply.github.com> Demitri Muna Demitri Muna Demitri Muna +Dhruv Yadav Derek Homeier Derek Homeier Derek Homeier <709020+dhomeier@users.noreply.github.com> @@ -109,24 +112,28 @@ Diego Asterio de Zaballa Douglas Burke Dylan Gregersen Edward Gomez -Edward Slavich Ed Slavich +Edward Slavich Eduardo Olinto <90293761+olintoeduardo@users.noreply.github.com> Eero Vaher +Eesh Saxena Elijah Bernstein-Cooper Emily Deibert Emma Hogan Eric Depagne -Eric Koch +Eric Koch E. Madison Bray E. Madison Bray E. Madison Bray E. Rykoff Emir Karamehmetoglu -Emir Karamehmetoglu Emir +Emir Karamehmetoglu +Evan Jones <60061381+E-W-Jones@users.noreply.github.com> +Everett Schlawin +Everett Schlawin Esteban Pardo Sánchez Francesco Montesano -Gabriel Brammer Gabriel Brammer -Gabriel Brammer Gabe Brammer +Gabriel Brammer +Gabriel Brammer Gabriel Perren Gabriel Perren Geert Barentsen @@ -138,10 +145,12 @@ Graham Kanarek Guillaume Pernot Guillaume Pernot Gustavo Bragança +Henrike F. Hannes Breytenbach Hans Moritz Günther Hans Moritz Günther Harry Ferguson +Harshada Raut Henrik Norman Henrik Norman Henry Schreiner @@ -151,6 +160,8 @@ Humna Awan Igor Lemos Ivo Busko Ivo Busko +J. Berg +Jackson Hayward Jaime Andrés Jake VanderPlas Jake VanderPlas @@ -160,6 +171,7 @@ James McCormac James Tocknell James Tocknell James Turner +James Turner Jane Rigby Jani Šumak Jason Segnini <47617351+JasonS09@users.noreply.github.com> @@ -185,6 +197,7 @@ John Parejko Johnny Greco Johnny Greco Jon Carifio +Joren Hammudoglu Jonathan Foster Jonathan Foster Jonathan Gagne @@ -204,7 +217,8 @@ Kacper Kowalik Kacper Kowalik Kang Wang Karan Grover -Karl Gordon Karl D. Gordon +Kartavay Verma +Karl Gordon Karl Vyhmeister Kelle Cruz Kevin Gullikson @@ -214,6 +228,7 @@ Kris Stern Kunam Balaram Reddy Kyle Barbary Kyle Oman +Kyle Oman Larry Bradley Larry Bradley Laura Watkins @@ -230,9 +245,12 @@ Lisa Walter Loïc Séguin-C Luke G. Bouma Luke Kelley -Luz Paz luz paz -Maximilian Linhoff Maximilian Nöthe +Luz Paz +Maximilian Linhoff Maximilian Linhoff +Maximilian Linhoff +M. Atakan Gürkan +M. S. R. Dinesh <39215691+msrdinesh@users.noreply.github.com> Madhura Parikh Magali Mebsout Magnus Persson @@ -240,15 +258,21 @@ Maneesh Yadav Mangala Gowri Krishnamoorthy Manon Marchand Marcello Nascif <118627858+marcellonascif@users.noreply.github.com> +Marten van Kerkwijk Marten van Kerkwijk -Marten van Kerkwijk Marten H. van Kerkwijk -Marten van Kerkwijk Marten Henric van Kerkwijk +Marten van Kerkwijk +Marten van Kerkwijk +Marten van Kerkwijk Matt Davis Matteo Bachetti Matthew Craig Matthias Stein Matthieu Baumann +Matthieu Bec +Matthieu Bec Mavani Bhautik +Mengjia Shang +Michael Belfrage <216956+mikez@users.noreply.github.com> Michael Brewer Michael Brewer Michael Hirsch @@ -265,7 +289,7 @@ Mike Alexandersen Mikhail Minin Moataz Hisham Mridul Seth -Mubin Manasia Mubin17 <48038715+Mubin17@users.noreply.github.com> +Mubin Manasia <48038715+Mubin17@users.noreply.github.com> Nabil Freij Nadia Dencheva Nadia Dencheva @@ -284,7 +308,7 @@ Ole Streicher Ole Streicher Parikshit Sakurikar Patricio Rojo -Pauline Barmby +Pauline Barmby Perry Greenfield P. L. Lim <2090236+pllim@users.noreply.github.com> P. L. Lim <2090236+pllim@users.noreply.github.com> @@ -295,13 +319,15 @@ Pritish Chakraborty Rachel Guo Ricardo Fonseca Ricardo Fonseca -Richard R -Richard R <58728519+rrjbca@users.noreply.github.com> +Richard R. +Richard R. <58728519+rrjbca@users.noreply.github.com> +R. Virinchi +Reem Hamraz Ricky O'Steen <39831871+rosteen@users.noreply.github.com> Ritiek Malhotra Ritwick DSouza Robel Geda -Robel Geda robelgeda +Robel Geda Rohan Rajpal Rohit Kapoor Rohit Patil @@ -317,7 +343,7 @@ Sara Ogaz Sarah Graves Sashank Mishra Sebastian Meßlinger <39328484+krachyon@users.noreply.github.com> -Sebastian Meßlinger Sebastian +Sebastian Meßlinger Sergio Pascual Shane Maloney Shane Maloney @@ -327,23 +353,25 @@ Shilpi Jain Shivansh Mishra Shivansh Mishra Simon Alinder <92031780+AlinderS@users.noreply.github.com> -Simon Alinder AlinderS +Simon Alinder Simon Conseil Simon Conseil Simon Conseil Simon Conseil Simon Conseil Simon Liedtke -Somia Floret Somia FLORET -Somia Floret Somia Floret <57394764+somilia@users.noreply.github.com> +Somia Floret +Somia Floret <57394764+somilia@users.noreply.github.com> Sourabh Cheedella +Stelios Voutsinas Steve Crawford Steve Crawford Steve Crawford -Stuart Littlefair +Stuart Littlefair Stuart Mumford Sudheesh Singanamalla Sudheesh Singanamalla +Surya K. Sushobhana Patra Tanvi Pooranmal Meena <96572616+mtanvi19@users.noreply.github.com> <96572616+TanviPooranmal@users.noreply.github.com> Thomas Erben @@ -352,16 +380,21 @@ Thompson Le Blanc Tiago Gomes Tim Jenness Tim Jenness -Timothy P. Ellsworth Bowers Timothy Ellsworth Bowers +Timothy P. Ellsworth Bowers Tom Aldcroft Tom Donaldson Tom J Wilson Tyler Finethy VSN Reddy Janga -Vishnunarayan K I +Vishnunarayan K. I. +Varun Kasyap Pentamaraju +Vishwas Vital Fernández William Jamieson +William Jamieson Yannick Copin +Yaocheng Chen +Yaocheng Chen <51320658+yaochengchen@users.noreply.github.com> Yash Kumar Yash Nandwana Yash Sharma @@ -369,5 +402,4 @@ Yingqi Ying <33911276+dyq0811@users.noreply.github.com> Zach Edwards Zac Hatfield-Dodds Zé Vinicius -Zhiyuan Ma Jerry Ma -Zhiyuan Ma Zhiyuan Ma +Zhiyuan Ma diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95bdfd1e42c4..5362c8e31205 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,6 +35,10 @@ repos: - id: end-of-file-fixer # Makes sure files end in a newline and only a newline. exclude: ".*(data.*|extern.*|licenses.*|_static.*|_parsetab.py)$" + - id: mixed-line-ending + args: ["--fix=lf"] + # Forces all text files to use LF line endings. + exclude: ".*(data.*|extern.*|licenses.*|_static.*|_parsetab.py)$" # - id: fix-encoding-pragma # covered by pyupgrade - id: trailing-whitespace # Trims trailing whitespace. diff --git a/.readthedocs.yaml b/.readthedocs.yaml index de4a5af85a08..322fa423119f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,7 +3,7 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "mambaforge-4.10" + python: "miniforge3-25.11" jobs: post_checkout: - git fetch --unshallow || true diff --git a/CHANGES.rst b/CHANGES.rst index bc647bb417fd..f5a46ec19ff6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,681 @@ +Version v7.2.2 (2026-07-03) +=========================== + +API Changes +----------- + +astropy.utils +^^^^^^^^^^^^^ + +- Astropy will now no longer download the latest IERS-A table over the network unless it is actually needed, and will also not warn about download issues unless all mirrors fail. The ``IERS_Auto.open`` method now always reads the table bundled in ``astropy-iers-data`` (or a ``finals2000A.all`` file in the current working directory, if present), and the auto-download is deferred until a calculation actually requests ``UT1-UTC`` or polar motion values beyond the predictive range of the bundled table while that table is older than ``astropy.utils.iers.conf.auto_max_age`` days. This avoids network access, and the associated warnings when offline, in the common case where the bundled table is recent enough. [#19995] + + +Bug Fixes +--------- + +astropy.modeling +^^^^^^^^^^^^^^^^ + +- Bugfix for a ``Parameter`` with a custom ``getter`` / ``setter`` pair raising an + ``AttributeError`` when accessing the ``value`` of the ``Parameter`` before any + value for the parameter has been set. The ``Parameter`` will now return the result + of the ``getter`` applied to a ``nan`` value in this case which is consistent + with the behavior of a ``Parameter`` without a custom ``getter`` / ``setter`` pair. [#19979] + +astropy.table +^^^^^^^^^^^^^ + +- Fixed a bug where a ``Table.loc[]`` range query using the default + ``SortedArray`` index engine silently dropped rows when the upper bound + matched a value that appears more than once in the indexed column. [#19903] + +- Fixed a bug where a ``Table.loc[]`` range query using the ``BST`` index + engine returned rows in scrambled order if rows had been added after the + index was created. [#19912] + +astropy.units +^^^^^^^^^^^^^ + +- Fixed a bug where ``numpy.lib.recfunctions.structured_to_unstructured`` failed when applied to a ``StructuredQuantity`` by improving unit extraction logic. [#19106] + +astropy.wcs +^^^^^^^^^^^ + +- Fix caching of ``wcs.world_axis_object_components`` and ``world_axis_object_classes`` in cases where the equinox was NaN [#19898] + + +Version 7.2.1 (2026-06-16) +========================== + + +Bug Fixes +--------- + +astropy.config +^^^^^^^^^^^^^^ + +- Disabling thread concurrency within ``set_temp_cache`` and ``set_temp_config`` + context managers, ensuring thread safety. [#19559] + +astropy.coordinates +^^^^^^^^^^^^^^^^^^^ + +- The ``refresh_cache`` parameter of ``EarthLocation.of_site()`` and + ``EarthLocation.get_site_names()`` methods now works. [#18687] + +- Relativistic Doppler shifts are now applied to ``SpectralCoord`` in the correct + direction even if the ``astropy.units.spectral()`` equivalency is enabled. + Previously enabling the equivalency could cause wrong results with the + ``SpectralCoord.to_rest()``, + ``SpectralCoord.with_observer_stationary_relative_to()`` or + ``SpectralCoord.with_radial_velocity_shift()`` methods. [#19001] + +- Fixed a broadcasting bug in ``astropy.uncertainty.distributions.uniform`` where + multi-dimensional inputs (``ndim >= 2``) would raise a ``ValueError``. [#19308] + +- Send a User-Agent header to the OpenStreetMap API in ``EarthLocation.of_address`` + to fix access denied errors. [#19330] + +- Fixed a missing f-string in a TypeError from ``BaseAffineTransform._apply_transform`` that caused ``{data.__class__}`` to appear literally in the error message. [#19403] + +astropy.io.fits +^^^^^^^^^^^^^^^ + +- Fix ``getdata()``'s lower and upper keywords. [#19023] + +- ``np.char.chararray`` being deprecated in Numpy 2.5, in a future version + ``io.fits`` will return a normal array instead of a ``chararray`` for string + columns. As a consequence the special chararray methods will be deprecated in + version 8.0 (e.g., ``.rstrip()`` or ``.decode()``). In version 7.2.x deprecation + warnings from Numpy are simply filtered. [#19335] + +- Fix a bug which caused CompImageHDU to have both SIMPLE and XTENSION keywords when initialized from a PrimaryHDU header. [#19362] + +- Fix support for reading FITS files using image tile compression with UNCOMPRESSED_DATA columns. [#19363] + +- Fixed a bug that caused reading FITS files to not work properly on WASM when relying on the default memmap settings. [#19367] + +- Fixed silent data corruption in ``FITS_rec.__setitem__`` when using negative + slice indices. Assigning to slices like ``data[-2:] = new_rows`` previously + wrote to the wrong rows because negative indices were clamped to 0 instead of + being resolved relative to the array length. [#19404] + +- Fixed a bug that caused compressed FITS files to become corrupted when opened in update mode and modifying the header. [#19416] + +- Fix a bug that caused header verification for CompImageHDU to not work correctly and + in some cases produce corrupt files when the decompressed header was a primary HDU + header not an extension header. [#19438] + +- Prevent creating RICE_1 or PLIO_1 compressed files with (u)int64 data. If + possible without overflow data is converted to (u)int32, otherwise an error is + raised. [#19592] + +- Fix a bug that caused uint64 data to be silently shifted by 2**63 when + round-tripping through compressed FITS files (the FITS BZERO=2**63 offset was + missing at compression time but still applied on read). Extend the existing + RICE_1 and PLIO_1 64-bit-to-32-bit conversion fallback to also cover uint64 + input and HCOMPRESS_1, and fix big-endian 64-bit input being silently truncated + by the conversion check instead of raising. Reject PLIO_1 with unsigned + multi-byte input outright, since the BZERO convention used to store unsigned + FITS data produces negative values that PLIO cannot encode. [#19670] + +- Fix a bug that caused int8 data to be shifted by 128 when round-tripping + through compressed FITS files (the FITS BZERO=-128 offset was missing at + compression time but still applied on read), so writing ``[0, 1]`` and + reading back returned ``[-128, -127]``. [#19738] + +astropy.modeling +^^^^^^^^^^^^^^^^ + +- Bugfix for ``Parameter.value`` accessor when the parameter value has not been set yet. [#19189] + +astropy.nddata +^^^^^^^^^^^^^^ + +- Fix unexpected behavior in ``Cutout2D.plot_on_original()``. The displayed region spanned pixels not included in the cutout array. [#19829] + +astropy.stats +^^^^^^^^^^^^^ + +- Fixed pickling of ``SigmaClip`` objects when Bottleneck is installed. ``_dtype_dispatch`` returned a local closure which cannot be pickled; replaced with a picklable ``_DtypeDispatch`` class. [#19372] + +astropy.table +^^^^^^^^^^^^^ + +- Tables can now be joined and stacked also if they contain columns with + user-defined data types such as ``QuadPrecDtype``. [#19199] + +- Fixed ``AssertionError`` when passing ``numpy.bool_`` as the + ``index`` argument to ``Table.to_pandas()`` or ``Table.to_dataframe()``. + The correct ``ValueError`` is now raised instead. [#19358] + +- Fixed table index getting corrupted when a row assignment raises an exception + mid-update. The index is now properly restored to its original state on failure. [#19450] + +- Fixes a problem where deepcopying a MaskedColumn did not correctly create the ``info`` + attribute. This resulted in an inability to print the column after the deepcopy. [#19466] + +astropy.time +^^^^^^^^^^^^ + +- Fixed missing ``goto fail`` in ``create_parser`` in ``parse_times.c`` after setting a ``ValueError`` for invalid parameter array size, preventing execution from continuing with an exception already set. [#19368] + +astropy.units +^^^^^^^^^^^^^ + +- Fixed a bug in the ``np.average`` function when weights with units and a + different shape to the input array were passed and the optionally-returned sum + of the weights was requested. The sum of the weights now has correct units. [#19055] + +astropy.utils +^^^^^^^^^^^^^ + +- ``AttributeError`` will no longer occur when attempting to pickle an unbound ``DataInfo`` instance. [#19141] + +- Pickling ``Masked`` subclasses with an initialized ``info`` attribute no longer fails. [#19142] + +- ``ShapedLikeNDArray.take()`` now raises ``NotImplementedError`` when ``out`` is passed, instead of returning the exception object. [#19351] + +- Ensure that ``utils.masked.get_data_and_mask`` works properly with containers, + returning ``None`` for the mask if ``masked`` is not set (instead of returning + an all-False array). [#19534] + +- The dummy file object used by ``astropy.utils.misc.silence`` to replace + ``sys.stdout``/``sys.stderr`` now implements ``flush()`` and ``isatty()``, + so importing libraries that probe the stream (e.g. IPython 9.13 at import + time) no longer raises ``AttributeError`` under ``silence``. [#19594] + +- Fixed a bug where values returned by ``cache_contents`` could include files and symlinks, instead of just directories. [#19672] + +- Add missing ``stacklevel`` arguments in warnings emitted from ``astropy.utils.data`` APIs [#19700] + +astropy.visualization +^^^^^^^^^^^^^^^^^^^^^ + +- Fixed issues with the placement of WCSAxes tick labels for non-perpendicular grids or ticks. [#19057] + +astropy.wcs +^^^^^^^^^^^ + +- Fixed a bug where degree units were hardcoded into the FITS WCS APE 14 ``world_to_pixel`` method. [#19506] + +- Lazily-populated caches on a ``WCS`` (such as the internal + ``world_axis_object_components``/``world_axis_object_classes`` cache) are no + longer included in the pickled state. They are regenerated on demand after + unpickling, which keeps pickling robust even when a cache entry holds a + non-picklable value. + + Fixed a bug where the ``preserve_units`` option passed to the ``WCS`` + constructor was silently reset to ``False`` when a ``WCS`` object was pickled + and unpickled. [#19591] + +- Fix reference-count handling after ``PyList_SetItem`` steals references. [#19720] + +- Made the WCS coordinate transform paths (``pixel_to_world``, ``world_to_pixel``, + ``all_pix2world``, ``wcs_pix2world``, ``wcs_world2pix``, ``mix``) safe to call + concurrently on a shared ``WCS`` instance from multiple threads. [#19819] + +Other Changes and Additions +--------------------------- + +- Publishing files to PyPI is now done using the Trusted Publisher mechanism (https://docs.pypi.org/trusted-publishers/). [#19347] + +Version 7.2.0 (2025-11-25) +========================== + + +New Features +------------ + +astropy.constants +^^^^^^^^^^^^^^^^^ + +- Added CODATA 2022 support in ``astropy.constants``. + + This update affects the following constants while the rest are unchanged from CODATA 2018: + + - ``m_p`` (Proton mass) + - ``m_n`` (Neutron mass) + - ``m_e`` (Electron mass) + - ``u`` (Atomic mass) + - ``eps0`` (Vacuum electric permittivity) + - ``Ryd`` (Rydberg constant) + - ``a0`` (Bohr radius) + - ``muB`` (Bohr magneton) + - ``alpha`` (Fine-structure constant) + - ``mu0`` (Vacuum magnetic permeability) + - ``sigma_T`` (Thomson scattering cross-section) [#18118] + +astropy.coordinates +^^^^^^^^^^^^^^^^^^^ + +- Allow ``np.concatenate``, ``np.stack`` and similar numpy functions to + be applied on representations and differentials. + + They can also be applied to coordinate frames and ``SkyCoord``, though + with the same limitation as for setting elements of frames and + coordinates: all frame attributes have to be scalars (or arrays with + only identical elements). [#18193] + +- The results of ``match_coordinates_3d()``, ``match_coordinates_sky()``, + ``search_around_3d()`` and ``search_around_sky()`` and the corresponding + ``SkyCoord`` methods now have named attributes. [#18459] + +astropy.cosmology +^^^^^^^^^^^^^^^^^ + +- The trait ``astropy.cosmology.traits.CurvatureComponent`` has been added to work with + objects that have attributes and methods related to the global curvature. [#18232] + +- The trait ``astropy.cosmology.traits.HubbleParameter`` has been added to work with objects that have attributes and methods related to the Hubble parameter. [#18271] + +- The trait ``astropy.cosmology.traits.DarkEnergyComponent`` has been added to work with objects that have attributes and methods related to the Dark Energy component. [#18447] + +- Cosmology methods now exclusively return arrays, not floats or other scalars. [#18632] + +- The trait ``astropy.cosmology.traits.DarkMatterComponent`` has been added to work with + objects that have attributes and methods related to dark matter. [#18760] + +- The trait ``astropy.cosmology.traits.MatterComponent`` has been added to work with + objects that have attributes and methods related to matter density. + The trait ``astropy.cosmology.traits.BaryonComponent`` has been added to work with + objects that have attributes and methods related to baryonic matter. + The trait ``astropy.cosmology.traits.CriticalDensity`` has been added to work with + objects that have attributes and methods related to the critical density. [#18769] + +- The trait ``astropy.cosmology.traits.PhotonComponent`` has been added to work with objects that have attributes and methods related to photons. [#18787] + +- The trait ``astropy.cosmology.traits.TotalComponent`` has been added to work with objects that have attributes and methods related to the total density component of the universe. [#18794] + +astropy.io.ascii +^^^^^^^^^^^^^^^^ + +- CDS table reader will find the metadata for gzipped tables in the accompanying ReadMe file. [#18506] + +astropy.io.fits +^^^^^^^^^^^^^^^ + +- Enable color and suggestion-on-typos in all ``argparse`` CLIs for Python 3.14 + (``fitscheck``, ``fitsdiff``, ``fitsheader`` and ``fitsinfo``). [#18151] + +- Allow reading a FITS file hosted on a cloud resource like Amazon S3 via + ``Table.read()``. This is done with a new ``fsspec_kwargs`` dict argument + that gets passed through to ``fsspec`` to access cloud resources. [#18379] + +- It is now possible to check the existence of ``Columns`` in ``ColDefs`` by using the membership operator. [#18717] + +astropy.io.misc +^^^^^^^^^^^^^^^ + +- Added a new ECSV table reading module that supports different backend engines for the + CSV data parsing. In addition to the default "io.ascii" engine, this includes engines + that use the PyArrow and Pandas CSV readers. These can be up to 16 times faster and are + more memory efficient than the native astropy ECSV reader. To get help with this + interface run ``Table.read.help(format="ecsv")``. [#18267] + +- Improve support for compressed file formats in the ECSV and the pyarrow CSV + Table readers. All formats supported by ``astropy.utils.data.get_readable_fileobj()`` + (currently gzip, bzip2, lzma (xz) or lzw (Z)) will now work with these readers. [#18712] + +astropy.io.registry +^^^^^^^^^^^^^^^^^^^ + +- Allow setting EXTNAME when writing a ``Table`` to a FITS file, e.g. + ``tbl.write("filename.fits", name="CAT", append=True)``. [#18470] + +astropy.io.votable +^^^^^^^^^^^^^^^^^^ + +- Enable color and suggestion-on-typos in ``volint`` CLI for Python 3.14 [#18151] + +- Modified the constructor for ``astropy.io.votable.tree.TableElement`` to use the version configuration information from the parent ``VOTableFile`` instance. This allows for better handling of version-specific features and ensures that the table element is created with the correct context regarding the VOTable version. [#18366] + +astropy.modeling +^^^^^^^^^^^^^^^^ + +- Add support for unit change propagation through the ``|`` (model composition) operator, + using either `~astropy.modeling.compose_models_with_units` or by setting the + ``unit_change_composition`` attribute on the model after composition. [#17304] + +astropy.nddata +^^^^^^^^^^^^^^ + +- The ``interpret_bit_flags`` function now strips whitespace from flag names. [#18205] + +astropy.samp +^^^^^^^^^^^^ + +- Enable color and suggestion-on-typos in ``samp_hub`` CLI for Python 3.14 [#18151] + +astropy.table +^^^^^^^^^^^^^ + +- Enable color and suggestion-on-typos in ``showtable`` CLI for Python 3.14 [#18151] + +- Added generic ``from_df`` and ``to_df`` methods to ``astropy.Table`` using + ``narwhals``. These methods provide a unified interface for converting between + Astropy Tables and various DataFrame formats (pandas, polars, pyarrow, etc.) + through the narwhals library. The ``to_df`` method converts an Astropy Table + to any supported DataFrame format, while ``from_df`` creates an Astropy Table + from any narwhals-compatible DataFrame. Narwhals is a lightweight compatibility + layer that provides a unified API across different DataFrame libraries, allowing + seamless interoperability without requiring all DataFrame libraries as dependencies. [#18435] + +- Setting the ``units`` or ``descriptions`` of ``QTable`` and ``Table`` + has been made more flexible for tables with optional columns that may + or may not appear in the data. This applies to directly creating a table + as well as reading formatted data with the ``read()`` method. + + In both cases you can supply ``units`` and ``description`` arguments as a + ``dict`` that specifies the units and descriptions for column names in + the table. Previously, if the input table did not contain a column that + was specified in the ``units`` or ``description`` dict, a ``ValueError`` + was raised. Now, such columns are simply ignored. [#18641] + +- A new method has been added for accessing a table index for tables with multiple + indices. You can now select the index with the ``with_index(index_id)`` method of the + ``.loc``, ``.iloc``, and ``.loc_indices`` properties. For example, for a table ``t`` + which has two indices on columns ``"a"`` and ``"b"`` respectively, + ``t.loc.with_index("b")[2]`` will use index ``"b"`` to find all the table rows where + ``t["b"] == 2``. Doing this query using the previous syntax ``t.loc["b", 2]`` is + deprecated and this functionality is planned for removal in astropy 9.0. + + In addition, support has been added for using ``.loc``, ``.iloc``, and ``.loc_indices`` + with an index based on two or more key columns. Previously this raised a ``ValueError``. [#18680] + +astropy.time +^^^^^^^^^^^^ + +- Allow ``np.concatenate``, ``np.stack`` and similar numpy functions to + be applied on ``Time`` and ``TimeDelta`` instances. [#18193] + +- Add a new time format ``galex`` for the GALEX satellite. + + In GALEX data, due to uncertainty in the spacecraft clock, the absolute time is only accurate to + about 1-10 seconds while the relative time within an observation is better than 0.005 s or so, + except on days with leap seconds, where relative times can be wrong by up to 1 s. + See question 101.2 in https://www.galex.caltech.edu/researcher/faq.html [#18330] + +astropy.units +^^^^^^^^^^^^^ + +- Some unit formats have deprecated units and converting such units to strings + emits a warning. + The new ``deprecations`` parameter of the unit ``to_string()`` methods allows + automatically converting deprecated units (if possible), silencing the warnings + or raising them as errors instead. [#18586] + +astropy.visualization +^^^^^^^^^^^^^^^^^^^^^ + +- Enable color and suggestion-on-typos in ``fits2bitmap`` CLI for Python 3.14 [#18151] + +- Added ``show_decimal_unit`` to ``set_major_formatter`` to control whether + or not units are shown in decimal mode. [#18312] + +- Added the methods ``set_visible()`` and ``set_position()`` to control the visibility and position of ticks, tick labels, and axis labels in a single call. + + Also added ``get_ticks_visible()``, ``get_ticklabel_visible()``, and ``get_axislabel_visible()`` methods to get the visibility state of each coordinate element. [#18443] + +- Added an image interval option (``SymmetricInterval``) for specifying a + symmetric extent about a midpoint, and the extent that contains both the image + minimum and maximum can be automatically determined. [#18602] + +astropy.wcs +^^^^^^^^^^^ + +- Enable color and suggestion-on-typos in all ``wcslint`` CLI for Python 3.14 [#18151] + +- Added a ``perserve_units`` keyword argument to ``WCS`` to optionally request + that units are not converted to SI (the default behavior is for celestial axes + to have units converted to degrees, and spectral axes to m or Hz). [#18338] + + +API Changes +----------- + +astropy.coordinates +^^^^^^^^^^^^^^^^^^^ + +- The functionality of ``astropy.coordinates.concatenate`` and + ``astropy.coordinates.concatenate_representations`` is now available using + ``np.concatenate``. Hence, these functions are being deprecated, emitting an + ``AstropyPendingDeprecationWarning`` starting with astropy 7.2. This will be + followed by a regular deprecation warning in astropy 8.0, and removal in 9.0. [#18193] + +- The ``matrix_utilities`` module was not included in the ``astropy`` API + documentation, but it was nonetheless explicitly referred to in some of the + other documentation. + This made it unclear if the functions in the module are public or private. + The public matrix utilities ``is_rotation_or_reflection()`` and + ``rotation_matrix()`` have been made available from the ``astropy.coordinates`` + namespace and should be imported from there. + Functions not available from the ``astropy.coordinate`` namespace are private + and may be changed or removed without warning. + However, three functions have been explicitly deprecated, despite being + private, as a courtesy to existing users. + ``matrix_utilites.angle_axis()`` and ``matrix_utilites.is_rotation()`` are + deprecated without replacement. + ``matrix_utilities.is_O3()`` is deprecated and the public + ``is_rotation_or_reflection()`` function can be used as a replacement. [#18418] + +- The undocumented ``earth_orientation`` module has been removed. [#18638] + +- ``astropy`` prefers reading data required for ``EarthLocation.of_site()`` from + a local cache and tries downloading (and caching) the data from the Internet if + the cache is empty. + As a last resort ``astropy`` has so far read a small bundled data file that + provided data for Greenwich as the single entry, but now ``astropy`` will raise + an error. [#18649] + +astropy.io.registry +^^^^^^^^^^^^^^^^^^^ + +- ``UnifiedInputRegistry`` and ``UnifiedOutputRegistry``'s ``delay_doc_updates`` + method's effect is disabled under Python's optimized mode (``-OO`` flag). [#17572] + +astropy.io.votable +^^^^^^^^^^^^^^^^^^ + +- Added a ``config`` property to ``astropy.io.votable.tree.VOTableFile``. + This property can be passed to the ``config`` parameter of constructors that need to know the associated VOTable version, such as ``TimeSys`` and ``CooSys``. [#18366] + +astropy.table +^^^^^^^^^^^^^ + +- Add additional detail to the text of the ``ValueError`` that is raised when + ``pprint`` cannot parse a column format string. [#17631] + +- Selecting a table index in the ``.loc``, ``.iloc``, or ``.loc_indices`` properties by + passing the index identifier as the first element of the item is deprecated and is + planned for removal in astropy 9.0. For example, if a table ``t`` has two indices on + columns ``"a"`` and ``"b"`` respectively, then ``t.loc["b", 2]`` (to find table rows + where ``t["b"] == 2``) is deprecated. This is replaced by ``t.loc.with_index("b")[2]``. [#18680] + +astropy.tests +^^^^^^^^^^^^^ + +- API changes towards a future deprecation of astropy test runner: + + * ``astropy.tests.runner.keyword`` is removed from public API. + It is used internally as a decorator within astropy test runner and + its exposure as public API was a mistake. In the future, it will be + removed without any deprecation. + * ``astropy.test``, ``astropy.tests.runner.TestRunnerBase``, and ``astropy.tests.runner.TestRunner`` + are now pending deprecation (``AstropyPendingDeprecationWarning``). + This will also affect downstream ``packagename.test`` generated using ``TestRunner``. + They may start to emit ``AstropyDeprecationWarning`` in v8.0 (but no earlier). [#17874] + +astropy.utils +^^^^^^^^^^^^^ + +- The ``isiterable()`` utility is deprecated. + ``numpy.iterable()`` can be used as a drop-in replacement. [#18053] + +- ``astropy.utils.metadata.MergeStrategy`` no longer modifies the ``merge()`` + methods of its subclasses at runtime to re-raise all exceptions as + ``MergeConflictError``. + This does not affect the functionality of ``MergeStrategy`` subclasses within + the ``astropy`` metadata merging machinery. [#18518] + +astropy.visualization +^^^^^^^^^^^^^^^^^^^^^ + +- A warning is now emitted for each axis name which is + invalid in ``set_ticklabel_position``, ``set_axislabel_position``, + and ``set_ticks_position``. This is a deprecation warning, + and in future invalid axis names will result in an error. [#18324] + +- A warning is now emitted if arguments are given to the getter method + ``get_axislabel_visibility_rule``. This is a deprecation warning, and in + future, giving arguments to this method will result in an error. [#18792] + + +Bug Fixes +--------- + +astropy.config +^^^^^^^^^^^^^^ + +- ``get_config_dir()`` and ``get_cache_dir()`` now emit warnings in all cases + where the ``XDG_CACHE_HOME`` (``XDG_CONFIG_HOME``, respectively) environment + variable doesn't meet internal assumptions and is ignored as a result. [#17934] + +astropy.coordinates +^^^^^^^^^^^^^^^^^^^ + +- The ``angle`` argument of the ``rotation_matrix()`` function can now be any + angle-like value, like its docstring states. + Previously some angle-like values (e.g. angle-like strings) were erroneously + rejected. [#18504] + +astropy.io.fits +^^^^^^^^^^^^^^^ + +- Fix bug with heap which was not updated after a VLA column is modified. [#18487] + +- Make ``fitscheck`` verify all HDUs before listing errors. [#18574] + +- Fix calculation of DATASUM/CHECKSUM for heap data in ``BinTableHDU``. [#18681] + +- Fixed a bug in ``fitsdiff`` script where failing to read a single file could + crash the entire program. A warning is now printed instead, and such files + are simply ignored. [#18882] + +astropy.io.misc +^^^^^^^^^^^^^^^ + +- Fixed a bug where writing a table to ECSV fails if meta + contains a value that is a numpy string. [#18677] + +astropy.io.votable +^^^^^^^^^^^^^^^^^^ + +- Updated IVOA UCD1+ controlled vocabulary from version 1.5 to 1.6. This adds + support for new atmospheric observation terms including ``obs.atmos.wind``, + ``obs.atmos.humidity``, ``obs.atmos.rain``, ``obs.atmos.turbulence``, + ``obs.atmos.turbulence.isoplanatic``, ``obs.atmos.water``, and + ``phys.temperature.dew`` which are now recognized when parsing UCDs with + ``check_controlled_vocabulary=True``. [#18483] + +- Fixed a bug in ``add_data_origin_info()`` where ``content`` is ignored for some INFO names. [#18771] + +astropy.modeling +^^^^^^^^^^^^^^^^ + +- Fixed a bug in ``modeling.tabular`` models when the ``lookup_table`` is a Quantity, where the result might lose its unit in some cases. [#18958] + +astropy.nddata +^^^^^^^^^^^^^^ + +- Fixed unexpected upcasting to 64 bits when doing arithmetic with Python scalars + on ``numpy`` 2. + + Don't upcast ``NDData`` unnecessarily when doing arithmetic involving a single + unit (consistent with the behaviour when there are no units). Upcasting still + occurs if an operand's unit gets converted to match the other, or where + required by the other operand's dtype. [#18392] + +astropy.samp +^^^^^^^^^^^^ + +- ``SAMPHubServer._call_and_wait`` raises a new ``SAMPProxyTimeoutError`` (derived from ``SAMPProxyError``) exception on timeout. + This allows client code to more easily distinguish timeouts from other kind of exceptions. [#18169] + +astropy.stats +^^^^^^^^^^^^^ + +- ``poisson_conf_interval`` ``kraft-burrows-nousek`` no longer fails for large N. [#18676] + +astropy.table +^^^^^^^^^^^^^ + +- Fix a bug when slicing a table that has a multi-column index. Previously, after slicing + the table then ``tbl.indices`` would show duplicates of the multi-column index, one for + each column in the index. The underlying indices on the index columns were incorrectly + distinct objects instead of the expected reference to a single index object. [#18694] + +- Fix bugs when indexing a ``QTable`` with a ``Quantity`` column. Previously, after adding + the index then indexed item access via with a ``Quantity`` or slicing was failing. [#18725] + +- Fix a bug where the ECSV writer was not correctly quoting column names if the first name + starts with the "#" character or any names contain leading/trailing whitespace. In this + situation, all column names are now surrounded by double quotes per the ECSV standard. + Likewise the ECSV reader was incorrectly stripping surrounding whitespace from column + names, leading to a consistency check failure when reading. [#18752] + +- Fixed a bug when writing ``Table`` to FITS files, if the table contained masked arrays of integers. [#18818] + +astropy.units +^^^^^^^^^^^^^ + +- The string representations of the liter with the different ``astropy`` unit + formatters are now more consistent with each other. + This change only affects converting units to strings, it has no effect on + parsing strings to units. [#18500] + +- So far only the ``"cds"`` unit format has been capable of parsing the string + ``"as"`` as the attosecond, but now the other unit formats recognize that + string too. [#18723] + +- The ``"ogip"`` unit formatter can now parse strings that include signed + fractions in the exponent, e.g. ``u.Unit("m**(-1/2)", format="ogip")``. [#18776] + +astropy.utils +^^^^^^^^^^^^^ + +- If ``numpy.msort()`` is called with a ``Masked`` array then ``astropy`` no + longer erroneously hides the deprecation warning (with ``numpy`` versions + 1.24-1.26). [#18173] + +- For ``numpy < 2.0``, applying ``np.atleast_*d`` to iterables of most astropy + classes will now return a list of instances instead of a tuple, to match the + behaviour for arrays. For numpy >= 2.0, tuples continue to be returned. [#18193] + +astropy.visualization +^^^^^^^^^^^^^^^^^^^^^ + +- Fixed an image-normalization bug where the interval on a ``ImageNormalize`` + instance could be ignored when plotting. [#18590] + +astropy.wcs +^^^^^^^^^^^ + +- Fixed a bug that caused world_to_array_index to return lists instead of Numpy arrays. [#18730] + +Other Changes and Additions +--------------------------- + +- The minimum required NumPy version is now 1.24. [#18160] + +- The minimum required Matplotlib version is now 3.8.0. [#18164] + +- Bundled ``expat`` is updated to version 2.7.3. [#18657] + +- Updated the bundled CFITSIO library to 4.6.3. [#18689] + +- Wheels are now provided for Windows arm64. [#18786] + Version 7.1.1 (2025-10-10) ========================== diff --git a/CITATION.cff b/CITATION.cff index 5dc6ddcd5b87..bf449e408403 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -685,6 +685,7 @@ preferred-citation: issue: 2 pages: 20 year: 2022 + doi: 10.3847/1538-4357/ac7c74 identifiers: - description: ADS bibcode type: url diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e44a9261c4ca..6ace29669d47 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1 +1 @@ -All Astropy community members are expected to abide by the [Astropy Project Code of Conduct](http://www.astropy.org/code_of_conduct.html). +All Astropy community members are expected to abide by the [Astropy Project Code of Conduct](https://www.astropy.org/code_of_conduct.html). diff --git a/LICENSE.rst b/LICENSE.rst index f09379c82ef5..5945355a7190 100644 --- a/LICENSE.rst +++ b/LICENSE.rst @@ -1,4 +1,4 @@ -Copyright (c) 2011-2024, Astropy Developers +Copyright (c) 2011-2026, Astropy Developers All rights reserved. diff --git a/README.rst b/README.rst index 79a3c3dd48be..f54057ae8ee3 100644 --- a/README.rst +++ b/README.rst @@ -1,10 +1,9 @@ |Astropy Logo| ----- - -|Actions Status| |CircleCI Status| |Coverage Status| |PyPI Status| |Supported Python Versions| |Documentation Status| |Pre-Commit| |Ruff| |Zenodo| - ----- +:Versions: |Zenodo| |PyPI Status| |Supported Python Versions| +:Status: |Coverage Status| |Actions Status| |CircleCI Status| |Documentation Status| +:Tools: |Pre-Commit| |Ruff| +:Community: |pyOpenSci Peer-Reviewed| The Astropy Project is a community effort to develop a single core package for astronomy in Python and foster interoperability between @@ -135,3 +134,7 @@ Astropy is licensed under a 3-clause BSD style license - see the .. |Codespaces| image:: https://github.com/codespaces/badge.svg :target: https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=2081289 :alt: Open in GitHub Codespaces + +.. |pyOpenSci Peer-Reviewed| image:: https://pyopensci.org/badges/peer-reviewed.svg + :target: https://github.com/pyOpenSci/software-review/issues/251 + :alt: pyOpenSci Peer-Reviewed diff --git a/astropy/config/paths.py b/astropy/config/paths.py index 201a1376187c..03b41ab309b3 100644 --- a/astropy/config/paths.py +++ b/astropy/config/paths.py @@ -10,6 +10,7 @@ from functools import partial, wraps from inspect import cleandoc from pathlib import Path +from threading import RLock from types import TracebackType from typing import Literal, ParamSpec from warnings import warn @@ -25,9 +26,14 @@ "set_temp_config", ] - P = ParamSpec("P") +# Any number of threads may call any combination of set_temp_cache and set_temp_config, +# in any order. Hence they need to share a single lock (otherwise deadlocks would be possible). +# In particular, the possibility of nesting more than one instance of each context imposes +# the use of a re-entrant lock (RLock). +_PATHS_LOCK = RLock() + def get_config_dir_path(rootname: str = "astropy") -> Path: """ @@ -48,7 +54,7 @@ def get_config_dir_path(rootname: str = "astropy") -> Path: Returns ------- - configdir : Path + pathlib.Path The absolute path to the configuration directory. """ @@ -62,7 +68,7 @@ def get_config_dir(rootname: str = "astropy") -> str: if get_config_dir_path.__doc__ is not None: # guard against PYTHONOPTIMIZE mode get_config_dir.__doc__ = cleandoc( - get_config_dir_path.__doc__ + get_config_dir_path.__doc__.replace("pathlib.Path", "str") + """ See Also -------- @@ -91,7 +97,7 @@ def get_cache_dir_path(rootname: str = "astropy") -> Path: Returns ------- - cachedir : Path + pathlib.Path The absolute path to the cache directory. """ @@ -105,7 +111,7 @@ def get_cache_dir(rootname: str = "astropy") -> str: if get_cache_dir_path.__doc__ is not None: # guard against PYTHONOPTIMIZE mode get_cache_dir.__doc__ = cleandoc( - get_cache_dir_path.__doc__ + get_cache_dir_path.__doc__.replace("pathlib.Path", "str") + """ See Also -------- @@ -131,14 +137,16 @@ def __init__( self._path = path self._delete = delete - self._prev_path = self.__class__._temp_path def __enter__(self) -> str: - self.__class__._temp_path = self._path + _PATHS_LOCK.acquire() + self._prev_path = self.__class__._temp_path try: + self.__class__._temp_path = self._path return str(self.__class__._get_dir_path(rootname="astropy")) except Exception: self.__class__._temp_path = self._prev_path + _PATHS_LOCK.release() raise def __exit__( @@ -147,10 +155,13 @@ def __exit__( value: BaseException | None, tb: TracebackType | None, ) -> None: - self.__class__._temp_path = self._prev_path + try: + self.__class__._temp_path = self._prev_path - if self._delete and self._path is not None: - shutil.rmtree(self._path) + if self._delete and self._path is not None: + shutil.rmtree(self._path) + finally: + _PATHS_LOCK.release() def __call__(self, func: Callable[P, object]) -> Callable[P, None]: """Implements use as a decorator.""" @@ -269,6 +280,9 @@ class set_temp_config(_SetTempPath): This may also be used as a decorator on a function to set the config path just within that function. + Thread safety is guaranteed since astropy 7.2.1, but concurrency isn't: + only a single thread at a time may execute code within this context. + Parameters ---------- path : str, optional @@ -294,9 +308,24 @@ def __enter__(self) -> str: # config file (e.g., iers.conf.auto_download=False for our tests). from .configuration import _cfgobjs - self._cfgobjs_copy = _cfgobjs.copy() - _cfgobjs.clear() - return super().__enter__() + _PATHS_LOCK.acquire() + + try: + _cfgobjs_copy = _cfgobjs.copy() + except Exception: + _PATHS_LOCK.release() + raise + + self._cfgobjs_copy = _cfgobjs_copy + + try: + _cfgobjs.clear() + return super().__enter__() + except Exception: + _cfgobjs.update(self._cfgobjs_copy) + del self._cfgobjs_copy + _PATHS_LOCK.release() + raise def __exit__( self, @@ -304,12 +333,15 @@ def __exit__( value: BaseException | None, tb: TracebackType | None, ) -> None: - from .configuration import _cfgobjs + try: + from .configuration import _cfgobjs - _cfgobjs.clear() - _cfgobjs.update(self._cfgobjs_copy) - del self._cfgobjs_copy - super().__exit__(type, value, tb) + _cfgobjs.clear() + _cfgobjs.update(self._cfgobjs_copy) + del self._cfgobjs_copy + super().__exit__(type, value, tb) + finally: + _PATHS_LOCK.release() class set_temp_cache(_SetTempPath): @@ -325,6 +357,9 @@ class set_temp_cache(_SetTempPath): This may also be used as a decorator on a function to set the cache path just within that function. + Thread safety is guaranteed since astropy 7.2.1, but concurrency isn't: + only a single thread at a time may execute code within this context. + Parameters ---------- path : str diff --git a/astropy/config/tests/test_concurrency.py b/astropy/config/tests/test_concurrency.py new file mode 100644 index 000000000000..5fd7daa569b9 --- /dev/null +++ b/astropy/config/tests/test_concurrency.py @@ -0,0 +1,149 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from threading import Barrier +from typing import Generic, TypeAlias, TypeVar +from uuid import uuid4 + +import pytest + +from astropy.config import ( + get_cache_dir_path, + get_config_dir_path, + set_temp_cache, + set_temp_config, +) +from astropy.config.paths import _SetTempPath + +N_THREADS = 10 + +T = TypeVar("T") + + +@dataclass(slots=True, frozen=True, kw_only=True) +class Result(Generic[T]): + actual: T + expected: T + + def match(self) -> bool: + return self.actual == self.expected + + +PathGetter: TypeAlias = Callable[[], Path] + + +def getter_from_manager(cm: type[_SetTempPath]) -> PathGetter: + # this is functionally equivalent to an immutable dict + # it could be refactored into a frozendict when Python 3.14 is unsupported + if cm is set_temp_cache: + return get_cache_dir_path + elif cm is set_temp_config: + return get_config_dir_path + else: + raise ValueError + + +def closure_base( + ctx_manager: type[_SetTempPath], + *, + directory: Path, + barrier: Barrier, +) -> Result[Path]: + local_path = directory / str(uuid4()) + local_path.mkdir() + getter = getter_from_manager(ctx_manager) + + barrier.wait() + with ctx_manager(local_path): + res = getter() + + return Result(actual=res, expected=local_path / "astropy") + + +def assert_valid_results(results: list[Result[T]]) -> None: + __tracebackhide__ = True + assert len(results) == N_THREADS + assert len(set(results)) == N_THREADS + assert all(r.match() for r in results) + + +@pytest.mark.parametrize("ctx_manager", [set_temp_cache, set_temp_config]) +@pytest.mark.usefixtures("ignore_config_paths_global_state") +def test_set_temp_dir(ctx_manager, tmp_path): + closure = partial( + closure_base, + ctx_manager, + directory=tmp_path, + barrier=Barrier(N_THREADS), + ) + with ThreadPoolExecutor(max_workers=N_THREADS) as executor: + futures = [executor.submit(closure) for _ in range(N_THREADS)] + + assert_valid_results([f.result() for f in futures]) + + +@pytest.mark.parametrize( + "cm_out, cm_in", + [ + pytest.param(set_temp_cache, set_temp_cache, id="cache-cache"), + pytest.param(set_temp_cache, set_temp_config, id="cache-config"), + pytest.param(set_temp_config, set_temp_cache, id="config-cache"), + pytest.param(set_temp_config, set_temp_config, id="config-config"), + ], +) +@pytest.mark.usefixtures("ignore_config_paths_global_state") +def test_nesting(cm_out: type[_SetTempPath], cm_in: type[_SetTempPath], tmp_path: Path): + # check that nesting doesn't deadlock + barrier = Barrier(N_THREADS) + getter_out = getter_from_manager(cm_out) + getter_in = getter_from_manager(cm_in) + + def closure() -> Result[tuple[Path, Path, Path, Path]]: + local_path_1 = tmp_path / str(uuid4()) + local_path_1.mkdir() + local_path_2 = tmp_path / str(uuid4()) + local_path_2.mkdir() + + barrier.wait() + with cm_out(local_path_1): + res0 = getter_out() + with cm_in(local_path_2): + res1 = getter_in() + res2 = getter_out() + res3 = getter_out() + return Result( + actual=(res0, res1, res2, res3), + expected=( + local_path_1 / "astropy", + local_path_2 / "astropy", + (local_path_1 if cm_in != cm_out else local_path_2) / "astropy", + local_path_1 / "astropy", + ), + ) + + with ThreadPoolExecutor(max_workers=N_THREADS) as executor: + futures = [executor.submit(closure) for _ in range(N_THREADS)] + + assert_valid_results([f.result() for f in futures]) + + +@pytest.mark.usefixtures("ignore_config_paths_global_state") +def test_mixed_settings(tmp_path): + barrier = Barrier(N_THREADS) + + cache_setter = partial( + closure_base, set_temp_cache, directory=tmp_path, barrier=barrier + ) + config_setter = partial( + closure_base, set_temp_config, directory=tmp_path, barrier=barrier + ) + + closures = [cache_setter if n % 2 else config_setter for n in range(N_THREADS)] + with ThreadPoolExecutor(max_workers=N_THREADS) as executor: + futures = [executor.submit(c) for c in closures] + + assert_valid_results([f.result() for f in futures]) diff --git a/astropy/config/tests/test_configs.py b/astropy/config/tests/test_configs.py index 2177478d9cd0..972c48ce7080 100644 --- a/astropy/config/tests/test_configs.py +++ b/astropy/config/tests/test_configs.py @@ -8,7 +8,6 @@ from contextlib import nullcontext from inspect import cleandoc from pathlib import Path -from threading import Lock import pytest @@ -18,31 +17,6 @@ OLD_CONFIG = {} -_IGNORE_CONFIG_PATHS_GLOBAL_STATE_LOCK = Lock() - - -@pytest.fixture -def ignore_config_paths_global_state(monkeypatch, tmp_path_factory): - # ignore global state of the test session - # and preserve thread safety across all users of this fixture - with _IGNORE_CONFIG_PATHS_GLOBAL_STATE_LOCK: - monkeypatch.delenv("XDG_CACHE_HOME", raising=False) - monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) - - monkeypatch.setattr(paths.set_temp_cache, "_temp_path", None) - monkeypatch.setattr(paths.set_temp_config, "_temp_path", None) - - # also mock $HOME as it's part of the global state taken into account - # for path detection - mock_home_dir = tmp_path_factory.mktemp("MOCK_HOME") - - def mock_home(): - return mock_home_dir - - monkeypatch.setattr(Path, "home", mock_home) - - yield - def setup_module(): OLD_CONFIG.clear() diff --git a/astropy/conftest.py b/astropy/conftest.py index 29cf1316a82c..a5e0b9e51918 100644 --- a/astropy/conftest.py +++ b/astropy/conftest.py @@ -9,6 +9,7 @@ import tempfile import warnings from pathlib import Path +from threading import Lock try: from pytest_astropy_header.display import PYTEST_HEADER_MODULES, TESTED_VERSIONS @@ -49,6 +50,34 @@ def fast_thread_switching(): sys.setswitchinterval(old) +_IGNORE_CONFIG_PATHS_GLOBAL_STATE_LOCK = Lock() + + +@pytest.fixture +def ignore_config_paths_global_state(monkeypatch, tmp_path_factory): + from astropy.config import set_temp_cache, set_temp_config + + # ignore global state of the test session + # and preserve thread safety across all users of this fixture + with _IGNORE_CONFIG_PATHS_GLOBAL_STATE_LOCK: + monkeypatch.delenv("XDG_CACHE_HOME", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + monkeypatch.setattr(set_temp_cache, "_temp_path", None) + monkeypatch.setattr(set_temp_config, "_temp_path", None) + + # also mock $HOME as it's part of the global state taken into account + # for path detection + mock_home_dir = tmp_path_factory.mktemp("MOCK_HOME") + + def mock_home(): + return mock_home_dir + + monkeypatch.setattr(Path, "home", mock_home) + + yield + + def pytest_configure(config): # Ensure number of columns and lines is deterministic for testing from astropy import conf diff --git a/astropy/convolution/src/convolve.c b/astropy/convolution/src/convolve.c index 8d66753c921c..07d7aed17030 100644 --- a/astropy/convolution/src/convolve.c +++ b/astropy/convolution/src/convolve.c @@ -25,14 +25,14 @@ */ +#include "convolve.h" + #include #include #include #include #include -#include "convolve.h" - #ifdef _OPENMP #include #endif diff --git a/astropy/convolution/tests/test_convolve.py b/astropy/convolution/tests/test_convolve.py index 4009e80a465e..1d939ce270fb 100644 --- a/astropy/convolution/tests/test_convolve.py +++ b/astropy/convolution/tests/test_convolve.py @@ -1247,7 +1247,7 @@ def test_uninterpolated_nan_regions(boundary, normalize_kernel): # Test case: kernel.shape > NaN_region.shape nan_centroid = np.full( (kernel.shape[0] - 1, kernel.shape[1] - 1), np.nan - ) # 1 smaller than kerenel + ) # 1 smaller than kernel image = np.pad( nan_centroid, pad_width=kernel.shape[0] * 2, mode="constant", constant_values=1 ) diff --git a/astropy/coordinates/builtin_frames/cirs.py b/astropy/coordinates/builtin_frames/cirs.py index 9df0eb0ae9c2..598318a64b8e 100644 --- a/astropy/coordinates/builtin_frames/cirs.py +++ b/astropy/coordinates/builtin_frames/cirs.py @@ -29,6 +29,60 @@ class CIRS(BaseRADecFrame): """ A coordinate or frame in the Celestial Intermediate Reference System (CIRS). + CIRS is a geocentric reference system that serves as the *intermediate* + step between the quasi-inertial `~astropy.coordinates.ICRS` (fixed to + distant quasars) and the Earth-fixed `~astropy.coordinates.ITRS`. It was + introduced by the International Astronomical Union (IAU) 2000 and 2006 + resolutions to replace the older equinox-based "apparent place" systems + with a cleaner, higher-precision framework. + + **Definition** + + CIRS is defined by two fundamental concepts: + + * **Celestial Intermediate Pole (CIP)** — the pole of the system. Its + motion in the `~astropy.coordinates.GCRS` is described by the IAU 2006 + precession model combined with the IAU 2000A nutation model, giving + micro-arcsecond accuracy. + + * **Celestial Intermediate Origin (CIO)** — the origin of right ascension + in CIRS. Unlike the classical vernal equinox (which is defined by the + intersection of the Earth's equator and the ecliptic and therefore + "swings" with precession and nutation), the CIO is a *non-rotating + origin*: it is defined so that it has no rotation component around the + CIP. This cleanly separates the Earth's spin (described by the + **Earth Rotation Angle**, ERA) from its wobble (precession and nutation). + + **Relation to other frames** + + * **ICRS → CIRS**: applying the IAU precession-nutation model rotates the + ICRS axes to the intermediate (CIP/CIO) axes. Astropy performs this + via the ERFA ``apco``/``atciqz`` routines. + + * **CIRS → ITRS**: a single rotation by the Earth Rotation Angle (ERA) + around the CIP axis. ERA is a linear function of UT1 and replaces the + more complex "equation of the equinoxes" needed in the older + equinox-based systems. + + * **CIRS vs. GCRS**: both share the same pole (CIP) and the same + geocentric origin, but they differ in their right-ascension origin. + GCRS uses the equinox; CIRS uses the CIO. The angular offset between + the two origins along the intermediate equator is called the + *equation of the origins* (EO). + + **Astropy implementation** + + Astropy implements CIRS using the `ERFA + `_ library (the open-source version of + IAU SOFA), which provides the IAU 2006/2000A precession-nutation model. + The default ``location`` is the geocentre; supply an + `~astropy.coordinates.EarthLocation` to get the topocentric variant + (which additionally accounts for diurnal aberration). + + For more background see Section 2.6 of `USNO Circular 179 + `_ and the references in the + :ref:`astropy:astropy-coordinates-seealso` section of the documentation. + The frame attributes are listed under **Other Parameters**. """ diff --git a/astropy/coordinates/builtin_frames/fk4_fk5_transforms.py b/astropy/coordinates/builtin_frames/fk4_fk5_transforms.py index f87737bd0d21..1fd2d86fd023 100644 --- a/astropy/coordinates/builtin_frames/fk4_fk5_transforms.py +++ b/astropy/coordinates/builtin_frames/fk4_fk5_transforms.py @@ -42,7 +42,7 @@ def _fk4_B_matrix(obstime): T = (obstime.jyear - 1950.0) / 100.0 if getattr(T, "shape", ()): # Ensure we broadcast possibly arrays of times properly. - T.shape += (1, 1) + T = np.reshape(T, (*T.shape, 1, 1)) return _B1950_TO_J2000_M + _FK4_CORR * T diff --git a/astropy/coordinates/builtin_frames/gcrs.py b/astropy/coordinates/builtin_frames/gcrs.py index e9b66707bbef..76bab76562a5 100644 --- a/astropy/coordinates/builtin_frames/gcrs.py +++ b/astropy/coordinates/builtin_frames/gcrs.py @@ -40,7 +40,7 @@ class GCRS(BaseRADecFrame): """ A coordinate or frame in the Geocentric Celestial Reference System (GCRS). - GCRS is distinct form ICRS mainly in that it is relative to the Earth's + GCRS is distinct from ICRS mainly in that it is relative to the Earth's center-of-mass rather than the solar system Barycenter. That means this frame includes the effects of aberration (unlike ICRS). For more background on the GCRS, see the references provided in the diff --git a/astropy/coordinates/earth.py b/astropy/coordinates/earth.py index 8b469acc0ad2..294f8807fe65 100644 --- a/astropy/coordinates/earth.py +++ b/astropy/coordinates/earth.py @@ -14,6 +14,8 @@ from astropy.units.quantity import QuantityInfoBase from astropy.utils import data from astropy.utils.compat import COPY_IF_NEEDED +from astropy.utils.data import conf as data_conf +from astropy.utils.data import get_pkg_data_contents from .angles import Angle, Latitude, Longitude from .errors import UnknownSiteException @@ -73,8 +75,10 @@ def _get_json_result(url, err_str, use_google): from .name_resolve import NameResolveError try: + headers = {"User-Agent": data_conf.default_http_user_agent} + req = urllib.request.Request(url, headers=headers) # Retrieve JSON response from Google maps API - resp = urllib.request.urlopen(url, timeout=data.conf.remote_timeout) + resp = urllib.request.urlopen(req, timeout=data.conf.remote_timeout) resp_data = json.loads(resp.read().decode("utf8")) except urllib.error.URLError as e: @@ -383,13 +387,13 @@ class = EarthLocation -------- get_site_names : the list of sites that this function can access """ - registry = cls._get_site_registry(force_download=refresh_cache) + registry = cls._get_site_registry(refresh_cache) try: el = registry[site_name] except UnknownSiteException as e: raise UnknownSiteException( e.site, "EarthLocation.get_site_names", close_names=e.close_names - ) from e + ) from None if cls is el.__class__: return el @@ -531,31 +535,28 @@ def get_site_names(cls, *, refresh_cache=False): of_site : Gets the actual location object for one of the sites names this returns. """ - return cls._get_site_registry(force_download=refresh_cache).names + return cls._get_site_registry(refresh_cache).names @classmethod - def _get_site_registry(cls, force_download=False): + def _get_site_registry(cls, refresh_cache=False): """ Gets the site registry. The first time this tries to download the data. Subsequent calls will use the cached version unless explicitly overridden. - - Parameters - ---------- - force_download : bool or str - If not False, force replacement of the cached registry with a - downloaded version. If a str, that will be used as the URL to - download from (if just True, the default URL will be used). - - Returns - ------- - reg : astropy.coordinates.sites.SiteRegistry """ # need to do this here at the bottom to avoid circular dependencies - from .sites import get_downloaded_sites - - if force_download or not cls._site_registry: - cls._site_registry = get_downloaded_sites( - force_download if isinstance(force_download, str) else None + from .sites import SiteRegistry + + if refresh_cache or not cls._site_registry: + # we explicitly set the encoding because the default is to leave it set by + # the users' locale, which may fail if it's not matched to the sites.json + cls._site_registry = SiteRegistry.from_json( + json.loads( + get_pkg_data_contents( + "coordinates/sites.json", + encoding="UTF-8", + cache="update" if refresh_cache else True, + ) + ) ) return cls._site_registry diff --git a/astropy/coordinates/matrix_utilities.py b/astropy/coordinates/matrix_utilities.py index ac1eea3b1f50..5f17e4871ac2 100644 --- a/astropy/coordinates/matrix_utilities.py +++ b/astropy/coordinates/matrix_utilities.py @@ -216,7 +216,7 @@ def is_rotation(matrix, allow_improper=False, atol=None): See Also -------- - astopy.coordinates.matrix_utilities.is_O3 : + astropy.coordinates.matrix_utilities.is_O3 : For the less restrictive check that a matrix is in the group O(3). Notes diff --git a/astropy/coordinates/representation/base.py b/astropy/coordinates/representation/base.py index 043f65eda251..3ed4bea53387 100644 --- a/astropy/coordinates/representation/base.py +++ b/astropy/coordinates/representation/base.py @@ -12,6 +12,7 @@ import astropy.units as u from astropy.coordinates.angles import Angle from astropy.utils import classproperty +from astropy.utils.compat import NUMPY_LT_2_5 from astropy.utils.data_info import MixinInfo from astropy.utils.decorators import deprecated from astropy.utils.exceptions import DuplicateRepresentationWarning @@ -461,15 +462,23 @@ def shape(self, shape): oldshape = self.shape for component in self.components: val = getattr(self, component) - if val.size > 1: - try: + if val.size <= 1: + continue + + try: + if NUMPY_LT_2_5: val.shape = shape - except Exception: - for val2 in reshaped: - val2.shape = oldshape - raise else: - reshaped.append(val) + val._set_shape(shape) + except Exception: + for val2 in reshaped: + if NUMPY_LT_2_5: + val2.shape = oldshape + else: + val2._set_shape(oldshape) + raise + else: + reshaped.append(val) @property def masked(self): diff --git a/astropy/coordinates/sites.py b/astropy/coordinates/sites.py index afaab3fa3ebc..0c1455b1871f 100644 --- a/astropy/coordinates/sites.py +++ b/astropy/coordinates/sites.py @@ -10,13 +10,11 @@ updating the ``location.json`` file. """ -import json from collections.abc import Iterator, Mapping from difflib import get_close_matches from typing import Any, Final, Self from astropy import units as u -from astropy.utils.data import get_file_contents, get_pkg_data_contents from .earth import EarthLocation from .errors import UnknownSiteException @@ -125,18 +123,3 @@ def from_json(cls, jsondb: Mapping[str, dict[str, Any]]) -> Self: reg.add_site([site] + aliases, location) return reg - - -def get_downloaded_sites(jsonurl: str | None = None) -> SiteRegistry: - """ - Load observatory database from data.astropy.org and parse into a SiteRegistry. - """ - # we explicitly set the encoding because the default is to leave it set by - # the users' locale, which may fail if it's not matched to the sites.json - if jsonurl is None: - content = get_pkg_data_contents("coordinates/sites.json", encoding="UTF-8") - else: - content = get_file_contents(jsonurl, encoding="UTF-8") - - jsondb = json.loads(content) - return SiteRegistry.from_json(jsondb) diff --git a/astropy/coordinates/solar_system.py b/astropy/coordinates/solar_system.py index c1b50889b5fc..f2c0e1d663a6 100644 --- a/astropy/coordinates/solar_system.py +++ b/astropy/coordinates/solar_system.py @@ -319,7 +319,9 @@ def _get_body_barycentric_posvel(body, time, ephemeris=None, get_velocity=True): ): body_p_or_v += p_or_v - body_posvel_bary.shape = body_posvel_bary.shape[:2] + jd1_shape + body_posvel_bary = np.reshape( + body_posvel_bary, body_posvel_bary.shape[:2] + jd1_shape + ) body_pos_bary = CartesianRepresentation( body_posvel_bary[0], unit=u.km, copy=False ) diff --git a/astropy/coordinates/spectral_coordinate.py b/astropy/coordinates/spectral_coordinate.py index c53463f74b77..f0f3e572cfd2 100644 --- a/astropy/coordinates/spectral_coordinate.py +++ b/astropy/coordinates/spectral_coordinate.py @@ -54,24 +54,20 @@ def _apply_relativistic_doppler_shift(scoord, velocity): # since we can't guarantee that their metadata would be correct/consistent. squantity = scoord.view(SpectralQuantity) - beta = velocity / c - doppler_factor = np.sqrt((1 + beta) / (1 - beta)) - - if squantity.unit.is_equivalent(u.m): # wavelength - return squantity * doppler_factor - elif ( - squantity.unit.is_equivalent(u.Hz) - or squantity.unit.is_equivalent(u.eV) - or squantity.unit.is_equivalent(1 / u.m) - ): - return squantity / doppler_factor - elif squantity.unit.is_equivalent(KMS): # velocity - return (squantity.to(u.Hz) / doppler_factor).to(squantity.unit) - else: # pragma: no cover - raise RuntimeError( - f"Unexpected units in velocity shift: {squantity.unit}. This should not" - " happen, so please report this in the astropy issue tracker!" - ) + beta = (velocity / c).to_value(u.dimensionless_unscaled) + doppler_factor = np.sqrt((1.0 + beta) / (1.0 - beta)) + + match squantity.unit.physical_type: + case u.physical.length: + return squantity * doppler_factor + case u.physical.frequency | u.physical.energy | u.physical.wavenumber: + return squantity / doppler_factor + case u.physical.velocity: + return (squantity.to(u.Hz) / doppler_factor).to(squantity.unit) + raise RuntimeError( # pragma: no cover + f"Unexpected units in velocity shift: {squantity.unit}. This should not" + " happen, so please report this in the astropy issue tracker!" + ) def update_differentials_to_match( diff --git a/astropy/coordinates/tests/test_angles.py b/astropy/coordinates/tests/test_angles.py index 0c5f17290df7..de0073ce183c 100644 --- a/astropy/coordinates/tests/test_angles.py +++ b/astropy/coordinates/tests/test_angles.py @@ -789,6 +789,10 @@ def test_lon_as_lat(): lat = Latitude([20], "deg") lat[0] = lon + # Also check list of longitudes (regression test for gh-11757). + with pytest.raises(TypeError, match="cannot be created from"): + Latitude([lon, lon]) + # Check we can work around the Lat vs Long checks by casting explicitly to Angle. lat = Latitude(Angle(lon)) assert lat.value == 10.0 diff --git a/astropy/coordinates/tests/test_sites.py b/astropy/coordinates/tests/test_sites.py index 06c7aac981a1..066cf72aa4a2 100644 --- a/astropy/coordinates/tests/test_sites.py +++ b/astropy/coordinates/tests/test_sites.py @@ -1,3 +1,4 @@ +import shutil from pathlib import Path from threading import Lock @@ -6,52 +7,36 @@ from astropy import units as u from astropy.config import set_temp_cache from astropy.coordinates import EarthLocation, Latitude, Longitude, UnknownSiteException -from astropy.coordinates.sites import SiteRegistry, get_downloaded_sites +from astropy.coordinates.sites import SiteRegistry from astropy.tests.helper import assert_quantity_allclose SITE_DATA_LOCK = Lock() +TEST_SITE_DATA_DIR = Path(__file__).with_name("data") / "sites" @pytest.fixture(scope="function") def local_site_data(monkeypatch): with SITE_DATA_LOCK: monkeypatch.setattr(EarthLocation, "_site_registry", None) - with set_temp_cache(Path(__file__).with_name("data") / "sites"): + with set_temp_cache(TEST_SITE_DATA_DIR): yield -@pytest.mark.remote_data(source="astropy") -def test_online_sites(): - reg = get_downloaded_sites() - - keck = reg["keck"] - lon, lat, el = keck.to_geodetic() - assert_quantity_allclose( - lon, -Longitude("155:28.7", unit=u.deg), atol=0.001 * u.deg - ) - assert_quantity_allclose(lat, Latitude("19:49.7", unit=u.deg), atol=0.001 * u.deg) - assert_quantity_allclose(el, 4160 * u.m, atol=1 * u.m) - - names = reg.names - assert "keck" in names - assert "ctio" in names +@pytest.fixture(scope="function") +def remote_site_data(monkeypatch, tmp_path): + with SITE_DATA_LOCK: + monkeypatch.setattr(EarthLocation, "_site_registry", None) + with set_temp_cache(tmp_path): + yield - # The JSON file contains `name` and `aliases` for each site, and astropy - # should use names from both, but not empty strings [#12721]. - assert "" not in names - assert "Royal Observatory Greenwich" in names - with pytest.raises( - KeyError, - match="Site 'nonexistent' not in database. Use the 'names' attribute to see", - ): - reg["nonexistent"] - - with pytest.raises( - KeyError, - match="Site 'kec' not in database. Use the 'names' attribute to see available", - ): - reg["kec"] +@pytest.fixture(scope="function") +def refreshable_site_data(monkeypatch, tmp_path): + with SITE_DATA_LOCK: + monkeypatch.setattr(EarthLocation, "_site_registry", None) + shutil.copytree(TEST_SITE_DATA_DIR, tmp_path, dirs_exist_ok=True) + with set_temp_cache(tmp_path): + yield @pytest.mark.usefixtures("local_site_data") @@ -91,23 +76,47 @@ def test_Earthlocation_refresh_cache_is_mandatory_kwarg(class_method, args): class_method(*args, False) -@pytest.mark.xfail(reason="regression test for #18572", raises=AssertionError) +@pytest.mark.remote_data(source="astropy") +@pytest.mark.usefixtures("remote_site_data") +def test_Earthlocation_get_site_names_empty_cache(): + assert len(EarthLocation.get_site_names()) > 3 + + @pytest.mark.usefixtures("local_site_data") +def test_Earthlocation_get_site_names_no_refresh_cache(): + assert EarthLocation.get_site_names() == [ + "Royal Observatory Greenwich", + "example_site", + "greenwich", + ] + + +@pytest.mark.remote_data(source="astropy") +@pytest.mark.usefixtures("refreshable_site_data") def test_Earthlocation_get_site_names_refresh_cache(): assert len(EarthLocation.get_site_names()) == 3 # Regression test for #18572 - refresh_cache=True had no effect assert len(EarthLocation.get_site_names(refresh_cache=True)) > 3 +@pytest.mark.remote_data(source="astropy") +@pytest.mark.usefixtures("remote_site_data") +def test_Earthlocation_of_site_empty_cache(): + keck = EarthLocation.of_site("keck") + assert_quantity_allclose(keck.lon, -155.47833333 * u.deg) + + @pytest.mark.usefixtures("local_site_data") def test_Earthlocation_of_site_no_refresh_cache(): with pytest.raises(UnknownSiteException, match="^Site 'keck' not in database"): EarthLocation.of_site("keck") -@pytest.mark.xfail(reason="regression test for #18572", raises=UnknownSiteException) -@pytest.mark.usefixtures("local_site_data") +@pytest.mark.remote_data(source="astropy") +@pytest.mark.usefixtures("refreshable_site_data") def test_Earthlocation_of_site_refresh_cache(): + with pytest.raises(UnknownSiteException, match="^Site 'keck' not in database"): + EarthLocation.of_site("keck") # Regression test for #18572 - refresh_cache=True had no effect keck = EarthLocation.of_site("keck", refresh_cache=True) assert_quantity_allclose(keck.lon, -155.47833333 * u.deg) diff --git a/astropy/coordinates/tests/test_skyoffset_transformations.py b/astropy/coordinates/tests/test_skyoffset_transformations.py index 39679f8aba02..3f8de42d966a 100644 --- a/astropy/coordinates/tests/test_skyoffset_transformations.py +++ b/astropy/coordinates/tests/test_skyoffset_transformations.py @@ -225,13 +225,15 @@ def test_skycoord_skyoffset_frame(): @pytest.mark.parametrize( "from_origin,to_origin", - combinations( - ( - ICRS(10.6847929 * u.deg, 41.2690650 * u.deg, M31_DISTANCE), - FK5(10.6847929 * u.deg, 41.2690650 * u.deg, M31_DISTANCE), - Galactic(121.1744050 * u.deg, -21.5729360 * u.deg, M31_DISTANCE), - ), - r=2, + list( + combinations( + ( + ICRS(10.6847929 * u.deg, 41.2690650 * u.deg, M31_DISTANCE), + FK5(10.6847929 * u.deg, 41.2690650 * u.deg, M31_DISTANCE), + Galactic(121.1744050 * u.deg, -21.5729360 * u.deg, M31_DISTANCE), + ), + r=2, + ) ), ) def test_m31_coord_transforms(from_origin, to_origin): diff --git a/astropy/coordinates/tests/test_spectral_coordinate.py b/astropy/coordinates/tests/test_spectral_coordinate.py index 78389412b5e6..b291e7c3b434 100644 --- a/astropy/coordinates/tests/test_spectral_coordinate.py +++ b/astropy/coordinates/tests/test_spectral_coordinate.py @@ -1119,5 +1119,14 @@ def test_spectralcoord_accuracy(specsys): ) +def test_spectralcoord_with_spectral_equivalency(): + # Regression test for #19001 - enabling the `u.spectral()` equivalency could cause + # the relativistic Doppler shift to be applied in the wrong direction. + sc = SpectralCoord(250 * u.MHz, radial_velocity=1000 * u.km / u.s) + assert_quantity_allclose(sc.to_rest(), 250.835306 * u.MHz) # sanity check + with u.set_enabled_equivalencies(u.spectral()): + assert_quantity_allclose(sc.to_rest(), 250.835306 * u.MHz) + + # TODO: add test when target is not ICRS # TODO: add test when SpectralCoord is in velocity to start with diff --git a/astropy/coordinates/tests/test_spectral_quantity.py b/astropy/coordinates/tests/test_spectral_quantity.py index 5646faf14b92..feac18ad7c8a 100644 --- a/astropy/coordinates/tests/test_spectral_quantity.py +++ b/astropy/coordinates/tests/test_spectral_quantity.py @@ -33,7 +33,9 @@ def test_init_invalid(self, unit): ): SpectralQuantity(1 * unit) - @pytest.mark.parametrize(("unit1", "unit2"), zip(SPECTRAL_UNITS, SPECTRAL_UNITS)) + @pytest.mark.parametrize( + ("unit1", "unit2"), list(zip(SPECTRAL_UNITS, SPECTRAL_UNITS)) + ) def test_spectral_conversion(self, unit1, unit2): sq1 = SpectralQuantity(1 * unit1) sq2 = sq1.to(unit2) diff --git a/astropy/coordinates/transformations/affine.py b/astropy/coordinates/transformations/affine.py index ef5a8516f1f9..e08ecf8ecbfa 100644 --- a/astropy/coordinates/transformations/affine.py +++ b/astropy/coordinates/transformations/affine.py @@ -64,7 +64,7 @@ def _apply_transform(self, fromcoord, matrix, offset): raise TypeError( "Position information stored on coordinate frame " "is insufficient to do a full-space position " - "transformation (representation class: {data.__class__})" + f"transformation (representation class: {data.__class__})" ) if ( diff --git a/astropy/cosmology/_src/signature_deprecations.c b/astropy/cosmology/_src/signature_deprecations.c index 6293d21d1059..fbcdce2adeaa 100644 --- a/astropy/cosmology/_src/signature_deprecations.c +++ b/astropy/cosmology/_src/signature_deprecations.c @@ -3,8 +3,8 @@ // MIT. see licenses/POSITIONAL_DEFAULTS.rst -#include // snprintf #include +#include // snprintf #include #define SINCE_CHAR_SIZE 32 diff --git a/astropy/cosmology/_src/tests/flrw/test_base.py b/astropy/cosmology/_src/tests/flrw/test_base.py index 01a77803f30b..48cc07ebbe4c 100644 --- a/astropy/cosmology/_src/tests/flrw/test_base.py +++ b/astropy/cosmology/_src/tests/flrw/test_base.py @@ -75,7 +75,8 @@ def setup_class(self): ) @pytest.fixture(scope="class") - def nonflatcosmo(self): + @classmethod + def nonflatcosmo(cls): """A non-flat cosmology used in equivalence tests.""" return LambdaCDM(70, 0.4, 0.8) diff --git a/astropy/cosmology/_src/tests/funcs/test_comparison.py b/astropy/cosmology/_src/tests/funcs/test_comparison.py index 80999a7bc81d..8ecb0cd55f2a 100644 --- a/astropy/cosmology/_src/tests/funcs/test_comparison.py +++ b/astropy/cosmology/_src/tests/funcs/test_comparison.py @@ -33,11 +33,13 @@ class ComparisonFunctionTestBase(ToFromTestMixinBase): """ @pytest.fixture(scope="class") - def cosmo(self): + @classmethod + def cosmo(cls): return Planck18 @pytest.fixture(scope="class") - def cosmo_eqvxflat(self, cosmo): + @classmethod + def cosmo_eqvxflat(cls, cosmo): if isinstance(cosmo, FlatCosmologyMixin): return cosmo.nonflat @@ -51,23 +53,27 @@ def cosmo_eqvxflat(self, cosmo): {k for k, _ in convert_registry._readers.keys()} - {"astropy.cosmology"} ), ) - def format(self, request): + @classmethod + def format(cls, request): return request.param @pytest.fixture(scope="class") - def xfail_cant_autoidentify(self, format): + @classmethod + def xfail_cant_autoidentify(cls, format): """`pytest.fixture` form of method ``can_autoidentify`.""" - if not self.can_autodentify(format): + if not cls.can_autodentify(format): pytest.xfail("cannot autoidentify") @pytest.fixture(scope="class") - def converted(self, to_format, format): + @classmethod + def converted(cls, to_format, format): if format == "astropy.model": # special case Model return to_format(format, method="comoving_distance") return to_format(format) @pytest.fixture(scope="class") - def pert_cosmo(self, cosmo): + @classmethod + def pert_cosmo(cls, cosmo): # change one parameter p, v = next(iter(cosmo.parameters.items())) return cosmo.clone( @@ -75,7 +81,8 @@ def pert_cosmo(self, cosmo): ) @pytest.fixture(scope="class") - def pert_cosmo_eqvxflat(self, pert_cosmo): + @classmethod + def pert_cosmo_eqvxflat(cls, pert_cosmo): if isinstance(pert_cosmo, FlatCosmologyMixin): return pert_cosmo.nonflat @@ -84,7 +91,8 @@ def pert_cosmo_eqvxflat(self, pert_cosmo): ) @pytest.fixture(scope="class") - def pert_converted(self, pert_cosmo, format): + @classmethod + def pert_converted(cls, pert_cosmo, format): if format == "astropy.model": # special case Model return pert_cosmo.to_format(format, method="comoving_distance") return pert_cosmo.to_format(format) @@ -94,7 +102,8 @@ class Test_parse_format(ComparisonFunctionTestBase): """Test functions ``_parse_format``.""" @pytest.fixture(scope="class") - def converted(self, to_format, format): + @classmethod + def converted(cls, to_format, format): if format == "astropy.model": # special case Model return to_format(format, method="comoving_distance") diff --git a/astropy/cosmology/_src/tests/io/base.py b/astropy/cosmology/_src/tests/io/base.py index 5ca1d4203bb6..26c3385ba6eb 100644 --- a/astropy/cosmology/_src/tests/io/base.py +++ b/astropy/cosmology/_src/tests/io/base.py @@ -36,16 +36,19 @@ class ToFromTestMixinBase(IOTestBase): """ @pytest.fixture(scope="class") - def from_format(self): + @classmethod + def from_format(cls): """Convert to Cosmology using ``Cosmology.from_format()``.""" return Cosmology.from_format @pytest.fixture(scope="class") - def to_format(self, cosmo): + @classmethod + def to_format(cls, cosmo): """Convert Cosmology instance using ``.to_format()``.""" return cosmo.to_format - def can_autodentify(self, format): + @staticmethod + def can_autodentify(format): """Check whether a format can auto-identify.""" return format in Cosmology.from_format.registry._identifiers @@ -61,12 +64,14 @@ class ReadWriteTestMixinBase(IOTestBase): """ @pytest.fixture(scope="class") - def read(self): + @classmethod + def read(cls): """Read Cosmology instance using ``Cosmology.read()``.""" return Cosmology.read @pytest.fixture(scope="class") - def write(self, cosmo): + @classmethod + def write(cls, cosmo): """Write Cosmology using ``.write()``.""" return cosmo.write @@ -95,7 +100,8 @@ class IODirectTestBase(IOTestBase): """ @pytest.fixture(scope="class", autouse=True) - def setup(self): + @classmethod + def setup(cls): """Setup and teardown for tests.""" @dataclass_decorator @@ -115,14 +121,16 @@ def __init__( _COSMOLOGY_CLASSES.pop(CosmologyWithKwargs.__qualname__, None) @pytest.fixture(scope="class", params=cosmo_instances) - def cosmo(self, request): + @classmethod + def cosmo(cls, request): """Cosmology instance.""" if isinstance(request.param, str): # CosmologyWithKwargs return _COSMOLOGY_CLASSES[request.param](Tcmb0=3) return request.param @pytest.fixture(scope="class") - def cosmo_cls(self, cosmo): + @classmethod + def cosmo_cls(cls, cosmo): """Cosmology classes.""" return cosmo.__class__ @@ -146,21 +154,23 @@ class ToFromDirectTestBase(IODirectTestBase, ToFromTestMixinBase): """ @pytest.fixture(scope="class") - def from_format(self): + @classmethod + def from_format(cls): """Convert to Cosmology using function ``from``.""" def use_from_format(*args, **kwargs): kwargs.pop("format", None) # specific to Cosmology.from_format - return self.functions["from"](*args, **kwargs) + return cls.functions["from"](*args, **kwargs) return use_from_format @pytest.fixture(scope="class") - def to_format(self, cosmo): + @classmethod + def to_format(cls, cosmo): """Convert Cosmology to format using function ``to``.""" def use_to_format(*args, **kwargs): - return self.functions["to"](cosmo, *args, **kwargs) + return cls.functions["to"](cosmo, *args, **kwargs) return use_to_format @@ -184,20 +194,22 @@ class ReadWriteDirectTestBase(IODirectTestBase, ToFromTestMixinBase): """ @pytest.fixture(scope="class") - def read(self): + @classmethod + def read(cls): """Read Cosmology from file using function ``read``.""" def use_read(*args, **kwargs): kwargs.pop("format", None) # specific to Cosmology.from_format - return self.functions["read"](*args, **kwargs) + return cls.functions["read"](*args, **kwargs) return use_read @pytest.fixture(scope="class") - def write(self, cosmo): + @classmethod + def write(cls, cosmo): """Write Cosmology to file using function ``write``.""" def use_write(*args, **kwargs): - return self.functions["write"](cosmo, *args, **kwargs) + return cls.functions["write"](cosmo, *args, **kwargs) return use_write diff --git a/astropy/cosmology/_src/tests/io/test_connect.py b/astropy/cosmology/_src/tests/io/test_connect.py index 34b9c8e527a0..8c3df96e6783 100644 --- a/astropy/cosmology/_src/tests/io/test_connect.py +++ b/astropy/cosmology/_src/tests/io/test_connect.py @@ -135,11 +135,13 @@ class TestCosmologyReadWrite(ReadWriteTestMixin): """Test the classes CosmologyRead/Write.""" @pytest.fixture(scope="class", params=cosmo_instances) - def cosmo(self, request): + @classmethod + def cosmo(cls, request): return getattr(cosmology.realizations, request.param) @pytest.fixture(scope="class") - def cosmo_cls(self, cosmo): + @classmethod + def cosmo_cls(cls, cosmo): return cosmo.__class__ # ============================================================== @@ -267,11 +269,13 @@ class TestCosmologyToFromFormat(ToFromFormatTestMixin): """Test Cosmology[To/From]Format classes.""" @pytest.fixture(scope="class", params=cosmo_instances) - def cosmo(self, request): + @classmethod + def cosmo(cls, request): return getattr(cosmology.realizations, request.param) @pytest.fixture(scope="class") - def cosmo_cls(self, cosmo): + @classmethod + def cosmo_cls(cls, cosmo): return cosmo.__class__ # ============================================================== diff --git a/astropy/cosmology/_src/tests/io/test_json.py b/astropy/cosmology/_src/tests/io/test_json.py index d6bc745eb253..4f04f6329a82 100644 --- a/astropy/cosmology/_src/tests/io/test_json.py +++ b/astropy/cosmology/_src/tests/io/test_json.py @@ -95,7 +95,8 @@ class ReadWriteJSONTestMixin(ReadWriteTestMixinBase): """ @pytest.fixture(scope="class", autouse=True) - def register_and_unregister_json(self): + @classmethod + def register_and_unregister_json(cls): """Setup & teardown for JSON read/write tests.""" # Register readwrite_registry.register_reader("json", Cosmology, read_json, force=True) diff --git a/astropy/cosmology/_src/tests/io/test_model.py b/astropy/cosmology/_src/tests/io/test_model.py index 1047649861e6..4fb0a64a93ef 100644 --- a/astropy/cosmology/_src/tests/io/test_model.py +++ b/astropy/cosmology/_src/tests/io/test_model.py @@ -32,7 +32,8 @@ class ToFromModelTestMixin(ToFromTestMixinBase): """ @pytest.fixture(scope="class") - def method_name(self, cosmo): + @classmethod + def method_name(cls, cosmo): # get methods, ignoring private and dunder methods = get_redshift_methods(cosmo, include_private=False, include_z2=True) diff --git a/astropy/cosmology/_src/tests/io/test_yaml.py b/astropy/cosmology/_src/tests/io/test_yaml.py index 0e2a70ceceac..4ddfc8f6f6be 100644 --- a/astropy/cosmology/_src/tests/io/test_yaml.py +++ b/astropy/cosmology/_src/tests/io/test_yaml.py @@ -168,7 +168,8 @@ def setup_class(self): self.functions = {"to": to_yaml, "from": from_yaml} @pytest.fixture(scope="class", autouse=True) - def setup(self): + @classmethod + def setup(cls): """ Setup and teardown for tests. This overrides from super because `ToFromDirectTestBase` adds a custom diff --git a/astropy/cosmology/_src/tests/parameter/test_parameter.py b/astropy/cosmology/_src/tests/parameter/test_parameter.py index e7b615748023..a1646a694578 100644 --- a/astropy/cosmology/_src/tests/parameter/test_parameter.py +++ b/astropy/cosmology/_src/tests/parameter/test_parameter.py @@ -225,22 +225,26 @@ def teardown_class(self): _COSMOLOGY_CLASSES.pop(cls.__qualname__, None) @pytest.fixture(scope="class", params=["Example1", "Example2"]) - def cosmo_cls(self, request): + @classmethod + def cosmo_cls(cls, request): """Cosmology class.""" - return self.classes[request.param] + return cls.classes[request.param] @pytest.fixture(scope="class") - def cosmo(self, cosmo_cls): + @classmethod + def cosmo(cls, cosmo_cls): """Cosmology instance""" return cosmo_cls() @pytest.fixture(scope="class") - def param(self, cosmo_cls): + @classmethod + def param(cls, cosmo_cls): """Get Parameter 'param' from cosmology class.""" return cosmo_cls.parameters["param"] @pytest.fixture(scope="class") - def param_cls(self, param): + @classmethod + def param_cls(cls, param): """Get Parameter class from cosmology class.""" return type(param) diff --git a/astropy/cosmology/_src/tests/test_core.py b/astropy/cosmology/_src/tests/test_core.py index 66b36eb9afe0..6f3dcc262150 100644 --- a/astropy/cosmology/_src/tests/test_core.py +++ b/astropy/cosmology/_src/tests/test_core.py @@ -119,9 +119,10 @@ def cls_args(self): return tuple(self._cls_args.values()) @pytest.fixture(scope="class") - def cosmo_cls(self): + @classmethod + def cosmo_cls(cls): """The Cosmology class as a :func:`pytest.fixture`.""" - return self.cls + return cls.cls @pytest.fixture(scope="function") # ensure not cached. def ba(self): @@ -131,9 +132,14 @@ def ba(self): return ba @pytest.fixture(scope="class") - def cosmo(self, cosmo_cls): + @classmethod + def cosmo(cls, cosmo_cls): """The cosmology instance with which to test.""" - ba = inspect.signature(self.cls).bind(*self.cls_args, **self.cls_kwargs) + # `cls_args` is a @property on the test class, which doesn't fire + # when accessed via cls (only via self), so dereference _cls_args + # directly here. + cls_args = tuple(cls._cls_args.values()) + ba = inspect.signature(cls.cls).bind(*cls_args, **cls.cls_kwargs) ba.apply_defaults() return cosmo_cls(*ba.args, **ba.kwargs) diff --git a/astropy/cosmology/_src/tests/test_units.py b/astropy/cosmology/_src/tests/test_units.py index 72497562de19..2c67498f4a9f 100644 --- a/astropy/cosmology/_src/tests/test_units.py +++ b/astropy/cosmology/_src/tests/test_units.py @@ -172,7 +172,8 @@ class Test_with_redshift: """Test `astropy.cosmology.units.with_redshift`.""" @pytest.fixture(scope="class") - def cosmo(self): + @classmethod + def cosmo(cls): """Test cosmology.""" return Planck13.clone(Tcmb0=3 * u.K) diff --git a/astropy/cosmology/_src/traits/baryons.py b/astropy/cosmology/_src/traits/baryons.py index 317023f9de40..11f553b4f25f 100644 --- a/astropy/cosmology/_src/traits/baryons.py +++ b/astropy/cosmology/_src/traits/baryons.py @@ -6,6 +6,7 @@ from collections.abc import Callable from typing import Any +import numpy as np from numpy.typing import ArrayLike, NDArray from astropy.cosmology._src.typing import FArray @@ -16,7 +17,7 @@ class BaryonComponent: """The cosmology has attributes and methods for the baryon density.""" - Ob0: float + Ob0: float | np.floating """Omega baryons: density of baryonic matter in units of the critical density at z=0.""" inv_efunc: Callable[[NDArray[Any]], NDArray[Any]] diff --git a/astropy/cosmology/_src/traits/darkenergy.py b/astropy/cosmology/_src/traits/darkenergy.py index e83a87eb67f5..67739c5ba6a9 100644 --- a/astropy/cosmology/_src/traits/darkenergy.py +++ b/astropy/cosmology/_src/traits/darkenergy.py @@ -12,7 +12,7 @@ class DarkEnergyComponent: # Subclasses should use `Parameter` to make this a parameter of the cosmology. - Ode0: float + Ode0: float | np.floating """Omega dark energy; dark energy density/critical density at z=0.""" @abstractmethod diff --git a/astropy/cosmology/_src/traits/darkmatter.py b/astropy/cosmology/_src/traits/darkmatter.py index 78a38f48aaf0..ac1e23b66435 100644 --- a/astropy/cosmology/_src/traits/darkmatter.py +++ b/astropy/cosmology/_src/traits/darkmatter.py @@ -3,6 +3,7 @@ from collections.abc import Callable from typing import Any +import numpy as np from numpy.typing import ArrayLike, NDArray from astropy.cosmology._src.typing import FArray @@ -17,7 +18,7 @@ class DarkMatterComponent: density parameter (i.e., total matter minus baryons) at redshift ``z``. """ - Odm0: float + Odm0: float | np.floating """Omega dark matter: dark matter density/critical density at z=0.""" inv_efunc: Callable[[NDArray[Any]], NDArray[Any]] diff --git a/astropy/cosmology/_src/traits/matterdensity.py b/astropy/cosmology/_src/traits/matterdensity.py index 3cb2a89fce34..68adeedc56e5 100644 --- a/astropy/cosmology/_src/traits/matterdensity.py +++ b/astropy/cosmology/_src/traits/matterdensity.py @@ -1,5 +1,6 @@ """Matter component.""" +import numpy as np from numpy.typing import ArrayLike from astropy.cosmology._src.typing import FArray @@ -10,7 +11,7 @@ class MatterComponent: - Om0: Quantity + Om0: float | np.floating """Omega matter; matter density/critical density at z=0.""" @deprecated_keywords("z", since="7.0") diff --git a/astropy/cosmology/_src/traits/photoncomponent.py b/astropy/cosmology/_src/traits/photoncomponent.py index 738f35554a65..c838d3fc66aa 100644 --- a/astropy/cosmology/_src/traits/photoncomponent.py +++ b/astropy/cosmology/_src/traits/photoncomponent.py @@ -6,6 +6,7 @@ from collections.abc import Callable from typing import Any +import numpy as np from numpy.typing import ArrayLike, NDArray from astropy.cosmology._src.typing import FArray @@ -16,7 +17,7 @@ class PhotonComponent: """The cosmology has attributes and methods for the photon density.""" - Ogamma0: float + Ogamma0: float | np.floating """Omega gamma; the density/critical density of photons at z=0.""" inv_efunc: Callable[[NDArray[Any]], NDArray[Any]] diff --git a/astropy/cosmology/_src/traits/totalcomponent.py b/astropy/cosmology/_src/traits/totalcomponent.py index cf4ef77b9298..402bafa42130 100644 --- a/astropy/cosmology/_src/traits/totalcomponent.py +++ b/astropy/cosmology/_src/traits/totalcomponent.py @@ -4,6 +4,7 @@ from abc import abstractmethod +import numpy as np from numpy.typing import ArrayLike from astropy.cosmology._src.typing import FArray @@ -19,7 +20,7 @@ class TotalComponent: @property @abstractmethod - def Otot0(self) -> float: + def Otot0(self) -> float | np.floating: """Omega total; the total density/critical density at z=0.""" raise NotImplementedError # pragma: no cover diff --git a/astropy/io/fits/column.py b/astropy/io/fits/column.py index 804adb07c95f..3203931646fc 100644 --- a/astropy/io/fits/column.py +++ b/astropy/io/fits/column.py @@ -1,22 +1,21 @@ # Licensed under a 3-clause BSD style license - see PYFITS.rst import copy +import math import numbers -import operator import re import sys import warnings import weakref from collections import OrderedDict from contextlib import suppress -from functools import reduce from itertools import pairwise from textwrap import indent import numpy as np -from numpy import char as chararray from astropy.utils import lazyproperty +from astropy.utils.compat import chararray, get_chararray from astropy.utils.exceptions import AstropyUserWarning from .card import CARD_LENGTH, Card @@ -696,14 +695,14 @@ def __init__( # input arrays can be just list or tuple, not required to be ndarray # does not include Object array because there is no guarantee # the elements in the object array are consistent. - if not isinstance(array, (np.ndarray, chararray.chararray, Delayed)): + if not isinstance(array, (np.ndarray, chararray, Delayed)): try: # try to convert to a ndarray first if array is not None: array = np.array(array) except Exception: try: # then try to convert it to a strings array itemsize = int(recformat[1:]) - array = chararray.array(array, itemsize=itemsize) + array = get_chararray(array, itemsize=itemsize) except ValueError: # then try variable length array # Note: This includes _FormatQ by inheritance @@ -1203,7 +1202,7 @@ def _verify_keywords( # TDIMs have different meaning for VLA format, # no warning should be thrown msg = None - elif reduce(operator.mul, dims_tuple) > format.repeat: + elif math.prod(dims_tuple) > format.repeat: msg = ( f"The repeat count of the column format {name!r} for column " f"{format!r} is fewer than the number of elements per the TDIM " @@ -1388,7 +1387,7 @@ def _convert_to_valid_data_type(self, array): fsize = dims[-1] else: fsize = np.dtype(format.recformat).itemsize - return chararray.array(array, itemsize=fsize, copy=False) + return get_chararray(array, itemsize=fsize, copy=False) else: return _convert_array(array, np.dtype(format.recformat)) elif "L" in format: @@ -2082,7 +2081,7 @@ def __new__(cls, input, dtype="S"): try: # this handles ['abc'] and [['a','b','c']] # equally, beautiful! - input = [chararray.array(x, itemsize=1) for x in input] + input = [get_chararray(x, itemsize=1) for x in input] except Exception: raise ValueError(f"Inconsistent input data array: {input}") @@ -2105,16 +2104,14 @@ def __setitem__(self, key, value): """ if isinstance(value, np.ndarray) and value.dtype == self.dtype: pass - elif isinstance(value, chararray.chararray) and value.itemsize == 1: + elif isinstance(value, chararray) and value.itemsize == 1: pass elif self.element_dtype == "S": - value = chararray.array(value, itemsize=1) + value = get_chararray(value, itemsize=1) else: value = np.array(value, dtype=self.element_dtype) np.ndarray.__setitem__(self, key, value) - nelem = value.shape - len_value = np.prod(nelem) - self.max = max(self.max, len_value) + self.max = max(self.max, value.size) def tolist(self): return [list(item) for item in super().tolist()] @@ -2262,12 +2259,11 @@ def _makep(array, descr_output, format, nrows=None): else: rowval = [0] * data_output.max if format.dtype == "S": - data_output[idx] = chararray.array(encode_ascii(rowval), itemsize=1) + data_output[idx] = get_chararray(encode_ascii(rowval), itemsize=1) else: data_output[idx] = np.array(rowval, dtype=format.dtype) - nelem = data_output[idx].shape - descr_output[idx, 0] = np.prod(nelem) + descr_output[idx, 0] = data_output[idx].size descr_output[idx, 1] = _offset # detect overflow when using P format @@ -2484,7 +2480,7 @@ def _convert_record2fits(format): ndims = len(shape) repeat = 1 if ndims > 0: - nel = np.array(shape, dtype="i8").prod() + nel = math.prod(shape) if nel > 1: repeat = nel diff --git a/astropy/io/fits/convenience.py b/astropy/io/fits/convenience.py index 7e9dcdce3a17..83b64816071d 100644 --- a/astropy/io/fits/convenience.py +++ b/astropy/io/fits/convenience.py @@ -251,7 +251,9 @@ def getdata(filename, *args, header=None, lower=None, upper=None, view=None, **k if data.dtype.descr[0][0] == "": # this data does not have fields return - data.dtype.names = [trans(n) for n in data.dtype.names] + + for n in data.dtype.names: + data.columns.change_name(n, trans(n)) # allow different views into the underlying ndarray. Keep the original # view just in case there is a problem @@ -553,7 +555,7 @@ def table_to_hdu(table, character_as_bytes=False, name=None): # Be careful that we do not set null for columns that were not masked! int_formats = ("B", "I", "J", "K") if ( - col.format in int_formats or col.format.p_format in int_formats + col.format.format in int_formats or col.format.p_format in int_formats ) and hasattr(table[col.name], "mask"): fill_value = tarray[col.name].fill_value col.null = fill_value.astype(int) diff --git a/astropy/io/fits/file.py b/astropy/io/fits/file.py index ca5912397fe2..266cce0d6a53 100644 --- a/astropy/io/fits/file.py +++ b/astropy/io/fits/file.py @@ -4,15 +4,14 @@ import gzip import http.client import io +import math import mmap -import operator import os import re import sys import tempfile import warnings import zipfile -from functools import reduce import numpy as np @@ -356,7 +355,7 @@ def readarray(self, size=None, offset=0, dtype=np.uint8, shape=None): shape = (size // dtype.itemsize,) if size and shape: - actualsize = np.prod(shape) * dtype.itemsize + actualsize = math.prod(shape) * dtype.itemsize if actualsize > size: raise ValueError( @@ -423,17 +422,29 @@ def readarray(self, size=None, offset=0, dtype=np.uint8, shape=None): access=MEMMAP_MODES["denywrite"], offset=0, ) + elif not self.strict_memmap: + # mmap failed but wasn't explicitly requested, + # fall back to non-mmap reading + warnings.warn( + "Could not memory map array; falling back to " + "non-memory-mapped file reading. You can disable " + "this warning by setting memmap=False", + AstropyUserWarning, + ) + self.memmap = False else: raise - return np.ndarray( - shape=shape, dtype=dtype, offset=offset, buffer=self._mmap - ) - else: - count = reduce(operator.mul, shape) - self._file.seek(offset) - data = _array_from_file(self._file, dtype, count) - return data.reshape(shape) + if self._mmap is not None: + return np.ndarray( + shape=shape, dtype=dtype, offset=offset, buffer=self._mmap + ) + + # Non-mmap path (also used as fallback when mmap fails) + count = math.prod(shape) + self._file.seek(offset) + data = _array_from_file(self._file, dtype, count) + return data.reshape(shape) finally: # Make sure we leave the file in the position we found it; on # some platforms (e.g. Windows) mmaping a file handle can also diff --git a/astropy/io/fits/fitsrec.py b/astropy/io/fits/fitsrec.py index 9787b618e32c..5d499af927dd 100644 --- a/astropy/io/fits/fitsrec.py +++ b/astropy/io/fits/fitsrec.py @@ -1,16 +1,15 @@ # Licensed under a 3-clause BSD style license - see PYFITS.rst import copy -import operator +import math import warnings import weakref from contextlib import suppress -from functools import reduce import numpy as np -from numpy import char as chararray from astropy.utils import lazyproperty +from astropy.utils.compat import chararray, get_chararray from .column import ( _VLF, @@ -575,13 +574,9 @@ def __setitem__(self, key, value): return if isinstance(key, slice): - end = min(len(self), key.stop or len(self)) - end = max(0, end) - start = max(0, key.start or 0) - end = min(end, start + len(value)) - - for idx in range(start, end): - self.__setitem__(idx, value[idx - start]) + start, stop, step = key.indices(len(self)) + for idx, val in zip(range(start, stop, step), value, strict=True): + self.__setitem__(idx, val) return if isinstance(value, FITS_record): @@ -831,7 +826,7 @@ def _convert_p(self, column, field, recformat): dt = np.dtype(recformat.dtype + str(1)) arr_len = count * dt.itemsize da = raw_data[offset : offset + arr_len].view(dt) - da = np.char.array(da.view(dtype=dt), itemsize=count) + da = get_chararray(da.view(dtype=dt), itemsize=count) dummy[idx] = decode_ascii(da) else: dt = np.dtype(recformat.dtype) @@ -842,9 +837,7 @@ def _convert_p(self, column, field, recformat): if vla_shape[0] == 1: dummy[idx] = dummy[idx].reshape(1, len(dummy[idx])) else: - vla_dim = vla_shape[1:] - vla_first = int(len(dummy[idx]) / np.prod(vla_dim)) - dummy[idx] = dummy[idx].reshape((vla_first,) + vla_dim) + dummy[idx] = dummy[idx].reshape((-1,) + vla_shape[1:]) dummy[idx] = dummy[idx].view(dummy[idx].dtype.newbyteorder(">")) # Each array in the field may now require additional @@ -942,7 +935,7 @@ def _convert_other(self, column, field, recformat): # ignore dim and don't convert dim = None else: - nitems = reduce(operator.mul, dim) + nitems = math.prod(dim) if _str: actual_nitems = field.itemsize elif len(field.shape) == 1: @@ -1020,7 +1013,7 @@ def _convert_other(self, column, field, recformat): if dim and not isinstance(recformat, _FormatP): # Apply the new field item dimensions - nitems = reduce(operator.mul, dim) + nitems = math.prod(dim) if field.ndim > 1: field = field[:, :nitems] if _str: @@ -1160,7 +1153,7 @@ def _scale_back(self, update_heap_pointers=True): # The VLA has potentially been updated, so we need to # update the array descriptors raw_field[:] = 0 # reset - npts = [np.prod(arr.shape) for arr in self._converted[name]] + npts = [arr.size for arr in self._converted[name]] raw_field[: len(npts), 0] = npts raw_field[1:, 1] = ( @@ -1204,7 +1197,7 @@ def _scale_back(self, update_heap_pointers=True): if isinstance(self._coldefs, _AsciiColDefs): self._scale_back_ascii(index, dummy, raw_field) # binary table string column - elif isinstance(raw_field, chararray.chararray): + elif isinstance(raw_field, chararray): self._scale_back_strings(index, dummy, raw_field) # all other binary table columns else: @@ -1361,8 +1354,8 @@ def _get_recarray_field(array, key): # This is currently needed for backwards-compatibility and for # automatic truncation of trailing whitespace field = np.recarray.field(array, key) - if field.dtype.char in ("S", "U") and not isinstance(field, chararray.chararray): - field = field.view(chararray.chararray) + if field.dtype.char in ("S", "U") and not isinstance(field, chararray): + field = field.view(chararray) return field diff --git a/astropy/io/fits/hdu/base.py b/astropy/io/fits/hdu/base.py index 5dcc0e726933..c988a5cb7067 100644 --- a/astropy/io/fits/hdu/base.py +++ b/astropy/io/fits/hdu/base.py @@ -885,6 +885,19 @@ def match_header(cls, header): """ return first(header.keys()) not in ("SIMPLE", "XTENSION") + @property + def _expected_header_primary_like(self): + """ + Whether verification should expect a primary-HDU-style header (SIMPLE + keyword, no PCOUNT/GCOUNT) or extension-HDU-style header (XTENSION + keyword, PCOUNT/GCOUNT required). + + By default this is determined by whether the HDU is an ExtensionHDU, + but subclasses can override this for special cases like CompImageHDU + where the header format depends on the file content. + """ + return not isinstance(self, ExtensionHDU) + @property def size(self): """ @@ -957,12 +970,12 @@ def _verify(self, option="warn"): # Verify location and value of mandatory keywords. # Do the first card here, instead of in the respective HDU classes, so # the checking is in order, in case of required cards in wrong order. - if isinstance(self, ExtensionHDU): - firstkey = "XTENSION" - firstval = self._extension - else: + if self._expected_header_primary_like: firstkey = "SIMPLE" firstval = True + else: + firstkey = "XTENSION" + firstval = self._extension self.req_cards(firstkey, 0, None, firstval, option, errs) self.req_cards( @@ -1544,13 +1557,16 @@ def _verify(self, option="warn"): errs = super()._verify(option=option) # Verify location and value of mandatory keywords. - naxis = self._header.get("NAXIS", 0) - self.req_cards( - "PCOUNT", naxis + 3, lambda v: (_is_int(v) and v >= 0), 0, option, errs - ) - self.req_cards( - "GCOUNT", naxis + 4, lambda v: (_is_int(v) and v == 1), 1, option, errs - ) + # PCOUNT and GCOUNT are only required for extension-style headers, + # not for primary-style headers (e.g., CompImageHDU with SIMPLE). + if not self._expected_header_primary_like: + naxis = self._header.get("NAXIS", 0) + self.req_cards( + "PCOUNT", naxis + 3, lambda v: (_is_int(v) and v >= 0), 0, option, errs + ) + self.req_cards( + "GCOUNT", naxis + 4, lambda v: (_is_int(v) and v == 1), 1, option, errs + ) return errs diff --git a/astropy/io/fits/hdu/compressed/_tiled_compression.py b/astropy/io/fits/hdu/compressed/_tiled_compression.py index 2ef5b90ebefa..e7ae2fc7a624 100644 --- a/astropy/io/fits/hdu/compressed/_tiled_compression.py +++ b/astropy/io/fits/hdu/compressed/_tiled_compression.py @@ -5,11 +5,13 @@ """ import sys +import warnings from math import prod import numpy as np from astropy.io.fits.hdu.base import BITPIX2DTYPE +from astropy.utils.exceptions import AstropyUserWarning from ._codecs import PLIO1, Gzip1, Gzip2, HCompress1, NoCompress, Rice1 from ._quantization import DITHER_METHODS, QuantizationFailedException, Quantize @@ -206,7 +208,12 @@ def _check_compressed_header(header): raise TypeError(f"{kw} should be an integer") for suffix in range(1, header["TFIELDS"] + 1): - if header.get(f"TTYPE{suffix}", "").endswith("COMPRESSED_DATA"): + # Only validate COMPRESSED_DATA and GZIP_COMPRESSED_DATA columns. + # UNCOMPRESSED_DATA columns can have floating-point formats (PE, PD, etc.) + if header.get(f"TTYPE{suffix}", "") in ( + "COMPRESSED_DATA", + "GZIP_COMPRESSED_DATA", + ): for valid in ["PB", "PI", "PJ", "QB", "QI", "QJ"]: if header[f"TFORM{suffix}"].startswith((valid, f"1{valid}")): break @@ -262,6 +269,10 @@ def _column_dtype(compressed_coldefs, column_name): dtype = ">i2" elif tform[1] == "J": dtype = ">i4" + elif tform[1] == "E": + dtype = ">f4" + elif tform[1] == "D": + dtype = ">f8" return np.dtype(dtype) @@ -362,6 +373,8 @@ def decompress_image_data_section( gzip_compressed_data_column = None gzip_compressed_data_dtype = None + uncompressed_data_column = None + uncompressed_data_dtype = None # If all the data is requested, read in all the heap. if tuple(buffer_shape) == tuple(data_shape): @@ -383,35 +396,61 @@ def decompress_image_data_section( settings = _update_tile_settings(settings, compression_type, actual_tile_shape) if compressed_data_column[row_index][0] == 0: - if gzip_compressed_data_column is None: - gzip_compressed_data_column = np.array( - compressed_data["GZIP_COMPRESSED_DATA"] - ) - gzip_compressed_data_dtype = _column_dtype( - compressed_coldefs, "GZIP_COMPRESSED_DATA" - ) - # When quantizing floating point data, sometimes the data will not # quantize efficiently. In these cases the raw floating point data can # be losslessly GZIP compressed and stored in the `GZIP_COMPRESSED_DATA` - # column. + # column, or stored uncompressed in the `UNCOMPRESSED_DATA` column. - cdata = _get_data_from_heap( - bintable, - *gzip_compressed_data_column[row_index], - gzip_compressed_data_dtype, - heap_cache=heap_cache, - ) + if "GZIP_COMPRESSED_DATA" in compressed_coldefs.dtype.names: + if gzip_compressed_data_column is None: + gzip_compressed_data_column = np.array( + compressed_data["GZIP_COMPRESSED_DATA"] + ) + gzip_compressed_data_dtype = _column_dtype( + compressed_coldefs, "GZIP_COMPRESSED_DATA" + ) - tile_buffer = _decompress_tile(cdata, algorithm="GZIP_1") + cdata = _get_data_from_heap( + bintable, + *gzip_compressed_data_column[row_index], + gzip_compressed_data_dtype, + heap_cache=heap_cache, + ) - tile_data = _finalize_array( - tile_buffer, - bitpix=zbitpix, - tile_shape=actual_tile_shape, - algorithm="GZIP_1", - lossless=True, - ) + tile_buffer = _decompress_tile(cdata, algorithm="GZIP_1") + + tile_data = _finalize_array( + tile_buffer, + bitpix=zbitpix, + tile_shape=actual_tile_shape, + algorithm="GZIP_1", + lossless=True, + ) + + elif "UNCOMPRESSED_DATA" in compressed_coldefs.dtype.names: + if uncompressed_data_column is None: + uncompressed_data_column = np.array( + compressed_data["UNCOMPRESSED_DATA"] + ) + uncompressed_data_dtype = _column_dtype( + compressed_coldefs, "UNCOMPRESSED_DATA" + ) + + tile_data = _get_data_from_heap( + bintable, + *uncompressed_data_column[row_index], + uncompressed_data_dtype, + heap_cache=heap_cache, + ) + + # Data is already uncompressed, just reshape it + tile_data = tile_data.reshape(actual_tile_shape) + + else: + raise ValueError( + "COMPRESSED_DATA column has zero length but neither " + "GZIP_COMPRESSED_DATA nor UNCOMPRESSED_DATA column exists" + ) else: cdata = _get_data_from_heap( @@ -504,6 +543,50 @@ def compress_image_data( if not isinstance(image_data, np.ndarray): raise TypeError("Image data must be a numpy.ndarray") + # PLIO_1 encodes only non-negative integers, but astropy stores unsigned + # FITS data via the BZERO=2**(N-1) convention which produces negative + # values for the lower half of the range. Reject unsigned multi-byte + # input to avoid corrupted writes. + if ( + compression_type == "PLIO_1" + and image_data.dtype.kind == "u" + and image_data.dtype.itemsize >= 2 + ): + raise ValueError( + "PLIO_1 compression does not support unsigned integers larger than 8 bits" + ) + + if ( + compression_type in ("RICE_1", "PLIO_1", "HCOMPRESS_1") + and image_data.dtype.kind in ("i", "u") + and image_data.dtype.itemsize == 8 + ): + # numpy's same_value check does not byteswap before comparing values, + # so non-native source data silently truncates instead of raising. + # Convert source to native byte order first. + native_source = image_data.astype( + image_data.dtype.newbyteorder("="), copy=False + ) + new_dt = f"{image_data.dtype.kind}4" + try: + image_data = native_source.astype(new_dt, casting="same_value") + compressed_header["ZBITPIX"] = 32 + if image_data.dtype.kind == "u": + # The input image header carries BZERO=2**63 for the original + # uint64 storage; after converting to uint32 the offset must + # be 2**31 so the FITS reader reconstructs the right values. + compressed_header["BZERO"] = 2**31 + warnings.warn( + f"{compression_type} compression doesn't support 64 integers, " + "data has been converted to 32 bits", + AstropyUserWarning, + ) + except ValueError: + raise ValueError( + f"{compression_type} compression doesn't support 64 integers, " + "but data cannot be converted to 32 bits without overflow", + ) + _check_compressed_header(compressed_header) # TODO: This implementation is memory inefficient as it generates all the @@ -528,10 +611,20 @@ def compress_image_data( tile_data = image_data[tile_slices] if tile_data.dtype.kind == "u": - if tile_data.dtype.itemsize == 4: + if tile_data.dtype.itemsize == 8: + # Subtract 2**63 in wrap-around uint64 arithmetic, then + # reinterpret the bits as int64. This is equivalent to flipping + # the high bit and matches the FITS BZERO=2**63 convention. + tile_data = (tile_data - np.uint64(2**63)).view(np.int64) + elif tile_data.dtype.itemsize == 4: tile_data = (tile_data.astype(np.int64) - 2**31).astype(np.int32) elif tile_data.dtype.itemsize == 2: tile_data = (tile_data.astype(np.int32) - 2**15).astype(np.int16) + elif tile_data.dtype.kind == "i" and tile_data.dtype.itemsize == 1: + # FITS BITPIX=8 storage is unsigned, so int8 input is recorded via + # BZERO=-128 and the stored bytes must be shifted by +128. Without + # this the BZERO=-128 applied on read offsets every value by 128. + tile_data = (tile_data.astype(np.int16) + 128).astype(np.uint8) settings = _update_tile_settings(settings, compression_type, tile_data.shape) diff --git a/astropy/io/fits/hdu/compressed/compressed.py b/astropy/io/fits/hdu/compressed/compressed.py index 8bac08aee0a1..cfa2a1a29a02 100644 --- a/astropy/io/fits/hdu/compressed/compressed.py +++ b/astropy/io/fits/hdu/compressed/compressed.py @@ -18,7 +18,6 @@ from astropy.io.fits.hdu.image import ImageHDU from astropy.io.fits.header import Header from astropy.io.fits.util import _is_int -from astropy.io.fits.verify import _ErrList from astropy.utils.decorators import lazyproperty from astropy.utils.exceptions import AstropyUserWarning @@ -335,7 +334,11 @@ def __init__( ) if header is not None and "SIMPLE" in header: - self.header["SIMPLE"] = header["SIMPLE"] + # SIMPLE and XTENSION are mutually exclusive per FITS standard + del self.header["XTENSION"] + del self.header["PCOUNT"] + del self.header["GCOUNT"] + self.header.insert(0, ("SIMPLE", header["SIMPLE"])) self.compression_type = compression_type self.tile_shape = _validate_tile_shape( @@ -395,6 +398,15 @@ def match_header(cls, header): return True + @property + def _expected_header_primary_like(self): + # CompImageHDU can have either a primary-style header (SIMPLE) or an + # extension-style header (XTENSION) depending on whether the original + # image was a primary HDU or an extension HDU. fpack-compressed primary + # HDUs have ZSIMPLE in the bintable header which becomes SIMPLE in the + # image header. + return "SIMPLE" in self._header + @property def compression_type(self): return self._compression_type @@ -669,27 +681,6 @@ def section(self): """ return CompImageSection(self) - def _verify(self, *args, **kwargs): - # The following is the default _verify for ImageHDU - errs = super()._verify(*args, **kwargs) - - # However in some cases the decompressed header is actually like a - # PrimaryHDU header rather than an ImageHDU header, in which case - # there are certain errors we can ignore - if "SIMPLE" in self.header: - errs_filtered = [] - for err in errs: - if len(err) >= 2 and err[1] in ( - "'XTENSION' card does not exist.", - "'PCOUNT' card does not exist.", - "'GCOUNT' card does not exist.", - ): - continue - errs_filtered.append(err) - return _ErrList(errs_filtered) - else: - return errs - def fileinfo(self): if self._bintable is not None: return self._bintable.fileinfo() @@ -732,3 +723,21 @@ def _data_size(self, value): # to None in __init__. if value is not None: raise RuntimeError("Cannot set CompImageHDU._data_size") + + @property + def _file(self): + # Delegate to bintable since that's where the actual file reference lives + if self._bintable is not None: + return self._bintable._file + return None + + @_file.setter + def _file(self, value): + # Delegate to bintable. When _flush_resize updates the HDU's file + # reference, this ensures the bintable's file reference is also updated. + # See https://github.com/astropy/astropy/issues/18612 + # + # Only set non-None values to avoid overwriting the bintable's valid + # file reference when the parent's __init__ sets _file = None. + if value is not None and self._bintable is not None: + self._bintable._file = value diff --git a/astropy/io/fits/hdu/compressed/tests/conftest.py b/astropy/io/fits/hdu/compressed/tests/conftest.py index 6b4990f71076..f5acee9a27eb 100644 --- a/astropy/io/fits/hdu/compressed/tests/conftest.py +++ b/astropy/io/fits/hdu/compressed/tests/conftest.py @@ -38,7 +38,10 @@ def _expand(*params): ALL_INTEGER_DTYPES = [ "".join(ele) - for ele in _expand([("<", ">"), ("i",), ("2", "4")], [("<", ">"), ("u",), ("1",)]) + for ele in _expand( + [("<", ">"), ("i",), ("1", "2", "4")], + [("<", ">"), ("u",), ("1", "2", "4")], + ) ] ALL_FLOAT_DTYPES = ["".join(ele) for ele in _expand([("<", ">"), ("f",), ("4", "8")])] diff --git a/astropy/io/fits/hdu/compressed/tests/test_compressed.py b/astropy/io/fits/hdu/compressed/tests/test_compressed.py index d7d5ec6cb917..8e845afe8be8 100644 --- a/astropy/io/fits/hdu/compressed/tests/test_compressed.py +++ b/astropy/io/fits/hdu/compressed/tests/test_compressed.py @@ -6,6 +6,7 @@ import pickle import re import time +import warnings from io import BytesIO import numpy as np @@ -95,7 +96,9 @@ def test_comp_image_quantize_level(self): # Basically what scipy.datasets.ascent() does. fname = download_file( - "https://github.com/scipy/dataset-ascent/blob/main/ascent.dat?raw=true" + "https://raw.githubusercontent.com/scipy/dataset-ascent/main/ascent.dat", + cache=True, + show_progress=False, ) with open(fname, "rb") as f: scipy_data = np.array(pickle.load(f)) @@ -225,6 +228,33 @@ def test_open_comp_image_in_update_mode(self): # opening and closing it. assert mtime == os.stat(testfile).st_mtime + def test_update_comp_image_header(self): + """ + Regression test for https://github.com/astropy/astropy/issues/18612 + + Test that modifying the header of a compressed image in update mode + does not corrupt the file. + """ + data = np.arange(100, dtype=np.float32).reshape(10, 10) + + # Create a compressed FITS file + primary = fits.PrimaryHDU() + comp_hdu = fits.CompImageHDU(data, name="SCI", compression_type="RICE_1") + hdul = fits.HDUList([primary, comp_hdu]) + hdul.writeto(self.temp("test_comp_header.fits")) + + # Open in update mode and modify header + with fits.open(self.temp("test_comp_header.fits"), mode="update") as hdul: + hdul[1].header["TEST"] = (1, "Test keyword") + # The bug is triggered if a user explicitly flushes the file - which + # unnecessary but not wrong + hdul.flush() + + # Verify the file can be read and changes are present + with fits.open(self.temp("test_comp_header.fits")) as hdul: + assert hdul[1].header.get("TEST") == 1 + np.testing.assert_array_equal(hdul[1].data, data) + @pytest.mark.slow def test_open_scaled_in_update_mode_compressed(self): """ @@ -276,7 +306,7 @@ def test_open_scaled_in_update_mode_compressed(self): # Try reshaping the data, then closing and reopening the file; let's # see if all the changes are preserved properly - hdul[1].data.shape = (42, 10) + hdul[1].data = hdul[1].data.reshape(42, 10) hdul.close() hdul = fits.open(self.temp("scale.fits")) @@ -1451,7 +1481,7 @@ def test_reserved_keywords_stripped(tmp_path): # # See also https://github.com/astropy/astropy/issues/18067 - data = np.arange(6).reshape((2, 3)) + data = np.arange(6, dtype="int32").reshape((2, 3)) hdu = fits.CompImageHDU(data) hdu.writeto(tmp_path / "compressed.fits") @@ -1470,3 +1500,77 @@ def test_reserved_keywords_stripped(tmp_path): assert "ZBLANK" not in hdud[1].header assert "ZSCALE" not in hdud[1].header assert "ZZERO" not in hdud[1].header + + +def test_compimghdu_with_primary_header_verify_fix(): + """ + Test that CompImageHDU created from a PrimaryHDU header can be verified + with 'fix' option without corrupting the header. + + When a CompImageHDU has SIMPLE in its header (indicating it came from a + primary HDU), verification should not try to insert XTENSION, PCOUNT, + or GCOUNT. + """ + + # Create a CompImageHDU using a PrimaryHDU's header + primary = fits.PrimaryHDU(data=np.arange(100, dtype=np.int32).reshape(10, 10)) + comp_hdu = fits.CompImageHDU( + data=np.arange(100, dtype=np.int32).reshape(10, 10), + header=primary.header, + ) + + # Header should have SIMPLE, not XTENSION + assert "SIMPLE" in comp_hdu.header + assert "XTENSION" not in comp_hdu.header + + # Run verify('fix') - this should not modify the header or emit warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + comp_hdu.verify("fix") + verify_warnings = [ + warning for warning in w if issubclass(warning.category, VerifyWarning) + ] + assert len(verify_warnings) == 0, f"Got warnings: {verify_warnings}" + + # Header should still have SIMPLE, not XTENSION (no corruption) + assert "SIMPLE" in comp_hdu.header + assert "XTENSION" not in comp_hdu.header + assert "PCOUNT" not in comp_hdu.header + assert "GCOUNT" not in comp_hdu.header + + +def test_compimghdu_with_primary_header_no_dual_keywords(tmp_path): + """ + Regression test for https://github.com/astropy/astropy/issues/19361 + + When creating a CompImageHDU using a PrimaryHDU's header, the resulting + header should not contain both SIMPLE/ZSIMPLE and XTENSION/ZTENSION. + According to the FITS standard, a header must have either SIMPLE or + XTENSION, never both. Additionally, PCOUNT/GCOUNT are extension-only + keywords and should not appear in a primary-style header. + """ + # Create a PrimaryHDU with some data + hdu = fits.PrimaryHDU(data=np.arange(100, dtype=np.int32).reshape(10, 10)) + + # Create a CompImageHDU using the PrimaryHDU's header + comp_hdu = fits.CompImageHDU( + data=np.arange(100, dtype=np.int32).reshape(10, 10), + header=hdu.header, + ) + + # The image header should have SIMPLE (not XTENSION) when created from PrimaryHDU + assert "SIMPLE" in comp_hdu.header + assert "XTENSION" not in comp_hdu.header + # PCOUNT/GCOUNT are extension-only keywords + assert "PCOUNT" not in comp_hdu.header + assert "GCOUNT" not in comp_hdu.header + + # Write to file and check the raw bintable header + hdul = fits.HDUList([fits.PrimaryHDU(), comp_hdu]) + hdul.writeto(tmp_path / "test.fits") + + # Check the underlying bintable header has ZSIMPLE but not ZTENSION + with fits.open(tmp_path / "test.fits", disable_image_compression=True) as hdul: + bintable_header = hdul[1].header + assert "ZSIMPLE" in bintable_header + assert "ZTENSION" not in bintable_header diff --git a/astropy/io/fits/hdu/compressed/tests/test_compression_failures.py b/astropy/io/fits/hdu/compressed/tests/test_compression_failures.py index 048bcb223231..c26a47095f6f 100644 --- a/astropy/io/fits/hdu/compressed/tests/test_compression_failures.py +++ b/astropy/io/fits/hdu/compressed/tests/test_compression_failures.py @@ -171,3 +171,91 @@ def test_header_value_no_double_int_image(self, kw): compress_image_data( hdu.data, hdu.compression_type, bintable.header, bintable.columns ) + + @pytest.mark.parametrize( + ("numpy_dtype", "fits_format", "big_endian_dtype"), + [ + (np.int16, "1PI", ">i2"), + (np.float32, "1PE", ">f4"), + ], + ) + def test_uncompressed_data_column( + self, tmp_path, numpy_dtype, fits_format, big_endian_dtype + ): + """ + Regression test for https://github.com/astropy/astropy/issues/15477 + + Test that data stored in UNCOMPRESSED_DATA column is correctly read. + UNCOMPRESSED_DATA columns can have integer (PI, PJ) or floating-point + (PE, PD) formats. This tests the case where some tiles have their data + in UNCOMPRESSED_DATA instead of COMPRESSED_DATA (which can happen when + compression fails for certain tiles). + """ + tile_size = 5 + original_data = np.arange(100, dtype=numpy_dtype).reshape((10, 10)) + hdu = fits.CompImageHDU( + data=original_data, + compression_type="RICE_1", + tile_shape=(tile_size, tile_size), + ) + + filename = tmp_path / "test_uncompressed_tiles.fits" + hdu.writeto(filename) + + # Read and modify: put first tile's data in UNCOMPRESSED_DATA instead. + # This simulates a file created by another tool (like cfitsio directly). + with fits.open(filename, disable_image_compression=True) as hdul: + orig_table = hdul[1].data + orig_header = hdul[1].header.copy() + orig_cols = hdul[1].columns + n_tiles = len(orig_table) + + compressed_col_data = np.empty(n_tiles, dtype=object) + uncompressed_col_data = np.empty(n_tiles, dtype=object) + + for i in range(n_tiles): + if i == 0: + # First tile: put in UNCOMPRESSED_DATA, leave COMPRESSED_DATA empty + compressed_col_data[i] = np.array([], dtype=np.uint8) + tile_data = original_data[:tile_size, :tile_size].flatten() + uncompressed_col_data[i] = tile_data.astype(big_endian_dtype) + else: + # Other tiles: keep in COMPRESSED_DATA + compressed_col_data[i] = orig_table["COMPRESSED_DATA"][i] + uncompressed_col_data[i] = np.array([], dtype=numpy_dtype) + + # Build column list: COMPRESSED_DATA, UNCOMPRESSED_DATA, plus any + # other columns from the original (ZSCALE, ZZERO for float data) + new_col_list = [ + fits.Column( + name="COMPRESSED_DATA", + format=orig_cols["COMPRESSED_DATA"].format, + array=compressed_col_data, + ), + fits.Column( + name="UNCOMPRESSED_DATA", + format=f"{fits_format}({tile_size * tile_size})", + array=uncompressed_col_data, + ), + ] + + # Preserve other columns (ZSCALE, ZZERO, etc.) needed for quantized data + for col in orig_cols: + if col.name not in ("COMPRESSED_DATA", "GZIP_COMPRESSED_DATA"): + new_col_list.append( + fits.Column( + name=col.name, + format=col.format, + array=orig_table[col.name], + ) + ) + + new_cols = fits.ColDefs(new_col_list) + new_table = fits.BinTableHDU.from_columns(new_cols, header=orig_header) + modified_filename = tmp_path / "test_uncompressed_tiles_modified.fits" + fits.HDUList([hdul[0].copy(), new_table]).writeto(modified_filename) + + # Verify the data is correctly read. Use atol=0.1 to account for + # quantization noise in float data (ZSCALE is typically ~0.13 for this data) + with fits.open(modified_filename) as hdul: + np.testing.assert_allclose(hdul[1].data, original_data, atol=0.1) diff --git a/astropy/io/fits/hdu/compressed/tests/test_fitsio.py b/astropy/io/fits/hdu/compressed/tests/test_fitsio.py index 0a1b4b446d5c..07fde0daa05d 100644 --- a/astropy/io/fits/hdu/compressed/tests/test_fitsio.py +++ b/astropy/io/fits/hdu/compressed/tests/test_fitsio.py @@ -10,13 +10,16 @@ """ import os +from contextlib import nullcontext import numpy as np import pytest from astropy.io import fits +from astropy.utils import NumpyRNGContext +from astropy.utils.exceptions import AstropyUserWarning -from .conftest import _expand, fitsio_param_to_astropy_param +from .conftest import COMPRESSION_TYPES, _expand, fitsio_param_to_astropy_param # This is so that tox can force this file to be run, and not be silently # skipped on CI, but in all other test runs it's skipped if fitsio isn't present. @@ -126,10 +129,29 @@ def fitsio_compressed_file_path( ): pytest.xfail("fitsio won't write these") + if compression_type == "HCOMPRESS_1" and "u2" in dtype: + # cfitsio's HCOMPRESS encoder underestimates the output buffer size + # for uint16 data on certain tile configurations, raising "encode: + # output buffer too small". astropy's encoder handles the same combos. + pytest.xfail("cfitsio HCOMPRESS encoder buffer-size bug for uint16 input") + + if compression_type == "HCOMPRESS_1" and "i1" in dtype: + # Same buffer-sizing limitation in cfitsio applies to int8 input. + pytest.xfail("cfitsio HCOMPRESS encoder buffer-size bug for int8 input") + if compression_type == "PLIO_1" and "f" in dtype: # fitsio fails with a compression error pytest.xfail("fitsio fails to write these") + if ( + compression_type == "PLIO_1" + and np.dtype(dtype).kind == "u" + and np.dtype(dtype).itemsize >= 2 + ): + # PLIO can't represent the BZERO-shifted unsigned values; cfitsio + # rejects 4/8-byte cases outright and segfaults on 2-byte ones. + pytest.xfail("PLIO_1 cannot encode unsigned multi-byte integers") + if compression_type == "NOCOMPRESS": pytest.xfail("fitsio does not support NOCOMPRESS") @@ -164,6 +186,13 @@ def astropy_compressed_file_path( compression_type, param, dtype = comp_param_dtype original_data = base_original_data.astype(dtype) + if ( + compression_type == "PLIO_1" + and np.dtype(dtype).kind == "u" + and np.dtype(dtype).itemsize >= 2 + ): + pytest.xfail("PLIO_1 cannot encode unsigned multi-byte integers") + tmp_path = tmp_path_factory.mktemp("astropy") filename = tmp_path / f"{compression_type}_{dtype}.fits" @@ -230,3 +259,216 @@ def test_compress( with fits.open(astropy_compressed_file_path) as hdul: np.testing.assert_allclose(data, hdul[1].data) + + +@pytest.mark.parametrize("kind", ["i", "u"]) +@pytest.mark.parametrize( + "nbytes,overflow", [(2, False), (4, False), (8, False), (8, True)] +) +@pytest.mark.parametrize("compression_type", COMPRESSION_TYPES) +def test_decompress_integers(nbytes, overflow, compression_type, kind, tmp_path): + if kind == "u" and compression_type == "PLIO_1" and nbytes >= 2: + pytest.skip( + "PLIO_1 cannot encode unsigned multi-byte integers (covered elsewhere)" + ) + + testfile = tmp_path / "test.fits.fz" + data = np.random.poisson(1000, size=(52, 57)).astype(f"{kind}{nbytes}") + if overflow: + # push past the 32-bit limit so the conversion fallback fails + data += np.iinfo(np.int32).max if kind == "i" else np.uint64(2**32) + data_hdu = fits.PrimaryHDU(data=data) + compressed_hdu = fits.CompImageHDU(data=data, compression_type=compression_type) + + if compression_type in ("RICE_1", "PLIO_1", "HCOMPRESS_1") and nbytes == 8: + test_func = pytest.raises if overflow else pytest.warns + ctx = test_func( + ValueError if overflow else AstropyUserWarning, + match=f"{compression_type} compression doesn't support 64 integers.*", + ) + nbytes = 4 + else: + ctx = nullcontext() + + with ctx: + compressed_hdu.writeto(testfile) + + if overflow: + # nothing more to test + return + + with fits.open(testfile, disable_image_compression=True) as hdul: + assert hdul[1].header["ZCMPTYPE"] == compression_type + if compression_type == "RICE_1": + assert hdul[1].header["ZNAME2"] == "BYTEPIX" + assert hdul[1].header["ZVAL2"] == nbytes + + with fits.open(testfile) as hdul: + np.testing.assert_array_equal(data, hdul[1].data) + assert hdul[1].data.dtype.kind == np.dtype(data.dtype).kind + assert hdul[1].data.dtype.itemsize == nbytes + + if compression_type != "NOCOMPRESS" and nbytes != 8: + # fitsio does not support NOCOMPRESS or 64-bit data + fts = fitsio.FITS(testfile) + data2 = fts[1].read() + np.testing.assert_array_equal(data, data2) + + +INTEGER_DTYPES_FULL_RANGE = [ + "i1", + "i2", + "i4", + "i8", + "u1", + "u2", + "u4", + "u8", +] + + +def _full_range_integer_data(dtype, shape=(32, 32)): + info = np.iinfo(dtype) + sentinels = np.array( + [ + info.min, + info.min + 1, + -1 if info.min < 0 else 0, + 0, + 1, + info.max - 1, + info.max, + ], + dtype=dtype, + ) + with NumpyRNGContext(0): + data = np.random.randint( + info.min, + info.max + 1, + size=shape, + dtype=dtype.lstrip("<>"), + ).astype(dtype) + data.ravel()[: sentinels.size] = sentinels + return data + + +@pytest.mark.parametrize("dtype", INTEGER_DTYPES_FULL_RANGE) +@pytest.mark.parametrize("compression_type", COMPRESSION_TYPES) +def test_integer_full_range_roundtrip(compression_type, dtype, tmp_path): + """Cross-check exact round-tripping of boundary-spanning integer data + between astropy and fitsio for every standard FITS integer dtype and + every compression algorithm that supports it.""" + data = _full_range_integer_data(dtype) + np_dtype = np.dtype(dtype) + info = np.iinfo(dtype) + astropy_path = tmp_path / "astropy.fits" + hdu = fits.CompImageHDU(data=data, compression_type=compression_type) + + # 64-bit integer data with full-range sentinels overflows the 32-bit + # conversion that RICE_1 and HCOMPRESS_1 fall back to. (PLIO_1 + i8 also + # lands here; PLIO_1 + u8 is caught by the unsigned-multi-byte rejection + # below.) For these combos cfitsio also rejects the input outright, so + # cross-check that fitsio raises too. + if ( + compression_type in ("RICE_1", "HCOMPRESS_1") + and np_dtype.kind in ("i", "u") + and np_dtype.itemsize == 8 + ) or ( + compression_type == "PLIO_1" and np_dtype.kind == "i" and np_dtype.itemsize == 8 + ): + with pytest.raises( + ValueError, + match=( + f"{compression_type} compression doesn't support 64 integers, " + "but data cannot be converted to 32 bits without overflow" + ), + ): + hdu.writeto(astropy_path) + if compression_type == "HCOMPRESS_1": + with pytest.raises( + OSError, + match=r"writing T(U)?LONGLONG to compressed image is not supported", + ): + with fitsio.FITS(tmp_path / "fitsio.fits", "rw") as fts: + fts.write(data, compress=compression_type) + return + + # PLIO_1 cannot encode unsigned multi-byte integers because the FITS + # BZERO=2**(N-1) offset astropy applies to unsigned data produces negative + # values that PLIO rejects. Astropy raises a clean ValueError for all + # unsigned itemsize >= 2; cfitsio raises only for itemsize >= 4 and + # segfaults on itemsize == 2 with full-range data, so the cross-check + # only runs for the 4/8-byte cases. + if compression_type == "PLIO_1" and np_dtype.kind == "u" and np_dtype.itemsize >= 2: + with pytest.raises( + ValueError, + match=r"PLIO_1 compression does not support unsigned integers", + ): + hdu.writeto(astropy_path) + if np_dtype.itemsize >= 4: + with pytest.raises( + ValueError, + match=r"Unsigned 4/8-byte integers currently not allowed", + ): + with fitsio.FITS(tmp_path / "fitsio.fits", "rw") as fts: + fts.write(data, compress=compression_type) + return + + # PLIO_1 also can't encode signed values outside [0, 2**24 - 1]. int8 is + # exempt because the BZERO=-128 transform leaves the stored bytes in + # [0, 255], which PLIO_1 encodes fine. + is_int8 = np_dtype.kind == "i" and np_dtype.itemsize == 1 + if compression_type == "PLIO_1" and ( + (not is_int8 and info.min < 0) or info.max > 2**24 - 1 + ): + with pytest.raises( + ValueError, + match=r"data out of range for PLIO compression", + ): + hdu.writeto(astropy_path) + return + + # 1. astropy writes a compressed file. + hdu.writeto(astropy_path) + + # 2. astropy reads its own compressed file. + with fits.open(astropy_path) as hdul: + rt = hdul[1].data + assert rt.dtype.kind == np_dtype.kind + assert rt.dtype.itemsize == np_dtype.itemsize + np.testing.assert_array_equal(rt, data) + + # fitsio doesn't support NOCOMPRESS, so the cross-checks stop here. + # cfitsio also refuses to read or write GZIP-compressed 64-bit integer + # images even though the FITS Tile Compression Convention permits them, + # so astropy's standard-compliant output cannot be cross-validated for + # those combinations. + if compression_type == "NOCOMPRESS" or ( + compression_type in ("GZIP_1", "GZIP_2") and np_dtype.itemsize == 8 + ): + return + + # 3. fitsio reads astropy's compressed file. + with fitsio.FITS(astropy_path) as fts: + rt_fitsio = fts[1].read() + assert rt_fitsio.dtype.kind == np_dtype.kind + assert rt_fitsio.dtype.itemsize == np_dtype.itemsize + np.testing.assert_array_equal(rt_fitsio, data) + + # 4. astropy reads a file fitsio compressed. + fitsio_path = tmp_path / "fitsio.fits" + with fitsio.FITS(fitsio_path, "rw") as fts: + fts.write(data, compress=compression_type) + with fits.open(fitsio_path) as hdul: + rt_astropy = hdul[1].data + assert rt_astropy.dtype.kind == np_dtype.kind + assert rt_astropy.dtype.itemsize == np_dtype.itemsize + np.testing.assert_array_equal(rt_astropy, data) diff --git a/astropy/io/fits/hdu/compressed/tests/test_tiled_compression.py b/astropy/io/fits/hdu/compressed/tests/test_tiled_compression.py index b72336403f9a..695130b9c5ed 100644 --- a/astropy/io/fits/hdu/compressed/tests/test_tiled_compression.py +++ b/astropy/io/fits/hdu/compressed/tests/test_tiled_compression.py @@ -203,6 +203,12 @@ def test_roundtrip_high_D( np.count_nonzero(np.array(shape[:2]) % tile_shape[:2]) != 0 ): pytest.xfail("HCOMPRESS requires 2D tiles.") + if ( + compression_type == "PLIO_1" + and np.dtype(dtype).kind == "u" + and np.dtype(dtype).itemsize >= 2 + ): + pytest.xfail("PLIO_1 cannot encode unsigned multi-byte integers") random = numpy_rng.uniform(high=255, size=shape) # Set first value to be exactly zero as zero values require special treatment # for SUBTRACTIVE_DITHER_2 diff --git a/astropy/io/fits/hdu/groups.py b/astropy/io/fits/hdu/groups.py index f88d0bad66b0..0906f886a153 100644 --- a/astropy/io/fits/hdu/groups.py +++ b/astropy/io/fits/hdu/groups.py @@ -1,5 +1,6 @@ # Licensed under a 3-clause BSD style license - see PYFITS.rst +import math import sys import numpy as np @@ -339,7 +340,7 @@ def columns(self): bscales.append(self._header.get("BSCALE")) bzeros.append(self._header.get("BZEROS")) data_shape = self.shape[:-1] - formats.append(str(int(np.prod(data_shape))) + format) + formats.append(str(math.prod(data_shape)) + format) dim.append(data_shape) parnames = _unique_parnames(parnames) diff --git a/astropy/io/fits/hdu/hdulist.py b/astropy/io/fits/hdu/hdulist.py index 9d4cbe5a4fd6..b803fdbab953 100644 --- a/astropy/io/fits/hdu/hdulist.py +++ b/astropy/io/fits/hdu/hdulist.py @@ -266,7 +266,7 @@ def __init__(self, hdus=[], file=None): self._in_read_next_hdu = False # If we have read all the HDUs from the file or not - # The assumes that all HDUs have been written when we first opened the + # This assumes that all HDUs have been written when we first opened the # file; we do not currently support loading additional HDUs from a file # while it is being streamed to. In the future that might be supported # but for now this is only used for the purpose of lazy-loading of diff --git a/astropy/io/fits/hdu/image.py b/astropy/io/fits/hdu/image.py index b42741102c99..6a4d1270fac5 100644 --- a/astropy/io/fits/hdu/image.py +++ b/astropy/io/fits/hdu/image.py @@ -509,8 +509,8 @@ def _scale_internal( min = self.data.min().compute() max = self.data.max().compute() else: - min = np.minimum.reduce(self.data.flat) - max = np.maximum.reduce(self.data.flat) + min = self.data.min() + max = self.data.max() if _type == np.uint8: # uint8 case _zero = min @@ -1077,6 +1077,7 @@ class PrimaryHDU(_ImageBaseHDU): """ _default_name = "PRIMARY" + _expected_header_primary_like = True def __init__( self, @@ -1262,13 +1263,16 @@ def _verify(self, option="warn"): ImageHDU verify method. """ errs = super()._verify(option=option) - naxis = self._header.get("NAXIS", 0) # PCOUNT must == 0, GCOUNT must == 1; the former is verified in # ExtensionHDU._verify, however ExtensionHDU._verify allows PCOUNT - # to be >= 0, so we need to check it here - self.req_cards( - "PCOUNT", naxis + 3, lambda v: (_is_int(v) and v == 0), 0, option, errs - ) + # to be >= 0, so we need to check it here. + # Only check for extension-style headers (not primary-style like + # CompImageHDU with SIMPLE). + if not self._expected_header_primary_like: + naxis = self._header.get("NAXIS", 0) + self.req_cards( + "PCOUNT", naxis + 3, lambda v: (_is_int(v) and v == 0), 0, option, errs + ) return errs diff --git a/astropy/io/fits/hdu/table.py b/astropy/io/fits/hdu/table.py index 3121c8d49077..9416de2c7491 100644 --- a/astropy/io/fits/hdu/table.py +++ b/astropy/io/fits/hdu/table.py @@ -3,6 +3,7 @@ import contextlib import csv +import math import operator import os import re @@ -11,7 +12,6 @@ from contextlib import suppress import numpy as np -from numpy import char as chararray # This module may have many dependencies on astropy.io.fits.column, but # astropy.io.fits.column has fewer dependencies overall, so it's easier to @@ -37,6 +37,7 @@ from astropy.io.fits.header import Header, _pad_length from astropy.io.fits.util import _is_int, _str_to_num, path_like from astropy.utils import lazyproperty +from astropy.utils.compat import NUMPY_LT_2_5, chararray from .base import DELAYED, ExtensionHDU, _ValidHDU @@ -207,7 +208,12 @@ def _get_tbdata(self): def _init_tbdata(self, data): columns = self.columns - data.dtype = data.dtype.newbyteorder(">") + if NUMPY_LT_2_5: + data.dtype = data.dtype.newbyteorder(">") + else: + # TODO: see if a a view is possible still; initial trial gives + # error in test_table.py::TestVLATables::test_empty_vla_raw_data. + np.ndarray._set_dtype(data, data.dtype.newbyteorder(">")) # hack to enable pseudo-uint support data._uint = self._uint @@ -1401,7 +1407,7 @@ def format_value(col, val): idx += vla_len elif dtype[col].shape: # This is an array column - array_size = int(np.multiply.reduce(dtype[col].shape)) + array_size = math.prod(dtype[col].shape) slice_ = slice(idx, idx + array_size) idx += array_size else: @@ -1489,7 +1495,7 @@ def _binary_table_byte_swap(data): formats.append(field_dtype) offsets.append(field_offset) - if isinstance(field, chararray.chararray): + if isinstance(field, chararray): continue # only swap unswapped @@ -1507,7 +1513,7 @@ def _binary_table_byte_swap(data): coldata = data.field(idx) for c in coldata: if ( - not isinstance(c, chararray.chararray) + not isinstance(c, chararray) and c.itemsize > 1 and c.dtype.str[0] in swap_types ): @@ -1516,11 +1522,20 @@ def _binary_table_byte_swap(data): for arr in reversed(to_swap): arr.byteswap(True) - data.dtype = np.dtype({"names": names, "formats": formats, "offsets": offsets}) + new_dtype = np.dtype({"names": names, "formats": formats, "offsets": offsets}) + if NUMPY_LT_2_5: + data.dtype = new_dtype + else: + # TODO: ensure self.data is never used with this context manager, so that + # a view here would work (and the second _set_dtype can be removed). + np.ndarray._set_dtype(data, new_dtype) yield data for arr in to_swap: arr.byteswap(True) - data.dtype = orig_dtype + if NUMPY_LT_2_5: + data.dtype = orig_dtype + else: + np.ndarray._set_dtype(data, orig_dtype) diff --git a/astropy/io/fits/scripts/fitsdiff.py b/astropy/io/fits/scripts/fitsdiff.py index a5e77eabf3f9..e12f0c33fae3 100644 --- a/astropy/io/fits/scripts/fitsdiff.py +++ b/astropy/io/fits/scripts/fitsdiff.py @@ -400,20 +400,29 @@ def main(args=None): try: for a, b in files: # TODO: pass in any additional arguments here too - diff = fits.diff.FITSDiff( - a, - b, - ignore_hdus=opts.ignore_hdus, - ignore_keywords=opts.ignore_keywords, - ignore_comments=opts.ignore_comments, - ignore_fields=opts.ignore_fields, - numdiffs=opts.numdiffs, - rtol=opts.rtol, - atol=opts.atol, - ignore_blanks=opts.ignore_blanks, - ignore_blank_cards=opts.ignore_blank_cards, - ) - + try: + diff = fits.diff.FITSDiff( + a, + b, + ignore_hdus=opts.ignore_hdus, + ignore_keywords=opts.ignore_keywords, + ignore_comments=opts.ignore_comments, + ignore_fields=opts.ignore_fields, + numdiffs=opts.numdiffs, + rtol=opts.rtol, + atol=opts.atol, + ignore_blanks=opts.ignore_blanks, + ignore_blank_cards=opts.ignore_blank_cards, + ) + except OSError: + if not opts.quiet: + msg = f"Warning: failed to open {a}" + if b != a: + msg += f" (or {b})" + msg += ". Skipping." + print(msg, file=sys.stderr) + identical.append(None) + continue diff.report(fileobj=out_file) identical.append(diff.identical) diff --git a/astropy/io/fits/tests/test_connect.py b/astropy/io/fits/tests/test_connect.py index ee7d3ae22777..af08b8a43dcc 100644 --- a/astropy/io/fits/tests/test_connect.py +++ b/astropy/io/fits/tests/test_connect.py @@ -1036,13 +1036,32 @@ def test_fits_mixins_per_column(table_cls, name_col, tmp_path): @pytest.mark.parametrize("name_col", unsupported_cols.items()) -@pytest.mark.xfail(reason="column type unsupported") -def test_fits_unsupported_mixin(self, name_col, tmp_path): - # Check that we actually fail in writing unsupported columns defined - # on top. +def test_fits_unsupported_mixin(name_col, tmp_path): + """Check that we actually fail in writing unsupported columns defined + on top. + """ filename = tmp_path / "test_simple.fits" name, col = name_col - Table([col], names=[name]).write(filename, format="fits") + t = Table([col], names=[name]) + if isinstance(col, Time): + # Time with different locations can in principle be supported, but + # not if there is another time column which has yet another location. + t["t2"] = Time(col.jd, format="jd", location=None) + ctx = pytest.warns( + AstropyUserWarning, match="Time Column .* has no specified location" + ) + elif name == "obj": + ctx = pytest.raises( + TypeError, match="Column .* contains unsupported object types" + ) + else: + ctx = pytest.warns( + AstropyUserWarning, + match="The unit .* could not be saved in native FITS format", + ) + + with ctx: + t.write(filename, format="fits", overwrite=True) def test_info_attributes_with_no_mixins(tmp_path): diff --git a/astropy/io/fits/tests/test_convenience.py b/astropy/io/fits/tests/test_convenience.py index 52bd61d2384a..a699f0b30534 100644 --- a/astropy/io/fits/tests/test_convenience.py +++ b/astropy/io/fits/tests/test_convenience.py @@ -91,6 +91,21 @@ def test_masked_table_to_hdu(self): filename = self.temp("test_table_to_hdu.fits") hdu.writeto(filename, overwrite=True) + def test_masked_integer_arrays(self): + # Regression test for #18817 + testfile = self.temp("test_masked_integer_arrays.fits") + t_w = Table( + rows=[ + [[np.ma.masked, np.ma.masked]], + [[1, 2]], + [[1, np.ma.masked]], + ], + names=["a"], + ) + t_w.write(testfile, overwrite=True) + t_r = Table.read(testfile) + assert repr(t_w) == repr(t_r) + def test_table_non_stringifyable_unit_to_hdu(self): table = Table( [[1, 2, 3], ["a", "b", "c"], [2.3, 4.5, 6.7]], @@ -498,3 +513,22 @@ def test_getdata_ext_not_given_nodata_noext(self): IndexError, match="No data in Primary HDU and no extension HDU found." ): fits.getdata(buf) + + def test_getdata_lower_upper(self, tmp_path): + t = Table([np.zeros(5), np.ones(5), np.arange(5)], names=["a", "b", "c"]) + t.write(tmp_path / "lower.fits") + t = Table([np.zeros(5), np.ones(5), np.arange(5)], names=["A", "B", "C"]) + t.write(tmp_path / "upper.fits") + + ref = np.zeros(5) + lowup = fits.getdata(tmp_path / "lower.fits", 1, upper=True) + assert lowup.dtype.names == ("A", "B", "C") + assert_array_equal(lowup["A"], ref) + assert_array_equal(lowup["a"], ref) + assert_array_equal(lowup.A, ref) + + uplow = fits.getdata(tmp_path / "upper.fits", 1, lower=True) + assert uplow.dtype.names == ("a", "b", "c") + assert_array_equal(uplow["A"], ref) + assert_array_equal(uplow["a"], ref) + assert_array_equal(uplow.a, ref) diff --git a/astropy/io/fits/tests/test_core.py b/astropy/io/fits/tests/test_core.py index e8b354860f37..f5d774e323f4 100644 --- a/astropy/io/fits/tests/test_core.py +++ b/astropy/io/fits/tests/test_core.py @@ -1247,15 +1247,52 @@ def mmap_patched(*args, **kwargs): return mmap_original(*args, **kwargs) with fits.open(self.data("test0.fits"), memmap=True) as hdulist: - with patch.object(mmap, "mmap", side_effect=mmap_patched) as p: - with pytest.warns( + with ( + patch.object(mmap, "mmap", side_effect=mmap_patched) as p, + pytest.warns( AstropyUserWarning, match=r"Could not memory map array with mode='readonly'", - ): - data = hdulist[1].data + ), + ): + data = hdulist[1].data p.reset_mock() assert not data.flags.writeable + def test_mmap_unsupported_fallback(self): + """ + Regression test for https://github.com/astropy/astropy/issues/19305 + + Tests that when mmap fails with an unsupported error (e.g., in WASM + environments), astropy falls back to non-mmap reading if memmap was + not explicitly requested, but raises the error if memmap=True was + explicitly set. + """ + + def mmap_patched(*args, **kwargs): + # Simulate mmap not being supported (e.g., WASM environment) + exc = OSError("mmap not supported") + exc.errno = errno.ENODEV + raise exc + + # With default memmap setting (not specified), we should fall back to + # non-mmap reading with a warning. We open file first, then patch only + # during data access to avoid interfering with cleanup. + with fits.open(self.data("test0.fits")) as hdul: + with ( + patch.object(mmap, "mmap", side_effect=mmap_patched), + pytest.warns(AstropyUserWarning, match=r"Could not memory map"), + ): + data = hdul[1].data + assert data is not None + + # With explicit memmap=True, we should raise the error + with fits.open(self.data("test0.fits"), memmap=True) as hdul: + with ( + patch.object(mmap, "mmap", side_effect=mmap_patched), + pytest.raises(OSError, match=r"mmap not supported"), + ): + hdul[1].data + def test_mmap_closing(self): """ Tests that the mmap reference is closed/removed when there aren't any diff --git a/astropy/io/fits/tests/test_fitsdiff.py b/astropy/io/fits/tests/test_fitsdiff.py index 1953e8495fe9..55ef702dc7c7 100644 --- a/astropy/io/fits/tests/test_fitsdiff.py +++ b/astropy/io/fits/tests/test_fitsdiff.py @@ -11,6 +11,7 @@ from astropy.io.fits.convenience import writeto from astropy.io.fits.hdu import PrimaryHDU, hdulist from astropy.io.fits.scripts import fitsdiff +from astropy.utils.compat.optional_deps import HAS_UNCOMPRESSPY from astropy.utils.misc import _NOT_OVERWRITING_MSG_MATCH from .conftest import FitsTestCase @@ -236,7 +237,6 @@ def test_quiet(self, capsys): assert out == "" assert err == "" - @pytest.mark.slow def test_path(self, capsys): os.mkdir(self.temp("sub/")) tmp_b = self.temp("sub/ascii.fits") @@ -256,7 +256,9 @@ def test_path(self, capsys): tmp_d = self.temp("sub/") assert fitsdiff.main(["-q", self.data_dir, tmp_d]) == 1 assert fitsdiff.main(["-q", tmp_d, self.data_dir]) == 1 - assert fitsdiff.main(["-q", self.data_dir, self.data_dir]) == 0 + + expected_retv = int(not HAS_UNCOMPRESSPY) + assert fitsdiff.main(["-q", self.data_dir, self.data_dir]) == expected_retv # no match tmp_c = self.data("arange.fits") @@ -273,6 +275,43 @@ def test_path(self, capsys): assert fitsdiff.main(["-q", tmp_f, self.data_dir]) == 0 assert fitsdiff.main(["-q", self.data_dir, tmp_f]) == 0 + @pytest.mark.filterwarnings("ignore:unclosed file:ResourceWarning") + def test_warning_unreadable_file(self, capsys, monkeypatch): + # simulate not having uncompresspy installed regardless of the actual state + monkeypatch.setattr(fits.file, "HAS_UNCOMPRESSPY", False) + + Zfile = self.data("lzw.fits.Z") + assert fitsdiff.main([Zfile, Zfile]) != 0 + out, err = capsys.readouterr() + assert out == "" + assert err == f"Warning: failed to open {Zfile}. Skipping.\n" + + os.mkdir(self.temp("sub/")) + tmp = self.temp("sub/ascii.fits") + tmp_h = self.data("group.fits") + with hdulist.fitsopen(tmp_h) as hdu: + hdu.writeto(tmp) + + assert fitsdiff.main([Zfile, tmp]) != 0 + out, err = capsys.readouterr() + assert out == "" + assert err == f"Warning: failed to open {Zfile} (or {tmp}). Skipping.\n" + + assert fitsdiff.main(["-q", Zfile, tmp]) != 0 + out, err = capsys.readouterr() + assert out == "" + assert err == "" + + assert fitsdiff.main([tmp, Zfile]) != 0 + out, err = capsys.readouterr() + assert out == "" + assert err == f"Warning: failed to open {tmp} (or {Zfile}). Skipping.\n" + + assert fitsdiff.main(["-q", tmp, Zfile]) != 0 + out, err = capsys.readouterr() + assert out == "" + assert err == "" + def test_ignore_hdus(self): a = np.arange(100).reshape(10, 10) b = a.copy() + 1 diff --git a/astropy/io/fits/tests/test_image.py b/astropy/io/fits/tests/test_image.py index 0d7b2d868035..2e6c1b31e310 100644 --- a/astropy/io/fits/tests/test_image.py +++ b/astropy/io/fits/tests/test_image.py @@ -9,6 +9,7 @@ from numpy.testing import assert_equal from astropy.io import fits +from astropy.utils.compat import NUMPY_LT_2_5 from astropy.utils.data import get_pkg_data_filename from astropy.utils.exceptions import AstropyUserWarning @@ -977,7 +978,12 @@ def test_open_scaled_in_update_mode(self): # Try reshaping the data, then closing and reopening the file; let's # see if all the changes are preserved properly - hdul[0].data.shape = (42, 10) + if NUMPY_LT_2_5: + hdul[0].data.shape = (42, 10) + else: + # ndarray._set_shape is semi-private, but the only + # non deprecated, strict semantic equivalent in Numpy 2.5+ + hdul[0].data._set_shape((42, 10)) hdul.close() hdul = fits.open(testfile) @@ -988,6 +994,13 @@ def test_open_scaled_in_update_mode(self): assert "BSCALE" not in hdul[0].header hdul.close() + with fits.open(testfile, mode="update") as hdul: + # Try reshaping the data again, this time using np.reshape + hdul[0].data = np.reshape(hdul[0].data, (21, 20)) + + with fits.open(testfile) as hdul: + assert hdul[0].shape == (21, 20) + def test_scale_back(self): """A simple test for https://aeon.stsci.edu/ssb/trac/pyfits/ticket/120 diff --git a/astropy/io/fits/tests/test_table.py b/astropy/io/fits/tests/test_table.py index 429868aee7b8..87bfe501203d 100644 --- a/astropy/io/fits/tests/test_table.py +++ b/astropy/io/fits/tests/test_table.py @@ -9,7 +9,6 @@ import numpy as np import pytest -from numpy import char as chararray try: import objgraph @@ -24,6 +23,7 @@ from astropy.io.fits.verify import VerifyError from astropy.table import Table from astropy.units import Unit, UnitsWarning, UnrecognizedUnit +from astropy.utils.compat import get_chararray from astropy.utils.exceptions import AstropyUserWarning from .conftest import FitsTestCase @@ -156,7 +156,7 @@ def test_open(self, home_is_data): fd = fits.open(self.data("test0.fits")) # create some local arrays - a1 = chararray.array(["abc", "def", "xx"]) + a1 = get_chararray(["abc", "def", "xx"]) r1 = np.array([11.0, 12.0, 13.0], dtype=np.float32) # create a table from scratch, using a mixture of columns from existing @@ -319,7 +319,7 @@ def test_ascii_table(self): # Test Start Column - a1 = chararray.array(["abcd", "def"]) + a1 = get_chararray(["abcd", "def"]) r1 = np.array([11.0, 12.0]) c1 = fits.Column(name="abc", format="A3", start=19, array=a1) c2 = fits.Column(name="def", format="E", start=3, array=r1) @@ -1176,6 +1176,63 @@ def test_row_setitem(self): assert row["c1"] == 12 assert row["c2"] == "xyz" + def test_fitsrec_negative_slice_setitem(self): + """Regression test for negative slice assignment in FITS_rec. + + Negative slices like data[-2:] were writing to wrong row indices + because negative start/stop values were clamped to 0 instead of + being resolved relative to the array length. + """ + + def _make_data(x=None, y=None): + x = x if x is not None else [1.0, 2.0, 3.0, 4.0, 5.0] + cols = [fits.Column(name="x", format="D", array=x)] + if y is not None: + cols.append(fits.Column(name="y", format="J", array=y)) + return fits.BinTableHDU.from_columns(cols) + + src_data = _make_data(x=[99.0, 88.0], y=[990, 880]).data + + # Test negative start slice: data[-2:] + data = _make_data(y=[10, 20, 30, 40, 50]).data + data[-2:] = [src_data[0], src_data[1]] + assert list(data["x"]) == [1.0, 2.0, 3.0, 99.0, 88.0] + assert list(data["y"]) == [10, 20, 30, 990, 880] + + # Test negative start and stop: data[-3:-1] + data = _make_data(y=[10, 20, 30, 40, 50]).data + data[-3:-1] = [src_data[0], src_data[1]] + assert list(data["x"]) == [1.0, 2.0, 99.0, 88.0, 5.0] + assert list(data["y"]) == [10, 20, 990, 880, 50] + + # Test positive slice still works + data = _make_data().data + data[0:2] = [src_data[0], src_data[1]] + assert list(data["x"]) == [99.0, 88.0, 3.0, 4.0, 5.0] + + # Test step slice: data[::2] + data = _make_data().data + data[::2] = [src_data[0], src_data[1], src_data[0]] + assert list(data["x"]) == [99.0, 2.0, 88.0, 4.0, 99.0] + + # Test negative step slice: data[::-1] + data = _make_data().data + reversed_src = _make_data(x=[55.0, 44.0, 33.0, 22.0, 11.0]).data + data[::-1] = [reversed_src[i] for i in range(5)] + assert list(data["x"]) == [11.0, 22.0, 33.0, 44.0, 55.0] + + # Test round-trip through a FITS file + hdu = _make_data() + hdu.data[-2:] = [src_data[0], src_data[1]] + hdu.writeto(self.temp("test_neg_slice.fits")) + saved = fits.getdata(self.temp("test_neg_slice.fits")) + assert list(saved["x"]) == [1.0, 2.0, 3.0, 99.0, 88.0] + + # Test that mismatched lengths raise ValueError (like NumPy does) + data = _make_data().data + with pytest.raises(ValueError): + data[-2:] = [src_data[0]] # slice covers 2 rows but only 1 value given + def test_fits_record_len(self): counts = np.array([312, 334, 308, 317]) names = np.array(["NGC1", "NGC2", "NGC3", "NCG4"]) @@ -1314,7 +1371,7 @@ def test_assign_multiple_rows_to_table(self): # Assign the 4 rows from the second table to rows 5 thru 8 of the # new table. Note that the last row of the new table will still be # initialized to the default values. - tbhdu2.data[4:] = tbhdu.data + tbhdu2.data[4:8] = tbhdu.data # Verify that all ndarray objects within the HDU reference the # same ndarray. @@ -1970,7 +2027,7 @@ def test_string_column_padding(self): "p\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) - acol = fits.Column(name="MEMNAME", format="A10", array=chararray.array(a)) + acol = fits.Column(name="MEMNAME", format="A10", array=get_chararray(a)) ahdu = fits.BinTableHDU.from_columns([acol]) assert ahdu.data.tobytes().decode("raw-unicode-escape") == s ahdu.writeto(self.temp("newtable.fits")) @@ -2803,13 +2860,7 @@ def test_update_string_column_inplace(self): with fits.open(self.temp("test2.fits"), mode="update") as hdul: assert hdul[1].header["TDIM1"] == "(3,3,2)" - # Note: Previously I wrote data['a'][0][1, 1] to address - # the single row. However, this is broken for chararray because - # data['a'][0] does *not* return a view of the original array--this - # is a bug in chararray though and not a bug in any FITS-specific - # code so we'll roll with it for now... - # (by the way the bug in question is fixed in newer Numpy versions) - hdul[1].data["a"][0, 1, 1] = "XYZ" + hdul[1].data["a"][0][1, 1] = "XYZ" assert np.all(hdul[1].data["a"][0] == expected) with fits.open(self.temp("test2.fits")) as hdul: diff --git a/astropy/io/fits/util.py b/astropy/io/fits/util.py index 14b353e78d72..97253ccc4979 100644 --- a/astropy/io/fits/util.py +++ b/astropy/io/fits/util.py @@ -20,6 +20,7 @@ from packaging.version import Version from astropy.utils import data +from astropy.utils.compat import NUMPY_LT_2_5 from astropy.utils.compat.optional_deps import HAS_DASK from astropy.utils.exceptions import AstropyUserWarning @@ -623,23 +624,9 @@ def _array_to_file_like(arr, fileobj): else: return - if hasattr(np, "nditer"): - # nditer version for non-contiguous arrays - for item in np.nditer(arr, order="C"): - fileobj.write(item.tobytes()) - else: - # Slower version for Numpy versions without nditer; - # The problem with flatiter is it doesn't preserve the original - # byteorder - byteorder = arr.dtype.byteorder - if (sys.byteorder == "little" and byteorder == ">") or ( - sys.byteorder == "big" and byteorder == "<" - ): - for item in arr.flat: - fileobj.write(item.byteswap().tobytes()) - else: - for item in arr.flat: - fileobj.write(item.tobytes()) + # nditer version for non-contiguous arrays + for item in np.nditer(arr, order="C"): + fileobj.write(item.tobytes()) def _write_string(f, s): @@ -870,7 +857,10 @@ def _rstrip_inplace(array): # Note: the code will work if this fails; the chunks will just be larger. if b.ndim > 2: try: - b.shape = -1, b.shape[-1] + if NUMPY_LT_2_5: + b.shape = -1, b.shape[-1] + else: + b._set_shape((-1, b.shape[-1])) except AttributeError: # can occur for non-contiguous arrays pass for j in range(0, b.shape[0], bufsize): diff --git a/astropy/io/misc/ecsv.py b/astropy/io/misc/ecsv.py index ea98f8e84dbd..efca71b7ab9b 100644 --- a/astropy/io/misc/ecsv.py +++ b/astropy/io/misc/ecsv.py @@ -116,7 +116,7 @@ class ColumnECSV: """ Class representing attributes of a column in an ECSV header. - Attributes + Parameters ---------- name : str The name of the column. @@ -133,7 +133,7 @@ class ColumnECSV: meta : dict or None, optional Additional metadata associated with the column. - Properties + Attributes ---------- csv_np_type : str Numpy type string describing the column CSV data. In practice this is the same @@ -141,7 +141,7 @@ class ColumnECSV: engine ``convert_np_type()`` method to generate the engine-specific type provided to the CSV reader. For instance, for pandas the ``int32`` type gets converted to ``Int32`` to read columns as a nullable int32. - dtype : np.dtype + dtype : `numpy.dtype` Numpy dtype in the final column data. This may be entirely different from ``csv_np_type`` in some cases, in particular JSON-encoded fields. shape : tuple of int @@ -182,7 +182,7 @@ class ECSVHeader: """ Class representing the information in an ECSV header. - Attributes + Parameters ---------- n_header : int Total number of header lines in the ECSV file (including empty). @@ -217,7 +217,7 @@ class ECSVEngine(metaclass=abc.ABCMeta): - `engines` is a base class-level dictionary that maps engine names to their respective engine classes. Subclasses should not modify this directly. - Properties + Parameters ---------- name : str Name of the engine, used for ``engine`` parameter in a call like: diff --git a/astropy/io/misc/pyarrow/csv.py b/astropy/io/misc/pyarrow/csv.py index 4bbf949fd4e2..910b37d26ca0 100644 --- a/astropy/io/misc/pyarrow/csv.py +++ b/astropy/io/misc/pyarrow/csv.py @@ -46,7 +46,7 @@ def read_csv( This function allows highly performant reading of text CSV files into an astropy ``Table`` using `PyArrow `_. The best performance is achieved for files with only numeric data types, but even for - files with mixed data types, the performance is still better than the the standard + files with mixed data types, the performance is still better than the standard ``astropy.io.ascii`` fast CSV reader. By default, empty values (zero-length string "") in the CSV file are read as masked diff --git a/astropy/io/misc/pyarrow/tests/test_csv.py b/astropy/io/misc/pyarrow/tests/test_csv.py index 23f794b64d61..c9373b0b0d08 100644 --- a/astropy/io/misc/pyarrow/tests/test_csv.py +++ b/astropy/io/misc/pyarrow/tests/test_csv.py @@ -16,6 +16,7 @@ if HAS_PYARROW: import pyarrow as pa + import pyarrow.csv else: pytest.skip("pyarrow is not available", allow_module_level=True) diff --git a/astropy/io/misc/tests/test_hdf5.py b/astropy/io/misc/tests/test_hdf5.py index 1a73f2cbfb49..629510b53a34 100644 --- a/astropy/io/misc/tests/test_hdf5.py +++ b/astropy/io/misc/tests/test_hdf5.py @@ -801,16 +801,20 @@ def test_hdf5_mixins_per_column(table_cls, name_col, tmp_path): assert t2[name]._time.jd2.__class__ is np.ndarray +@pytest.mark.skipif(not HAS_H5PY, reason="requires h5py") @pytest.mark.parametrize("name_col", unsupported_cols.items()) -@pytest.mark.xfail(reason="column type unsupported") -def test_fits_unsupported_mixin(self, name_col, tmp_path): +def test_fits_unsupported_mixin(name_col, tmp_path): # Check that we actually fail in writing unsupported columns defined # on top. filename = tmp_path / "test_simple.fits" name, col = name_col - Table([col], names=[name]).write( - filename, format="hdf5", path="root", serialize_meta=True - ) + t = Table([col], names=[name]) + with pytest.raises( + TypeError, match="Object dtype .* has no native HDF5 equivalent" + ): + t.write( + filename, format="hdf5", path="root", serialize_meta=True, overwrite=True + ) @pytest.mark.skipif(not HAS_H5PY, reason="requires h5py") diff --git a/astropy/io/votable/data/VOTable.v1.5.xsd b/astropy/io/votable/data/VOTable.v1.5.xsd index 84a7cba03712..f59d8b9e76e1 100644 --- a/astropy/io/votable/data/VOTable.v1.5.xsd +++ b/astropy/io/votable/data/VOTable.v1.5.xsd @@ -255,7 +255,7 @@ This is a time origin of a time coordinate, given as a - Julian Date for the the time scale and reference point + Julian Date for the time scale and reference point defined. It is usually given as a floating point literal; for convenience, the magic strings “MJD-origin” (standing for 2400000.5) and “JD-origin” (standing for 0) diff --git a/astropy/io/votable/src/.gitignore b/astropy/io/votable/src/.gitignore index 970d78fd3f93..3fc66339e375 100644 --- a/astropy/io/votable/src/.gitignore +++ b/astropy/io/votable/src/.gitignore @@ -1 +1,2 @@ !*.c +fast_converters.c diff --git a/astropy/io/votable/tree.py b/astropy/io/votable/tree.py index 31af4b73538a..148208622a79 100644 --- a/astropy/io/votable/tree.py +++ b/astropy/io/votable/tree.py @@ -2309,7 +2309,7 @@ def ref(self): def get_ref(self): """ - Lookup the :class:`Param` instance that this :class:``PARAMref`` + Lookup the :class:`Param` instance that this ``PARAMref`` references. """ for param in self._table._votable.iter_fields_and_params(): diff --git a/astropy/io/votable/util.py b/astropy/io/votable/util.py index 970a9cbd0384..4ec8c0f72c49 100644 --- a/astropy/io/votable/util.py +++ b/astropy/io/votable/util.py @@ -33,7 +33,7 @@ def convert_to_writable_filelike(fd, compressed=False): - a file path string, in which case it is opened, and the file object is returned. - - an object with a :meth:``write`` method, in which case that + - an object with a ``write`` method, in which case that object is returned. compressed : bool, optional diff --git a/astropy/logger.py b/astropy/logger.py index da691dd82df9..427618a7a85a 100644 --- a/astropy/logger.py +++ b/astropy/logger.py @@ -229,7 +229,7 @@ def _showwarning(self, *args, **kwargs): # name. The module.__file__ is the original source file name. mod_name = None mod_path = Path(mod_path).with_suffix("") - for mod in sys.modules.values(): + for mod in list(sys.modules.values()): try: # Believe it or not this can fail in some cases: # https://github.com/astropy/astropy/issues/2671 @@ -455,8 +455,6 @@ def log_to_list(self, filter_level=None, filter_origin=None): Parameters ---------- - filename : str - The file to log messages to. filter_level : str If set, any log messages less important than ``filter_level`` will not be output to the file. Note that this is in addition to the diff --git a/astropy/modeling/bounding_box.py b/astropy/modeling/bounding_box.py index ff386fdb7502..360d7bc8ab82 100644 --- a/astropy/modeling/bounding_box.py +++ b/astropy/modeling/bounding_box.py @@ -819,7 +819,7 @@ def fix_inputs(self, model, fixed_inputs: dict, _keep_ignored=False) -> Self: The new model for which this will be a bounding_box fixed_inputs : dict Dictionary of inputs which have been fixed by this bounding box. - keep_ignored : bool + _keep_ignored : bool Keep the ignored inputs of the bounding box (internal argument only) """ new = self.copy() diff --git a/astropy/modeling/core.py b/astropy/modeling/core.py index e11f0f0ad6e6..3894fb1747c8 100644 --- a/astropy/modeling/core.py +++ b/astropy/modeling/core.py @@ -2742,9 +2742,10 @@ def _array_to_parameters(self): param_metrics = self._param_metrics for name in self.param_names: param = getattr(self, name) - value = self._parameters[param_metrics[name]["slice"]] - value.shape = param_metrics[name]["shape"] - param.value = value + param.value = np.reshape( + self._parameters[param_metrics[name]["slice"]], + param_metrics[name]["shape"], + ) def _check_param_broadcast(self, max_ndim): """ diff --git a/astropy/modeling/functional_models.py b/astropy/modeling/functional_models.py index 49e3b99fd568..16e4448dc992 100644 --- a/astropy/modeling/functional_models.py +++ b/astropy/modeling/functional_models.py @@ -9,6 +9,7 @@ from astropy import units as u from astropy.units import Quantity, UnitsError +from astropy.utils.compat import NUMPY_LT_2_5 from astropy.utils.compat.optional_deps import HAS_SCIPY from astropy.utils.exceptions import AstropyDeprecationWarning @@ -380,6 +381,15 @@ def __init__( raise ValueError("Covariance matrix must be 2x2") eig_vals, eig_vecs = np.linalg.eig(cov_matrix) + if not NUMPY_LT_2_5 or eig_vals.dtype.kind == "c": + # in numpy 2.5+, return values are *always* complex + if np.any(eig_vals.imag != 0) or np.any(eig_vecs.imag != 0): + raise TypeError( + "Expected cov_matrix's eigen values to be real. Complex values were found." + ) + eig_vals = eig_vals.real + eig_vecs = eig_vecs.real + x_stddev, y_stddev = np.sqrt(eig_vals) y_vec = eig_vecs[:, 0] theta = np.arctan2(y_vec[1], y_vec[0]) diff --git a/astropy/modeling/parameters.py b/astropy/modeling/parameters.py index be70fee0fc45..221e1d796910 100644 --- a/astropy/modeling/parameters.py +++ b/astropy/modeling/parameters.py @@ -242,6 +242,7 @@ def __init__( # use this to convert to and from the public unit defined for the # parameter. self._internal_unit = None + self._internal_value = None if not self._model_required: if self._default is not None: self.value = self._default @@ -354,7 +355,7 @@ def value(self): else: value = self._getter(self._internal_value) - if value.size == 1: + if value is not None and value.size == 1: # return scalar number as np.float64 object return np.float64(value.item()) @@ -465,7 +466,7 @@ def shape(self, value): if value not in ((), (1,)): raise ValueError("Cannot assign this shape to a scalar quantity") else: - self.value.shape = value + self.value = np.reshape(self.value, value) @property def size(self): @@ -780,6 +781,12 @@ def _wrapper(value, raw_unit=None, orig_unit=None): orig_unit is the value after the ufunc has been applied it is assumed ufunc(raw_unit) == orig_unit """ + # Make sure value is ufunc compatible + # parameters are expected to be real floats + # If the value is `None` this will result in a NaN + if not isinstance(value, Quantity): + value = np.float64(value) + if orig_unit is not None: return ufunc(value) * orig_unit elif raw_unit is not None: diff --git a/astropy/modeling/projections.py b/astropy/modeling/projections.py index fb9a57a05ef1..c660362db741 100644 --- a/astropy/modeling/projections.py +++ b/astropy/modeling/projections.py @@ -1627,8 +1627,8 @@ def evaluate(cls, x, y, matrix, translation): augmented_matrix = cls._create_augmented_matrix(matrix, translation) result = np.dot(augmented_matrix, inarr) - x, y = result[0], result[1] - x.shape = y.shape = shape + # Project by taking just x and y of the result. + x, y, _ = np.reshape(result, (3, *shape)) return x, y diff --git a/astropy/modeling/rotations.py b/astropy/modeling/rotations.py index 316b893ba5de..3b9755820eb1 100644 --- a/astropy/modeling/rotations.py +++ b/astropy/modeling/rotations.py @@ -132,8 +132,7 @@ def evaluate(self, x, y, z, angles): orig_shape = x.shape or (1,) inarr = np.array([x.ravel(), y.ravel(), z.ravel()]) result = np.dot(_create_matrix(angles[0], self.axes_order), inarr) - x, y, z = result[0], result[1], result[2] - x.shape = y.shape = z.shape = orig_shape + x, y, z = np.reshape(result, (3, *orig_shape)) return x, y, z @@ -544,8 +543,7 @@ def evaluate(cls, x, y, angle): if isinstance(angle, u.Quantity): angle = angle.to_value(u.rad) result = np.dot(cls._compute_matrix(angle), inarr) - x, y = result[0], result[1] - x.shape = y.shape = orig_shape + x, y = np.reshape(result, (2, *orig_shape)) if has_units: return u.Quantity(x, unit=x_unit, subok=True), u.Quantity( y, unit=y_unit, subok=True diff --git a/astropy/modeling/tabular.py b/astropy/modeling/tabular.py index 7203ee142cdd..15dce2aa72dc 100644 --- a/astropy/modeling/tabular.py +++ b/astropy/modeling/tabular.py @@ -247,7 +247,7 @@ def evaluate(self, *inputs): # return_units not respected when points has no units if isinstance(self.lookup_table, u.Quantity) and not isinstance( - self.points[0], u.Quantity + result, u.Quantity ): result = result * self.lookup_table.unit diff --git a/astropy/modeling/tests/test_constraints.py b/astropy/modeling/tests/test_constraints.py index 867482295195..d576430ba53c 100644 --- a/astropy/modeling/tests/test_constraints.py +++ b/astropy/modeling/tests/test_constraints.py @@ -586,12 +586,10 @@ def test_2d_model(fitter): Y = np.linspace(-1, 7, 200) x, y = np.meshgrid(X, Y) z = gauss2d(x, y) - w = np.ones(x.size) - w.shape = x.shape + w = np.ones(x.shape) with NumpyRNGContext(1234567890): - n = np.random.randn(x.size) - n.shape = x.shape + n = np.random.randn(*x.shape) m = fitter(gauss2d, x, y, z + 2 * n, weights=w) assert_allclose(m.parameters, gauss2d.parameters, rtol=0.05) m = fitter(gauss2d, x, y, z + 2 * n, weights=None) diff --git a/astropy/modeling/tests/test_models.py b/astropy/modeling/tests/test_models.py index fc401f1b3e5b..427f4a304b54 100644 --- a/astropy/modeling/tests/test_models.py +++ b/astropy/modeling/tests/test_models.py @@ -118,8 +118,8 @@ def test_inconsistent_input_shapes(): # check scalar input broadcasting works assert np.abs(g(x, 0) - g(x, 0 * x)).sum() == 0 # and that array broadcasting works - x.shape = (10, 1) - y.shape = (1, 10) + x = x.reshape((10, 1)) + y = y.reshape((1, 10)) result = g(x, y) assert result.shape == (10, 10) # incompatible shapes do _not_ work @@ -729,6 +729,17 @@ def test_tabular_interp_1d(): assert_quantity_allclose(model(xnew.to(u.nm)), ans1) assert model.bounding_box == (0 * u.nm, 4 * u.nm) + # Test with no units on points. + model = LookupTable(points=points, lookup_table=values * u.nJy) + assert_quantity_allclose(model(xnew), ans1) + assert_quantity_allclose(model(xnew.to(u.nm)), ans1) + assert model.bounding_box == (0, 4) + + model = LookupTable(points=points, lookup_table=values * u.nJy, method="nearest") + assert_quantity_allclose(model(xnew), np.array([1, 10, 10, 2, -3]) * u.nJy) + assert_quantity_allclose(model(xnew.to(u.nm)), np.array([1, 10, 10, 2, -3]) * u.nJy) + assert model.bounding_box == (0, 4) + # Test fill value unit conversion and unitless input on table with unit model = LookupTable( [1, 2, 3], diff --git a/astropy/modeling/tests/test_parameters.py b/astropy/modeling/tests/test_parameters.py index 4077586cee98..4f1caeddbf24 100644 --- a/astropy/modeling/tests/test_parameters.py +++ b/astropy/modeling/tests/test_parameters.py @@ -743,6 +743,10 @@ def test_value(self): assert not isinstance(param.value, np.ndarray) assert param.value == 1 + param = Parameter(name="test", setter=setter1, getter=getter1) + assert not isinstance(param.value, np.ndarray) + assert np.isnan(param.value) + def test_raw_value(self): param = Parameter(name="test", default=[1, 2, 3, 4]) @@ -848,6 +852,11 @@ def test_param_repr_oneline(self): param = Parameter(name="test", default=[1, 2, 3, 4] * u.m) assert param_repr_oneline(param) == "[1., 2., 3., 4.] m" + def test_param_repr_getter_no_value(self): + """Regression test for #19963""" + param = Parameter(name="test", getter=np.deg2rad, setter=np.rad2deg) + assert repr(param) == "Parameter('test', value=nan)" + def test_getter_setter(self): msg = "setter and getter must both be input" with pytest.raises(ValueError, match=msg): @@ -1248,3 +1257,17 @@ def test_setter(): for x, y in pars: np.testing.assert_almost_equal(model(x, y), (x + 1) ** 2 + (y - np.pi * 3) ** 2) + + +def test_none_parameter(): + """ + When Parameter internal value is None, the value property should also be + NaN. + + See issue #19188 + """ + param = Parameter() + # Internal value is None + assert param._value is None + + assert np.isnan(param.value) diff --git a/astropy/modeling/tests/test_pickle.py b/astropy/modeling/tests/test_pickle.py index d8dcea322417..cdc1a3c6bdfa 100644 --- a/astropy/modeling/tests/test_pickle.py +++ b/astropy/modeling/tests/test_pickle.py @@ -22,7 +22,7 @@ from astropy.modeling.math_functions import ArctanhUfunc from astropy.utils.compat.optional_deps import HAS_SCIPY -MATH_FUNCTIONS = (func for func in math_functions.__all__ if func != "ArctanhUfunc") +MATH_FUNCTIONS = [func for func in math_functions.__all__ if func != "ArctanhUfunc"] PROJ_TO_REMOVE = ( @@ -45,7 +45,7 @@ + [f"Sky2Pix_{code}" for code in projections.projcodes] ) -PROJECTIONS = (func for func in projections.__all__ if func not in PROJ_TO_REMOVE) +PROJECTIONS = [func for func in projections.__all__ if func not in PROJ_TO_REMOVE] OTHER_MODELS = [ mappings.Mapping((1, 0)), diff --git a/astropy/nddata/mixins/tests/test_ndarithmetic.py b/astropy/nddata/mixins/tests/test_ndarithmetic.py index f99e61dc052d..29453dbcde9d 100644 --- a/astropy/nddata/mixins/tests/test_ndarithmetic.py +++ b/astropy/nddata/mixins/tests/test_ndarithmetic.py @@ -1272,7 +1272,7 @@ def test_two_argument_useage_non_nddata_first_arg(meth): # Call add on the class (not the instance) ndd3 = getattr(NDDataArithmetic, meth)(data1, data2) - # Compare it with the instance-useage and two identical NDData-like + # Compare it with the instance-usage and two identical NDData-like # classes: ndd1 = NDDataArithmetic(data1) ndd2 = NDDataArithmetic(data2) diff --git a/astropy/nddata/nduncertainty.py b/astropy/nddata/nduncertainty.py index fc7c37eb1d64..8a897aafa8b4 100644 --- a/astropy/nddata/nduncertainty.py +++ b/astropy/nddata/nduncertainty.py @@ -556,10 +556,6 @@ def _propagate_collapse(self, numpy_op, axis=None): numpy_op : function Numpy operation like `np.sum` or `np.max` to use in the collapse - subtract : bool, optional - If ``True``, propagate for subtraction, otherwise propagate for - addition. - axis : tuple, optional Axis on which to compute collapsing operations. """ diff --git a/astropy/nddata/tests/test_nddata.py b/astropy/nddata/tests/test_nddata.py index 820be3fc8345..5dfa57f03ce4 100644 --- a/astropy/nddata/tests/test_nddata.py +++ b/astropy/nddata/tests/test_nddata.py @@ -640,7 +640,9 @@ def test_overriden_wcs(): @pytest.mark.parametrize( "mask, unit, propagate_uncertainties, operation_ignores_mask", - zip(collapse_masks, collapse_units, collapse_propagate, collapse_ignore_masked), + list( + zip(collapse_masks, collapse_units, collapse_propagate, collapse_ignore_masked) + ), ) def test_collapse(mask, unit, propagate_uncertainties, operation_ignores_mask): # unique set of combinations of each of the N-1 axes for an N-D cube: diff --git a/astropy/nddata/tests/test_nduncertainty.py b/astropy/nddata/tests/test_nduncertainty.py index 398b678d7cb2..0df66d54afe4 100644 --- a/astropy/nddata/tests/test_nduncertainty.py +++ b/astropy/nddata/tests/test_nduncertainty.py @@ -379,9 +379,11 @@ def test_self_conversion_via_variance_supported(UncertClass): @pytest.mark.parametrize( "UncertClass,to_variance_func", - zip( - uncertainty_types_with_conversion_support, - (lambda x: x**2, lambda x: x, lambda x: 1 / x), + list( + zip( + uncertainty_types_with_conversion_support, + (lambda x: x**2, lambda x: x, lambda x: 1 / x), + ) ), ) def test_conversion_to_from_variance_supported(UncertClass, to_variance_func): diff --git a/astropy/nddata/tests/test_utils.py b/astropy/nddata/tests/test_utils.py index bd3039b46106..dd9430dba08b 100644 --- a/astropy/nddata/tests/test_utils.py +++ b/astropy/nddata/tests/test_utils.py @@ -19,6 +19,7 @@ subpixel_indices, ) from astropy.tests.helper import assert_quantity_allclose +from astropy.utils.compat.optional_deps import HAS_MATPLOTLIB from astropy.wcs import WCS, Sip from astropy.wcs.utils import proj_plane_pixel_area @@ -482,7 +483,7 @@ def test_add_array_equal_shape(): @pytest.mark.parametrize( - ("position", "subpixel_index"), zip(test_positions, test_position_indices) + ("position", "subpixel_index"), list(zip(test_positions, test_position_indices)) ) def test_subpixel_indices(position, subpixel_index): """ @@ -700,6 +701,41 @@ def test_crpix_maps_to_crval(self): w.all_pix2world(*w.wcs.crpix, 1), w.wcs.crval, rtol=0.0, atol=1e-6 * pscale ) + @pytest.mark.skipif(not HAS_MATPLOTLIB, reason="requires matplotlib") + @pytest.mark.parametrize( + "position, size", + [ + ((2, 2), (4, 4)), # even-sized cutout + ((2, 2), (3, 3)), # odd-sized cutout + ((2.1, 1.9), (3, 5)), # non-integer position, asymmetric size + ], + ) + def test_plot_on_original(self, position, size): + """ + Regression test to ensure plot_on_original draws a rectangle + that matches the actual pixel extent of the cutout. + """ + import matplotlib.pyplot as plt + + cutout = Cutout2D(self.data, position, size) + + fig, ax = plt.subplots() + cutout.plot_on_original(ax=ax) + patch = ax.patches[0] + + # The rectangle should span from (xmin - 0.5) to (xmax + 0.5) + # and (ymin - 0.5) to (ymax + 0.5) in pixel coordinates. + x0, y0 = patch.get_xy() + width = patch.get_width() + height = patch.get_height() + + assert_allclose(x0, cutout.xmin_original - 0.5) + assert_allclose(y0, cutout.ymin_original - 0.5) + assert_allclose(width, cutout.xmax_original - cutout.xmin_original + 1) + assert_allclose(height, cutout.ymax_original - cutout.ymin_original + 1) + + plt.close(fig) + def test_cutout_with_nddata_as_input(self): # This is essentially a copy/paste of test_skycoord with the # input a ccd with wcs attribute instead of passing the diff --git a/astropy/nddata/utils.py b/astropy/nddata/utils.py index cbc69b192a2d..84715cbd842c 100644 --- a/astropy/nddata/utils.py +++ b/astropy/nddata/utils.py @@ -785,11 +785,19 @@ def plot_on_original(self, ax=None, fill=False, **kwargs): if ax is None: ax = plt.gca() - height, width = self.shape - hw, hh = width / 2.0, height / 2.0 - pos_xy = self.position_original - np.array([hw, hh]) + xmin = self.xmin_original - 0.5 + xmax = self.xmax_original + 0.5 + + ymin = self.ymin_original - 0.5 + ymax = self.ymax_original + 0.5 + + pos_xy = (xmin, ymin) + width = xmax - xmin + height = ymax - ymin + patch = mpatches.Rectangle(pos_xy, width, height, angle=0.0, **kwargs) ax.add_patch(patch) + return ax @staticmethod diff --git a/astropy/samp/tests/test_client.py b/astropy/samp/tests/test_client.py index d951c5bc6e7b..b8ba133b6f32 100644 --- a/astropy/samp/tests/test_client.py +++ b/astropy/samp/tests/test_client.py @@ -1,5 +1,7 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst +import platform + import pytest # By default, tests should not use the internet. @@ -14,11 +16,13 @@ def setup_module(module): conf.use_internet = False +@pytest.mark.skipif(platform.system() == "Darwin", reason="Takes too long on OSX") def test_SAMPHubProxy(): """Test that SAMPHubProxy can be instantiated""" SAMPHubProxy() +@pytest.mark.skipif(platform.system() == "Darwin", reason="Takes too long on OSX") @pytest.mark.slow def test_SAMPClient(): """Test that SAMPClient can be instantiated""" @@ -26,6 +30,7 @@ def test_SAMPClient(): SAMPClient(proxy) +@pytest.mark.skipif(platform.system() == "Darwin", reason="Takes too long on OSX") def test_SAMPIntegratedClient(): """Test that SAMPIntegratedClient can be instantiated""" SAMPIntegratedClient() @@ -40,6 +45,7 @@ def samp_hub(): my_hub.stop() +@pytest.mark.skipif(platform.system() == "Darwin", reason="Takes too long on OSX") @pytest.mark.filterwarnings("ignore:unclosed NDArray[float]: - r"""Compute optimal segmentation of data with Scargle's Bayesian Blocks. - - This is a flexible implementation of the Bayesian Blocks algorithm - described in Scargle 2013 [1]_. - - Parameters - ---------- - t : array-like - data times (one dimensional, length N) - x : array-like, optional - data values - sigma : array-like or float, optional - data errors - fitness : str or object - the fitness function to use for the model. - If a string, the following options are supported: - - - 'events' : binned or unbinned event data. Arguments are ``gamma``, - which gives the slope of the prior on the number of bins, or - ``ncp_prior``, which is :math:`-\ln({\tt gamma})`. - - 'regular_events' : non-overlapping events measured at multiples of a - fundamental tick rate, ``dt``, which must be specified as an - additional argument. Extra arguments are ``p0``, which gives the - false alarm probability to compute the prior, or ``gamma``, which - gives the slope of the prior on the number of bins, or ``ncp_prior``, - which is :math:`-\ln({\tt gamma})`. - - 'measures' : fitness for a measured sequence with Gaussian errors. - Extra arguments are ``p0``, which gives the false alarm probability - to compute the prior, or ``gamma``, which gives the slope of the - prior on the number of bins, or ``ncp_prior``, which is - :math:`-\ln({\tt gamma})`. - - In all three cases, if more than one of ``p0``, ``gamma``, and - ``ncp_prior`` is chosen, ``ncp_prior`` takes precedence over ``gamma`` - which takes precedence over ``p0``. - - Alternatively, the fitness parameter can be an instance of - :class:`FitnessFunc` or a subclass thereof. - - **kwargs : - any additional keyword arguments will be passed to the specified - :class:`FitnessFunc` derived class. - - Returns - ------- - edges : ndarray - array containing the (N+1) edges defining the N bins - - Examples - -------- - .. testsetup:: - - >>> np.random.seed(12345) - - Event data: - - >>> t = np.random.normal(size=100) - >>> edges = bayesian_blocks(t, fitness='events', p0=0.01) - - Event data with repeats: - - >>> t = np.random.normal(size=100) - >>> t[80:] = t[:20] - >>> edges = bayesian_blocks(t, fitness='events', p0=0.01) - - Regular event data: - - >>> dt = 0.05 - >>> t = dt * np.arange(1000) - >>> x = np.zeros(len(t)) - >>> x[np.random.randint(0, len(t), len(t) // 10)] = 1 - >>> edges = bayesian_blocks(t, x, fitness='regular_events', dt=dt) - - Measured point data with errors: - - >>> t = 100 * np.random.random(100) - >>> x = np.exp(-0.5 * (t - 50) ** 2) - >>> sigma = 0.1 - >>> x_obs = np.random.normal(x, sigma) - >>> edges = bayesian_blocks(t, x_obs, sigma, fitness='measures') - - References - ---------- - .. [1] Scargle, J et al. (2013) - https://ui.adsabs.harvard.edu/abs/2013ApJ...764..167S - - .. [2] Bellman, R.E., Dreyfus, S.E., 1962. Applied Dynamic - Programming. Princeton University Press, Princeton. - https://press.princeton.edu/books/hardcover/9780691651873/applied-dynamic-programming - - .. [3] Bellman, R., Roth, R., 1969. Curve fitting by segmented - straight lines. J. Amer. Statist. Assoc. 64, 1079–1084. - https://www.tandfonline.com/doi/abs/10.1080/01621459.1969.10501038 - - See Also - -------- - astropy.stats.histogram : compute a histogram using bayesian blocks - """ - FITNESS_DICT = { - "events": Events, - "regular_events": RegularEvents, - "measures": PointMeasures, - } - fitness = FITNESS_DICT.get(fitness, fitness) - - if type(fitness) is type and issubclass(fitness, FitnessFunc): - fitfunc = fitness(**kwargs) - elif isinstance(fitness, FitnessFunc): - fitfunc = fitness - else: - raise ValueError("fitness parameter not understood") - - return fitfunc.fit(t, x, sigma) - - class FitnessFunc: """Base class for bayesian blocks fitness functions. @@ -602,3 +478,125 @@ def validate_input( if x is None: raise ValueError("x must be specified for point measures") return super().validate_input(t, x, sigma) + + +def bayesian_blocks( + t: ArrayLike, + x: ArrayLike | None = None, + sigma: ArrayLike | float | None = None, + fitness: Literal["events", "regular_events", "measures"] | FitnessFunc = "events", + **kwargs, +) -> NDArray[float]: + r"""Compute optimal segmentation of data with Scargle's Bayesian Blocks. + + This is a flexible implementation of the Bayesian Blocks algorithm + described in Scargle 2013 [1]_. + + Parameters + ---------- + t : array-like + data times (one dimensional, length N) + x : array-like, optional + data values + sigma : array-like or float, optional + data errors + fitness : str or object + the fitness function to use for the model. + If a string, the following options are supported: + + - 'events' : binned or unbinned event data. Arguments are ``gamma``, + which gives the slope of the prior on the number of bins, or + ``ncp_prior``, which is :math:`-\ln({\tt gamma})`. + - 'regular_events' : non-overlapping events measured at multiples of a + fundamental tick rate, ``dt``, which must be specified as an + additional argument. Extra arguments are ``p0``, which gives the + false alarm probability to compute the prior, or ``gamma``, which + gives the slope of the prior on the number of bins, or ``ncp_prior``, + which is :math:`-\ln({\tt gamma})`. + - 'measures' : fitness for a measured sequence with Gaussian errors. + Extra arguments are ``p0``, which gives the false alarm probability + to compute the prior, or ``gamma``, which gives the slope of the + prior on the number of bins, or ``ncp_prior``, which is + :math:`-\ln({\tt gamma})`. + + In all three cases, if more than one of ``p0``, ``gamma``, and + ``ncp_prior`` is chosen, ``ncp_prior`` takes precedence over ``gamma`` + which takes precedence over ``p0``. + + Alternatively, the fitness parameter can be an instance of + :class:`FitnessFunc` or a subclass thereof. + + **kwargs : + any additional keyword arguments will be passed to the specified + :class:`FitnessFunc` derived class. + + Returns + ------- + edges : ndarray + array containing the (N+1) edges defining the N bins + + Examples + -------- + .. testsetup:: + + >>> np.random.seed(12345) + + Event data: + + >>> t = np.random.normal(size=100) + >>> edges = bayesian_blocks(t, fitness='events', p0=0.01) + + Event data with repeats: + + >>> t = np.random.normal(size=100) + >>> t[80:] = t[:20] + >>> edges = bayesian_blocks(t, fitness='events', p0=0.01) + + Regular event data: + + >>> dt = 0.05 + >>> t = dt * np.arange(1000) + >>> x = np.zeros(len(t)) + >>> x[np.random.randint(0, len(t), len(t) // 10)] = 1 + >>> edges = bayesian_blocks(t, x, fitness='regular_events', dt=dt) + + Measured point data with errors: + + >>> t = 100 * np.random.random(100) + >>> x = np.exp(-0.5 * (t - 50) ** 2) + >>> sigma = 0.1 + >>> x_obs = np.random.normal(x, sigma) + >>> edges = bayesian_blocks(t, x_obs, sigma, fitness='measures') + + References + ---------- + .. [1] Scargle, J et al. (2013) + https://ui.adsabs.harvard.edu/abs/2013ApJ...764..167S + + .. [2] Bellman, R.E., Dreyfus, S.E., 1962. Applied Dynamic + Programming. Princeton University Press, Princeton. + https://press.princeton.edu/books/hardcover/9780691651873/applied-dynamic-programming + + .. [3] Bellman, R., Roth, R., 1969. Curve fitting by segmented + straight lines. J. Amer. Statist. Assoc. 64, 1079–1084. + https://www.tandfonline.com/doi/abs/10.1080/01621459.1969.10501038 + + See Also + -------- + astropy.stats.histogram : compute a histogram using bayesian blocks + """ + FITNESS_DICT = { + "events": Events, + "regular_events": RegularEvents, + "measures": PointMeasures, + } + fitness = FITNESS_DICT.get(fitness, fitness) + + if type(fitness) is type and issubclass(fitness, FitnessFunc): + fitfunc = fitness(**kwargs) + elif isinstance(fitness, FitnessFunc): + fitfunc = fitness + else: + raise ValueError("fitness parameter not understood") + + return fitfunc.fit(t, x, sigma) diff --git a/astropy/stats/histogram.py b/astropy/stats/histogram.py index e5b6bc6de7db..c4fcf59a4788 100644 --- a/astropy/stats/histogram.py +++ b/astropy/stats/histogram.py @@ -61,14 +61,17 @@ def calculate_bin_edges( bins : ndarray Histogram bin edges """ - # if range is specified, we need to truncate the data for - # the bin-finding routines - if range is not None: - a = a[(a >= range[0]) & (a <= range[1])] - # if bins is a string, first compute bin edges with the desired heuristic if isinstance(bins, str): + # Astropy's bin-width estimators (knuth, scott, freedman, blocks) + # compute bin widths from the data distribution, so they should only + # consider values within the requested range. Filter `a` here. + # + # Weights are not supported for these estimators (see the + # NotImplementedError below), so they do not need to be filtered. a = np.asarray(a).ravel() + if range is not None: + a = a[(a >= range[0]) & (a <= range[1])] # TODO: if weights is specified, we need to modify things. # e.g. we could use point measures fitness for Bayesian blocks @@ -99,7 +102,8 @@ def calculate_bin_edges( bins[-1] = range[1] elif np.ndim(bins) == 0: - # Number of bins was given + # Number of bins was given. Delegate range and weight handling + # to NumPy's histogram_bin_edges() bins = np.histogram_bin_edges(a, bins, range=range, weights=weights) return bins diff --git a/astropy/stats/info_theory.py b/astropy/stats/info_theory.py index 7b2d1e02da0c..99b5b5fb5907 100644 --- a/astropy/stats/info_theory.py +++ b/astropy/stats/info_theory.py @@ -4,8 +4,6 @@ This module contains simple functions for model selection. """ -from __future__ import annotations - import numpy as np __all__ = [ diff --git a/astropy/stats/nanfunctions.py b/astropy/stats/nanfunctions.py index efa9679d401b..20f64eb85def 100644 --- a/astropy/stats/nanfunctions.py +++ b/astropy/stats/nanfunctions.py @@ -101,29 +101,38 @@ def _apply_bottleneck( nanvar=np.nanvar, ) - def _dtype_dispatch(func_name): - # dispatch to bottleneck or numpy depending on the input array dtype - # this is done to workaround known accuracy bugs in bottleneck - # affecting float32 calculations - # see https://github.com/pydata/bottleneck/issues/379 - # see https://github.com/pydata/bottleneck/issues/462 - # see https://github.com/astropy/astropy/issues/17185 - # see https://github.com/astropy/astropy/issues/11492 - def wrapped(*args, **kwargs): - if args[0].dtype.str[1:] == "f8": - return bn_funcs[func_name](*args, **kwargs) - else: - return np_funcs[func_name](*args, **kwargs) + class _DtypeDispatch: + """Picklable dispatcher that routes to bottleneck or numpy based on dtype. + + Using a class instead of a closure ensures instances can be pickled, + which is required for pickling objects that store these functions as + attributes (e.g., SigmaClip._cenfunc_parsed). + + Bottleneck is only used for float64 (f8) arrays to work around known + accuracy bugs affecting float32 calculations: + - https://github.com/pydata/bottleneck/issues/379 + - https://github.com/pydata/bottleneck/issues/462 + - https://github.com/astropy/astropy/issues/17185 + - https://github.com/astropy/astropy/issues/11492 + """ - return wrapped + def __init__(self, func_name): + self.func_name = func_name - nansum = _dtype_dispatch("nansum") - nanmin = _dtype_dispatch("nanmin") - nanmax = _dtype_dispatch("nanmax") - nanmean = _dtype_dispatch("nanmean") - nanmedian = _dtype_dispatch("nanmedian") - nanstd = _dtype_dispatch("nanstd") - nanvar = _dtype_dispatch("nanvar") + def __call__(self, *args, **kwargs): + dt = args[0].dtype + if dt.kind == "f" and dt.itemsize == 8: + return bn_funcs[self.func_name](*args, **kwargs) + else: + return np_funcs[self.func_name](*args, **kwargs) + + nansum = _DtypeDispatch("nansum") + nanmin = _DtypeDispatch("nanmin") + nanmax = _DtypeDispatch("nanmax") + nanmean = _DtypeDispatch("nanmean") + nanmedian = _DtypeDispatch("nanmedian") + nanstd = _DtypeDispatch("nanstd") + nanvar = _DtypeDispatch("nanvar") else: nansum = np.nansum diff --git a/astropy/stats/setup_package.py b/astropy/stats/setup_package.py index d2c4c7140f29..d2739cbbdd09 100644 --- a/astropy/stats/setup_package.py +++ b/astropy/stats/setup_package.py @@ -1,7 +1,5 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst -from __future__ import annotations - import os from numpy import get_include as get_numpy_include diff --git a/astropy/stats/sigma_clipping.py b/astropy/stats/sigma_clipping.py index c8b3d70b0665..5a6536bdaae6 100644 --- a/astropy/stats/sigma_clipping.py +++ b/astropy/stats/sigma_clipping.py @@ -867,6 +867,26 @@ def sigma_clip( Note that along the other axis, no points would be clipped, as the standard deviation is higher. + + The behavior of ``masked=False`` depends on whether ``axis`` is + specified. When ``axis=None`` (the default), clipped values are + *removed* from the output, so the returned array may be shorter + than the input:: + + >>> import numpy as np + >>> from astropy.stats import sigma_clip + >>> x = np.ones(10) + >>> x[5] = 1000.0 + >>> clipped = sigma_clip(x, masked=False) + >>> len(clipped) # shorter than input: outlier was removed + 9 + + When ``axis`` is specified, clipped values are replaced with + ``np.nan`` instead:: + + >>> clipped_nan = sigma_clip(x, masked=False, axis=0) + >>> bool(np.isnan(clipped_nan[5])) # outlier replaced with nan + True """ sigclip = SigmaClip( sigma=sigma, diff --git a/astropy/stats/tests/test_funcs.py b/astropy/stats/tests/test_funcs.py index 47a2fc4605d3..1ac322e77d90 100644 --- a/astropy/stats/tests/test_funcs.py +++ b/astropy/stats/tests/test_funcs.py @@ -60,7 +60,7 @@ def test_median_absolute_deviation(): def test_median_absolute_deviation_masked(): - # Based on the changes introduces in #4658 + # Based on the changes introduced in #4658 # normal masked arrays without masked values are handled like normal # numpy arrays diff --git a/astropy/stats/tests/test_histogram.py b/astropy/stats/tests/test_histogram.py index 0c0432f51803..ef8d814fd3b9 100644 --- a/astropy/stats/tests/test_histogram.py +++ b/astropy/stats/tests/test_histogram.py @@ -182,3 +182,11 @@ def test_histogram_badargs(): # bad bins arg gives ValueError with pytest.raises(ValueError): histogram(x, bins="bad_argument") + + +def test_histogram_range_weights(): + data = np.array([-10.0, 2.5, 5.0, 7.5, 50.0]) + weights = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + hist, edges = histogram(data, bins=5, range=(0, 10), weights=weights) + assert_allclose(hist, [0.0, 2.0, 3.0, 4.0, 0.0]) + assert_allclose(edges, [0.0, 2.0, 4.0, 6.0, 8.0, 10.0]) diff --git a/astropy/stats/tests/test_sigma_clipping.py b/astropy/stats/tests/test_sigma_clipping.py index b69a576315b0..9e95bd77e2f6 100644 --- a/astropy/stats/tests/test_sigma_clipping.py +++ b/astropy/stats/tests/test_sigma_clipping.py @@ -1,5 +1,7 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst +import pickle + import numpy as np import pytest from numpy.testing import assert_allclose, assert_equal @@ -670,7 +672,7 @@ def test_mad_std(): # We now check this again but with the axis= keyword set since at the time # of writing this test this relies on a fast C implementation in which we - # have re-inplemented mad_std. + # have re-implemented mad_std. result_std = sigma_clip( array, @@ -722,3 +724,18 @@ def test_propagation_of_mask(): y = np.ma.masked_where(x > 1, x) assert_allclose(sigma_clipped_stats(y, grow=1), (1, 1, 0)) + + +def test_sigmaclip_pickle(): + """Regression test for gh-19343: SigmaClip cannot be pickled + when Bottleneck is installed.""" + + sigclip = SigmaClip(sigma=3.0, maxiters=5) + restored = pickle.loads(pickle.dumps(sigclip)) + assert restored.sigma == sigclip.sigma + assert restored.maxiters == sigclip.maxiters + # verify the restored object still works correctly + data = np.ma.array([1, 2, 3, 100, 4, 5]) + result = restored(data) + expected = sigclip(data) + np.testing.assert_array_equal(result, expected) diff --git a/astropy/table/_dataframes.py b/astropy/table/_dataframes.py index 71ed8e7b1abc..470f132f59f0 100644 --- a/astropy/table/_dataframes.py +++ b/astropy/table/_dataframes.py @@ -65,10 +65,10 @@ def _encode_mixins(tbl: Table) -> Table: for col in time_cols: if isinstance(col, TimeDelta): new_col = (col.sec * 1e9).astype("timedelta64[ns]") - nat = np.timedelta64("NaT") + nat = np.timedelta64("NaT", "ns") else: new_col = col.datetime64.copy() - nat = np.datetime64("NaT") + nat = np.datetime64("NaT", "ns") if col.masked: new_col[col.mask] = nat tbl[col.info.name] = new_col @@ -154,7 +154,6 @@ def _handle_index_argument( elif index is None: return False else: - assert index is True raise ValueError("index=True requires a single-column primary key.") elif isinstance(index, str): @@ -186,8 +185,14 @@ def to_df( backend_impl = _get_backend_impl(backend) - # Using private API while narwhals-dev/narwhals#3150 is not merged - if not nw._utils.is_eager_allowed(backend_impl): + # Ensure eager-compatible backend + if backend_impl not in { + nw.Implementation.CUDF, + nw.Implementation.MODIN, + nw.Implementation.PANDAS, + nw.Implementation.POLARS, + nw.Implementation.PYARROW, + }: raise ValueError("Must export to eager compatible DataFrame") # Handle index argument @@ -247,11 +252,13 @@ def to_df( def from_df( - df: Any, *, index: bool = False, units: Mapping[str, UnitLike] | None = None + cls: type[Table], + df: Any, + *, + index: bool = False, + units: Mapping[str, UnitLike] | None = None, ) -> Table: - """Create a Table from any narwhals-compatible DataFrame.""" - from .table import Table - + """Create an instance of ``cls`` from any narwhals-compatible DataFrame.""" if not HAS_NARWHALS: raise ModuleNotFoundError( "The narwhals library is required for the generic from_df method. " @@ -357,7 +364,7 @@ def from_df( else: out[name] = Column(data=data, unit=unit, copy=False) - return Table(out) + return cls(out) def to_pandas( @@ -432,11 +439,12 @@ def to_pandas( def from_pandas( - dataframe: Any, index: bool = False, units: Mapping[str, UnitLike] | None = None + cls: type[Table], + dataframe: Any, + index: bool = False, + units: Mapping[str, UnitLike] | None = None, ) -> Table: - """Create a Table from a pandas DataFrame.""" - from .table import Table - + """Create an instance of ``cls`` from a pandas DataFrame.""" out = {} names = list(dataframe.columns) @@ -514,4 +522,4 @@ def from_pandas( else: out[name] = Column(data=data, name=name, unit=unit) - return Table(out) + return cls(out) diff --git a/astropy/table/_np_utils.pyx b/astropy/table/_np_utils.pyx index 3290fd761091..703d6b80d3f4 100644 --- a/astropy/table/_np_utils.pyx +++ b/astropy/table/_np_utils.pyx @@ -27,6 +27,33 @@ def join_inner(np.ndarray[DTYPE_t, ndim=1] idxs, """ Do the inner-loop cartesian product for np_utils.join() processing. (The "inner" is about the inner loop, not inner join). + + Parameters + ---------- + idxs : ndarray + Array of boundary indices for matching keys. + idx_sort : ndarray + Sorted indices of the combined left and right keys. + len_left : int + Length of the left table. + jointype : int + Integer encoding the type of join to perform: + + * 0 = inner (strict intersection of keys) + * 1 = outer / cartesian (union of all keys) + * 2 = left (all keys from the left table) + * 3 = right (all keys from the right table) + + Returns + ------- + masked : int + Flag indicating if any values were masked during the join (1 if true, 0 if false). + n_out : int + The final number of rows in the joined output. + left_out, right_out : ndarray + Integer arrays of indices mapping the output rows back to the original left/right tables. + left_mask, right_mask : ndarray + Boolean arrays indicating which output rows are missing corresponding data in the left/right tables. """ cdef DTYPE_t n_out = 0 cdef DTYPE_t max_key_idxs = 0 diff --git a/astropy/table/bst.py b/astropy/table/bst.py index 40f97ec68a8f..c7eeab6061b1 100644 --- a/astropy/table/bst.py +++ b/astropy/table/bst.py @@ -451,22 +451,26 @@ def same_prefix(self, val): return [x for node in nodes for x in node.data] def _range(self, lower, upper, op1, op2, node, lst): + # In-order traversal (left, node, right) so that matching nodes + # are collected in ascending key order. + if lower < node.key and node.left is not None: + self._range(lower, upper, op1, op2, node.left, lst) if op1(lower, node.key) and op2(upper, node.key): lst.append(node) if upper > node.key and node.right is not None: self._range(lower, upper, op1, op2, node.right, lst) - if lower < node.key and node.left is not None: - self._range(lower, upper, op1, op2, node.left, lst) return lst def _same_prefix(self, val, node, lst): + # In-order traversal (left, node, right) so that matching nodes + # are collected in ascending key order. prefix = node.key[: len(val)] + if prefix >= val and node.left is not None: + self._same_prefix(val, node.left, lst) if prefix == val: lst.append(node) if prefix <= val and node.right is not None: self._same_prefix(val, node.right, lst) - if prefix >= val and node.left is not None: - self._same_prefix(val, node.left, lst) return lst def __repr__(self): diff --git a/astropy/table/column.py b/astropy/table/column.py index 78da49a126ba..c18a4482755e 100644 --- a/astropy/table/column.py +++ b/astropy/table/column.py @@ -9,7 +9,7 @@ from numpy import ma from astropy.units import Quantity, StructuredUnit, Unit -from astropy.utils.compat import COPY_IF_NEEDED, NUMPY_LT_2_0 +from astropy.utils.compat import COPY_IF_NEEDED, NUMPY_LT_2_0, NUMPY_LT_2_5 from astropy.utils.console import color_print from astropy.utils.data_info import BaseColumnInfo, dtype_info_name from astropy.utils.metadata import MetaData @@ -1153,7 +1153,7 @@ def _encode_str(value): def tolist(self): if self.dtype.kind == "S": - return np.char.chararray.decode(self, encoding="utf-8").tolist() + return np.char.decode(self, encoding="utf-8").tolist() else: return super().tolist() @@ -1269,7 +1269,10 @@ def __setattr__(self, item, value): raise AttributeError( "cannot set mask value to a column in non-masked Table" ) - super().__setattr__(item, value) + if not NUMPY_LT_2_5 and item == "dtype": + self._set_dtype(value) + else: + super().__setattr__(item, value) if item == "unit" and issubclass(self.dtype.type, np.number): try: @@ -1668,6 +1671,23 @@ def __new__( return self + def __deepcopy__(self, memo=None): + out = super().__deepcopy__(memo) + + # MaskedArray.__deepcopy__ copies self.__dict__ entries directly via + # deepcopy(), bypassing the DataInfo descriptor. That leaves a raw + # deep-copied MaskedColumnInfo in out.__dict__["info"] which has + # _attrs (e.g. serialize_method) but lacks bound-only slot state such + # as _format_funcs. Re-assigning through the descriptor (DataInfo.__set__) + # creates a fresh bound MaskedColumnInfo, copies across the _attrs from + # the raw object, and sets _format_funcs = {} as expected. + # + # See https://github.com/astropy/astropy/pull/19466 for more details. + if "info" in out.__dict__: + out.info = self.info + + return out + @property def fill_value(self): return self.get_fill_value() # defer to native ma.MaskedArray method diff --git a/astropy/table/groups.py b/astropy/table/groups.py index 55c670770d05..a2d352fe746d 100644 --- a/astropy/table/groups.py +++ b/astropy/table/groups.py @@ -215,7 +215,11 @@ def __getitem__(self, item): mask[i0:i1] = True out = parent[mask] out.groups._keys = parent.groups.keys[item] - out.groups._indices = np.concatenate([[0], np.cumsum(i1s - i0s)]) + out.groups._indices = ( + np.array([], dtype=int) # No selected groups so no indices + if len(out) == 0 + else np.concatenate([[0], np.cumsum(i1s - i0s)]) + ) return out @@ -223,7 +227,11 @@ def __repr__(self): return f"<{self.__class__.__name__} indices={self.indices}>" def __len__(self): - return len(self.indices) - 1 + _len = len(self.indices) + if _len == 1: + # Should never happen, indices should either have length = 0 or length >= 2. + raise RuntimeError("malformed groups.indices with length=1, this is a bug") + return _len - 1 if _len > 0 else 0 class ColumnGroups(BaseGroups): @@ -240,7 +248,11 @@ def indices(self): return self.parent_table.groups.indices else: if self._indices is None: - return np.array([0, len(self.parent_column)]) + # No explicit groups have been defined so default to a single group if + # the column has any rows, otherwise return an empty array of indices to + # match what group_by() does for an empty column. + _len = len(self.parent_column) + return np.array([0, _len]) if _len > 0 else np.array([], dtype=int) else: return self._indices @@ -344,7 +356,11 @@ def key_colnames(self): @property def indices(self): if self._indices is None: - return np.array([0, len(self.parent_table)]) + # No explicit groups have been defined so default to a single group if + # the table has any rows, otherwise return an empty array of indices to + # match what group_by() does for an empty table. + _len = len(self.parent_table) + return np.array([0, _len]) if _len > 0 else np.array([], dtype=int) else: return self._indices diff --git a/astropy/table/index.py b/astropy/table/index.py index fd357d981bb3..d78f25841e7e 100644 --- a/astropy/table/index.py +++ b/astropy/table/index.py @@ -571,10 +571,28 @@ def replace(self, row, col_name, val): val : col.info.dtype Value to insert at specified row of col """ + # Save the original key before removing so it can be restored if the + # subsequent add fails (e.g. incompatible dtype). Without this, a + # failed assignment silently drops the row from the index even though + # the underlying column data was never changed. + orig_key = tuple(c[row] for c in self.columns) self.remove_row(row, reorder=False) - key = [c[row] for c in self.columns] + key = list(orig_key) key[self.col_position(col_name)] = val - self.data.add(tuple(key), row) + try: + self.data.add(tuple(key), row) + except Exception as exc: + self.data.add(orig_key, row) + if not isinstance(exc, ValueError): + # Some index engines (BST, SCEngine) raise UFuncTypeError or + # other TypeError subclasses when comparing incompatible types + # during the sorted-insert. Convert to ValueError so callers + # always get a consistent, descriptive error. + raise ValueError( + f"Cannot update index for column '{col_name}' " + f"with value {val!r}: {exc}" + ) from exc + raise def replace_rows(self, col_slice): """ diff --git a/astropy/table/operations.py b/astropy/table/operations.py index 43a4409f3fdf..4846145df26e 100644 --- a/astropy/table/operations.py +++ b/astropy/table/operations.py @@ -1008,7 +1008,7 @@ def get_descrs(arrays, col_name_map): # Output dtype is the superset of all dtypes in in_arrays try: - dtype = common_dtype(in_cols) + dtype = result_type(in_cols) except TableMergeError as tme: # Beautify the error message when we are trying to merge columns with incompatible # types by including the name of the columns that originated the error. @@ -1030,7 +1030,7 @@ def get_descrs(arrays, col_name_map): return out_descrs -def common_dtype(cols): +def result_type(cols): """ Use numpy to find the common dtype for a list of columns. @@ -1038,7 +1038,7 @@ def common_dtype(cols): np.bool_, np.object_, np.number, np.character, np.void """ try: - return metadata.common_dtype(cols) + return metadata.utils.result_type(cols) except metadata.MergeConflictError as err: tme = TableMergeError(f"Columns have incompatible types {err._incompat_types}") tme._incompat_types = err._incompat_types @@ -1085,7 +1085,7 @@ def _get_join_sort_idxs(keys, left, right): sort_right[sort_key] = right_sort_col # Build up dtypes for the structured array that gets sorted. - dtype_str = common_dtype([left_sort_col, right_sort_col]) + dtype_str = result_type([left_sort_col, right_sort_col]) sort_keys_dtypes.append((sort_key, dtype_str)) ii += 1 diff --git a/astropy/table/pprint.py b/astropy/table/pprint.py index 92946c097f8f..5b0907bd55d1 100644 --- a/astropy/table/pprint.py +++ b/astropy/table/pprint.py @@ -58,7 +58,7 @@ def get_auto_format_func( Parameters ---------- - col_name : object, optional + col : object, optional Hashable object to identify column like id or name. Default is None. possible_string_format_functions : func, optional diff --git a/astropy/table/sorted_array.py b/astropy/table/sorted_array.py index eb660e3ad76d..f8aecad12313 100644 --- a/astropy/table/sorted_array.py +++ b/astropy/table/sorted_array.py @@ -93,7 +93,7 @@ def _get_key_slice(self, i, begin, end): def find_pos(self, key, data, exact=False): """ - Return the index of the largest key in data greater than or + Return the index of the first key in data greater than or equal to the given key, data pair. Parameters @@ -190,27 +190,40 @@ def range( argument corresponds to an inclusive lower bound, and the second argument to an inclusive upper bound. """ - # Find initial positions for lower and upper bounds. Just like a slice object, - # None values for `lower` or `upper` correspond to no bound in that direction. - lower_pos = 0 if lower is None else self.find_pos(lower, 0) - upper_pos = len(self.row_index) if upper is None else self.find_pos(upper, 0) - - if lower_pos == len(self.row_index): - return [] - - lower_bound = tuple(col[lower_pos] for col in self.cols) - if not bounds[0] and lower_bound == lower: - lower_pos += 1 # data[lower_pos] > lower - - # data[lower_pos] >= lower - # data[upper_pos] >= upper - if upper_pos < len(self.row_index): - upper_bound = tuple(col[upper_pos] for col in self.cols) - if not bounds[1] and upper_bound == upper: - upper_pos -= 1 # data[upper_pos] < upper - elif upper_bound > upper: - upper_pos -= 1 # data[upper_pos] <= upper - return self.row_index[lower_pos : upper_pos + 1] + n = len(self.row_index) + + def bisect_left(key): + # Position of the first entry whose key is >= ``key``. + return self.find_pos(key, 0) + + def bisect_right(key): + # Position of the first entry whose key is > ``key`` (i.e. just + # past the last entry equal to ``key``). + if self.unique: + pos = self.find_pos(key, 0) + if pos < n and tuple(col[pos] for col in self.cols) == key: + pos += 1 + return pos + # For a non-unique index the stored keys include the row number, so + # searching with a row number larger than any real row lands just + # past the last entry equal to ``key`` (and thus past all duplicates). + return self.find_pos(key, n) + + # Compute a half-open range [lower_pos, upper_pos). Just like a slice + # object, a None value for `lower` or `upper` means no bound in that + # direction. Using bisect_left/bisect_right ensures all entries that + # share a value with an inclusive bound are included, even duplicates. + if lower is None: + lower_pos = 0 + else: + lower_pos = bisect_left(lower) if bounds[0] else bisect_right(lower) + + if upper is None: + upper_pos = n + else: + upper_pos = bisect_right(upper) if bounds[1] else bisect_left(upper) + + return self.row_index[lower_pos:upper_pos] def remove(self, key: tuple, data: int) -> bool: """ diff --git a/astropy/table/table.py b/astropy/table/table.py index e08b790d23ea..540ba48ec62f 100644 --- a/astropy/table/table.py +++ b/astropy/table/table.py @@ -4192,7 +4192,7 @@ def from_df( """ from ._dataframes import from_df - return from_df(df, index=index, units=units) + return from_df(cls, df, index=index, units=units) def to_pandas( self, index: bool | str | None = None, use_nullable_int: bool = True @@ -4333,7 +4333,7 @@ def from_pandas( """ from ._dataframes import from_pandas - return from_pandas(dataframe, index=index, units=units) + return from_pandas(cls, dataframe, index=index, units=units) info = TableInfo() diff --git a/astropy/table/tests/test_column.py b/astropy/table/tests/test_column.py index 33c73bda1649..b3fd73b07145 100644 --- a/astropy/table/tests/test_column.py +++ b/astropy/table/tests/test_column.py @@ -1131,6 +1131,19 @@ def test_masked_column_serialize_method_propagation(): assert mc5.info.serialize_method["ecsv"] == "data_mask" +def test_masked_column_deepcopy_info_format_funcs(): + """Test the fix for #19412""" + mc = table.MaskedColumn([1.0, 2.0, 3.0], mask=[True, False, True]) + # Set a non-default serialize method to make sure that gets copied over. + mc.info.serialize_method["ecsv"] = "data_mask" + + mc_copy = copy.deepcopy(mc) + + assert mc_copy.info.serialize_method["ecsv"] == "data_mask" + # Prior to the fix, the _format_funcs did not exist on the info object. + assert mc_copy.info._format_funcs == {} + + @pytest.mark.parametrize("dtype", ["S", "U", "i"]) def test_searchsorted(Column, dtype): c = Column([1, 2, 2, 3], dtype=dtype) diff --git a/astropy/table/tests/test_df.py b/astropy/table/tests/test_df.py index 7d8d92c062bc..fd88cb1b7e9d 100644 --- a/astropy/table/tests/test_df.py +++ b/astropy/table/tests/test_df.py @@ -328,6 +328,24 @@ def __init__(self, *args, **kwargs): with pytest.raises(ValueError, match="is not in the table columns"): self._to_dataframe(t, backend, use_legacy_pandas_api, index="not a column") + def test_to_df_index_numpy_bool_true_without_primary_key( + self, backend, use_legacy_pandas_api + ): + if backend != "pandas": + pytest.skip("requires pandas") + + t = table.QTable([[1, 2], [3, 4]], names=["a", "b"]) + + with pytest.raises( + ValueError, match="index=True requires a single-column primary key" + ): + self._to_dataframe( + t, + backend, + use_legacy_pandas_api, + index=np.bool_(True), + ) + def test_from_df_index(self, backend, use_legacy_pandas_api): """Test index handling in from_dataframe conversion.""" tm = Time([1998, 2002], format="jyear") @@ -665,3 +683,18 @@ def test_masked_int_data(self, backend, use_legacy_pandas_api): assert val == 2 assert nulls_first_two.all() + + +@pytest.mark.skipif( + not HAS_PANDAS or not HAS_NARWHALS, + reason="requires pandas and narwhals", +) +@pytest.mark.parametrize("method", ["from_df", "from_pandas"]) +def test_from_pandas_df_with_qtable(method): + """Test fix for QTable.from_pandas / from_df returns Table not QTable #18909""" + t = table.QTable() + t["a"] = [1, 2] + t["q"] = [3.0, 4.0] + df = t.to_pandas() + qt = getattr(table.QTable, method)(df) + assert isinstance(qt, table.QTable) diff --git a/astropy/table/tests/test_groups.py b/astropy/table/tests/test_groups.py index e5ca374a461e..75bc706d6076 100644 --- a/astropy/table/tests/test_groups.py +++ b/astropy/table/tests/test_groups.py @@ -581,10 +581,25 @@ def test_table_aggregate_reduceat_empty(): }, masked=masked, ) - tga = tg.group_by("action").groups.aggregate(np.sum) + # Test default groups for empty table + assert len(tg.groups) == 0 + assert list(tg.groups) == [] + + # Test explicit grouping by one column + grouped = tg.group_by("action") + assert len(grouped.groups) == 0 + assert list(grouped.groups) == [] + tga = grouped.groups.aggregate(np.sum) assert tga.pformat() == ["action duration", "------ --------"] +def test_groups_len_malformed_indices_raises_runtime_error(): + tg = Table({"a": [1, 2], "b": [3, 4]}).group_by("a") + tg.groups._indices = np.array([1], dtype=int) + with pytest.raises(RuntimeError, match="malformed groups.indices"): + len(tg.groups) + + def test_column_aggregate(T1): """ Aggregate a single table column @@ -604,6 +619,17 @@ def test_column_aggregate_f8(): assert tga.pformat() == [" a ", "---", "0.0", "1.0"] +def test_table_group_select_empty(): + """Test selecting no groups returns a table with empty keys and no indices""" + tg = Table({"a": [1, 2], "b": [3, 4]}).group_by("a") + tgs = tg.groups[[]] + assert len(tgs) == 0 + assert len(tgs.groups) == 0 + assert len(tgs.groups.keys) == 0 + assert len(tgs.groups.indices) == 0 + assert tgs.groups.keys.colnames == ["a"] + + def test_table_filter(): """ Table groups filtering diff --git a/astropy/table/tests/test_index.py b/astropy/table/tests/test_index.py index f55a643eb45f..d4783a1ab201 100644 --- a/astropy/table/tests/test_index.py +++ b/astropy/table/tests/test_index.py @@ -848,3 +848,56 @@ def test_unique_indices_after_multicol_index_slice(): t2 = t[:1] assert len(t2.indices) == 1 # without fix would be 2, both with id ("a", "b"). assert t2.indices[0].id == ("a", "b") + + +def test_index_not_corrupted_on_failed_row_assignment(engine): + """Regression test: index must survive a failed row assignment. + + When ``table[row] = values`` raises because one of the values is + incompatible with its column dtype, the table index was left in an + inconsistent state. Specifically, ``Index.replace`` removed the + existing key from the sorted array *before* trying to insert the new + one; if the insert failed the old key was permanently gone even though + the column data was never changed. + + After the fix the index must round-trip correctly: the original key is + still findable and no ghost key is present. The test is run for all + three available index engines (BST, SortedArray, SCEngine). + """ + t = Table({"x": [1, 2, 3], "y": [4, 5, 6]}) + t.add_index("y", engine=engine) + + with pytest.raises(ValueError): + # "bad" is not convertible to the int64 dtype of column y + t[0] = (99, "bad") + + # Data must be unchanged + assert t[0]["x"] == 1 + assert t[0]["y"] == 4 + + # Index must still find the original key + result = t.loc[4] + assert result["x"] == 1 + assert result["y"] == 4 + + # No ghost entry for the attempted new value + with pytest.raises(KeyError): + t.loc[99] + + +def test_loc_range_sorted_after_add_row(engine): + """Regression test: a range query must return rows in ascending key order, + also for rows added after the index was created. + + The ``BST`` engine previously collected nodes in node-right-left order, + so after ``add_row`` the tree was no longer a degenerate chain and + ``t.loc[lower:upper]`` returned rows in scrambled order. + """ + t = Table([[1, 5, 9], [10, 50, 90]], names=("a", "b")) + t.add_index("a", engine=engine) + for a, b in [(7, 70), (3, 30), (8, 80), (2, 20), (6, 60), (4, 40)]: + t.add_row((a, b)) + + assert t.loc[2:8]["a"].tolist() == [2, 3, 4, 5, 6, 7, 8] + assert t.loc[2:8]["b"].tolist() == [20, 30, 40, 50, 60, 70, 80] + assert t.loc[:]["a"].tolist() == [1, 2, 3, 4, 5, 6, 7, 8, 9] diff --git a/astropy/table/tests/test_operations.py b/astropy/table/tests/test_operations.py index 505afc7929c0..5c0038ccb218 100644 --- a/astropy/table/tests/test_operations.py +++ b/astropy/table/tests/test_operations.py @@ -6,6 +6,7 @@ import numpy as np import pytest +from numpy.testing import assert_array_equal from astropy import table from astropy import units as u @@ -33,7 +34,7 @@ from astropy.units.quantity import Quantity from astropy.utils import metadata from astropy.utils.compat import NUMPY_LT_2_0 -from astropy.utils.compat.optional_deps import HAS_SCIPY +from astropy.utils.compat.optional_deps import HAS_NUMPY_QUADDTYPE, HAS_SCIPY from astropy.utils.masked import Masked from astropy.utils.metadata import MergeConflictError @@ -2625,3 +2626,16 @@ def test_empty_skycoord_vstack(): table2 = table.vstack([table1, table1]) # Used to fail. assert len(table2) == 0 assert isinstance(table2["foo"], SkyCoord) + + +@pytest.mark.skipif(not HAS_NUMPY_QUADDTYPE, reason="Tests QuadDtype") +def test_user_dtype_vstack(): + # Regression test for gh-19197 + from numpy_quaddtype import QuadPrecDType + + c = Column([1, 2], dtype=QuadPrecDType()) + 1e-25 + t = Table([c], names=["c"]) + t2 = table.vstack([t, t]) # This used to fail + assert t2["c"].dtype == c.dtype + assert_array_equal(t2[:2]["c"], c) + assert_array_equal(t2[2:]["c"], c) diff --git a/astropy/table/tests/test_pickle.py b/astropy/table/tests/test_pickle.py index 7335be6ae6d3..8c670ff66101 100644 --- a/astropy/table/tests/test_pickle.py +++ b/astropy/table/tests/test_pickle.py @@ -2,7 +2,7 @@ import numpy as np -from astropy.coordinates import SkyCoord +from astropy.coordinates import Angle, SkyCoord from astropy.table import Column, MaskedColumn, QTable, Table from astropy.table.table_helpers import simple_table from astropy.time import Time @@ -18,7 +18,7 @@ def test_pickle_column(protocol): unit="cm", meta={"a": 1}, ) - cs = pickle.dumps(c) + cs = pickle.dumps(c, protocol=protocol) cp = pickle.loads(cs) assert np.all(cp == c) assert cp.attrs_equal(c) @@ -38,7 +38,7 @@ def test_pickle_masked_column(protocol): c.mask[1] = True c.fill_value = -99 - cs = pickle.dumps(c) + cs = pickle.dumps(c, protocol=protocol) cp = pickle.loads(cs) assert np.all(cp._data == c._data) @@ -54,7 +54,7 @@ def test_pickle_multidimensional_column(protocol): a = np.zeros((3, 2)) c = Column(a, name="a") - cs = pickle.dumps(c) + cs = pickle.dumps(c, protocol=protocol) cp = pickle.loads(cs) assert np.all(c == cp) @@ -87,7 +87,7 @@ def test_pickle_table(protocol): t["d"] = Time(["2001-01-02T12:34:56", "2001-02-03T00:01:02"]) t["e"] = SkyCoord([125.0, 180.0] * deg, [-45.0, 36.5] * deg) - ts = pickle.dumps(t) + ts = pickle.dumps(t, protocol=protocol) tp = pickle.loads(ts) assert tp.__class__ is table_class @@ -129,7 +129,7 @@ def test_pickle_masked_table(protocol): t["a"].mask[1] = True t["a"].fill_value = -99 - ts = pickle.dumps(t) + ts = pickle.dumps(t, protocol=protocol) tp = pickle.loads(ts) for colname in ("a", "b"): @@ -141,6 +141,29 @@ def test_pickle_masked_table(protocol): assert tp.meta == t.meta +def test_pickle_masked_qtable(protocol): + t = QTable(meta={"a": 1}, masked=True) + t["a"] = Quantity([1, 2], unit="m") + t["b"] = Angle([1, 2], unit=deg) + + t["a"].mask[0] = True + t["b"].mask[1] = True + + ts = pickle.dumps(t, protocol=protocol) + tp = pickle.loads(ts) + + assert tp.__class__ is QTable + + assert np.all(tp["a"].mask == t["a"].mask) + assert np.all(tp["a"].unmasked == t["a"].unmasked) + assert np.all(tp["b"].mask == t["b"].mask) + assert np.all(tp["b"].unmasked == t["b"].unmasked) + assert type(tp["a"]) is type(t["a"]) # nopep8 + assert type(tp["b"]) is type(t["b"]) # nopep8 + assert tp.meta == t.meta + assert type(tp) is type(t) + + def test_pickle_indexed_table(protocol): """ Ensure that any indices that have been added will survive pickling. @@ -148,7 +171,7 @@ def test_pickle_indexed_table(protocol): t = simple_table() t.add_index("a") t.add_index(["a", "b"]) - ts = pickle.dumps(t) + ts = pickle.dumps(t, protocol=protocol) tp = pickle.loads(ts) assert len(t.indices) == len(tp.indices) diff --git a/astropy/tests/figures/py311-test-image-mpl380-cov.json b/astropy/tests/figures/py311-test-image-mpl380-cov.json index 2fccead9d1fa..d46b5bc1a7aa 100644 --- a/astropy/tests/figures/py311-test-image-mpl380-cov.json +++ b/astropy/tests/figures/py311-test-image-mpl380-cov.json @@ -1,9 +1,9 @@ { - "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_custom_frame": "cceb87beabe25ac4187b2ade40b1d4af551dbb77a35f71b59d41cbfb85a2c769", + "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_custom_frame": "ed4d134ec6599ed1343f94c7099ab5d4d56ace2c950507017e597bc74c84d208", "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_rectangular": "30e13643c770a26b2707745143f50a735daa6f37a6a8258e733ae35338a4a1bb", - "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_nonrectangular": "37de34740cef2897effc9de5e6726ef3955a3449d0fa6a929350301500557b8e", + "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_nonrectangular": "c13d765eb65803590b89bb71adfc08065bb74e3db4eb2ccad62a18b14a83ad20", "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_change_wcs": "c68e961f0a21cc0dcc43c523cee1c564d94bd96c795f976d51e5198fc52f83cc", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tight_layout": "80f167d1d14c8c22f1ffc2f403951e60397a4dcf35b9ecfd3ebba297d3fe0ff7", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tight_layout": "b07a6595053b6931cb70f23d18a27897fd9fa48af173bb87f5f4f7675d560d48", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_image_plot": "4fe4c89d089e8f1f9584584826f25d3c884d4914f76a863e1a624f9ef2295b07", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axes_off": "3580258468fc0ff0fa6d140299b79f1f05c16c3e222447de38228e01983fbdd1", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[True]": "5364e39cab14f28b08073b31cce3dae8501c0431b1a49985810b764902747c8e", @@ -12,33 +12,33 @@ "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contour_overlay": "8679305e8a8b5b3f5b896343f4e16dfc65e9289c31505e3b35ef773d7837c8ea", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contourf_overlay": "fa928ab57291e8a128009320323ffb8955cd75d28b2a589074defb7b0889b3eb", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_overlay_features_image": "30d5562cb7a2484db0b898bc886253829de884fbf92bc0e6575f091438ec5f32", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_curvilinear_grid_patches_image": "ad0a985a324a73b5bea839a231f7537075306c874c279c6a6ea2cfe16529d815", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image": "822d96a93eb4ab59b1a0095384bb315b025b481a18821b3c558e4a937c77f489", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image_lonlat": "224f7e2f7d106ab028584bdc51c73296e277a211b7f3ed36a0452eb4842afa5b", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_coord": "9352d254af8add2918f98fb8a8d6d220bb95ab8096a1c56584d6c80ff3d5de39", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scatter_coord": "9352d254af8add2918f98fb8a8d6d220bb95ab8096a1c56584d6c80ff3d5de39", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_text_coord": "c6e7575becb6381f45fe549ea7415bd038778406e43b44e8a72f8c6bce82e466", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_line": "74ce73bcbb1dd170ae36d38a1ad4a62c626517a84e05922dab0f989a12a27758", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_changed_axis_units": "056ec7123c67fa7ee6269bec47641bfe4932630a79d02029e71164b522fadacd", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_curvilinear_grid_patches_image": "3be3f50e37e40b67e482ac6a418c95209d3d74db472a6960571c77d9ca3a4651", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image": "a0d13538d6c6aad4a44d86c094c62f2c74a669050815332a1d2e547dfeab84ef", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image_lonlat": "a137066f5472305b53cf69e9fa32dd452bba9e7d28d6ce3b9c8d668e85ea6ca9", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_coord": "357c431732a20f850aeb7f395344a787b3828de0c0874e7cc67c36585ebc26cb", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scatter_coord": "357c431732a20f850aeb7f395344a787b3828de0c0874e7cc67c36585ebc26cb", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_text_coord": "c0c6b1153494509df8a8fb9a041b0749cc0143b2381263b1409969d0a3187f48", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_line": "519133d4b90a83b602b059d8ce6df525236abdad347bb68c1820d67edf8e182a", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_changed_axis_units": "8692bdad0bab9889f22cc725c288c30f659082b537bb42d87748f76b76b706ad", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_minor_ticks": "5224f8fa725901ff74a4558987ceaae0363f98894d2c6a12d8c557db500ff7fe", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_labels": "f4944296c37a6e8ea9d30126709da3567a25908c1665e8cad10e894dd87cd54d", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_labels": "98f5d686d52be75b6a8e114a9d0d0deb5db32b486e50dff70f618e54806e9d75", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_no_ticks": "5836f6d36ce6ce89156b251055e5b00b6063a35bff4b7ca1b2da725da14a67ad", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_rcparams": "1af0c66ba343df4f03efc18e707f5b0c2a54dbce7351e0f9024154937a30b371", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_rcparams": "8fde44ec7f3b5190219c0712bb864b41c261c764ca90713ce558b1f17a2d3435", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles": "11ef218080920301ada1ef9cea558599ca110ba49e3dc4e9dc547c013b87fcc7", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles_non_square_axes": "2ffe1157db233bc80e0eda08a30b916cc56a8ff0a7f34c3a0a1b1037695fe1a5", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_set_coord_type": "ee5e873fd467292f7e76f0890051f33762b399fa3936357797e7338f80e914eb", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles_non_square_axes": "5e4275c37e0d70fd04fb763b31bdc0512ebc4c89fe4016a4e5c672b23c0c7eda", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_set_coord_type": "b14d974eb1b498cd1bfd1adf6f425b855d1df67241c28ebc2b57785f2748cca0", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_regression": "d295b08d88c6f8cd0eb56f9aa9c1892b6e72e6819a0f5b6265f3a29acb0a1545", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axislabels_regression": "431b65a14f107dd47c3ea56240b92e246ba25be682f6487c8e8565b78f1fc0c8", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_noncelestial_angular": "cafe2fe3ca40e358ad8e92040c698c1e7f2913968f226a12b635fc47c0acc063", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axislabels_regression": "d91389c6b91df92f26326648bfd63e0e970c9eea582797d870fff926e98cf126", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_noncelestial_angular": "a4b11994128d6a31a224019e46a3ea9b513d8667714ebc4e19081eecf86c6fb3", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_patches_distortion": "704af668b56837628f11b3dedbba60c0d11fda2e896ddbba9dee0e8acb5b99d7", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_quadrangle": "0223dba7d207f37c644c5c0860f6ef18b11b8013438030c89d88d700ea2ed437", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_args": "5bb6436777ab08013e747807f7db670b068229d6fb2b6b250787e44eaac36dab", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_header": "d7309b91db792bd42fdccd53ebe0738611c7cec56adb2972f0d684d83c8c75f6", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scalebar": "29931954f6c7c5131a63bdfabbe4c220ee255d2efde756e31977d13a3c69af98", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_elliptical_frame": "3aee6e8bcb0b1283993d6743683f13077aab179a85540e3dcb301fef8c66aaf7", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_hms_labels": "1047e944e3cb798a39702be44c9612cb27a26e4fd246a5cf0abe047a51985d5c", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_latex_labels": "22c900ff73a60d58fbc29ee61485cad76aa483e3c8efa89bffbdf6d660d0bf9f", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_params": "78bdce5072b3e9ac87b7e76832f3fc889c115aef3f894004a50b7eeba0d32ebd", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_args": "71af38927a3d76c39833266f715d0beebcde62427e16c567a010b261b36893c8", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_header": "1b91ff2ca8ba76447ca510f511a190e163ee3602563dd27c675f38a5e2cf8c12", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scalebar": "38e1c901e5b825d36a5a260b24dc5265d4204d41965fe381fef4daa974884e0b", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_elliptical_frame": "332e90410ca2a5b0a4151c325f1878dd800fe3e400fbb14af3a80fd8c3895ee2", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_hms_labels": "c0ef16030e9df22b539db367637d851a1abcfca559f126092a9ddb4210bf3d73", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_latex_labels": "8d30459ca3b04d3135fb0dbe0cefd6d992abdb7187a347d424d57d13090706e4", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_params": "80bbcaf1a84e45a6dda31f4dd94055f5ddb4def32481f057d1fb8a2d269907b7", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs": "190a14699654f7eae01556013f4fee1bbcddd3b34e62e9e3d92a92f7ce9a7695", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs_format_unit": "e0eea94f0ca0e37e96d903c3f441ecf0eb20b6870bfbd8a3426ad3e8305d1cee", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs_get_transform": "73e65438281efd42cda9e459467b18f7695aa50589782d0ce0cfbd298b69cb14", @@ -47,16 +47,16 @@ "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_sliced_low_level_wcs[slices1-custom:pos.helioprojective.lat]": "0a14e5153eb91e2a3a96501f02a21581effc7d6b7ae31e29a402a3abf30d5b12", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_put_varying_axis_on_bottom_lon[slices0-hpln]": "60c967eef03cc2f038d7915f1b988dfa60852fa7b950b83c6d3f49054862d5d1", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_put_varying_axis_on_bottom_lon[slices1-hplt]": "0a14e5153eb91e2a3a96501f02a21581effc7d6b7ae31e29a402a3abf30d5b12", - "astropy.visualization.wcsaxes.tests.test_images.test_allsky_labels_wrap": "8908818b526d4a506505da890eff47121b23ee41656dc15dff3e19f7d03094af", - "astropy.visualization.wcsaxes.tests.test_images.test_tickable_gridlines": "9d91bcf571af6dcc1425b6dfd76fb92e3e0180fb9fd0c852f9eba3fb06e079fc", - "astropy.visualization.wcsaxes.tests.test_images.test_overlay_nondegree_unit": "aa9b85520da54dc61d4db2988883ff184fb59cd27c52a04899cb33599fcab4b7", - "astropy.visualization.wcsaxes.tests.test_images.test_nosimplify": "565b8bf068323147ae0fcc72263f258c552285aadcf2fc67786818fccc0b812d", - "astropy.visualization.wcsaxes.tests.test_images.test_custom_formatter": "e04eb07365c6bbc4774a20150c13dadcf72b9c5a664805aff2fffd106fdecd87", + "astropy.visualization.wcsaxes.tests.test_images.test_allsky_labels_wrap": "c518cddf11b21a622367b676afaebfb42093932ca46a9287f8c59e4c373234f2", + "astropy.visualization.wcsaxes.tests.test_images.test_tickable_gridlines": "65127c0f9e786105f1219c576f361f88d5987f4d0d6fb5f555a16a5417c570f3", + "astropy.visualization.wcsaxes.tests.test_images.test_overlay_nondegree_unit": "24b94479a00308811f35e9572c9796adf7f24b993e5468e086f39b3dbd20f079", + "astropy.visualization.wcsaxes.tests.test_images.test_nosimplify": "7691427db965ad2884232cb1017cb2ad703f2bcb172064b1cb5a6db6f3562756", + "astropy.visualization.wcsaxes.tests.test_images.test_custom_formatter": "e5e336f23352c6e75cca8a13ef932b15e6f9f4b097734588797495754e21cceb", "astropy.visualization.wcsaxes.tests.test_images.test_equatorial_arcsec": "2e2ad683a3acdda19dd4353654fa6d399f4b5cd528cd41591a7bcba8776a67ce", "astropy.visualization.wcsaxes.tests.test_images.test_wcs_preserve_units": "464a0503099d3f7381479ddbba0e417011adb6e24301692056b124d608b1067b", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay": "0a87473ff8e5b5610f4adac1518c608afcd2178b25584fb42f77bcc594c4f47a", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay_auto_coord_meta": "2f737bb70fb1a5452cb0efa79010376614dc559e9aff607f638f044ac6b04448", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_direct_init": "1f24c5243bfdf0f30e88afc4f2f5d66db85954cea50a2910af94975ff8765b45", - "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_5d_with_names": "c90ae6f3b0f9f407ca7a866fa648dc3ef4bea78369315292bc82f16104ed5736", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay": "94d53bee535a009789e31f71443158c765f4401031b75bbbe3935edbad5a39d6", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay_auto_coord_meta": "d4a0f6a8bb05133a9fcc325fd2bc3d52e1a93185ed3591dfc900828215dd1f90", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_direct_init": "ac5f1e7e04fad78d519d0558009a79fa0ef369c328babbf921467b5b10d4ee43", + "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_5d_with_names": "57adce0a9e991840cfd70e8cacfc63e547c4186994c9d2f63b0711a9bbaa0d34", "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_2d_celestial_arcsec": "4b743d645a85d7516decbcf4a831c127af5a1800072597c2a1299d17fb186adb" } diff --git a/astropy/tests/figures/py311-test-image-mpldev-cov.json b/astropy/tests/figures/py311-test-image-mpldev-cov.json index 5ef0af9c9369..dc22576c7536 100644 --- a/astropy/tests/figures/py311-test-image-mpldev-cov.json +++ b/astropy/tests/figures/py311-test-image-mpldev-cov.json @@ -1,62 +1,62 @@ { - "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_custom_frame": "d79ed179997672de0a929aedee3c2ac590e04b544558808ac9bace8937a60275", - "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_rectangular": "30e13643c770a26b2707745143f50a735daa6f37a6a8258e733ae35338a4a1bb", - "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_nonrectangular": "37de34740cef2897effc9de5e6726ef3955a3449d0fa6a929350301500557b8e", - "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_change_wcs": "c68e961f0a21cc0dcc43c523cee1c564d94bd96c795f976d51e5198fc52f83cc", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tight_layout": "80f167d1d14c8c22f1ffc2f403951e60397a4dcf35b9ecfd3ebba297d3fe0ff7", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_image_plot": "4fe4c89d089e8f1f9584584826f25d3c884d4914f76a863e1a624f9ef2295b07", + "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_custom_frame": "7df863a91ce010794cdee1f71d8dc22370f4713a93e944f32884b3346f2dbcd6", + "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_rectangular": "45669dd577a0750602555da53c590da0b70a64d899e38a6b3be1c8c0c393dc52", + "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_nonrectangular": "378e4585a4ec759b4df60b5589859d8988205191463c8475d34fdf4f3f602b44", + "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_change_wcs": "6a3b3e21d12fb83e24c3307c24fe300e520db4b158d407f6df7fc79dfa2210cf", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tight_layout": "5bd7b10da254a8cee16b4d2304faf3a9d876f9f512f2778f1326aec2d9c10371", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_image_plot": "a10da5ff6a071bb278b51fe811887e978773cf9f69717eeae9ded395e37bccfd", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axes_off": "3580258468fc0ff0fa6d140299b79f1f05c16c3e222447de38228e01983fbdd1", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[True]": "5364e39cab14f28b08073b31cce3dae8501c0431b1a49985810b764902747c8e", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[False]": "ee1e46856a19ba472ae9ea4588c614fbbd1b3a4a0787ef40e33f991a8f79d6c0", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[line]": "516c3882bbaa31a0d241eec2c70aca45b27b44929662371718b10025735db772", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contour_overlay": "8679305e8a8b5b3f5b896343f4e16dfc65e9289c31505e3b35ef773d7837c8ea", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contourf_overlay": "fa928ab57291e8a128009320323ffb8955cd75d28b2a589074defb7b0889b3eb", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_overlay_features_image": "30d5562cb7a2484db0b898bc886253829de884fbf92bc0e6575f091438ec5f32", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_curvilinear_grid_patches_image": "ad0a985a324a73b5bea839a231f7537075306c874c279c6a6ea2cfe16529d815", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image": "822d96a93eb4ab59b1a0095384bb315b025b481a18821b3c558e4a937c77f489", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image_lonlat": "224f7e2f7d106ab028584bdc51c73296e277a211b7f3ed36a0452eb4842afa5b", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_coord": "9352d254af8add2918f98fb8a8d6d220bb95ab8096a1c56584d6c80ff3d5de39", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scatter_coord": "9352d254af8add2918f98fb8a8d6d220bb95ab8096a1c56584d6c80ff3d5de39", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_text_coord": "c6e7575becb6381f45fe549ea7415bd038778406e43b44e8a72f8c6bce82e466", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_line": "74ce73bcbb1dd170ae36d38a1ad4a62c626517a84e05922dab0f989a12a27758", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_changed_axis_units": "056ec7123c67fa7ee6269bec47641bfe4932630a79d02029e71164b522fadacd", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_minor_ticks": "5224f8fa725901ff74a4558987ceaae0363f98894d2c6a12d8c557db500ff7fe", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_labels": "f4944296c37a6e8ea9d30126709da3567a25908c1665e8cad10e894dd87cd54d", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_no_ticks": "5836f6d36ce6ce89156b251055e5b00b6063a35bff4b7ca1b2da725da14a67ad", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_rcparams": "1af0c66ba343df4f03efc18e707f5b0c2a54dbce7351e0f9024154937a30b371", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[True]": "3492f9ddb93014073086269f862aa7c6183da0c28a3a98afd330aa9e0f27cf09", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[False]": "8019d129a842be8d6c91543790f1e7b1933b2b91edfa7885a0d71b0ca8ef00d7", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[line]": "75516135332695601d2fe25381c360b03ebf5526c9376bdeab0e618069b1516a", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contour_overlay": "c3d92fbc94e9396fd814e8ed4e38996558ee90d9adaaaf5d73c831d60d81574b", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contourf_overlay": "19a5b2448716cd74fcd36a3c171d596507aa63631e0187b55b6421cbcbf2f680", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_overlay_features_image": "bf160601fe1e0bf8996f71c2907422cffaa468eb2755f4fd065dd2b5694af848", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_curvilinear_grid_patches_image": "b8375ec9b69cbe58d8cbf557244d26ee8c029cecbc3f2f9b674b68825277d1f6", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image": "6b26921c7898609cfa86ff59e2184c7e0dd5133960061042c554853fc61cee5f", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image_lonlat": "398a19ac15f5f822ca6e4078b98a39f231adb808cbdff70567083e06db766751", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_coord": "8f8189f785348caaf8c109adc581ece3d071602057d49d99a7ad896bb578e333", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scatter_coord": "8f8189f785348caaf8c109adc581ece3d071602057d49d99a7ad896bb578e333", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_text_coord": "c07febd6a45e52b461435c5dff7f6db9d11662e6d7ffb0950c40846a6f747137", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_line": "c003af1cd835f1fd7173aacfdc591645661da47d00e6c6992459785313a4f6d5", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_changed_axis_units": "c8b0ac1c48f9beba0261caea8aae466574c296ea088f4439e54497f04f7940f5", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_minor_ticks": "c314eb4ef0f20621c2fc62ba09d0d71afe67baafbdb289d1b79308ea6164291d", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_labels": "a8f77b5ebea7735027ddc085bb5d6d74476093358cfea4e9bfdfc290ed8eff18", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_no_ticks": "7ac52e09ffde02327bf01d233fd7947a74a1a36e9ec273f94ffd04a5d3f699d5", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_rcparams": "132410d077bf5d6ff682f285961b4a3b8b6750c344ffd4660f1fb176aa424680", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles": "11ef218080920301ada1ef9cea558599ca110ba49e3dc4e9dc547c013b87fcc7", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles_non_square_axes": "2ffe1157db233bc80e0eda08a30b916cc56a8ff0a7f34c3a0a1b1037695fe1a5", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_set_coord_type": "ee5e873fd467292f7e76f0890051f33762b399fa3936357797e7338f80e914eb", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_regression": "d295b08d88c6f8cd0eb56f9aa9c1892b6e72e6819a0f5b6265f3a29acb0a1545", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axislabels_regression": "431b65a14f107dd47c3ea56240b92e246ba25be682f6487c8e8565b78f1fc0c8", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_noncelestial_angular": "cafe2fe3ca40e358ad8e92040c698c1e7f2913968f226a12b635fc47c0acc063", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles_non_square_axes": "8717c72274f25d42846ea8296a5b346ce6c467fc7ea194a9d1f5e208b0ea97f2", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_set_coord_type": "c66c6870631760871da3d722cf4739f2e542f6d10937f5c1e87054ea67121ae4", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_regression": "e1cbb0d489ecc3ca72258e87bbf0006bbc1bd2979df464cb61c68f5f38615987", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axislabels_regression": "72598fe74fb8fc1d0ab83df6ef4886accd08c8b8f47938408fa9327c8a37e2eb", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_noncelestial_angular": "191e3eab7bef77a5ee6ea956a5fb1bc15b95769dfef472a1cad4a8042f6136d4", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_patches_distortion": "704af668b56837628f11b3dedbba60c0d11fda2e896ddbba9dee0e8acb5b99d7", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_quadrangle": "0223dba7d207f37c644c5c0860f6ef18b11b8013438030c89d88d700ea2ed437", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_args": "5bb6436777ab08013e747807f7db670b068229d6fb2b6b250787e44eaac36dab", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_header": "d7309b91db792bd42fdccd53ebe0738611c7cec56adb2972f0d684d83c8c75f6", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scalebar": "29931954f6c7c5131a63bdfabbe4c220ee255d2efde756e31977d13a3c69af98", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_elliptical_frame": "3aee6e8bcb0b1283993d6743683f13077aab179a85540e3dcb301fef8c66aaf7", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_hms_labels": "1047e944e3cb798a39702be44c9612cb27a26e4fd246a5cf0abe047a51985d5c", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_latex_labels": "22c900ff73a60d58fbc29ee61485cad76aa483e3c8efa89bffbdf6d660d0bf9f", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_params": "78bdce5072b3e9ac87b7e76832f3fc889c115aef3f894004a50b7eeba0d32ebd", - "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs": "190a14699654f7eae01556013f4fee1bbcddd3b34e62e9e3d92a92f7ce9a7695", - "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs_format_unit": "e0eea94f0ca0e37e96d903c3f441ecf0eb20b6870bfbd8a3426ad3e8305d1cee", - "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs_get_transform": "73e65438281efd42cda9e459467b18f7695aa50589782d0ce0cfbd298b69cb14", - "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_2d_wcs_correlated": "c2fd3acaa4739844288e1ce23dcb0a70b71aaf190d85f4a9164e7ab90836a76b", - "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_sliced_low_level_wcs[slices0-custom:pos.helioprojective.lon]": "60c967eef03cc2f038d7915f1b988dfa60852fa7b950b83c6d3f49054862d5d1", - "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_sliced_low_level_wcs[slices1-custom:pos.helioprojective.lat]": "0a14e5153eb91e2a3a96501f02a21581effc7d6b7ae31e29a402a3abf30d5b12", - "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_put_varying_axis_on_bottom_lon[slices0-hpln]": "60c967eef03cc2f038d7915f1b988dfa60852fa7b950b83c6d3f49054862d5d1", - "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_put_varying_axis_on_bottom_lon[slices1-hplt]": "0a14e5153eb91e2a3a96501f02a21581effc7d6b7ae31e29a402a3abf30d5b12", - "astropy.visualization.wcsaxes.tests.test_images.test_allsky_labels_wrap": "8908818b526d4a506505da890eff47121b23ee41656dc15dff3e19f7d03094af", - "astropy.visualization.wcsaxes.tests.test_images.test_tickable_gridlines": "9d91bcf571af6dcc1425b6dfd76fb92e3e0180fb9fd0c852f9eba3fb06e079fc", - "astropy.visualization.wcsaxes.tests.test_images.test_overlay_nondegree_unit": "aa9b85520da54dc61d4db2988883ff184fb59cd27c52a04899cb33599fcab4b7", - "astropy.visualization.wcsaxes.tests.test_images.test_nosimplify": "565b8bf068323147ae0fcc72263f258c552285aadcf2fc67786818fccc0b812d", - "astropy.visualization.wcsaxes.tests.test_images.test_custom_formatter": "e04eb07365c6bbc4774a20150c13dadcf72b9c5a664805aff2fffd106fdecd87", - "astropy.visualization.wcsaxes.tests.test_images.test_equatorial_arcsec": "2e2ad683a3acdda19dd4353654fa6d399f4b5cd528cd41591a7bcba8776a67ce", - "astropy.visualization.wcsaxes.tests.test_images.test_wcs_preserve_units": "464a0503099d3f7381479ddbba0e417011adb6e24301692056b124d608b1067b", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay": "0a87473ff8e5b5610f4adac1518c608afcd2178b25584fb42f77bcc594c4f47a", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay_auto_coord_meta": "2f737bb70fb1a5452cb0efa79010376614dc559e9aff607f638f044ac6b04448", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_direct_init": "1f24c5243bfdf0f30e88afc4f2f5d66db85954cea50a2910af94975ff8765b45", - "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_5d_with_names": "c90ae6f3b0f9f407ca7a866fa648dc3ef4bea78369315292bc82f16104ed5736", - "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_2d_celestial_arcsec": "4b743d645a85d7516decbcf4a831c127af5a1800072597c2a1299d17fb186adb" + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_args": "01dffa3f79ddbfa5c119a49fc1282fee63f2792ccebc189ad44ab644b59f1559", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_header": "c4b34fab02c5be0d9d69b2615a1b05c3b73ddf9e40031c6873abe26ab3090472", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scalebar": "4b5c3fc70a8bbd7aaeed0d15d78bc491c8253ca2cc7d239c343306e0a71f44c5", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_elliptical_frame": "1524cbcdd819e04638a891660069467f9cc31e6f1430b525f3fee93992a636d0", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_hms_labels": "97e91caed53309d31e24cffad39c6a256739fd35123436f142092c37a99cfd26", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_latex_labels": "b08ef30472956763adafaf4d50a250d04f0cd2a47f96fcbb55528bfd1e2c80ef", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_params": "b197947dedc38821e02f31edb4e259a2d87439470947aa5c53bdfc89faa9d9b3", + "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs": "bf85a4cc85935869d4c26a39d155d90f68a9d266adcf2bac0d10b5da384d7045", + "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs_format_unit": "f632e2eddb9d683a085558339c23995049a7463644d6d6f997d923f5c6aaaa42", + "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs_get_transform": "0bcd3100d9d347cb14ae767107ea39885473964318d8fb14066c6217ee38a23b", + "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_2d_wcs_correlated": "47ffb96cf60b000028f26ae80e1ce9c35d8d43bf75d32f00b6e00e74656fcea3", + "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_sliced_low_level_wcs[slices0-custom:pos.helioprojective.lon]": "24af7845ef230f2be4949377f8d2a8e7b3bccd2622d8f488442ed09fc07b2b5d", + "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_sliced_low_level_wcs[slices1-custom:pos.helioprojective.lat]": "03708033709fe9b63375f96fbf5ea44b4e17cae968d385f5899d4b2e42be1f6c", + "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_put_varying_axis_on_bottom_lon[slices0-hpln]": "24af7845ef230f2be4949377f8d2a8e7b3bccd2622d8f488442ed09fc07b2b5d", + "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_put_varying_axis_on_bottom_lon[slices1-hplt]": "03708033709fe9b63375f96fbf5ea44b4e17cae968d385f5899d4b2e42be1f6c", + "astropy.visualization.wcsaxes.tests.test_images.test_allsky_labels_wrap": "cc83d998e367896cd7f9c112ad0bfc61dbd83297df19b76d5822f70deaeffe52", + "astropy.visualization.wcsaxes.tests.test_images.test_tickable_gridlines": "317db2a0b2c5b69abbd015b23a929a3cfc954481167dc2ad95a59ccd7daa656b", + "astropy.visualization.wcsaxes.tests.test_images.test_overlay_nondegree_unit": "f4908df4bea70a091d8145296d4cd06bbdd45726b010315bdd8780cac9d07ad6", + "astropy.visualization.wcsaxes.tests.test_images.test_nosimplify": "3b7ef11648af0e54623ec891a498f03908031119aa4b0061d6d3f342bb8d117a", + "astropy.visualization.wcsaxes.tests.test_images.test_custom_formatter": "01b81670f49bec5d721a4d35ad1705ff1b08397ce7c04f4c840cbdd200615529", + "astropy.visualization.wcsaxes.tests.test_images.test_equatorial_arcsec": "f048285e01f99235e711068e06ea5729b1ff5258604a944a5fa26042e613dae6", + "astropy.visualization.wcsaxes.tests.test_images.test_wcs_preserve_units": "4111f2731c268bbe8ff57551b011afd6dd918efe07236507625f6e99e69b7263", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay": "fc11a76eb5f492e78908056cfc952b230866d5eb926ef7c51caea3fd873a77c2", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay_auto_coord_meta": "34946e9b2a0e99223ec06f20c6df26db1716ea01c0495cf7ba519db78f5d0d41", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_direct_init": "bd7af8c8ba136338e52619e594e7a926ed215b0d192429b2aa1a5654aef1449a", + "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_5d_with_names": "b243aec60020235d04c816743c0fcc643495f58d4d4d5086588c59628d9cea76", + "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_2d_celestial_arcsec": "ecb11a595e1352b3b8e6967494fa5eac63992fc878bf15a934329bb28aeb61d6" } diff --git a/astropy/time/core.py b/astropy/time/core.py index 52d476d222da..5037db2b59e4 100644 --- a/astropy/time/core.py +++ b/astropy/time/core.py @@ -27,7 +27,7 @@ from astropy.extern import _strptime from astropy.units import UnitConversionError from astropy.utils import lazyproperty -from astropy.utils.compat import COPY_IF_NEEDED +from astropy.utils.compat import COPY_IF_NEEDED, NUMPY_LT_2_5 from astropy.utils.data_info import MixinInfo, data_info_factory from astropy.utils.decorators import deprecated from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyWarning @@ -926,15 +926,22 @@ def shape(self, shape): (self, "location"), ): val = getattr(obj, attr, None) - if val is not None and val.size > 1: - try: + if val is None or val.size <= 1: + continue + try: + if NUMPY_LT_2_5: val.shape = shape - except Exception: - for val2 in reshaped: - val2.shape = oldshape - raise else: - reshaped.append(val) + val._set_shape(shape) + except Exception: + for val2 in reshaped: + if NUMPY_LT_2_5: + val2.shape = oldshape + else: + val2._set_shape(oldshape) + raise + else: + reshaped.append(val) def _shaped_like_input(self, value): if self.masked: diff --git a/astropy/time/src/parse_times.c b/astropy/time/src/parse_times.c index 0890b621bcd6..113ce2c3b31f 100644 --- a/astropy/time/src/parse_times.c +++ b/astropy/time/src/parse_times.c @@ -411,6 +411,7 @@ create_parser(PyObject *NPY_UNUSED(dummy), PyObject *args, PyObject *kwds) PyErr_SetString(PyExc_ValueError, "Parameter array must have 7 entries" "(year, month, day, hour, minute, integer second, fraction)"); + goto fail; } gufunc = (PyUFuncObject *)PyUFunc_FromFuncAndDataAndSignature( diff --git a/astropy/time/tests/test_basic.py b/astropy/time/tests/test_basic.py index cc209485b05b..6a23b35191f1 100644 --- a/astropy/time/tests/test_basic.py +++ b/astropy/time/tests/test_basic.py @@ -5,6 +5,7 @@ import functools import gc import os +import warnings from copy import deepcopy from decimal import Decimal, localcontext from io import StringIO @@ -30,6 +31,7 @@ conf, ) from astropy.utils import iers +from astropy.utils.compat import NUMPY_LT_2_5 from astropy.utils.compat.optional_deps import HAS_H5PY, HAS_PYTZ from astropy.utils.exceptions import AstropyDeprecationWarning @@ -1082,7 +1084,16 @@ def test_plot_date(self): # val = matplotlib.dates.date2num('2000-01-01') val = 730120.0 else: - val = date2num(datetime.datetime(2000, 1, 1)) + # Ignore warnings from numpy for older matplotlib + # https://github.com/astropy/astropy/issues/19586 + if NUMPY_LT_2_5: + val = date2num(datetime.datetime(2000, 1, 1)) + else: + with warnings.catch_warnings( + action="ignore", category=DeprecationWarning + ): + val = date2num(datetime.datetime(2000, 1, 1)) + t = Time("2000-01-01 00:00:00", scale="utc") assert np.allclose(t.plot_date, val, atol=1e-5, rtol=0) diff --git a/astropy/time/tests/test_fast_parser.py b/astropy/time/tests/test_fast_parser.py index 74cb0590893e..ffa18600a8f2 100644 --- a/astropy/time/tests/test_fast_parser.py +++ b/astropy/time/tests/test_fast_parser.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from astropy.time import Time, TimeYearDayTime, conf +from astropy.time import Time, TimeYearDayTime, _parse_times, conf iso_times = [ "2000-02-29", @@ -153,3 +153,14 @@ def test_fast_large_arrays(): assert t.size == 501 with pytest.raises(ValueError, match="Input values did not match any"): Time(["parrot"] * 1000) + + +def test_create_parser_invalid_pars_size(): + """Regression test: create_parser must raise ValueError cleanly + for parameter arrays that don't have exactly 7 entries, without + continuing execution with an active exception (missing goto fail).""" + + # 6 entries instead of required 7 - should raise ValueError cleanly + bad_pars = np.zeros(6, dtype=_parse_times.dt_pars) + with pytest.raises(ValueError, match="Parameter array must have 7 entries"): + _parse_times.create_parser(bad_pars) diff --git a/astropy/time/tests/test_mask.py b/astropy/time/tests/test_mask.py index b70820942c9a..eb27078a3ef4 100644 --- a/astropy/time/tests/test_mask.py +++ b/astropy/time/tests/test_mask.py @@ -367,7 +367,7 @@ def test_all_formats(format_, masked_cls, masked_array_type): def test_datetime64_with_nat(): - dt64 = np.array(["nat", "2001-02-03", "2001-02-04"], dtype="datetime64") + dt64 = np.array(["nat", "2001-02-03", "2001-02-04"], dtype="datetime64[ns]") mdt64 = Masked(dt64, mask=[False, True, False]) t = Time(mdt64) assert t.masked diff --git a/astropy/time/tests/test_methods.py b/astropy/time/tests/test_methods.py index 69bf39ef4403..8039974b3578 100644 --- a/astropy/time/tests/test_methods.py +++ b/astropy/time/tests/test_methods.py @@ -13,6 +13,7 @@ from astropy.time.utils import day_frac from astropy.units.quantity_helper.function_helpers import ARRAY_FUNCTION_ENABLED from astropy.utils import iers +from astropy.utils.compat import NUMPY_LT_2_5 from astropy.utils.exceptions import AstropyDeprecationWarning needs_array_function = pytest.mark.xfail( @@ -320,7 +321,7 @@ def test_shape_setting(self, use_mask): t0_reshape = self.t0.copy() mjd = t0_reshape.mjd # Creates a cache of the mjd attribute - t0_reshape.shape = (5, 2, 5) + t0_reshape = np.reshape(t0_reshape, (5, 2, 5)) assert t0_reshape.shape == (5, 2, 5) assert mjd.shape != t0_reshape.mjd.shape # Cache got cleared assert np.all(t0_reshape.jd1 == self.t0._time.jd1.reshape(5, 2, 5)) @@ -328,16 +329,26 @@ def test_shape_setting(self, use_mask): assert t0_reshape.location is None # But if the shape doesn't work, one should get an error. t0_reshape_t = t0_reshape.T - with pytest.raises(ValueError): - t0_reshape_t.shape = (12,) # Wrong number of elements. - with pytest.raises(AttributeError): - t0_reshape_t.shape = (10, 5) # Cannot be done without copy. + + if NUMPY_LT_2_5: + depr_warning_action = "error" + else: + depr_warning_action = "ignore" + with warnings.catch_warnings(): + warnings.simplefilter(depr_warning_action, DeprecationWarning) + with pytest.raises(ValueError): + t0_reshape_t.shape = (12,) # Wrong number of elements. + with pytest.raises((AttributeError, ValueError)): + # the exact exception type isn't really in our control, + # as it ultimately comes from numpy but depends on astropy's + # execution flow + t0_reshape_t.shape = (10, 5) # Cannot be done without copy. # check no shape was changed. assert t0_reshape_t.shape == t0_reshape.T.shape assert t0_reshape_t.jd1.shape == t0_reshape.T.shape assert t0_reshape_t.jd2.shape == t0_reshape.T.shape t1_reshape = self.t1.copy() - t1_reshape.shape = (2, 5, 5) + t1_reshape = np.reshape(t1_reshape, (2, 5, 5)) assert t1_reshape.shape == (2, 5, 5) assert np.all(t1_reshape.jd1 == self.t1.jd1.reshape(2, 5, 5)) # location is a single element, so its shape should not change. @@ -570,7 +581,7 @@ def create_data(self, use_mask): self.t2 = self.__class__.t2[use_mask] self.jd = self.__class__.jd[use_mask] - @pytest.mark.parametrize("kw, func", itertools.product(kwargs, functions)) + @pytest.mark.parametrize("kw, func", list(itertools.product(kwargs, functions))) def test_argfuncs(self, kw, func, use_mask): """ Test that ``np.argfunc(jd, **kw)`` is the same as ``t0.argfunc(**kw)`` @@ -594,7 +605,7 @@ def test_argfuncs(self, kw, func, use_mask): assert t0v.shape == jdv.shape assert t1v.shape == jdv.shape - @pytest.mark.parametrize("kw, func", itertools.product(kwargs, functions)) + @pytest.mark.parametrize("kw, func", list(itertools.product(kwargs, functions))) def test_funcs(self, kw, func, use_mask): """ Test that ``np.func(jd, **kw)`` is the same as ``t1.func(**kw)`` where diff --git a/astropy/timeseries/periodograms/lombscargle/implementations/chi2_impl.py b/astropy/timeseries/periodograms/lombscargle/implementations/chi2_impl.py index 67c279f33f39..e19b136b4d20 100644 --- a/astropy/timeseries/periodograms/lombscargle/implementations/chi2_impl.py +++ b/astropy/timeseries/periodograms/lombscargle/implementations/chi2_impl.py @@ -22,7 +22,7 @@ def lombscargle_chi2( ---------- t, y, dy : array-like times, values, and errors of the data points. These should be - broadcastable to the same shape. None should be `~astropy.units.Quantity``. + broadcastable to the same shape. None should be `~astropy.units.Quantity`. frequency : array-like frequencies (not angular frequencies) at which to calculate periodogram normalization : str, optional diff --git a/astropy/uncertainty/core.py b/astropy/uncertainty/core.py index 7f73b6226cf9..83180bc6c4cf 100644 --- a/astropy/uncertainty/core.py +++ b/astropy/uncertainty/core.py @@ -10,6 +10,7 @@ from astropy import stats from astropy import units as u +from astropy.utils.compat import NUMPY_LT_2_5 from astropy.utils.compat.numpycompat import NUMPY_LT_2_0 if NUMPY_LT_2_0: @@ -100,9 +101,15 @@ def __new__(cls, samples): interface["strides"] = samples.strides[:-1] structured = np.asarray(DummyArray(interface, base=samples)) # Set our new structured dtype. - structured.dtype = new_dtype + if NUMPY_LT_2_5: + structured.dtype = new_dtype + else: + structured._set_dtype(new_dtype) # Get rid of trailing dimension of 1. - structured.shape = samples.shape[:-1] + if NUMPY_LT_2_5: + structured.shape = samples.shape[:-1] + else: + structured._set_shape(samples.shape[:-1]) # Now view as the Distribution subclass, and finalize based on the # original samples (e.g., to set the unit for QuantityDistribution). diff --git a/astropy/uncertainty/distributions.py b/astropy/uncertainty/distributions.py index 91f617e78c6c..d4687b95bf91 100644 --- a/astropy/uncertainty/distributions.py +++ b/astropy/uncertainty/distributions.py @@ -199,11 +199,8 @@ def uniform( ) newshape = lower.shape + (n_samples,) - if lower.shape == () and upper.shape == (): - width = upper - lower # scalar - else: - width = (upper - lower)[:, np.newaxis] - lower = lower[:, np.newaxis] + width = (upper - lower)[..., np.newaxis] + lower = lower[..., np.newaxis] samples = lower + width * np.random.uniform(size=newshape) return cls(samples, **kwargs) diff --git a/astropy/uncertainty/tests/test_distribution.py b/astropy/uncertainty/tests/test_distribution.py index 9fe47457e569..8d2b1de4f0d3 100644 --- a/astropy/uncertainty/tests/test_distribution.py +++ b/astropy/uncertainty/tests/test_distribution.py @@ -688,3 +688,29 @@ def setup_class(cls): cls.d = cls.d * u.Unit("km,m") cls.item = cls.item * u.Unit("Mm,km") cls.b_item = cls.b_item * u.km + + +def test_helper_uniform_multidim_regression(): + """ + Regression test: uniform() must support ndim >= 2 inputs. + + Before fix (using [:, np.newaxis]) this raised a broadcasting ValueError. + After fix (using [..., np.newaxis]) it should work and preserve trailing + sample axis. + """ + lower = np.zeros((3, 4)) + upper = np.ones((3, 4)) + n_samples = 7 + + d = ds.uniform(lower=lower, upper=upper, n_samples=n_samples) + + # Public shape hides sample axis + assert d.shape == (3, 4) + assert d.n_samples == n_samples + + # Underlying samples must include trailing sampling axis + assert d.distribution.shape == (3, 4, n_samples) + + # Bounds must hold for all samples + assert np.all(d.distribution >= lower[..., np.newaxis]) + assert np.all(d.distribution <= upper[..., np.newaxis]) diff --git a/astropy/units/format/latex.py b/astropy/units/format/latex.py index 248bd3546faf..eb959c1e8be2 100644 --- a/astropy/units/format/latex.py +++ b/astropy/units/format/latex.py @@ -19,7 +19,7 @@ class Latex(console.Console): Output LaTeX to display the unit based on IAU style guidelines. Attempts to follow the `IAU Style Manual - `_. + `_. """ _space: ClassVar[str] = r"\," @@ -75,7 +75,7 @@ class LatexInline(Latex): powers. Attempts to follow the `IAU Style Manual - `_ and the + `_ and the `ApJ and AJ style guide `_. """ diff --git a/astropy/units/quantity_helper/converters.py b/astropy/units/quantity_helper/converters.py index 5c02ab022c22..56e4783e580d 100644 --- a/astropy/units/quantity_helper/converters.py +++ b/astropy/units/quantity_helper/converters.py @@ -375,7 +375,7 @@ def check_output(output, unit, inputs, function=None): else: # output is not a Quantity, so cannot obtain a unit. - if not (unit is None or unit is dimensionless_unscaled): + if not (unit is None or unit == dimensionless_unscaled): raise UnitTypeError( "Cannot store quantity with dimension " "{}in a non-Quantity instance.".format( diff --git a/astropy/units/quantity_helper/function_helpers.py b/astropy/units/quantity_helper/function_helpers.py index ec411be87576..86ac14416836 100644 --- a/astropy/units/quantity_helper/function_helpers.py +++ b/astropy/units/quantity_helper/function_helpers.py @@ -48,6 +48,7 @@ NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_4, + NUMPY_LT_2_4_1, ) if NUMPY_LT_2_0: @@ -103,7 +104,7 @@ np.round, np.around, np.fix, np.angle, np.i0, np.clip, np.isposinf, np.isneginf, np.isreal, np.iscomplex, - np.average, np.mean, np.std, np.var, np.trace, + np.mean, np.std, np.var, np.trace, np.nanmax, np.nanmin, np.nanargmin, np.nanargmax, np.nanmean, np.nansum, np.nancumsum, np.nanprod, np.nancumprod, np.einsum_path, np.linspace, @@ -146,6 +147,8 @@ SUBCLASS_SAFE_FUNCTIONS |= {np.trapezoid} if not NUMPY_LT_2_1: SUBCLASS_SAFE_FUNCTIONS |= {np.unstack, np.cumulative_prod, np.cumulative_sum} +if not NUMPY_LT_2_4_1: + SUBCLASS_SAFE_FUNCTIONS |= {np.average} # Implemented as methods on Quantity: # np.ediff1d is from setops, but we support it anyway; the others @@ -345,8 +348,7 @@ def full_like(a, fill_value, *args, **kwargs): return (a.view(np.ndarray), a._to_own_unit(fill_value)) + args, kwargs, unit, None -@function_helper -def putmask(a, mask, values): +def putmask_impl(a, /, mask, values): from astropy.units import Quantity if isinstance(a, Quantity): @@ -357,6 +359,19 @@ def putmask(a, mask, values): raise NotImplementedError +if not NUMPY_LT_2_4: + + @function_helper + def putmask(a, /, mask, values): + return putmask_impl(a, mask=mask, values=values) + +else: + + @function_helper + def putmask(a, mask, values): + return putmask_impl(a, mask=mask, values=values) + + @function_helper def place(arr, mask, vals): from astropy.units import Quantity @@ -459,6 +474,29 @@ def _quantities2arrays(*args, unit_from_first=False): return arrays, q.unit +if NUMPY_LT_2_4_1: + + @function_helper + def average(a, axis=None, weights=None, returned=False, *, keepdims=np._NoValue): + a_value, a_unit = (_a := _as_quantity(a)).value, _a.unit + w_value, w_unit = ( + (None, dimensionless_unscaled) + if weights is None + else ((_w := _as_quantity(weights)).value, _w.unit) + ) + return ( + (a_value,), + { + "axis": axis, + "weights": w_value, + "returned": returned, + "keepdims": keepdims, + }, + ((a_unit, w_unit) if returned else a_unit), + None, + ) + + def _iterable_helper(*args, out=None, **kwargs): """Convert arguments to Quantity, and treat possible 'out'.""" if out is not None: @@ -468,12 +506,27 @@ def _iterable_helper(*args, out=None, **kwargs): return arrays, kwargs, unit, out -@function_helper -def concatenate(arrays, axis=0, out=None, **kwargs): - # TODO: make this smarter by creating an appropriately shaped - # empty output array and just filling it. - arrays, kwargs, unit, out = _iterable_helper(*arrays, out=out, axis=axis, **kwargs) - return (arrays,), kwargs, unit, out +if NUMPY_LT_2_4: + + @function_helper + def concatenate(arrays, axis=0, out=None, **kwargs): + # TODO: make this smarter by creating an appropriately shaped + # empty output array and just filling it. + arrays, kwargs, unit, out = _iterable_helper( + *arrays, out=out, axis=axis, **kwargs + ) + return (arrays,), kwargs, unit, out + +else: + + @function_helper + def concatenate(arrays, /, axis=0, out=None, **kwargs): + # TODO: make this smarter by creating an appropriately shaped + # empty output array and just filling it. + arrays, kwargs, unit, out = _iterable_helper( + *arrays, out=out, axis=axis, **kwargs + ) + return (arrays,), kwargs, unit, out def _block(arrays, max_depth, result_ndim, depth=0): @@ -489,68 +542,115 @@ def _block(arrays, max_depth, result_ndim, depth=0): UNIT_FROM_LIKE_ARG = object() -if NUMPY_LT_2_0: - @function_helper - def arange(*args, start=None, stop=None, step=None, dtype=None): - return arange_impl(*args, start=start, stop=stop, step=step, dtype=dtype) -else: +if not NUMPY_LT_2_0: @function_helper - def arange(*args, start=None, stop=None, step=None, dtype=None, device=None): + def arange( + start_or_stop, + /, + stop=None, + step=1, + *, + dtype=None, + device=None, + ): return arange_impl( - *args, start=start, stop=stop, step=step, dtype=dtype, device=device + start_or_stop, stop=stop, step=step, dtype=dtype, device=device ) +else: -def arange_impl(*args, start=None, stop=None, step=None, dtype=None, **kwargs): - # NumPy is supposed to validate the input parameters before this dispatched - # function is reached. Nevertheless, we'll sprinkle a few rundundant - # sanity checks in the form of `assert` statements. - # As they are not part of the business logic, it is fine if they are - # compiled-away (e.g. the Python interpreter runs with -O) - assert len(args) <= 4 - - # bind positional arguments to their meaningful names - # following the (complex) logic of np.arange - match args: - case (pos1,): - assert stop is None or start is None - if stop is None: - stop = pos1 - elif start is None: - start = pos1 - case start, stop, *rest: - if start is not None and stop is None: - start, stop = stop, start - match rest: - # rebind step and dtype if possible - case (step,): - pass - case step, dtype: - pass - - # as the only required argument, we want stop to set the unit of the output - # so it's important that it comes first in the qty_kwargs - qty_kwargs = { - k: v - for k, v in (("stop", stop), ("start", start), ("step", step)) - if v is not None - } + @function_helper + def arange(start_or_stop, /, stop=None, step=1, *, dtype=None): + return arange_impl(start_or_stop, stop=stop, step=step, dtype=dtype) + + +def unwrap_arange_args(*, start_or_stop, stop_, step_): + # handle the perilous task of disentangling original arguments + # This isn't trivial because start_or_stop may actually bind to two + # different inputs, as the name suggests. + # We (ab)use structural pattern matching here to bind output variables + # (start, stop, step), so no additional logic is actually needed after + # a match is found. + match (start_or_stop, stop_, step_): + case (stop, None as start, step): + pass + case (start, stop, step): + pass + + # purely defensive programming + assert stop is not None, "Please report this." + return start, stop, step + + +def wrap_arange_args(*, start, stop, step, expected_out_unit): + # do the reverse operation than unwrap_arange_args + # this is needed because start_or_stop *must* be passed as positional + + # purely defensive programming + assert stop is not None, "Please report this." + + match start, stop: + case (None, _): + qty_args = (stop,) + case _: + qty_args = (start, stop) + + step_val = step.to_value(expected_out_unit) if hasattr(step, "unit") else step + + kwargs = {} if step == 1 else {"step": step_val} + + # reverse positional arguments so `stop` always comes first + # this is done to ensure that the arrays are first converted to the + # expected unit, which we guarantee should be stop's + args_rev, out_unit = _quantities2arrays(*qty_args[::-1]) + if expected_out_unit is not UNIT_FROM_LIKE_ARG: + assert out_unit == expected_out_unit + if hasattr(stop, "unit"): + assert out_unit == stop.unit + + # reverse args again to restore initial order + args = args_rev[::-1] + + if "step" in kwargs: + kwargs["step"] = step_val + return args, kwargs + + +def arange_impl(start_or_stop, /, *, stop, step, dtype, device=None): + # Because this wrapper requires exceptional amounts of additional logic + # to unwrap/wrap its complicated signature, we'll sprinkle a few + # sanity checks in the form of `assert` statements, which should help making + # heads or tails of what's happening in the event of an unexpected exception. + # + # also note that we intentionally choose to match numpy.arange's signature + # at typecheck time, as opposed to its actual runtime signature, which is + # even richer (as of numpy 2.4). For instance, this means we don't support + # `start` being passed as keyword, or `dtype` being passed as positional. + # This is done to improve the overall stability and maintainability of this + # complicated wrapper function. + start, stop, step = unwrap_arange_args( + start_or_stop=start_or_stop, stop_=stop, step_=step + ) out_unit = getattr(stop, "unit", UNIT_FROM_LIKE_ARG) - if out_unit is UNIT_FROM_LIKE_ARG: - if hasattr(start, "unit") or hasattr(step, "unit"): - raise TypeError( - "stop without a unit cannot be combined with start or step with a unit." - ) - kwargs.update(qty_kwargs) - else: - # Convert possible start, step to stop units. - new_values, _ = _quantities2arrays(*qty_kwargs.values()) - kwargs.update(zip(qty_kwargs.keys(), new_values)) + + if out_unit is UNIT_FROM_LIKE_ARG and ( + hasattr(start, "unit") or hasattr(step, "unit") + ): + raise TypeError( + "stop without a unit cannot be combined with start or step with a unit." + ) + + args, kwargs = wrap_arange_args( + start=start, stop=stop, step=step, expected_out_unit=out_unit + ) kwargs["dtype"] = dtype - return (), kwargs, out_unit, None + if not NUMPY_LT_2_0: + kwargs["device"] = device + + return args, kwargs, out_unit, None if NUMPY_LT_2_0: @@ -558,6 +658,7 @@ def arange_impl(*args, start=None, stop=None, step=None, dtype=None, **kwargs): @function_helper(helps={np.empty, np.ones, np.zeros}) def creation_helper(shape, dtype=None, order="C"): return (shape, dtype, order), {}, UNIT_FROM_LIKE_ARG, None + else: @function_helper(helps={np.empty, np.ones, np.zeros}) @@ -570,6 +671,7 @@ def creation_helper(shape, dtype=None, order="C", *, device=None): @function_helper def full(shape, fill_value, dtype=None, order="C"): return full_impl(shape, fill_value, dtype, order) + else: @function_helper @@ -592,23 +694,49 @@ def require(a, dtype=None, requirements=None): return (a, dtype, requirements), {}, out_unit, None -@function_helper -def array(object, dtype=None, *, copy=True, order="K", subok=False, ndmin=0): +if not NUMPY_LT_2_4: + + @function_helper + def array( + object, dtype=None, *, copy=True, order="K", subok=False, ndmin=0, ndmax=0 + ): + return array_impl( + object, + dtype=dtype, + copy=copy, + order=order, + subok=subok, + ndmin=ndmin, + ndmax=ndmax, + ) + +else: + + @function_helper + def array(object, dtype=None, *, copy=True, order="K", subok=False, ndmin=0): + return array_impl( + object, dtype=dtype, copy=copy, order=order, subok=subok, ndmin=ndmin + ) + + +def array_impl(object, *, dtype, copy, order, subok, ndmin, ndmax=0): out_unit = getattr(object, "unit", UNIT_FROM_LIKE_ARG) if out_unit is not UNIT_FROM_LIKE_ARG: object = _as_quantity(object).value kwargs = {"copy": copy, "order": order, "subok": subok, "ndmin": ndmin} + if not NUMPY_LT_2_4: + kwargs |= {"ndmax": ndmax} return (object, dtype), kwargs, out_unit, None if NUMPY_LT_2_0: asarray_impl_1_helps = {np.asarray, np.asanyarray} - asarray_impl_2_helps = {} + asarray_impl_2_helps = set() elif NUMPY_LT_2_1: asarray_impl_1_helps = {np.asanyarray} asarray_impl_2_helps = {np.asarray} else: - asarray_impl_1_helps = {} + asarray_impl_1_helps = set() asarray_impl_2_helps = {np.asarray, np.asanyarray} @@ -777,7 +905,7 @@ def pad(array, pad_width, mode="constant", **kwargs): @function_helper -def where(condition, *args): +def where(condition, /, *args): from astropy.units import Quantity if isinstance(condition, Quantity) or len(args) != 2: @@ -880,14 +1008,14 @@ def cross_like_a_b(a, b, *args, **kwargs): return (a.view(np.ndarray), b.view(np.ndarray)) + args, kwargs, unit, None -@function_helper( - helps={ - np.inner, - np.vdot, - np.correlate, - np.convolve, - } -) +@function_helper(helps={np.inner, np.vdot}) +def cross_like_a_b_posonly(a, b, /): + a, b = _as_quantities(a, b) + unit = a.unit * b.unit + return (a.view(np.ndarray), b.view(np.ndarray)), {}, unit, None + + +@function_helper(helps={np.correlate, np.convolve}) def cross_like_a_v(a, v, *args, **kwargs): a, v = _as_quantities(a, v) unit = a.unit * v.unit @@ -911,7 +1039,7 @@ def einsum(*operands, out=None, **kwargs): @function_helper -def bincount(x, weights=None, minlength=0): +def bincount(x, /, weights=None, minlength=0): from astropy.units import Quantity if isinstance(x, Quantity): @@ -1277,7 +1405,10 @@ def array2string(a, *args, **kwargs): # also work around this by passing on a formatter (as is done in Angle). # So, we do nothing if the formatter argument is present and has the # relevant formatter for our dtype. - formatter = args[6] if len(args) >= 7 else kwargs.get("formatter") + if NUMPY_LT_2_4: + formatter = args[6] if len(args) >= 7 else kwargs.get("formatter") + else: + formatter = kwargs.get("formatter") if formatter is None: a = a.value @@ -1470,7 +1601,11 @@ def structured_to_unstructured(arr, *args, **kwargs): """ from astropy.units import StructuredUnit - target_unit = arr.unit.values()[0] + if isinstance(arr.unit, StructuredUnit) and len(arr.unit) > 0: + target_unit = next(iter(arr.unit.values())) + else: + # Fallback if the unit isn't structured or is empty + target_unit = arr.unit def replace_unit(x): if isinstance(x, StructuredUnit): @@ -1478,7 +1613,8 @@ def replace_unit(x): else: return target_unit - to_unit = arr.unit._recursively_apply(replace_unit) + to_unit = replace_unit(arr.unit) + return (arr.to_value(to_unit),) + args, kwargs, target_unit, None diff --git a/astropy/units/quantity_helper/helpers.py b/astropy/units/quantity_helper/helpers.py index 68eec348ab3d..95be38fc724a 100644 --- a/astropy/units/quantity_helper/helpers.py +++ b/astropy/units/quantity_helper/helpers.py @@ -18,6 +18,7 @@ NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_3, + NUMPY_LT_2_5, ) if NUMPY_LT_2_0: @@ -460,6 +461,9 @@ def helper_clip(f, unit1, unit2, unit3): np.trunc, np.positive, ) +if not NUMPY_LT_2_5: + invariant_ufuncs += (np_umath.imag, np_umath.real) + for ufunc in invariant_ufuncs: UFUNC_HELPERS[ufunc] = helper_invariant diff --git a/astropy/units/tests/test_logarithmic.py b/astropy/units/tests/test_logarithmic.py index 19b05f1ab599..21b73a506bed 100644 --- a/astropy/units/tests/test_logarithmic.py +++ b/astropy/units/tests/test_logarithmic.py @@ -36,7 +36,7 @@ def test_logarithmic_units(self): assert u.dex.to(u.mag) == -2.5 assert u.mag.to(u.dB) == -4 - @pytest.mark.parametrize("lu_unit, lu_cls", zip(lu_units, lu_subclasses)) + @pytest.mark.parametrize("lu_unit, lu_cls", list(zip(lu_units, lu_subclasses))) def test_callable_units(self, lu_unit, lu_cls): assert isinstance(lu_unit, u.UnitBase) assert callable(lu_unit) @@ -510,7 +510,8 @@ def test_hashable(): class TestLogQuantityCreation: @pytest.mark.parametrize( - "lq, lu", zip(lq_subclasses + [u.LogQuantity], lu_subclasses + [u.LogUnit]) + "lq, lu", + list(zip(lq_subclasses + [u.LogQuantity], lu_subclasses + [u.LogUnit])), ) def test_logarithmic_quantities(self, lq, lu): """Check logarithmic quantities are all set up correctly""" diff --git a/astropy/units/tests/test_quantity_non_ufuncs.py b/astropy/units/tests/test_quantity_non_ufuncs.py index 5ae22f17ac8e..737fbc20c439 100644 --- a/astropy/units/tests/test_quantity_non_ufuncs.py +++ b/astropy/units/tests/test_quantity_non_ufuncs.py @@ -34,6 +34,7 @@ POSITIONAL_ONLY = inspect.Parameter.POSITIONAL_ONLY KEYWORD_ONLY = inspect.Parameter.KEYWORD_ONLY POSITIONAL_OR_KEYWORD = inspect.Parameter.POSITIONAL_OR_KEYWORD +EMPTY_DEFAULT = inspect.Parameter.empty ARCSEC_PER_DEGREE = 60 * 60 ARCSEC_PER_RADIAN = ARCSEC_PER_DEGREE * np.rad2deg(1) @@ -396,29 +397,33 @@ def test_astype(self): id="pos: start; kw: stop", ), pytest.param( - (10 * u.radian, None), - {}, - np.rad2deg(np.arange(10, dtype=float) * ARCSEC_PER_DEGREE), - id="pos: stop, followed by 1 None", + (10,), + {"step": 2}, + np.arange(10, step=2, dtype=float) * ARCSEC_PER_DEGREE, + id="pos: stop; kw: step; unit from like", ), pytest.param( - (10 * u.radian, None, None), + (10 * u.radian, None), {}, np.rad2deg(np.arange(10, dtype=float) * ARCSEC_PER_DEGREE), - id="pos: stop, followed by 2 None", + id="pos: stop, None", ), pytest.param( - (10 * u.radian, None, None, None), + (10 * u.radian, None, 1), {}, np.rad2deg(np.arange(10, dtype=float) * ARCSEC_PER_DEGREE), - id="pos: stop, followed by 3 None", + id="pos: stop, None, 1", ), ], ) def test_arange(self, args, kwargs, expected): arr = np.arange(*args, **kwargs, like=u.Quantity([], u.degree)) assert type(arr) is u.Quantity - assert arr.unit == u.radian + if any(hasattr(arg, "unit") for arg in itertools.chain(args, kwargs.keys())): + expected_unit = u.radian + else: + expected_unit = u.degree + assert arr.unit == expected_unit assert arr.dtype == expected.dtype assert_allclose(arr.to_value(u.arcsec), expected) @@ -434,13 +439,6 @@ class AngularUnits(u.SpecificTypeQuantity): assert arr.dtype == np.dtype(float) assert_array_equal(arr.value, np.arange(10)) - def test_arange_pos_dtype(self): - arr = np.arange(0 * u.s, 10 * u.s, 1 * u.s, int, like=u.Quantity([], u.radian)) - assert type(arr) is u.Quantity - assert arr.unit == u.s - assert arr.dtype == np.dtype(int) - assert_array_equal(arr.value, np.arange(10)) - def test_arange_default_unit(self): arr = np.arange(10, like=u.Quantity([], u.s)) assert type(arr) is u.Quantity @@ -455,10 +453,12 @@ def test_arange_invalid_inputs(self): def test_arange_unit_from_stop(self): Q = 1 * u.km - a = np.arange(start=1 * u.s, stop=10 * u.min, like=Q) - b = np.arange(stop=10 * u.min, start=1 * u.s, like=Q) - assert a.unit == u.min - assert b.unit == u.min + start = 1 * u.s + stop = 10 * u.min + a = np.arange(start, stop, like=Q) + b = np.arange(start, stop=stop, like=Q) + assert a.unit == stop.unit + assert b.unit == stop.unit assert_array_equal(a.value, b.value) @@ -943,6 +943,7 @@ def test_round_(self): def test_around(self): self.check(np.around) + @pytest.mark.filterwarnings("ignore:numpy.fix is deprecated:DeprecationWarning") def test_fix(self): self.check(np.fix) @@ -1118,6 +1119,24 @@ def test_average(self): expected = np.average(q1.value, weights=q2.value) * u.m assert np.all(o == expected) + def test_average_no_weights(self): + q1 = np.arange(9.0).reshape(3, 3) * u.m + o = np.average(q1) + expected = np.average(q1.value) * u.m + assert np.all(o == expected) + + def test_average_a_and_weights_different_shapes_and_returned(self): + q1 = np.arange(9.0).reshape(3, 3) * u.m + q2 = np.ones(3) / u.s + avg, wsum = np.average(q1, weights=q2, axis=1, returned=True) + expected_avg, expected_wsum = np.average( + q1.value, weights=q2.value, axis=1, returned=True + ) + expected_avg *= u.m + expected_wsum /= u.s + assert np.all(avg == expected_avg) + assert np.all(wsum == expected_wsum) + def test_mean(self): self.check(np.mean) @@ -2066,10 +2085,10 @@ def test_array2string(self): expected2 = "[0.0 Jy, 1.0 Jy, 2.0 Jy]" assert out2 == expected2 # Also as positional argument (no, nobody will do this!) - out3 = np.array2string( - self.q, None, None, None, ", ", "", np._NoValue, {"float": str} - ) - assert out3 == expected2 + if NUMPY_LT_2_4: + args = (self.q, None, None, None, ", ", "", np._NoValue, {"float": str}) + out3 = np.array2string(*args) + assert out3 == expected2 # But not if the formatter is not relevant for us. out4 = np.array2string(self.q, separator=", ", formatter={"int": str}) assert out4 == expected1 @@ -2833,6 +2852,25 @@ def test_merge_arrays_str(self, flatten): ): rfn.merge_arrays((self.q_pv, np.array(["a", "b", "c"])), flatten=flatten) + def test_structured_to_unstructured_success(self): + """ + Test that structured_to_unstructured correctly extracts + the target unit for structured quantities where all fields + share the same compatible unit initialized as a standard unit. + """ + # Using a standard unit (not StructuredUnit) triggers the bug + # where `unit` (a standard unit) has no `values()` method. + dtype = np.dtype([("a", "f8"), ("b", "f8")]) + data = np.array([(100.0, 2.0), (300.0, 4.0)], dtype=dtype) + unit = u.m + q = u.Quantity(data, unit=unit) + + result = rfn.structured_to_unstructured(q) + + assert isinstance(result, u.Quantity) + assert result.unit == u.m + assert_array_equal(result.value, np.array([[100.0, 2.0], [300.0, 4.0]])) + all_wrapped_functions = get_wrapped_functions( np, np.fft, np.linalg, np.lib.recfunctions @@ -2871,14 +2909,16 @@ def test_testing_completeness(): class TestFunctionHelpersCompleteness: @pytest.mark.parametrize( "one, two", - itertools.combinations( - ( - SUBCLASS_SAFE_FUNCTIONS, - UNSUPPORTED_FUNCTIONS, - set(FUNCTION_HELPERS.keys()), - set(DISPATCHED_FUNCTIONS.keys()), + list( + itertools.combinations( + ( + SUBCLASS_SAFE_FUNCTIONS, + UNSUPPORTED_FUNCTIONS, + set(FUNCTION_HELPERS.keys()), + set(DISPATCHED_FUNCTIONS.keys()), + ), + 2, ), - 2, ), ) def test_no_duplicates(self, one, two): @@ -2926,7 +2966,12 @@ def test_all_arguments_reexposed(self, target, helper): try: sig_target = inspect.signature(target) except ValueError: - pytest.skip("Non Python function cannot be inspected at runtime") + if NUMPY_LT_2_4: + pytest.skip("Non Python function cannot be inspected at runtime") + elif target is np.fromstring: + pytest.skip(f"known case of missing runtime signature ({target})") + else: + raise params_target = sig_target.parameters sig_helper = inspect.signature(helper) @@ -2987,7 +3032,12 @@ def test_known_arguments(self, target, helper): try: sig_target = inspect.signature(target) except ValueError: - pytest.skip("Non Python function cannot be inspected at runtime") + if NUMPY_LT_2_4: + pytest.skip("Non Python function cannot be inspected at runtime") + elif target is np.fromstring: + pytest.skip(f"known case of missing runtime signature ({target})") + else: + raise params_target = sig_target.parameters sig_helper = inspect.signature(helper) @@ -3013,12 +3063,18 @@ def test_known_arguments(self, target, helper): self.get_param_group(params_helper, [KEYWORD_ONLY, POSITIONAL_OR_KEYWORD]) ) - # additional private keyword-only argument are allowed because - # they are only intended for testing purposes. + # additional private keyword-only argument are allowed, as long as they + # have default values. They are only intended for testing purposes. # For instance, quantile has such a parameter '_q_unit' - keyword_allowed_helper = { - name for name in keyword_allowed_helper if not name.startswith("_") + private_kwargs = { + name for name in keyword_allowed_helper if name.startswith("_") } + for pk in private_kwargs: + assert params_helper[pk].default is not EMPTY_DEFAULT, ( + f"private argument {pk} must provide a default value" + ) + + keyword_allowed_helper -= private_kwargs diff = keyword_allowed_helper - keyword_allowed_target assert not diff, ( diff --git a/astropy/units/tests/test_quantity_ufuncs.py b/astropy/units/tests/test_quantity_ufuncs.py index 17c25638299d..51846f8c225f 100644 --- a/astropy/units/tests/test_quantity_ufuncs.py +++ b/astropy/units/tests/test_quantity_ufuncs.py @@ -1112,6 +1112,14 @@ def test_ndarray_inplace_op_with_quantity(self): a[:2] += q # This used to fail assert_array_equal(a, np.array([0.125, 1.25, 2.0])) + def test_ndarray_inplace_op_with_dimensionless_quantity(self): + # Regression test for #18866 - multiplying a bare array inplace with + # a dimensionless Quantity required the unit to be u.dimensionless_unscaled. + # Mere equality was not good enough. + arr = np.ones((1,)) + arr *= 1 / np.cos(0 * u.deg) + assert arr[0] == 1 + class TestWhere: """Test the where argument in ufuncs.""" diff --git a/astropy/units/tests/test_structured.py b/astropy/units/tests/test_structured.py index 3967e39cd796..8ed24cbf2c21 100644 --- a/astropy/units/tests/test_structured.py +++ b/astropy/units/tests/test_structured.py @@ -659,7 +659,7 @@ def test_structured_to_unstructured(self): # ``test_quantity_non_ufuncs.TestRecFunctions.test_structured_to_unstructured`` def test_unstructured_to_structured(self): - # can't structure something that's already structured + # can't structure something with incompatible shape dtype = np.dtype([("f1", float), ("f2", float)]) with pytest.raises(ValueError, match="The length of the last dimension"): rfn.unstructured_to_structured(self.q_pv, dtype=self.q_pv.dtype) diff --git a/astropy/utils/compat/numpycompat.py b/astropy/utils/compat/numpycompat.py index f089ec39589d..5fd0231a4fc2 100644 --- a/astropy/utils/compat/numpycompat.py +++ b/astropy/utils/compat/numpycompat.py @@ -4,6 +4,8 @@ earlier versions of Numpy. """ +import warnings + import numpy as np from astropy.utils import minversion @@ -17,6 +19,10 @@ "NUMPY_LT_2_2", "NUMPY_LT_2_3", "NUMPY_LT_2_4", + "NUMPY_LT_2_4_1", + "NUMPY_LT_2_5", + "chararray", + "get_chararray", ] # TODO: It might also be nice to have aliases to these named for specific @@ -29,6 +35,14 @@ NUMPY_LT_2_2 = not minversion(np, "2.2.0.dev0") NUMPY_LT_2_3 = not minversion(np, "2.3.0.dev0") NUMPY_LT_2_4 = not minversion(np, "2.4.0.dev0") +NUMPY_LT_2_4_1 = not minversion(np, "2.4.1.dev0") +NUMPY_LT_2_5 = not minversion(np, "2.5.0.dev0") COPY_IF_NEEDED = False if NUMPY_LT_2_0 else None + +with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + + get_chararray = np.char.array + chararray = np.char.chararray diff --git a/astropy/utils/compat/optional_deps.py b/astropy/utils/compat/optional_deps.py index 808a9eba20c2..505944640d94 100644 --- a/astropy/utils/compat/optional_deps.py +++ b/astropy/utils/compat/optional_deps.py @@ -32,6 +32,7 @@ "matplotlib", "mpmath", "narwhals", + "numpy_quaddtype", "pandas", "PIL", "polars", diff --git a/astropy/utils/data.py b/astropy/utils/data.py index 3b437cf04d56..01d3d8d07d10 100644 --- a/astropy/utils/data.py +++ b/astropy/utils/data.py @@ -19,6 +19,7 @@ import urllib.request import zipfile from importlib import import_module +from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory, gettempdir from types import MappingProxyType from warnings import warn @@ -86,7 +87,7 @@ class _NonClosingBufferedReader(io.BufferedReader): def __del__(self): try: # NOTE: self.raw will not be closed, but left in the state - # it was in at detactment + # it was in at detachment self.detach() except Exception: pass @@ -96,7 +97,7 @@ class _NonClosingTextIOWrapper(io.TextIOWrapper): def __del__(self): try: # NOTE: self.stream will not be closed, but left in the state - # it was in at detactment + # it was in at detachment self.detach() except Exception: pass @@ -177,7 +178,7 @@ def is_url(string): return url.scheme.lower() in ["http", "https", "ftp", "sftp", "ssh", "file"] -# Backward compatibility because some downstream packages allegedly uses it. +# Backward compatibility because some downstream packages allegedly use it. _is_url = is_url @@ -370,7 +371,7 @@ def get_readable_fileobj( # Check if the file object supports random access, and if not, # then wrap it in a BytesIO buffer. It would be nicer to use a - # BufferedReader to avoid reading loading the whole file first, + # BufferedReader to avoid loading the whole file first, # but that might not be compatible with all possible I/O classes. if not hasattr(fileobj, "seek"): try: @@ -1057,6 +1058,7 @@ def get_pkg_data_path(*path, package=None): f"use astropy.utils.iers.{_IERS_DATA_REDIRECTS[filename][0]} " "instead.", AstropyDeprecationWarning, + stacklevel=2, ) return _IERS_DATA_REDIRECTS[filename][1] @@ -1235,7 +1237,7 @@ def _try_url_open( # Always try first with a secure connection # _build_urlopener uses lru_cache, so the ssl_context argument must be - # converted to a hashshable type (a set of 2-tuples) + # converted to a hashable type (a set of 2-tuples) ssl_context = frozenset(ssl_context.items() if ssl_context else []) urlopener = _build_urlopener( ftp_tls=ftp_tls, ssl_context=ssl_context, allow_insecure=False @@ -1268,7 +1270,7 @@ def _try_url_open( raise urllib.error.URLError(msg) else: msg += ". Re-trying with allow_insecure=True." - warn(msg, AstropyWarning) + warn(msg, AstropyWarning, stacklevel=2) # Try again with a new urlopener allowing insecure connections urlopener = _build_urlopener( ftp_tls=ftp_tls, ssl_context=ssl_context, allow_insecure=True @@ -1606,7 +1608,7 @@ def download_file( # FIXME: other kinds of cache problem can occur? if missing_cache: - warn(CacheMissingWarning(missing_cache, f_name)) + warn(CacheMissingWarning(missing_cache, f_name), stacklevel=2) if conf.delete_temporary_downloads_at_exit: _tempfilestodel.append(f_name) return os.path.abspath(f_name) @@ -1759,6 +1761,7 @@ def download_files_in_parallel( 'it will be set to ``"update"``. You may need to manually remove ' "the cached files with clear_download_cache() afterwards.", AstropyWarning, + stacklevel=2, ) cache = "update" @@ -1851,7 +1854,7 @@ def clear_download_cache(hashorurl=None, pkgname="astropy"): # Just a warning, though msg = "Not clearing data cache - cache inaccessible due to " estr = "" if len(e.args) < 1 else (": " + str(e)) - warn(CacheMissingWarning(msg + e.__class__.__name__ + estr)) + warn(CacheMissingWarning(msg + e.__class__.__name__ + estr), stacklevel=2) return try: if hashorurl is None: @@ -1887,10 +1890,10 @@ def clear_download_cache(hashorurl=None, pkgname="astropy"): except OSError as e: msg = "Not clearing data from cache - problem arose " estr = "" if len(e.args) < 1 else (": " + str(e)) - warn(CacheMissingWarning(msg + e.__class__.__name__ + estr)) + warn(CacheMissingWarning(msg + e.__class__.__name__ + estr), stacklevel=2) -def _get_download_cache_loc(pkgname="astropy"): +def _get_download_cache_loc(pkgname: str = "astropy") -> Path: """Finds the path to the cache directory and makes them if they don't exist. Parameters @@ -1902,7 +1905,7 @@ def _get_download_cache_loc(pkgname="astropy"): Returns ------- - datadir : str + datadir : pathlib.Path The path to the data cache directory. """ try: @@ -1921,7 +1924,7 @@ def _get_download_cache_loc(pkgname="astropy"): except OSError as e: msg = "Remote data cache could not be accessed due to " estr = "" if len(e.args) < 1 else (": " + str(e)) - warn(CacheMissingWarning(msg + e.__class__.__name__ + estr)) + warn(CacheMissingWarning(msg + e.__class__.__name__ + estr), stacklevel=2) raise @@ -2059,12 +2062,13 @@ def _rmtree(path, replace=None): f"Unable to remove directory {path} because a file in it " "is in use and you are on Windows", path, - ) + ), + stacklevel=2, ) raise except OSError as e: if e.errno == errno.EXDEV: - warn(e.strerror, AstropyWarning) + warn(e.strerror or "", AstropyWarning, stacklevel=2) shutil.move(path, os.path.join(d, "to-zap")) else: raise @@ -2080,7 +2084,7 @@ def _rmtree(path, replace=None): # already there, fine pass elif e.errno == errno.EXDEV: - warn(e.strerror, AstropyWarning) + warn(e.strerror or "", AstropyWarning, stacklevel=2) shutil.move(replace, path) else: raise @@ -2198,7 +2202,7 @@ def cache_contents(pkgname="astropy"): return _NOTHING with os.scandir(dldir) as it: for entry in it: - if entry.is_dir: + if entry.is_dir(): url = get_file_contents( os.path.join(dldir, entry.name, "url"), encoding="utf-8" ) diff --git a/astropy/utils/data_info.py b/astropy/utils/data_info.py index 9aa85037bfa6..0a43ac911c74 100644 --- a/astropy/utils/data_info.py +++ b/astropy/utils/data_info.py @@ -376,10 +376,16 @@ def __set__(self, instance, value): raise TypeError("info must be set with a DataInfo instance") def __getstate__(self): - return self._attrs + # If this is an unbound descriptor, _attrs is uninitialized, so + # return None to indicate no state for it. + return getattr(self, "_attrs", None) def __setstate__(self, state): - self._attrs = state + # Only assign _attrs if state is not None. This prevents _attrs + # from being set to None when unpickling an object that did not + # originally have _attrs initialized. + if state is not None: + self._attrs = state def _represent_as_dict(self, attrs=None): """Get the values for the parent ``attrs`` and return as a dict. This @@ -738,7 +744,7 @@ def getattrs(col): ) # Output dtype is the superset of all dtypes in in_cols - out["dtype"] = metadata.common_dtype(cols) + out["dtype"] = metadata.utils.result_type(cols) # Make sure all input shapes are the same uniq_shapes = {col.shape[1:] for col in cols} diff --git a/astropy/utils/iers/iers.py b/astropy/utils/iers/iers.py index efb59b589076..a1e659759cf8 100644 --- a/astropy/utils/iers/iers.py +++ b/astropy/utils/iers/iers.py @@ -783,20 +783,19 @@ class IERS_Auto(IERS_A): @classmethod def open(cls): - """If the configuration setting ``astropy.utils.iers.conf.auto_download`` - is set to True (default), then open a recent version of the IERS-A - table with predictions for UT1-UTC and polar motion out to - approximately one year from now. If the available version of this file - is older than ``astropy.utils.iers.conf.auto_max_age`` days old - (or non-existent) then it will be downloaded over the network and cached. + """ + This reads in the bundled IERS_A table (or the version of the file in + the current working directory, if present - see `IERS_A.read`). - If the configuration setting ``astropy.utils.iers.conf.auto_download`` - is set to False then the bundled IERS-A table will be used rather than - any downloaded version of the IERS-A table. + Subsequently, if values are requested which are more recent than the + range of validity of the bundled table, an updated version of the table + will be downloaded if the configuration setting + ``astropy.utils.iers.conf.auto_download`` is set to True (default) and + if the available version of this file is older than + ``astropy.utils.iers.conf.auto_max_age`` days old. - On the first call in a session, the table will be memoized (in the - ``iers_table`` class attribute), and further calls to ``open`` will - return this stored table. + If the configuration setting ``astropy.utils.iers.conf.auto_download`` + is set to False then the bundled IERS-A table will always be used. Returns ------- @@ -804,47 +803,8 @@ def open(cls): With IERS (Earth rotation) data columns """ - if not conf.auto_download: - # If auto_download is changed to False mid-session, iers_table may have already been - # made from non-bundled files, so it should be remade from bundled files - if not hasattr(cls, "_iers_table_bundled"): - cls._iers_table_bundled = cls.read() - cls.iers_table = cls._iers_table_bundled - return cls.iers_table - - all_urls = (conf.iers_auto_url, conf.iers_auto_url_mirror) - - if cls.iers_table is not None: - # If the URL has changed, we need to redownload the file, so we - # should ignore the internally cached version. - - if cls.iers_table.meta.get("data_url") in all_urls: - return cls.iers_table - - for url in all_urls: - try: - filename = download_file(url, cache=True) - except Exception as err: - warn(f"failed to download {url}: {err}", IERSWarning) - continue - - try: - cls.iers_table = cls.read(file=filename) - except Exception as err: - warn(f"malformed IERS table from {url}: {err}", IERSWarning) - continue - cls.iers_table.meta["data_url"] = url - break - - else: - # Issue a warning here, perhaps user is offline. An exception - # will be raised downstream if actually trying to interpolate - # predictive values. - warn( - "unable to download valid IERS file, using bundled IERS-A", IERSWarning - ) + if cls.iers_table is None: cls.iers_table = cls.read() - return cls.iers_table def _check_interpolate_indices(self, indices_orig, indices_clipped, max_input_mjd): @@ -924,7 +884,26 @@ def _refresh_table_as_needed(self, mjd): ) return - new_table = self.__class__.read(file=filename) + try: + new_table = self.__class__.read(file=filename) + except Exception as err: + # The download succeeded but the content could not be parsed as + # an IERS table (e.g. the server returned an error page). Keep + # using the existing table; an exception will be raised + # downstream when actually trying to interpolate predictive + # values. + warn( + AstropyWarning( + "malformed IERS table downloaded from" + f" {' and '.join(all_urls)}: {err}.\nA coordinate or" + " time-related calculation might be compromised or fail" + " because the dates are not covered by the available IERS" + ' file. See the "IERS data access" section of the astropy' + " documentation for additional information on working" + " offline." + ) + ) + return new_table.meta["data_url"] = str(all_urls[0]) # New table has new values? diff --git a/astropy/utils/iers/tests/test_iers.py b/astropy/utils/iers/tests/test_iers.py index 29f0e802e401..e544e4ccbf69 100644 --- a/astropy/utils/iers/tests/test_iers.py +++ b/astropy/utils/iers/tests/test_iers.py @@ -20,7 +20,7 @@ from astropy.tests.helper import CI, assert_quantity_allclose from astropy.time import Time, TimeDelta from astropy.utils.data import get_pkg_data_filename -from astropy.utils.exceptions import AstropyDeprecationWarning +from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyWarning from astropy.utils.iers import iers FILE_NOT_FOUND_ERROR = getattr(__builtins__, "FileNotFoundError", OSError) @@ -247,6 +247,12 @@ def setup_class(self): self._auto_download = iers.conf.auto_download iers.conf.auto_download = True + # Ensure no IERS_Auto table is cached from an earlier test, so that the + # first test's open() re-reads the (possibly monkeypatched) bundled file + # rather than returning a stale memoized table; teardown_method keeps it + # clean for the remaining tests. + iers.IERS_Auto.close() + # auto_download = False is tested in test_IERS_B_parameters_loading_into_IERS_Auto() def teardown_class(self): @@ -257,10 +263,11 @@ def teardown_method(self, method): """Run this after every test.""" iers.IERS_Auto.close() - def test_interpolate_error_formatting(self): + def test_interpolate_error_formatting(self, monkeypatch): """Regression test: make sure the error message in IERS_Auto._check_interpolate_indices() is formatted correctly. """ + monkeypatch.setattr(iers, "IERS_A_FILE", self.iers_a_file_1) with iers.conf.set_temp("iers_auto_url", self.iers_a_url_1): with iers.conf.set_temp("iers_auto_url_mirror", self.iers_a_url_1): with iers.conf.set_temp("auto_max_age", self.ame): @@ -276,10 +283,11 @@ def test_interpolate_error_formatting(self): warnings.simplefilter("ignore", iers.IERSStaleWarning) iers_table.ut1_utc(self.t.jd1, self.t.jd2) - def test_auto_max_age_none(self): + def test_auto_max_age_none(self, monkeypatch): """Make sure that iers.INTERPOLATE_ERROR's advice about setting auto_max_age = None actually works. """ + monkeypatch.setattr(iers, "IERS_A_FILE", self.iers_a_file_1) with iers.conf.set_temp("iers_auto_url", self.iers_a_url_1): with iers.conf.set_temp("auto_max_age", None): iers_table = iers.IERS_Auto.open() @@ -302,7 +310,8 @@ def test_auto_max_age_minimum(self): iers_table = iers.IERS_Auto.open() _ = iers_table.ut1_utc(self.t.jd1, self.t.jd2) - def test_simple(self): + def test_simple(self, monkeypatch): + monkeypatch.setattr(iers, "IERS_A_FILE", self.iers_a_file_1) with iers.conf.set_temp("iers_auto_url", self.iers_a_url_1): dat = iers.IERS_Auto.open() assert dat["MJD"][0] == 57359.0 * u.d @@ -482,33 +491,58 @@ def test_iers_b_out_of_range_handling(): (now + 100 * u.day).ut1 +@pytest.fixture +def reset_iers_auto_cache(): + """Clear the IERS_A/IERS_Auto/IERS caches around a test. + + IERS_Auto.open() memoizes its table in a class attribute, so a test that + loads a non-default bundled table (e.g. a truncated fixture) would otherwise + leak that table into later tests through earth_orientation_table. close() + only nulls the cached table, so this is safe regardless of the current + IERS_A_FILE value. + """ + for cls in (iers.IERS_A, iers.IERS_Auto, iers.IERS): + cls.close() + yield + for cls in (iers.IERS_A, iers.IERS_Auto, iers.IERS): + cls.close() + + @pytest.mark.remote_data -def test_iers_download_error_handling(tmp_path): - # Make sure an IERS-A table isn't already loaded +def test_iers_download_error_handling(tmp_path, monkeypatch, reset_iers_auto_cache): + # IERS_Auto.open() now reads the bundled table and only attempts a download + # later, when predictive values beyond the table range are requested while + # the table is older than auto_max_age. Point at an old bundled table so + # that requesting a recent date triggers a download attempt. The truncated + # fixture it loads is cleared afterwards by reset_iers_auto_cache. + monkeypatch.setattr( + iers, + "IERS_A_FILE", + get_pkg_data_filename(os.path.join("data", "finals2000A-2016-02-30-test")), + ) with set_temp_cache(tmp_path), iers.conf.set_temp("auto_download", True): - iers.IERS_A.close() - iers.IERS_Auto.close() - iers.IERS.close() now = Time.now() - # bad site name - with iers.conf.set_temp("iers_auto_url", "FAIL FAIL"): - # site that exists but doesn't have IERS data - with iers.conf.set_temp("iers_auto_url_mirror", "https://google.com"): - with pytest.warns(iers.IERSWarning) as record: - with iers.conf.set_temp("iers_degraded_accuracy", "ignore"): - (now + 400 * u.day).ut1 - - assert len(record) == 3 - assert str(record[0].message).startswith( - "failed to download FAIL FAIL: Malformed URL" - ) - assert str(record[1].message).startswith( - "malformed IERS table from https://google.com" - ) - assert str(record[2].message).startswith( - "unable to download valid IERS file, using bundled IERS-A" - ) + # Primary URL is a bad site name and the mirror is a site that exists + # but does not provide a valid IERS table, so the refresh cannot find + # usable data. + with ( + iers.conf.set_temp("iers_auto_url", "FAIL FAIL"), + iers.conf.set_temp("iers_auto_url_mirror", "https://google.com"), + iers.conf.set_temp("iers_degraded_accuracy", "ignore"), + ): + # The failed refresh is reported with a warning (the download fails + # or the downloaded content cannot be parsed), and since the bundled + # table is too old to cover the requested predictive date the + # interpolation then raises. + with pytest.warns( + AstropyWarning, match="failed to download|malformed IERS table" + ): + with pytest.raises( + ValueError, + match="interpolating from IERS_Auto using predictive values", + ): + (now + 400 * u.day).ut1 OLD_DATA_FILES = { diff --git a/astropy/utils/introspection.py b/astropy/utils/introspection.py index fe9cd499af24..ad7bec8f2d2e 100644 --- a/astropy/utils/introspection.py +++ b/astropy/utils/introspection.py @@ -105,8 +105,8 @@ def minversion(module: ModuleType | str, version: str, inclusive: bool = True) - Examples -------- - >>> import astropy - >>> minversion(astropy, '0.4.4') + >>> import numpy + >>> minversion(numpy, '1.21.0') True """ if inspect.ismodule(module): diff --git a/astropy/utils/masked/core.py b/astropy/utils/masked/core.py index b87ac39ce429..e65dbc106e09 100644 --- a/astropy/utils/masked/core.py +++ b/astropy/utils/masked/core.py @@ -22,7 +22,7 @@ import numpy as np -from astropy.utils.compat import COPY_IF_NEEDED, NUMPY_LT_2_0 +from astropy.utils.compat import COPY_IF_NEEDED, NUMPY_LT_2_0, NUMPY_LT_2_5 from astropy.utils.data_info import ParentDtypeInfo from astropy.utils.shapes import NDArrayShapeMethods, ShapedLikeNDArray @@ -55,7 +55,8 @@ def get_data_and_mask(array): Parameters ---------- array : array-like - Possibly masked item, judged by whether it has a ``mask`` attribute. + Possibly masked item, judged by whether it has a ``mask`` attribute + (but ignored when a possible ``masked`` attribute is `False`). If so, checks for having an ``unmasked`` attribute (as expected for instances of `~astropy.utils.masked.Masked`), or uses the ``_data`` attribute if the inpuit is an instance of `~numpy.ma.MaskedArray`. @@ -75,7 +76,7 @@ def get_data_and_mask(array): """ mask = getattr(array, "mask", None) - if mask is None: + if mask is None or not getattr(array, "masked", True): return array, None try: @@ -618,10 +619,13 @@ def __new__(newcls, *args, mask=None, **kwargs): if "info" not in cls.__dict__ and hasattr(cls._data_cls, "info"): data_info = cls._data_cls.info attr_names = data_info.attr_names | {"serialize_method"} + # Ensure the new info class uses the class's module. + # Without this, the DataInfoMeta metaclass incorrectly sets + # __module__ to its own. new_info = type( cls.__name__ + "Info", (MaskedArraySubclassInfo, data_info.__class__), - dict(attr_names=attr_names), + dict(attr_names=attr_names, __module__=cls.__module__), ) cls.info = new_info() @@ -741,14 +745,26 @@ def shape(self): @shape.setter def shape(self, shape): + return self._set_shape(shape) + + def _set_shape(self, shape): old_shape = self.shape - self._mask.shape = shape + if NUMPY_LT_2_5: + self._mask.shape = shape + else: + self._mask._set_shape(shape) # Reshape array proper in try/except just in case some broadcasting # or so causes it to fail. try: - super(MaskedNDArray, type(self)).shape.__set__(self, shape) + if NUMPY_LT_2_5: + super(MaskedNDArray, type(self)).shape.__set__(self, shape) + else: + super()._set_shape(shape) except Exception as exc: - self._mask.shape = old_shape + if NUMPY_LT_2_5: + self._mask.shape = old_shape + else: + self._mask._set_shape(old_shape) # Given that the mask reshaping succeeded, the only logical # reason for an exception is something like a broadcast error in # in __array_finalize__, or a different memory ordering between @@ -1430,13 +1446,14 @@ def __repr__(self): def __getattr__(key): - """Make commonly used Masked subclasses importable for ASDF support. + """Make commonly used Masked subclasses and their info classes importable for ASDF support. Registered types associated with ASDF converters must be importable by their fully qualified name. Masked classes are dynamically created and have apparent names like ``astropy.utils.masked.core.MaskedQuantity`` although they aren't actually attributes of this module. Customize module attribute - lookup so that certain commonly used Masked classes are importable. + lookup so that certain commonly used Masked classes are importable. The same + is done for their dynamically created info classes. See: - https://asdf.readthedocs.io/en/latest/asdf/extending/converters.html#entry-point-performance-considerations @@ -1450,13 +1467,14 @@ def __getattr__(key): base_class_name = key[len(Masked.__name__) :] for base_class_qualname in __construct_mixin_classes: module, _, name = base_class_qualname.rpartition(".") - if name == base_class_name: + is_info = name + "Info" == base_class_name + if name == base_class_name or is_info: base_class = getattr(importlib.import_module(module), name) # Try creating the masked class masked_class = Masked(base_class) # But only return it if it is a standard one, not one # where we just used the ndarray fallback. if base_class in Masked._masked_classes: - return masked_class + return masked_class.info.__class__ if is_info else masked_class raise AttributeError(f"module '{__name__}' has no attribute '{key}'") diff --git a/astropy/utils/masked/function_helpers.py b/astropy/utils/masked/function_helpers.py index 61aeef1e4cc9..786d50b8a0c5 100644 --- a/astropy/utils/masked/function_helpers.py +++ b/astropy/utils/masked/function_helpers.py @@ -24,6 +24,7 @@ import numpy._core as np_core from numpy.lib._function_base_impl import _quantile_is_valid, _ureduce +from astropy.utils.compat import NUMPY_LT_2_5 # This module should not really be imported, but we define __all__ # such that sphinx can typeset the functions with docstrings. @@ -297,9 +298,8 @@ def outer(a, b, out=None): if not NUMPY_LT_2_0: - @dispatched_function - def empty_like( - prototype, dtype=None, order="K", subok=True, shape=None, *, device=None + def empty_like_impl( + prototype, /, dtype=None, order="K", subok=True, shape=None, *, device=None ): """Return a new array with the same shape and type as a given array. @@ -330,6 +330,35 @@ def empty_like( return unmasked, mask, None + if not NUMPY_LT_2_4: + + @dispatched_function + def empty_like( + prototype, /, dtype=None, order="K", subok=True, shape=None, *, device=None + ): + return empty_like_impl( + prototype, + dtype=dtype, + order=order, + subok=subok, + shape=shape, + device=device, + ) + else: + + @dispatched_function + def empty_like( + prototype, dtype=None, order="K", subok=True, shape=None, *, device=None + ): + return empty_like_impl( + prototype, + dtype=dtype, + order=order, + subok=subok, + shape=shape, + device=device, + ) + @dispatched_function def zeros_like(a, dtype=None, order="K", subok=True, shape=None, *, device=None): """Return an array of zeros with the same shape and type as a given array. @@ -457,8 +486,7 @@ def put(a, ind, v, mode="raise"): np.put(a.mask, ind, v_mask, mode=mode) -@dispatched_function -def putmask(a, mask, values): +def putmask_impl(a, /, mask, values): """Changes elements of an array based on conditional and input values. Like `numpy.putmask`, but for masked array ``a`` and possibly masked @@ -477,6 +505,18 @@ def putmask(a, mask, values): np.putmask(a.mask, mask, values_mask) +if not NUMPY_LT_2_4: + + @dispatched_function + def putmask(a, /, mask, values): + return putmask_impl(a, mask=mask, values=values) +else: + + @dispatched_function + def putmask(a, mask, values): + return putmask_impl(a, mask=mask, values=values) + + @dispatched_function def place(arr, mask, vals): """Change elements of an array based on conditional and input values. @@ -518,14 +558,14 @@ def copyto(dst, src, casting="same_kind", where=True): @dispatched_function -def packbits(a, *args, **kwargs): +def packbits(a, /, *args, **kwargs): result = np.packbits(a.unmasked, *args, **kwargs) mask = np.packbits(a.mask, *args, **kwargs).astype(bool) return result, mask, None @dispatched_function -def unpackbits(a, *args, **kwargs): +def unpackbits(a, /, *args, **kwargs): result = np.unpackbits(a.unmasked, *args, **kwargs) mask = np.zeros(a.shape, dtype="u1") mask[a.mask] = 255 @@ -534,7 +574,7 @@ def unpackbits(a, *args, **kwargs): @dispatched_function -def bincount(x, weights=None, minlength=0): +def bincount(x, /, weights=None, minlength=0): """Count number of occurrences of each value in array of non-negative ints. Like `numpy.bincount`, but masked entries in ``x`` will be skipped. @@ -600,8 +640,7 @@ def sort_complex(a): return result, None, None -@dispatched_function -def concatenate(arrays, axis=0, out=None, dtype=None, casting="same_kind"): +def concatenate_impl(arrays, /, axis=0, out=None, *, dtype=None, casting="same_kind"): data, masks = _get_data_and_mask_arrays(arrays) if out is None: return ( @@ -619,6 +658,22 @@ def concatenate(arrays, axis=0, out=None, dtype=None, casting="same_kind"): return out, None, None +if NUMPY_LT_2_4: + + @dispatched_function + def concatenate(arrays, axis=0, out=None, dtype=None, casting="same_kind"): + return concatenate_impl( + arrays, axis=axis, out=out, dtype=dtype, casting=casting + ) +else: + + @dispatched_function + def concatenate(arrays, /, axis=0, out=None, *, dtype=None, casting="same_kind"): + return concatenate_impl( + arrays, axis=axis, out=out, dtype=dtype, casting=casting + ) + + @apply_to_both def append(arr, values, axis=None): data, masks = _get_data_and_mask_arrays((arr, values)) @@ -923,7 +978,7 @@ def array_equiv(a1, a2): @dispatched_function -def where(condition, *args): +def where(condition, /, *args): from astropy.utils.masked import Masked, get_data_and_mask if not args: @@ -1184,7 +1239,7 @@ def from_data(cls, data, **options): return cls(_get_format_function(data, **options)) -def _array2string(a, options, separator=" ", prefix=""): +def _array2string_impl(a, options, separator=" ", prefix=""): # Mostly copied from numpy.core.arrayprint, except: # - The format function is wrapped in a mask-aware class; # - Arrays scalars are not cast as arrays. @@ -1222,25 +1277,24 @@ def _array2string(a, options, separator=" ", prefix=""): return lst -@dispatched_function -def array2string( +def _array2string_main( a, - max_line_width=None, - precision=None, - suppress_small=None, - separator=" ", - prefix="", - style=np._NoValue, - formatter=None, - threshold=None, - edgeitems=None, - sign=None, - floatmode=None, - suffix="", - *, - legacy=None, + *, # make most arguments keyword only to minimize the risk of a human mistake + max_line_width, + precision, + suppress_small, + separator, + prefix, + formatter, + threshold, + edgeitems, + sign, + floatmode, + suffix, + legacy, + style=np._NoValue, # deprecated, removed in numpy 2.4 ): - # Copied from numpy.core.arrayprint, but using _array2string above. + # Copied from numpy.core.arrayprint, but using _array2string_impl above. if NUMPY_LT_2_1: if NUMPY_LT_2_0: from numpy.core.arrayprint import _format_options @@ -1263,11 +1317,11 @@ def array2string( edgeitems, max_line_width, suppress_small, - None, - None, - sign, - formatter, - floatmode, + nanstr=None, + infstr=None, + sign=sign, + formatter=formatter, + floatmode=floatmode, ) options.update(overrides) @@ -1280,11 +1334,83 @@ def array2string( if a.size == 0: result = "[]" else: - result = _array2string(a, options, separator, prefix) + result = _array2string_impl(a, options, separator, prefix) return result, None, None +if not NUMPY_LT_2_4: + + @dispatched_function + def array2string( + a, + max_line_width=None, + precision=None, + suppress_small=None, + separator=" ", + prefix="", + *, + formatter=None, + threshold=None, + edgeitems=None, + sign=None, + floatmode=None, + suffix="", + legacy=None, + ): + return _array2string_main( + a, + max_line_width=max_line_width, + precision=precision, + suppress_small=suppress_small, + separator=separator, + prefix=prefix, + formatter=formatter, + threshold=threshold, + edgeitems=edgeitems, + sign=sign, + floatmode=floatmode, + suffix=suffix, + legacy=legacy, + ) +else: + + @dispatched_function + def array2string( + a, + max_line_width=None, + precision=None, + suppress_small=None, + separator=" ", + prefix="", + style=np._NoValue, # removed in numpy 2.4 + formatter=None, + threshold=None, + edgeitems=None, + sign=None, + floatmode=None, + suffix="", + *, + legacy=None, + ): + return _array2string_main( + a, + max_line_width=max_line_width, + precision=precision, + suppress_small=suppress_small, + separator=separator, + prefix=prefix, + style=style, + formatter=formatter, + threshold=threshold, + edgeitems=edgeitems, + sign=sign, + floatmode=floatmode, + suffix=suffix, + legacy=legacy, + ) + + def _array_str_scalar(x): # This wraps np.array_str for use as a format function in # MaskedFormat. We cannot use it directly as format functions @@ -1438,7 +1564,10 @@ def in1d(ar1, ar2, assume_unique=False, invert=False, *, kind=None): def isin(element, test_elements, assume_unique=False, invert=False, *, kind=None): element = np.asanyarray(element) result = _in1d(element, test_elements, assume_unique, invert, kind=kind) - result.shape = element.shape + if NUMPY_LT_2_5: + result.shape = element.shape + else: + result._set_shape(element.shape) return result, _copy_of_mask(element), None diff --git a/astropy/utils/masked/tests/test_containers.py b/astropy/utils/masked/tests/test_containers.py index 0ac5ea624fc1..c76c931419c5 100644 --- a/astropy/utils/masked/tests/test_containers.py +++ b/astropy/utils/masked/tests/test_containers.py @@ -8,7 +8,7 @@ from astropy.coordinates import SkyCoord from astropy.coordinates import representation as r from astropy.time import Time -from astropy.utils.masked import Masked +from astropy.utils.masked import Masked, get_data_and_mask class TestRepresentations: @@ -31,6 +31,14 @@ def test_initialization(self): assert_array_equal(self.mc.y, self.my) assert_array_equal(self.mc.z, self.mz) + def test_get_data_and_mask(self): + c, m = get_data_and_mask(self.c) + assert m is None + assert c is self.c + c, m = get_data_and_mask(self.mc) + assert_array_equal(m, self.mask) + assert not c.masked + def test_norm(self): # Need stacking and erfa override. norm = self.mc.norm() @@ -74,6 +82,14 @@ def test_initialization(self): assert_array_equal(self.msc.data.lon, self.mra) assert_array_equal(self.msc.data.lat, self.mdec) + def test_get_data_and_mask(self): + sc, m = get_data_and_mask(self.sc) + assert m is None + assert sc is self.sc + sc, m = get_data_and_mask(self.msc) + assert_array_equal(m, self.mask) + assert not sc.masked + def test_transformation(self): gcrs = self.sc.gcrs mgcrs = self.msc.gcrs @@ -129,6 +145,14 @@ def test_initialization(self): assert_array_equal(self.mt.jd1.unmasked, self.t.jd1) assert_array_equal(self.mt.jd2.unmasked, self.t.jd2) + def test_get_data_and_mask(self): + t, m = get_data_and_mask(self.t) + assert m is None + assert t is self.t + t, m = get_data_and_mask(self.mt) + assert_array_equal(m, self.mask) + assert not t.masked + @pytest.mark.parametrize("format_", ["jd", "cxcsec", "jyear"]) def test_different_formats(self, format_): # Formats do not yet work with everything; e.g., isot is not supported diff --git a/astropy/utils/masked/tests/test_dynamic_subclasses.py b/astropy/utils/masked/tests/test_dynamic_subclasses.py index f05a632ec9c3..972b09ac9177 100644 --- a/astropy/utils/masked/tests/test_dynamic_subclasses.py +++ b/astropy/utils/masked/tests/test_dynamic_subclasses.py @@ -1,5 +1,5 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst -"""Test importability of common Masked classes.""" +"""Test importability of common Masked classes and their info classes.""" import pytest @@ -11,3 +11,11 @@ @pytest.mark.parametrize("base_class", [Quantity, Angle, Latitude, Longitude]) def test_importable(base_class): assert getattr(core, f"Masked{base_class.__name__}") is core.Masked(base_class) + + +@pytest.mark.parametrize("base_class", [Quantity, Angle, Latitude, Longitude]) +def test_importable_info(base_class): + assert ( + getattr(core, f"Masked{base_class.__name__}Info") + is core.Masked(base_class).info.__class__ + ) diff --git a/astropy/utils/masked/tests/test_function_helpers.py b/astropy/utils/masked/tests/test_function_helpers.py index 86dce9770531..04aca6ee30bb 100644 --- a/astropy/utils/masked/tests/test_function_helpers.py +++ b/astropy/utils/masked/tests/test_function_helpers.py @@ -328,7 +328,7 @@ def test_empty_like(self): assert o.shape == (2, 3) assert isinstance(o, Masked) assert isinstance(o, np.ndarray) - o2 = np.empty_like(prototype=self.ma) + o2 = np.empty_like(self.ma) assert o2.shape == (2, 3) assert isinstance(o2, Masked) assert isinstance(o2, np.ndarray) @@ -739,6 +739,7 @@ def test_var(self): class TestUfuncLike(InvariantMaskTestSetup): + @pytest.mark.filterwarnings("ignore:numpy.fix is deprecated:DeprecationWarning") def test_fix(self): self.check(np.fix) # Check np.fix with out argument for completeness @@ -1299,10 +1300,10 @@ def test_array2string(self): out2 = np.array2string(self.ma, separator=", ", formatter={"all": hex}) assert out2 == "[———, 0x1, 0x2]" # Also as positional argument (no, nobody will do this!) - out3 = np.array2string( - self.ma, None, None, None, ", ", "", np._NoValue, {"int": hex} - ) - assert out3 == out2 + if NUMPY_LT_2_4: + args = (self.ma, None, None, None, ", ", "", np._NoValue, {"int": hex}) + out3 = np.array2string(*args) + assert out3 == out2 # But not if the formatter is not relevant for us. out4 = np.array2string(self.ma, separator=", ", formatter={"float": hex}) assert out4 == out1 @@ -1772,14 +1773,16 @@ def test_testing_completeness(): class TestFunctionHelpersCompleteness: @pytest.mark.parametrize( "one, two", - itertools.combinations( - ( - MASKED_SAFE_FUNCTIONS, - UNSUPPORTED_FUNCTIONS, - set(APPLY_TO_BOTH_FUNCTIONS.keys()), - set(DISPATCHED_FUNCTIONS.keys()), + list( + itertools.combinations( + ( + MASKED_SAFE_FUNCTIONS, + UNSUPPORTED_FUNCTIONS, + set(APPLY_TO_BOTH_FUNCTIONS.keys()), + set(DISPATCHED_FUNCTIONS.keys()), + ), + 2, ), - 2, ), ) def test_no_duplicates(self, one, two): diff --git a/astropy/utils/masked/tests/test_masked.py b/astropy/utils/masked/tests/test_masked.py index f06cba7192eb..f2123cd2ccf6 100644 --- a/astropy/utils/masked/tests/test_masked.py +++ b/astropy/utils/masked/tests/test_masked.py @@ -14,7 +14,7 @@ from astropy import units as u from astropy.coordinates import Longitude from astropy.units import Quantity -from astropy.utils.compat import NUMPY_LT_2_0, NUMPY_LT_2_2, NUMPY_LT_2_3 +from astropy.utils.compat import NUMPY_LT_2_0, NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_3 from astropy.utils.compat.optional_deps import HAS_PLT from astropy.utils.masked import Masked, MaskedNDArray @@ -304,8 +304,11 @@ def test_viewing(self): def test_viewing_independent_shape(self): mms = Masked(self.a, mask=self.m) - mms2 = mms.view() - mms2.shape = mms2.shape[::-1] + if NUMPY_LT_2_1: + mms2 = mms.view() + mms2.shape = mms2.shape[::-1] + else: + mms2 = np.reshape(mms, mms.shape[::-1], copy=False) assert mms2.shape == mms.shape[::-1] assert mms2.mask.shape == mms.shape[::-1] # This should not affect the original array! @@ -563,14 +566,21 @@ def test_reshape(self): assert_array_equal(ma_reshape.mask, expected_mask) def test_shape_setting(self): - ma_reshape = self.ma.copy() - ma_reshape.shape = (6,) + if NUMPY_LT_2_1: + ma_reshape = self.ma.copy() + ma_reshape.shape = (6,) + else: + ma_reshape = np.reshape(self.ma, (6,), copy=True) + expected_data = self.a.reshape((6,)) expected_mask = self.mask_a.reshape((6,)) assert ma_reshape.shape == expected_data.shape assert_array_equal(ma_reshape.unmasked, expected_data) assert_array_equal(ma_reshape.mask, expected_mask) + @pytest.mark.filterwarnings( + "default:Setting the shape on a NumPy array has been deprecated in NumPy 2.5:DeprecationWarning" + ) def test_shape_setting_failure(self): ma = self.ma.copy() with pytest.raises(ValueError, match="cannot reshape"): diff --git a/astropy/utils/masked/tests/test_pickle.py b/astropy/utils/masked/tests/test_pickle.py new file mode 100644 index 000000000000..1429a91fbd43 --- /dev/null +++ b/astropy/utils/masked/tests/test_pickle.py @@ -0,0 +1,37 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import pickle + +import numpy as np +import pytest + +import astropy.units as u +from astropy.coordinates import Angle, Latitude, Longitude +from astropy.units import Quantity +from astropy.utils.masked import Masked + + +@pytest.mark.parametrize( + "data", + [ + Quantity([1, 2, 3], u.m), + Angle([1, 2, 3], u.deg), + Latitude([1, 2, 3], u.deg), + Longitude([1, 2, 3], u.deg), + ], +) +def test_masked_pickle(data): + mask = [True, False, False] + m = Masked(data, mask=mask) + + # Force creation of the info object (see #19142) + assert m.info is not None + + m2 = pickle.loads(pickle.dumps(m)) + + np.testing.assert_array_equal(m.unmasked, m2.unmasked) + np.testing.assert_array_equal(m.mask, m2.mask) + + assert m.unit == m2.unit + assert type(m) is type(m2) + assert type(m.info) is type(m2.info) diff --git a/astropy/utils/metadata/merge.py b/astropy/utils/metadata/merge.py index c7ee740fb861..ec21cada0347 100644 --- a/astropy/utils/metadata/merge.py +++ b/astropy/utils/metadata/merge.py @@ -8,7 +8,7 @@ import numpy as np from .exceptions import MergeConflictError, MergeConflictWarning -from .utils import common_dtype +from .utils import result_type __all__ = [ "MERGE_STRATEGIES", @@ -130,7 +130,7 @@ class MergeNpConcatenate(MergeStrategy): @classmethod def merge(cls, left, right): left, right = np.asanyarray(left), np.asanyarray(right) - common_dtype([left, right]) # Ensure left and right have compatible dtype + result_type([left, right]) # Ensure left and right have compatible dtype return np.concatenate([left, right]) diff --git a/astropy/utils/metadata/tests/test_metadata.py b/astropy/utils/metadata/tests/test_metadata.py index 900e109635df..436c2f602586 100644 --- a/astropy/utils/metadata/tests/test_metadata.py +++ b/astropy/utils/metadata/tests/test_metadata.py @@ -13,6 +13,7 @@ enable_merge_strategies, merge, ) +from astropy.utils.metadata.utils import result_type class OrderedDictSubclass(OrderedDict): @@ -244,23 +245,123 @@ class MergeConcatStrings(metadata.MergePlus): metadata.MERGE_STRATEGIES = original_merge_strategies -def test_common_dtype_string(): +def test_result_type_string(): u3 = np.array(["123"]) u4 = np.array(["1234"]) b3 = np.array([b"123"]) b5 = np.array([b"12345"]) - assert common_dtype([u3, u4]).endswith("U4") - assert common_dtype([b5, u4]).endswith("U5") - assert common_dtype([b3, b5]).endswith("S5") + assert result_type([u3, u4]) == np.dtype("U4") + assert result_type([b5, u4]) == np.dtype("U5") + assert result_type([b3, b5]) == np.dtype("S5") -def test_common_dtype_basic(): +def test_result_type_basic(): i8 = np.array(1, dtype=np.int64) f8 = np.array(1, dtype=np.float64) u3 = np.array("123") with pytest.raises(MergeConflictError): - common_dtype([i8, u3]) + result_type([i8, u3]) - assert common_dtype([i8, i8]).endswith("i8") - assert common_dtype([i8, f8]).endswith("f8") + assert result_type([i8, i8]) == np.dtype("i8") + assert result_type([i8, f8]) == np.dtype("f8") + + +@pytest.mark.parametrize("function", [result_type, common_dtype]) +def test_finding_common_type_exhaustive(function): + """ + Test that allowed combinations are those expected. + """ + dtype = [ + ("int", int), + ("uint8", np.uint8), + ("float32", np.float32), + ("float64", np.float64), + ("str", "S2"), + ("uni", "U2"), + ("bool", bool), + ("object", np.object_), + ] + arr = np.empty(1, dtype=dtype) + fail = set() + succeed = set() + for name1, _ in dtype: + for name2, _ in dtype: + try: + function([arr[name1], arr[name2]]) + succeed.add(f"{name1} {name2}") + except MergeConflictError: + fail.add(f"{name1} {name2}") + + # known bad combinations + bad = { + "str int", + "str bool", + "uint8 bool", + "uint8 str", + "object float32", + "bool object", + "uni uint8", + "int str", + "bool str", + "bool float64", + "bool uni", + "str float32", + "uni float64", + "uni object", + "bool uint8", + "object float64", + "float32 bool", + "str uint8", + "uni bool", + "float64 bool", + "float64 object", + "int bool", + "uni int", + "uint8 object", + "int uni", + "uint8 uni", + "float32 uni", + "object uni", + "bool float32", + "uni float32", + "object str", + "int object", + "str float64", + "object int", + "float64 uni", + "bool int", + "object bool", + "object uint8", + "float32 object", + "str object", + "float64 str", + "float32 str", + } + assert fail == bad + + good = { + "float64 int", + "int int", + "uint8 float64", + "uint8 int", + "str uni", + "float32 float32", + "float64 float64", + "float64 uint8", + "float64 float32", + "int uint8", + "int float32", + "uni str", + "int float64", + "uint8 float32", + "float32 int", + "float32 uint8", + "bool bool", + "uint8 uint8", + "str str", + "float32 float64", + "object object", + "uni uni", + } + assert succeed == good diff --git a/astropy/utils/metadata/utils.py b/astropy/utils/metadata/utils.py index 8f4f16b93732..19573d6462a6 100644 --- a/astropy/utils/metadata/utils.py +++ b/astropy/utils/metadata/utils.py @@ -3,8 +3,6 @@ import numpy as np -from astropy.utils.misc import dtype_bytes_or_chars - from .exceptions import MergeConflictError __all__ = ["common_dtype"] @@ -31,32 +29,41 @@ def common_dtype(arrs): dtype_str : str String representation of dytpe (dtype ``str`` attribute) """ + dt = result_type(arrs) + return dt.str if dt.names is None else dt.descr + + +def result_type(arrs): + """ + Use numpy to find the common type for a list of ndarray. + + The difference with `numpy.result_type` is that all arrays should + share the same fundamental numpy data type, one of: + ``np.bool_``, ``np.object_``, ``np.number``, ``np.character``, ``np.void`` + Hence, a mix like integer and string will raise instead of resulting + in a string type. + + Parameters + ---------- + arrs : list of ndarray + Array likes for which to find the common dtype. Anything not + an array (e.g, |Time|), will be considered an object array. + + Returns + ------- + dtype : ~numpy.dtype + The common type. + """ + dtypes = [dtype(arr) for arr in arrs] np_types = (np.bool_, np.object_, np.number, np.character, np.void) uniq_types = { - tuple(issubclass(dtype(arr).type, np_type) for np_type in np_types) - for arr in arrs + tuple(issubclass(dt.type, np_type) for np_type in np_types) for dt in dtypes } if len(uniq_types) > 1: # Embed into the exception the actual list of incompatible types. - incompat_types = [dtype(arr).name for arr in arrs] + incompat_types = [dt.name for dt in dtypes] tme = MergeConflictError(f"Arrays have incompatible types {incompat_types}") tme._incompat_types = incompat_types raise tme - arrs = [np.empty(1, dtype=dtype(arr)) for arr in arrs] - - # For string-type arrays need to explicitly fill in non-zero - # values or the final arr_common = .. step is unpredictable. - for i, arr in enumerate(arrs): - if arr.dtype.kind in ("S", "U"): - arrs[i] = [ - ("0" if arr.dtype.kind == "U" else b"0") - * dtype_bytes_or_chars(arr.dtype) - ] - - arr_common = np.array([arr[0] for arr in arrs]) - return ( - arr_common.dtype.str - if arr_common.dtype.names is None - else arr_common.dtype.descr - ) + return np.result_type(*dtypes) diff --git a/astropy/utils/misc.py b/astropy/utils/misc.py index d1689a3b761a..52b473b78e86 100644 --- a/astropy/utils/misc.py +++ b/astropy/utils/misc.py @@ -80,6 +80,12 @@ class _DummyFile: def write(self, s: str) -> None: pass + def flush(self) -> None: + pass + + def isatty(self) -> bool: + return False + @contextlib.contextmanager def silence() -> Generator[None, None, None]: diff --git a/astropy/utils/shapes.py b/astropy/utils/shapes.py index 12cd520a7bb3..b42f22c07877 100644 --- a/astropy/utils/shapes.py +++ b/astropy/utils/shapes.py @@ -154,7 +154,7 @@ def take(self, indices, axis=None, out=None, mode="raise"): obviously, no output array can be given. """ if out is not None: - return NotImplementedError("cannot pass 'out' argument to 'take.") + raise NotImplementedError("cannot pass 'out' argument to 'take.") return self._apply("take", indices, axis=axis, mode=mode) diff --git a/astropy/utils/tests/test_data_info.py b/astropy/utils/tests/test_data_info.py index 521133ed95d1..ea2c929af931 100644 --- a/astropy/utils/tests/test_data_info.py +++ b/astropy/utils/tests/test_data_info.py @@ -1,5 +1,7 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst +import pickle + import numpy as np import pytest @@ -8,7 +10,7 @@ from astropy.table import QTable from astropy.table.index import SlicedIndex from astropy.time import Time -from astropy.utils.data_info import dtype_info_name +from astropy.utils.data_info import DataInfo, dtype_info_name STRING_TYPE_NAMES = {(True, "S"): "bytes", (True, "U"): "str"} @@ -106,3 +108,10 @@ def test_setting_info_name_to_with_invalid_type(): qt["a"] = [1, 2] * u.m with pytest.raises(TypeError, match="Expected a str value, got 1 with type int"): qt["a"].info.name = 1 + + +def test_pickle_unbound_data_info(): + """Test that an unbound DataInfo object can be pickled.""" + di = DataInfo() + di_rt = pickle.loads(pickle.dumps(di)) + assert isinstance(di_rt, DataInfo) diff --git a/astropy/utils/tests/test_misc.py b/astropy/utils/tests/test_misc.py index c3b60a2f3720..3ea33d2793de 100644 --- a/astropy/utils/tests/test_misc.py +++ b/astropy/utils/tests/test_misc.py @@ -178,3 +178,12 @@ def test_indent_deprecation(): def test_format_exception_deprecation(): with pytest.warns(AstropyDeprecationWarning): misc.format_exception("this is deprecated") + + +def test_silence_stdout_file_protocol(): + import sys + + with misc.silence(): + assert sys.stdout.isatty() is False + sys.stdout.flush() + sys.stdout.write("ignored") diff --git a/astropy/utils/tests/test_shapes.py b/astropy/utils/tests/test_shapes.py index b4f1b0456b4c..8f14eabdba9d 100644 --- a/astropy/utils/tests/test_shapes.py +++ b/astropy/utils/tests/test_shapes.py @@ -7,7 +7,28 @@ from numpy.testing import assert_equal from astropy.utils.exceptions import AstropyDeprecationWarning -from astropy.utils.shapes import check_broadcast, simplify_basic_index, unbroadcast +from astropy.utils.shapes import ( + ShapedLikeNDArray, + check_broadcast, + simplify_basic_index, + unbroadcast, +) + + +class _ShapedDummy(ShapedLikeNDArray): + def __init__(self, values): + self._values = np.asarray(values) + + @property + def shape(self): + return self._values.shape + + def _apply(self, method, *args, **kwargs): + if callable(method): + values = method(self._values, *args, **kwargs) + else: + values = getattr(self._values, method)(*args, **kwargs) + return self.__class__(values) def test_check_broadcast_deprecation(): @@ -39,6 +60,12 @@ def test_unbroadcast(): assert z.shape == (3, 5) +def test_take_out_raises(): + shaped = _ShapedDummy([1, 2, 3, 4]) + with pytest.raises(NotImplementedError, match="cannot pass 'out' argument"): + shaped.take((0, 1), out=np.empty(2, dtype=int)) + + TEST_SHAPE = (13, 16, 4, 90) diff --git a/astropy/utils/xml/src/iterparse.c b/astropy/utils/xml/src/iterparse.c index f932f74550f0..b948bec86d5e 100644 --- a/astropy/utils/xml/src/iterparse.c +++ b/astropy/utils/xml/src/iterparse.c @@ -22,12 +22,12 @@ * library. ******************************************************************************/ +#include #include #include // memcpy #ifndef _MSC_VER # include // read #endif -#include #include "structmember.h" #include "expat.h" diff --git a/astropy/visualization/mpl_normalize.py b/astropy/visualization/mpl_normalize.py index 6d8e0d4d21bb..9adce48f5ba2 100644 --- a/astropy/visualization/mpl_normalize.py +++ b/astropy/visualization/mpl_normalize.py @@ -231,7 +231,7 @@ class SimpleNorm: Parameters ---------- - stretch : {'linear', 'sqrt', 'power', log', 'asinh', 'sinh'}, optional + stretch : {'linear', 'sqrt', 'power', 'log', 'asinh', 'sinh'}, optional The stretch function to apply to the image. The default is 'linear'. @@ -489,7 +489,7 @@ def simple_norm( data : ndarray The image array. - stretch : {'linear', 'sqrt', 'power', log', 'asinh', 'sinh'}, optional + stretch : {'linear', 'sqrt', 'power', 'log', 'asinh', 'sinh'}, optional The stretch function to apply to the image. The default is 'linear'. diff --git a/astropy/visualization/scripts/fits2bitmap.py b/astropy/visualization/scripts/fits2bitmap.py index 388a7d051449..0e8805e74b5b 100644 --- a/astropy/visualization/scripts/fits2bitmap.py +++ b/astropy/visualization/scripts/fits2bitmap.py @@ -42,7 +42,7 @@ def fits2bitmap( The filename of the output bitmap image. The type of bitmap is determined by the filename extension (e.g. '.jpg', '.png'). The default is a PNG file with the same name as the FITS file. - stretch : {'linear', 'sqrt', 'power', log', 'asinh'} + stretch : {'linear', 'sqrt', 'power', 'log', 'asinh'} The stretching function to apply to the image. The default is 'linear'. power : float, optional diff --git a/astropy/visualization/wcsaxes/coordinate_helpers.py b/astropy/visualization/wcsaxes/coordinate_helpers.py index 3f490a8fc835..629245065ae5 100644 --- a/astropy/visualization/wcsaxes/coordinate_helpers.py +++ b/astropy/visualization/wcsaxes/coordinate_helpers.py @@ -1019,23 +1019,35 @@ def _update_ticks(self): continue axes0 = transData.transform(pixel0) - # Advance 2 pixels in figure coordinates - pixel1 = axes0.copy() - pixel1[:, 0] += 2.0 - pixel1 = invertedTransLimits.transform(pixel1) - with np.errstate(invalid="ignore"): - world1 = self.transform.transform(pixel1)[:, self.coord_index] + # Define a helper function to minimize code repetition + def shifted_pixel_to_world(index, shift): + pixel = axes0.copy() + pixel[:, index] += shift + pixel = invertedTransLimits.transform(pixel) + with np.errstate(invalid="ignore"): + return self.transform.transform(pixel)[:, self.coord_index] + + # Advance 2 pixels to the right in figure coordinates + world1 = shifted_pixel_to_world(0, 2) + dx = world1 - world0 - # Advance 2 pixels in figure coordinates - pixel2 = axes0.copy() - pixel2[:, 1] += 2.0 if self.frame.origin == "lower" else -2.0 - pixel2 = invertedTransLimits.transform(pixel2) - with np.errstate(invalid="ignore"): - world2 = self.transform.transform(pixel2)[:, self.coord_index] + # Where advancing to the right results in NaN, advance to the left instead + invalid1 = np.isnan(world1) + if invalid1.any(): + world1 = shifted_pixel_to_world(0, -2) + dx[invalid1] = (world0 - world1)[invalid1] - dx = world1 - world0 + # Advance 2 pixels up in figure coordinates + amount = 2.0 if self.frame.origin == "lower" else -2.0 + world2 = shifted_pixel_to_world(1, amount) dy = world2 - world0 + # Where advancing up results in NaN, advance down instead + invalid2 = np.isnan(world2) + if invalid2.any(): + world2 = shifted_pixel_to_world(1, -amount) + dy[invalid2] = (world0 - world2)[invalid2] + # Rotate by 90 degrees dx, dy = -dy, dx @@ -1191,6 +1203,7 @@ def _compute_ticks( data=(x_data_i, y_data_i), world=world, angle=spine.normal_angle[imin], + tick_angle=angle_i, axis_displacement=imin + frac, ) ) diff --git a/astropy/visualization/wcsaxes/formatter_locator.py b/astropy/visualization/wcsaxes/formatter_locator.py index 01af93a9db8c..53197ca4c8c2 100644 --- a/astropy/visualization/wcsaxes/formatter_locator.py +++ b/astropy/visualization/wcsaxes/formatter_locator.py @@ -424,7 +424,7 @@ def formatter(self, values, spacing, format="auto"): # from the end. We do this rather than just trusting e.g. # str() because str(15.) == 15.0. We format using 10 decimal # places by default before stripping the zeros since this - # corresponds to a resolution of less than a microarcecond, + # corresponds to a resolution of less than a microarcsecond, # which should be sufficient. spacing = spacing.to_value(unit) fields = 0 diff --git a/astropy/visualization/wcsaxes/tests/test_misc.py b/astropy/visualization/wcsaxes/tests/test_misc.py index e2395e4fe876..6e93ffa1e268 100644 --- a/astropy/visualization/wcsaxes/tests/test_misc.py +++ b/astropy/visualization/wcsaxes/tests/test_misc.py @@ -578,7 +578,7 @@ def test_set_labels_with_coords(ignore_matplotlibrc, frame_class): @pytest.mark.parametrize("atol", [0.2, 1.0e-8]) def test_bbox_size(atol): # Test for the size of a WCSAxes bbox (only have Matplotlib >= 3.0 now) - extents = [11.38888888888889, 3.5, 576.0, 432.0] + extents = [11.38888888888889, 3.388888888888889, 576.0, 432.0] fig = Figure() canvas = FigureCanvasAgg(fig) diff --git a/astropy/visualization/wcsaxes/tests/test_wcsapi.py b/astropy/visualization/wcsaxes/tests/test_wcsapi.py index 1c6fb7adf23d..7c2106e7ab81 100644 --- a/astropy/visualization/wcsaxes/tests/test_wcsapi.py +++ b/astropy/visualization/wcsaxes/tests/test_wcsapi.py @@ -644,7 +644,7 @@ def world_axis_object_classes(self): def test_edge_axes(): - # Check that axes on the edge of a spherical projection are shown properley + # Check that axes on the edge of a spherical projection are shown properly # (see https://github.com/astropy/astropy/issues/10441) shape = [180, 360] data = np.random.rand(*shape) diff --git a/astropy/visualization/wcsaxes/ticklabels.py b/astropy/visualization/wcsaxes/ticklabels.py index fdc55a47b63d..e4cd92f88666 100644 --- a/astropy/visualization/wcsaxes/ticklabels.py +++ b/astropy/visualization/wcsaxes/ticklabels.py @@ -10,8 +10,6 @@ from astropy.utils.decorators import deprecated_renamed_argument from astropy.utils.exceptions import AstropyDeprecationWarning -from .frame import RectangularFrame - def sort_using(X, Y): return [x for (y, x) in sorted(zip(Y, X))] @@ -66,6 +64,7 @@ def clear(self): self.world = defaultdict(list) self.data = defaultdict(list) self.angle = defaultdict(list) + self.tick_angle = defaultdict(list) self.text = defaultdict(list) self.disp = defaultdict(list) @@ -78,6 +77,7 @@ def add( text=None, axis_displacement=None, data=None, + tick_angle=None, ): """ Add a label. @@ -98,6 +98,8 @@ def add( Displacement from axis. data : [float, float] Data coordinates of the label. + tick_angle : float + Angle of the corresponding tick. Defaults to be equal to ``angle``. """ required_args = ["axis", "world", "angle", "text", "axis_displacement", "data"] if pixel is not None: @@ -123,6 +125,7 @@ def add( self.world[axis].append(world) self.data[axis].append(data) self.angle[axis].append(angle) + self.tick_angle[axis].append(tick_angle if tick_angle is not None else angle) self.text[axis].append(text) self.disp[axis].append(axis_displacement) @@ -137,6 +140,7 @@ def sort(self): self.world[axis] = sort_using(self.world[axis], self.disp[axis]) self.data[axis] = sort_using(self.data[axis], self.disp[axis]) self.angle[axis] = sort_using(self.angle[axis], self.disp[axis]) + self.tick_angle[axis] = sort_using(self.tick_angle[axis], self.disp[axis]) self.text[axis] = sort_using(self.text[axis], self.disp[axis]) self.disp[axis] = sort_using(self.disp[axis], self.disp[axis]) self._stale = True @@ -229,89 +233,60 @@ def _set_xy_alignments(self, renderer): x, y = self._frame.parent_axes.transData.transform(self.data[axis][i]) pad = renderer.points_to_pixels(self.get_pad() + self._tick_out_size) - if isinstance(self._frame, RectangularFrame): - # This is just to preserve the current results, but can be - # removed next time the reference images are re-generated. - if np.abs(self.angle[axis][i]) < 45.0: - ha = "right" - va = "bottom" - dx = -pad - dy = -text_size * 0.5 - elif np.abs(self.angle[axis][i] - 90.0) < 45: - ha = "center" - va = "bottom" - dx = 0 - dy = -text_size - pad - elif np.abs(self.angle[axis][i] - 180.0) < 45: - ha = "left" - va = "bottom" - dx = pad - dy = -text_size * 0.5 + # Set initial position and find bounding box + self.set_text(self.text[axis][i]) + self.set_position((x, y)) + bb = super().get_window_extent(renderer) + + width = bb.width + height = bb.height + + # The pad direction (typically perpendicular to the spine) + pad_angle = np.radians(self.angle[axis][i]) + px = np.cos(pad_angle) + py = np.sin(pad_angle) + + # If the tick angle is NaN, use the pad angle for the tick angle + tick_angle = ( + np.radians(self.tick_angle[axis][i]) + if not np.isnan(self.tick_angle[axis][i]) + else pad_angle + ) + tx = np.cos(tick_angle) + ty = np.sin(tick_angle) + + # Set anchor point for label where the pad direction intersects bounding box + with np.errstate(divide="ignore"): + if np.abs(py / px) < np.abs(height / width): + ax = width / 2 * np.sign(px) + ay = width / 2 * py / np.abs(px) else: - ha = "center" - va = "bottom" - dx = 0 - dy = pad - - x = x + dx - y = y + dy - - else: - # This is the more general code for arbitrarily oriented - # axes - - # Set initial position and find bounding box - self.set_text(self.text[axis][i]) - self.set_position((x, y)) - bb = super().get_window_extent(renderer) - - # Find width and height, as well as angle at which we - # transition which side of the label we use to anchor the - # label. - width = bb.width - height = bb.height - - # Project axis angle onto bounding box - ax = np.cos(np.radians(self.angle[axis][i])) - ay = np.sin(np.radians(self.angle[axis][i])) - - # Set anchor point for label - if np.abs(self.angle[axis][i]) < 45.0: - dx = width - dy = ay * height - elif np.abs(self.angle[axis][i] - 90.0) < 45: - dx = ax * width - dy = height - elif np.abs(self.angle[axis][i] - 180.0) < 45: - dx = -width - dy = ay * height - else: - dx = ax * width - dy = -height - - dx *= 0.5 - dy *= 0.5 - - # Find normalized vector along axis normal, so as to be - # able to nudge the label away by a constant padding factor - - dist = np.hypot(dx, dy) - - ddx = dx / dist - ddy = dy / dist - - dx += ddx * pad - dy += ddy * pad - - x = x - dx - y = y - dy - - ha = "center" - va = "center" - - self.xy[axis][i] = (x, y) - self.ha[axis][i] = ha - self.va[axis][i] = va + ax = height / 2 * px / np.abs(py) + ay = height / 2 * np.sign(py) + + # Extract the component of the tick direction perpendicular to the pad direction + scale = tx * px + ty * py + vx = tx - px * scale + vy = ty - py * scale + + # We scale the above vector for adding to the pad direction to get a combined + # displacement. When the tick direction is close to the pad direction, the scaling + # is such that the displacement direction is exactly the tick direction. When the + # tick direction is close to perpendicular to the pad direction, we cap the scaling, + # which means that the effective tick direction is never more than 60 degrees + # perpendicular to the pad direction. This prevents pushing the tick label too far + # away from the tick. + scale = np.max([scale, 0.5]) # 0.5 == cos(60 deg) + vx /= scale + vy /= scale + + # Pad the anchor point in the combined displacement direction + dx = ax + (vx + px) * pad + dy = ay + (vy + py) * pad + + self.xy[axis][i] = (x - dx, y - dy) + self.ha[axis][i] = "center" + self.va[axis][i] = "center" self._stale = False diff --git a/astropy/wcs/include/astropy_wcs/pyutil.h b/astropy/wcs/include/astropy_wcs/pyutil.h index 13744207d20b..9122a3fbcfd5 100644 --- a/astropy/wcs/include/astropy_wcs/pyutil.h +++ b/astropy/wcs/include/astropy_wcs/pyutil.h @@ -6,12 +6,11 @@ #ifndef __PYUTIL_H__ #define __PYUTIL_H__ +#include #include "util.h" #define PY_ARRAY_UNIQUE_SYMBOL astropy_wcs_numpy_api -#include - #define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION #include #include diff --git a/astropy/wcs/src/astropy_wcs.c b/astropy/wcs/src/astropy_wcs.c index e98e9b7c4172..b72ec6acb76a 100644 --- a/astropy/wcs/src/astropy_wcs.c +++ b/astropy/wcs/src/astropy_wcs.c @@ -249,24 +249,38 @@ Wcs_all_pix2world( goto exit; } - // Here we force a call to wcsset. Normally, WCSLIB will call wcsset automatically when - // calling wcsp2s, but we need to call it ourselves using PyWcsprm_cset so that we can - // catch cases where the units might change if e.g. they are not in SI to start with. - /* Force a call to wcsset here*/ - if (((PyWcsprm*)(self->py_wcsprm))->preserve_units && PyWcsprm_cset(((PyWcsprm*)(self->py_wcsprm)), 1)) { + // Force a call to wcsset via Wcsprm_cset before entering the parallel + // region. This serves two purposes: (1) preserve_units unit-change + // detection (the original reason), and (2) ensuring wcs->flag == WCSSET + // so the wcsp2s call below will not invoke wcsset itself. wcsset is the + // only writer wcsp2s would otherwise trigger, and by running it eagerly + // under the GIL here (short-circuited by wcsenq on subsequent calls) we + // can safely drop the wcsprm_python2c / wcsprm_c2python round-trip from + // around pipeline_all_pixel2world. + if (PyWcsprm_cset(((PyWcsprm*)(self->py_wcsprm)), 1)) { return NULL; } - /* Make the call */ + /* Make the call. + * + * The pipeline (det2im / SIP / distortion / wcsp2s stages) is read-only + * on the wcsprm struct -- after wcsset has run (now hoisted into + * Wcsprm_cset above), the transform routines consume the precomputed + * sub-structs wcs->lin / wcs->cel / wcs->spc plus wcs->crval[i], and + * never re-read the raw arrays (cd, cdelt, crpix, crota, obsgeo, + * mjdobs, ...) that the wcsprm_python2c / wcsprm_c2python pair was + * rewriting NaN <-> UNDEFINED in place. The round-trip can therefore + * be dropped here without changing the transform's output, and + * concurrent threads no longer race on those raw arrays + *. + */ Py_BEGIN_ALLOW_THREADS preoffset_array(pixcrd, origin); - wcsprm_python2c(self->x.wcs); status = pipeline_all_pixel2world(&self->x, (unsigned int)ncoord, (unsigned int)nelem, (double*)PyArray_DATA(pixcrd), (double*)PyArray_DATA(world)); - wcsprm_c2python(self->x.wcs); unoffset_array(pixcrd, origin); Py_END_ALLOW_THREADS /* unoffset_array(world, origin); */ diff --git a/astropy/wcs/src/pyutil.c b/astropy/wcs/src/pyutil.c index 0b23eede6dfe..504d4f8f0625 100644 --- a/astropy/wcs/src/pyutil.c +++ b/astropy/wcs/src/pyutil.c @@ -5,14 +5,13 @@ #define NO_IMPORT_ARRAY -#include // malloc, free -#include // memcpy - /* util.h must be imported first */ #include "astropy_wcs/pyutil.h" - #include "astropy_wcs/docstrings.h" +#include // malloc, free +#include // memcpy + #include "wcsfix.h" #include "wcshdr.h" #include "wcsprintf.h" @@ -736,7 +735,6 @@ get_pscards( } if (PyList_SetItem(result, i, subresult)) { - Py_DECREF(subresult); Py_DECREF(result); return NULL; } @@ -847,7 +845,6 @@ get_pvcards( } if (PyList_SetItem(result, i, subresult)) { - Py_DECREF(subresult); Py_DECREF(result); return NULL; } diff --git a/astropy/wcs/src/str_list_proxy.c b/astropy/wcs/src/str_list_proxy.c index 3e5a31f3d27e..c0d283fa1c76 100644 --- a/astropy/wcs/src/str_list_proxy.c +++ b/astropy/wcs/src/str_list_proxy.c @@ -5,8 +5,8 @@ #define NO_IMPORT_ARRAY -#include // malloc, free #include "astropy_wcs/pyutil.h" +#include // malloc, free /*************************************************************************** * List-of-strings proxy object diff --git a/astropy/wcs/src/unit_list_proxy.c b/astropy/wcs/src/unit_list_proxy.c index 4657534da992..169815ed6398 100644 --- a/astropy/wcs/src/unit_list_proxy.c +++ b/astropy/wcs/src/unit_list_proxy.c @@ -5,9 +5,9 @@ #define NO_IMPORT_ARRAY -#include // strncmp #include "astropy_wcs/pyutil.h" #include "astropy_wcs/str_list_proxy.h" +#include // strncmp /*************************************************************************** * List-of-units proxy object diff --git a/astropy/wcs/src/wcslib_celprm_wrap.c b/astropy/wcs/src/wcslib_celprm_wrap.c index 0b392bbc6a65..e00c1809f7a8 100644 --- a/astropy/wcs/src/wcslib_celprm_wrap.c +++ b/astropy/wcs/src/wcslib_celprm_wrap.c @@ -1,11 +1,11 @@ #define NO_IMPORT_ARRAY -#include // calloc, malloc, free -#include // memcpy - #include "astropy_wcs/wcslib_celprm_wrap.h" #include "astropy_wcs/wcslib_prjprm_wrap.h" +#include // calloc, malloc, free +#include // memcpy + #include #include #include diff --git a/astropy/wcs/src/wcslib_prjprm_wrap.c b/astropy/wcs/src/wcslib_prjprm_wrap.c index d8575f7411cd..7fcef537a087 100644 --- a/astropy/wcs/src/wcslib_prjprm_wrap.c +++ b/astropy/wcs/src/wcslib_prjprm_wrap.c @@ -1,12 +1,12 @@ #define NO_IMPORT_ARRAY +#include "astropy_wcs/wcslib_celprm_wrap.h" +#include "astropy_wcs/wcslib_prjprm_wrap.h" + #include #include #include // calloc, malloc, free #include // memcpy, strlen, strncpy -#include "astropy_wcs/wcslib_celprm_wrap.h" -#include "astropy_wcs/wcslib_prjprm_wrap.h" - #include #include #include diff --git a/astropy/wcs/src/wcslib_wrap.c b/astropy/wcs/src/wcslib_wrap.c index 5c21d3707edf..bb2acf193d32 100755 --- a/astropy/wcs/src/wcslib_wrap.c +++ b/astropy/wcs/src/wcslib_wrap.c @@ -5,9 +5,6 @@ #define NO_IMPORT_ARRAY -#include // calloc, malloc, free -#include // memset, strlen, strncpy - #include "astropy_wcs/wcslib_wrap.h" #include "astropy_wcs/wcslib_auxprm_wrap.h" #include "astropy_wcs/wcslib_prjprm_wrap.h" @@ -18,6 +15,9 @@ #include "astropy_wcs/unit_list_proxy.h" #include /* from Python */ +#include // calloc, malloc, free +#include // memset, strlen, strncpy + #include #include #include @@ -930,7 +930,6 @@ PyWcsprm_find_all_wcs( } if (PyList_SetItem(result, i, (PyObject *)subresult) == -1) { - Py_DECREF(subresult); Py_DECREF(result); wcsvfree(&nwcs, &wcs); return NULL; @@ -1501,10 +1500,18 @@ PyWcsprm_mix( goto exit; } - /* Convert pixel coordinates to 1-based */ + /* Force a call to wcsset via PyWcsprm_cset before entering the parallel + * region (see the matching note in Wcs_all_pix2world). This ensures + * wcs->flag == WCSSET so that wcsmix below does not invoke wcsset + * itself, allowing the wcsprm_python2c / wcsprm_c2python round-trip + * to be dropped. */ + if (PyWcsprm_cset(self, 1)) { + goto exit; + } + + /* Convert pixel coordinates to 1-based. */ Py_BEGIN_ALLOW_THREADS preoffset_array(pixcrd, origin); - wcsprm_python2c(&self->x); status = wcsmix( &self->x, mixpix, @@ -1517,7 +1524,6 @@ PyWcsprm_mix( (double*)PyArray_DATA(theta), (double*)PyArray_DATA(imgcrd), (double*)PyArray_DATA(pixcrd)); - wcsprm_c2python(&self->x); unoffset_array(pixcrd, origin); unoffset_array(imgcrd, origin); Py_END_ALLOW_THREADS @@ -1633,18 +1639,29 @@ PyWcsprm_p2s( goto exit; } - // Here we force a call to wcsset. Normally, WCSLIB will call wcsset automatically when - // calling wcsp2s, but we need to call it ourselves using PyWcsprm_cset so that we can - // catch cases where the units might change if e.g. they are not in SI to start with. - /* Force a call to wcsset here*/ - if (self->preserve_units && PyWcsprm_cset(self, 1)) { + // Force a call to wcsset via PyWcsprm_cset before entering the parallel + // region (see the matching note in Wcs_all_pix2world). This ensures + // wcs->flag == WCSSET so that wcsp2s below does not invoke wcsset + // itself -- with wcsset moved out of the parallel region, wcsp2s is + // read-only on the wcsprm struct, and the wcsprm_python2c / + // wcsprm_c2python round-trip can be dropped. + if (PyWcsprm_cset(self, 1)) { return NULL; } - /* Make the call */ + /* Make the call. + * + * After wcsset has run (now hoisted into PyWcsprm_cset above), wcsp2s is + * read-only on the wcsprm struct: it consumes the precomputed sub-structs + * wcs->lin / wcs->cel / wcs->spc plus wcs->crval[i], and never re-reads + * the raw arrays (cd, cdelt, crpix, crota, obsgeo, mjdobs, ...) that the + * wcsprm_python2c / wcsprm_c2python pair was rewriting NaN <-> UNDEFINED + * in place. The round-trip can therefore be dropped here without + * changing the transform's output, and concurrent threads no longer + * race on those raw arrays. + */ Py_BEGIN_ALLOW_THREADS preoffset_array(pixcrd, origin); - wcsprm_python2c(&self->x); status = wcsp2s( &self->x, ncoord, @@ -1655,7 +1672,6 @@ PyWcsprm_p2s( (double*)PyArray_DATA(theta), (double*)PyArray_DATA(world), (int*)PyArray_DATA(stat)); - wcsprm_c2python(&self->x); unoffset_array(pixcrd, origin); /* unoffset_array(world, origin); */ unoffset_array(imgcrd, origin); @@ -1763,12 +1779,13 @@ PyWcsprm_s2p( goto exit; } - // Here we force a call to wcsset. Normally, WCSLIB will call wcsset automatically when - // calling wcsp2s, but we need to call it ourselves using PyWcsprm_cset so that we can - // catch cases where the units might change if e.g. they are not in SI to start with. - /* Force a call to wcsset here*/ + // Force a call to wcsset via PyWcsprm_cset before entering the parallel + // region (see the matching note in Wcs_all_pix2world). This ensures + // wcs->flag == WCSSET so that wcss2p below does not invoke wcsset + // itself, allowing the wcsprm_python2c / wcsprm_c2python round-trip to + // be dropped. - if (self->preserve_units && PyWcsprm_cset(self, 1)) { + if (PyWcsprm_cset(self, 1)) { return NULL; } @@ -1830,10 +1847,13 @@ PyWcsprm_s2p( goto exit; } - /* Make the call */ + /* Make the call. See the comment in Wcsprm_p2s for the rationale -- + * wcss2p is read-only on the wcsprm struct after wcsset has run, so + * the wcsprm_python2c / wcsprm_c2python round-trip can be dropped and + * concurrent transforms no longer race on the raw arrays + *. */ Py_BEGIN_ALLOW_THREADS /* preoffset_array(world, origin); */ - wcsprm_python2c(&self->x); status = wcss2p( &self->x, ncoord, @@ -1844,7 +1864,6 @@ PyWcsprm_s2p( (double*)PyArray_DATA(imgcrd), (double*)PyArray_DATA(pixcrd), (int*)PyArray_DATA(stat)); - wcsprm_c2python(&self->x); /* unoffset_array(world, origin); */ unoffset_array(pixcrd, origin); unoffset_array(imgcrd, origin); @@ -1912,6 +1931,26 @@ PyWcsprm_cset( return 0; } +#ifdef Py_GIL_DISABLED + // On a free-threaded build the GIL no longer serialises concurrent callers + // of PyWcsprm_cset, so the wcsenq guard alone cannot prevent two threads from + // running wcsset simultaneously on the same struct (which is not thread + // safe). A single process-wide PyMutex around the wcsset path is enough: + // the fast path (wcsenq-succeeds) above is lock-free, so contention only + // happens the first time a WCS is set or after a user mutation, which is + // rare. On a GIL build this macro is undefined and the lock is compiled + // out -- the GIL itself serialises us since PyWcsprm_cset never releases it. + static PyMutex wcsset_mutex; + PyMutex_Lock(&wcsset_mutex); + // Double-checked locking: re-check under the lock so that if two threads + // both missed the fast path above, only the first one actually runs + // wcsset and the second returns immediately. + if (wcsenq(&self->x, WCSENQ_CHK)) { + PyMutex_Unlock(&wcsset_mutex); + return 0; + } +#endif + initialize_preserve_units(self); if (convert) wcsprm_python2c(&self->x); @@ -1920,6 +1959,10 @@ PyWcsprm_cset( check_unit_changes(self); +#ifdef Py_GIL_DISABLED + PyMutex_Unlock(&wcsset_mutex); +#endif + if (status == 0) { return 0; } else { @@ -2379,7 +2422,7 @@ PyWcsprm_to_header( // If the user has requested to preserve the original units, then we // could try and edit the header returned by wcshdo - however, this // might be tricky and not robust to future WCSLIB changes. Instead, - // we make a copy of the Wcsprm object, change the units on that and + // we make a copy of the PyWcsprm object, change the units on that and // prevent WCSLIB fixing the units, then convert to a header. if (self->unit_scaling != NULL) { @@ -4305,7 +4348,6 @@ PyWcsprm_get_tab( } if (PyList_SetItem(result, i, subresult) == -1) { - Py_DECREF(subresult); Py_DECREF(result); return NULL; } @@ -4657,7 +4699,6 @@ int add_prj_codes(PyObject* module) for (k = 0; k < prj_ncode; k++) { code = PyUnicode_FromString(prj_codes[k]); if (PyList_SetItem(list, k, code)) { - Py_DECREF(code); Py_DECREF(list); return -1; } diff --git a/astropy/wcs/tests/test_pickle.py b/astropy/wcs/tests/test_pickle.py index 5d17ac43c1fa..07c26c710fc9 100644 --- a/astropy/wcs/tests/test_pickle.py +++ b/astropy/wcs/tests/test_pickle.py @@ -170,3 +170,35 @@ def test_alt_wcskey(): w2 = pickle.loads(pickle.dumps(w)) assert w2.wcs.alt == "A" + + +@pytest.mark.parametrize("preserve_units", [False, True]) +def test_preserve_units(preserve_units): + # Use non-SI units so that the world coordinates returned by + # ``wcs_pix2world`` differ between ``preserve_units=True`` (arcsec) and + # ``preserve_units=False`` (converted to deg). This ensures the pickle + # round-trip is exercising the actual transformation behavior, not just + # the stored flag. + header = fits.Header.fromstring( + "WCSAXES = 2\n" + "CTYPE1 = 'RA---TAN'\n" + "CTYPE2 = 'DEC--TAN'\n" + "CUNIT1 = 'arcsec'\n" + "CUNIT2 = 'arcsec'\n" + "CRVAL1 = 4\n" + "CRVAL2 = 6\n" + "CRPIX1 = 1\n" + "CRPIX2 = 1\n" + "CDELT1 = 4\n" + "CDELT2 = 2\n", + sep="\n", + ) + w = wcs.WCS(header, preserve_units=preserve_units) + assert w._preserve_units is preserve_units + + coords = np.array([[1.0, 2.0], [3.0, 4.0]]) + expected = w.wcs_pix2world(coords, 0) + + w2 = pickle.loads(pickle.dumps(w)) + assert w2._preserve_units is preserve_units + assert_array_almost_equal(w2.wcs_pix2world(coords, 0), expected) diff --git a/astropy/wcs/tests/test_wcs.py b/astropy/wcs/tests/test_wcs.py index 8eddd5d9863c..240c3ccc5955 100644 --- a/astropy/wcs/tests/test_wcs.py +++ b/astropy/wcs/tests/test_wcs.py @@ -2417,7 +2417,7 @@ def test_unit_scaling(self): def test_no_scaling_if_no_unit_changes(self): # This checks that if a WCS doesn't actually have units changing in # WCSLIB, that we aren't using the unit scaling machinery with scales - # all set to 1 since this would have an unecessary impact on performance. + # all set to 1 since this would have an unnecessary impact on performance. simple_wcs = wcs.WCS(naxis=3, preserve_units=True) simple_wcs.wcs.ctype = "RA---TAN", "DEC--TAN", "FREQ" diff --git a/astropy/wcs/wcs.py b/astropy/wcs/wcs.py index 24dad12579f4..c9c0201451cd 100644 --- a/astropy/wcs/wcs.py +++ b/astropy/wcs/wcs.py @@ -529,6 +529,7 @@ def __init__( self._init_kwargs = { "keysel": copy.copy(keysel), "colsel": copy.copy(colsel), + "preserve_units": preserve_units, } if header is None: @@ -3352,7 +3353,11 @@ def __reduce__(self): buffer = io.BytesIO() hdulist.writeto(buffer) - dct = self.__dict__.copy() + # Exclude lazily-populated caches: they are pure functions of the + # WCS state, so unpickling can regenerate them on demand. Keeping + # them out of the pickle avoids bloating the payload and prevents + # non-picklable cache contents (e.g. closures) from breaking pickle. + dct = {k: v for k, v in self.__dict__.items() if not k.endswith("_cache")} dct["_alt_wcskey"] = self.wcs.alt return ( diff --git a/astropy/wcs/wcsapi/fitswcs.py b/astropy/wcs/wcsapi/fitswcs.py index 12342ef7fb70..c54157e17d7e 100644 --- a/astropy/wcs/wcsapi/fitswcs.py +++ b/astropy/wcs/wcsapi/fitswcs.py @@ -7,8 +7,8 @@ import numpy as np +import astropy.constants from astropy import units as u -from astropy.constants import c from astropy.coordinates import ICRS, Galactic, SpectralCoord from astropy.coordinates.spectral_coordinate import ( attach_zero_velocities, @@ -23,7 +23,7 @@ __all__ = ["FITSWCSAPIMixin", "SlicedFITSWCS", "custom_ctype_to_ucd_mapping"] -C_SI = c.si.value +C_SI = astropy.constants.c.si.value VELOCITY_FRAMES = { "GEOCENT": "gcrs", @@ -99,7 +99,7 @@ l=263.85 * u.deg, b=48.25 * u.deg, distance=0 * u.km, - radial_velocity=-(3.346e-3 / 2.725 * c).to(u.km / u.s), + radial_velocity=-(3.346e-3 / 2.725 * astropy.constants.c).to(u.km / u.s), ) @@ -397,13 +397,16 @@ def _get_components_and_classes(self): # it. We start off by defining a hash based on the attributes of the # WCS that matter here (we can't just use the WCS object as a hash since # it is mutable) + # NaN values must be normalized since NaN != NaN would otherwise + # defeat the cache comparison below. + equinox = self.wcs.equinox wcs_hash = ( self.naxis, list(self.wcs.ctype), list(self.wcs.cunit), self.wcs.radesys, self.wcs.specsys, - self.wcs.equinox, + None if np.isnan(equinox) else equinox, self.wcs.dateobs, self.wcs.lng, self.wcs.lat, @@ -438,16 +441,25 @@ def _get_components_and_classes(self): else: kwargs = {} kwargs["frame"] = celestial_frame - # Very occasionally (i.e. with TAB) wcs does not convert the units to degrees + # Very occasionally (i.e. with TAB) wcs does not convert the units + lon_unit = u.Unit(self.wcs.cunit[self.wcs.lng]) + lat_unit = u.Unit(self.wcs.cunit[self.wcs.lat]) kwargs["unit"] = ( - u.Unit(self.wcs.cunit[self.wcs.lng]), - u.Unit(self.wcs.cunit[self.wcs.lat]), + lon_unit, + lat_unit, ) classes["celestial"] = (SkyCoord, (), kwargs) - - components[self.wcs.lng] = ("celestial", 0, "spherical.lon.degree") - components[self.wcs.lat] = ("celestial", 1, "spherical.lat.degree") + components[self.wcs.lng] = ( + "celestial", + 0, + lambda c: c.spherical.lon.to_value(lon_unit), + ) + components[self.wcs.lat] = ( + "celestial", + 1, + lambda c: c.spherical.lat.to_value(lat_unit), + ) # Next, we check for spectral components diff --git a/astropy/wcs/wcsapi/high_level_api.py b/astropy/wcs/wcsapi/high_level_api.py index 413e94f194ae..862dc4ebe228 100644 --- a/astropy/wcs/wcsapi/high_level_api.py +++ b/astropy/wcs/wcsapi/high_level_api.py @@ -62,7 +62,7 @@ class BaseHighLevelWCS(metaclass=abc.ABCMeta): Abstract base class for the high-level WCS interface. This is described in `APE 14: A shared Python interface for World Coordinate - Systems `_. + Systems `_. """ @property diff --git a/astropy/wcs/wcsapi/low_level_api.py b/astropy/wcs/wcsapi/low_level_api.py index 147a852f4614..47ea5e59bd31 100644 --- a/astropy/wcs/wcsapi/low_level_api.py +++ b/astropy/wcs/wcsapi/low_level_api.py @@ -11,7 +11,7 @@ class BaseLowLevelWCS(metaclass=abc.ABCMeta): Abstract base class for the low-level WCS interface. This is described in `APE 14: A shared Python interface for World Coordinate - Systems `_. + Systems `_. """ @property @@ -162,7 +162,7 @@ def world_axis_object_components(self): See the document `APE 14: A shared Python interface for World Coordinate Systems - `_ for examples. + `_ for examples. """ @property @@ -218,7 +218,7 @@ class and gets passed the positional and keyword arguments. It should See the document `APE 14: A shared Python interface for World Coordinate Systems - `_ for examples . + `_ for examples. """ # The following three properties have default fallback implementations, so diff --git a/astropy/wcs/wcsapi/tests/helpers.py b/astropy/wcs/wcsapi/tests/helpers.py new file mode 100644 index 000000000000..6a8acdb143b8 --- /dev/null +++ b/astropy/wcs/wcsapi/tests/helpers.py @@ -0,0 +1,3 @@ +def assert_celestial_component(component, index): + assert component[:2] == ("celestial", index) + assert callable(component[2]) diff --git a/astropy/wcs/wcsapi/tests/test_fitswcs.py b/astropy/wcs/wcsapi/tests/test_fitswcs.py index c6dc6898adac..6e6b2acabb8c 100644 --- a/astropy/wcs/wcsapi/tests/test_fitswcs.py +++ b/astropy/wcs/wcsapi/tests/test_fitswcs.py @@ -32,6 +32,7 @@ from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyUserWarning from astropy.wcs.wcs import WCS, WCSLIB_VERSION, FITSFixedWarning, NoConvergence, Sip from astropy.wcs.wcsapi.fitswcs import VELOCITY_FRAMES, custom_ctype_to_ucd_mapping +from astropy.wcs.wcsapi.tests.helpers import assert_celestial_component ############################################################################### # The following example is the simplest WCS with default values @@ -137,10 +138,10 @@ def test_simple_celestial(): assert_equal(wcs.axis_correlation_matrix, True) - assert wcs.world_axis_object_components == [ - ("celestial", 0, "spherical.lon.degree"), - ("celestial", 1, "spherical.lat.degree"), - ] + components = wcs.world_axis_object_components + assert len(components) == 2 + assert_celestial_component(components[0], 0) + assert_celestial_component(components[1], 1) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -259,18 +260,11 @@ def test_spectral_cube(): [[True, False, True], [False, True, False], [True, False, True]], ) - assert len(wcs.world_axis_object_components) == 3 - assert wcs.world_axis_object_components[0] == ( - "celestial", - 1, - "spherical.lat.degree", - ) - assert wcs.world_axis_object_components[1][:2] == ("spectral", 0) - assert wcs.world_axis_object_components[2] == ( - "celestial", - 0, - "spherical.lon.degree", - ) + components = wcs.world_axis_object_components + assert len(components) == 3 + assert_celestial_component(components[0], 1) + assert components[1][:2] == ("spectral", 0) + assert_celestial_component(components[2], 0) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -379,18 +373,11 @@ def test_spectral_cube_nonaligned(): # again here because in the past this failed when non-aligned axes were # present, so this serves as a regression test. - assert len(wcs.world_axis_object_components) == 3 - assert wcs.world_axis_object_components[0] == ( - "celestial", - 1, - "spherical.lat.degree", - ) - assert wcs.world_axis_object_components[1][:2] == ("spectral", 0) - assert wcs.world_axis_object_components[2] == ( - "celestial", - 0, - "spherical.lon.degree", - ) + components = wcs.world_axis_object_components + assert len(components) == 3 + assert_celestial_component(components[0], 1) + assert components[1][:2] == ("spectral", 0) + assert_celestial_component(components[2], 0) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -483,8 +470,8 @@ def test_time_cube(): ) components = wcs.world_axis_object_components - assert components[0] == ("celestial", 1, "spherical.lat.degree") - assert components[1] == ("celestial", 0, "spherical.lon.degree") + assert_celestial_component(components[0], 1) + assert_celestial_component(components[1], 0) assert components[2][:2] == ("time", 0) assert callable(components[2][2]) @@ -904,10 +891,10 @@ def test_caching_components_and_classes(): wcs = WCS_SIMPLE_CELESTIAL.deepcopy() - assert wcs.world_axis_object_components == [ - ("celestial", 0, "spherical.lon.degree"), - ("celestial", 1, "spherical.lat.degree"), - ] + components = wcs.world_axis_object_components + assert len(components) == 2 + assert_celestial_component(components[0], 0) + assert_celestial_component(components[1], 1) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -927,6 +914,30 @@ def test_caching_components_and_classes(): assert frame.equinox.jyear == 2010.0 +@pytest.mark.parametrize("unit", ["arcsec", "mas"]) +def test_world_to_pixel_preserves_non_degree_celestial_units(unit): + header = Header() + header["NAXIS"] = 2 + header["NAXIS1"] = 100 + header["NAXIS2"] = 100 + header["CTYPE1"] = "RA---TAN" + header["CTYPE2"] = "DEC--TAN" + header["CUNIT1"] = unit + header["CUNIT2"] = unit + header["CRPIX1"] = 1 + header["CRPIX2"] = 1 + header["CRVAL1"] = 0 + header["CRVAL2"] = 0 + header["CDELT1"] = 1 + header["CDELT2"] = 1 + + wcs = WCS(header, preserve_units=True) + sky = wcs.pixel_to_world(0, 56) + + assert wcs.world_axis_units == [unit, unit] + assert_allclose(wcs.world_to_pixel(sky), (0, 56), atol=1e-14) + + def test_sub_wcsapi_attributes(): # Regression test for a bug that caused some of the WCS attributes to be # incorrect when using WCS.sub or WCS.celestial (which is an alias for sub @@ -1652,3 +1663,13 @@ def test_array_index_conversions_scalars_2d(): x, y = wcs.world_to_pixel(scoord) assert isinstance(x, np.ndarray) and x.ndim == 0 assert isinstance(y, np.ndarray) and y.ndim == 0 + + +def test_components_and_classes_cache(): + # Regression test for the components and classes cache never hitting + # when the equinox was undefined, because the NaN it contributed to the + # cache key compares unequal to itself + wcs = WCS(naxis=2) + wcs.wcs.ctype = ["RA---TAN", "DEC--TAN"] + wcs.wcs.set() + assert wcs.world_axis_object_components is wcs.world_axis_object_components diff --git a/astropy/wcs/wcsapi/wrappers/tests/test_sliced_wcs.py b/astropy/wcs/wcsapi/wrappers/tests/test_sliced_wcs.py index bdad84df0426..5af013d46bf8 100644 --- a/astropy/wcs/wcsapi/wrappers/tests/test_sliced_wcs.py +++ b/astropy/wcs/wcsapi/wrappers/tests/test_sliced_wcs.py @@ -11,6 +11,7 @@ from astropy.time import Time from astropy.units import Quantity from astropy.wcs.wcs import WCS, FITSFixedWarning +from astropy.wcs.wcsapi.tests.helpers import assert_celestial_component from astropy.wcs.wcsapi.wrappers.sliced_wcs import ( SlicedLowLevelWCS, combine_slices, @@ -130,18 +131,11 @@ def test_ellipsis(): [[True, False, True], [False, True, False], [True, False, True]], ) - assert len(wcs.world_axis_object_components) == 3 - assert wcs.world_axis_object_components[0] == ( - "celestial", - 1, - "spherical.lat.degree", - ) - assert wcs.world_axis_object_components[1][:2] == ("spectral", 0) - assert wcs.world_axis_object_components[2] == ( - "celestial", - 0, - "spherical.lon.degree", - ) + components = wcs.world_axis_object_components + assert len(components) == 3 + assert_celestial_component(components[0], 1) + assert components[1][:2] == ("spectral", 0) + assert_celestial_component(components[2], 0) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -219,10 +213,10 @@ def test_spectral_slice(): assert_equal(wcs.axis_correlation_matrix, [[True, True], [True, True]]) - assert wcs.world_axis_object_components == [ - ("celestial", 1, "spherical.lat.degree"), - ("celestial", 0, "spherical.lon.degree"), - ] + components = wcs.world_axis_object_components + assert len(components) == 2 + assert_celestial_component(components[0], 1) + assert_celestial_component(components[1], 0) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -289,18 +283,11 @@ def test_spectral_range(): [[True, False, True], [False, True, False], [True, False, True]], ) - assert len(wcs.world_axis_object_components) == 3 - assert wcs.world_axis_object_components[0] == ( - "celestial", - 1, - "spherical.lat.degree", - ) - assert wcs.world_axis_object_components[1][:2] == ("spectral", 0) - assert wcs.world_axis_object_components[2] == ( - "celestial", - 0, - "spherical.lon.degree", - ) + components = wcs.world_axis_object_components + assert len(components) == 3 + assert_celestial_component(components[0], 1) + assert components[1][:2] == ("spectral", 0) + assert_celestial_component(components[2], 0) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -369,18 +356,11 @@ def test_celestial_slice(): wcs.axis_correlation_matrix, [[False, True], [True, False], [False, True]] ) - assert len(wcs.world_axis_object_components) == 3 - assert wcs.world_axis_object_components[0] == ( - "celestial", - 1, - "spherical.lat.degree", - ) - assert wcs.world_axis_object_components[1][:2] == ("spectral", 0) - assert wcs.world_axis_object_components[2] == ( - "celestial", - 0, - "spherical.lon.degree", - ) + components = wcs.world_axis_object_components + assert len(components) == 3 + assert_celestial_component(components[0], 1) + assert components[1][:2] == ("spectral", 0) + assert_celestial_component(components[2], 0) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -451,18 +431,11 @@ def test_celestial_range(): [[True, False, True], [False, True, False], [True, False, True]], ) - assert len(wcs.world_axis_object_components) == 3 - assert wcs.world_axis_object_components[0] == ( - "celestial", - 1, - "spherical.lat.degree", - ) - assert wcs.world_axis_object_components[1][:2] == ("spectral", 0) - assert wcs.world_axis_object_components[2] == ( - "celestial", - 0, - "spherical.lon.degree", - ) + components = wcs.world_axis_object_components + assert len(components) == 3 + assert_celestial_component(components[0], 1) + assert components[1][:2] == ("spectral", 0) + assert_celestial_component(components[2], 0) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -542,18 +515,11 @@ def test_celestial_range_rot(): [[True, False, True], [False, True, False], [True, False, True]], ) - assert len(wcs.world_axis_object_components) == 3 - assert wcs.world_axis_object_components[0] == ( - "celestial", - 1, - "spherical.lat.degree", - ) - assert wcs.world_axis_object_components[1][:2] == ("spectral", 0) - assert wcs.world_axis_object_components[2] == ( - "celestial", - 0, - "spherical.lon.degree", - ) + components = wcs.world_axis_object_components + assert len(components) == 3 + assert_celestial_component(components[0], 1) + assert components[1][:2] == ("spectral", 0) + assert_celestial_component(components[2], 0) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -645,18 +611,11 @@ def test_no_array_shape(): [[True, False, True], [False, True, False], [True, False, True]], ) - assert len(wcs.world_axis_object_components) == 3 - assert wcs.world_axis_object_components[0] == ( - "celestial", - 1, - "spherical.lat.degree", - ) - assert wcs.world_axis_object_components[1][:2] == ("spectral", 0) - assert wcs.world_axis_object_components[2] == ( - "celestial", - 0, - "spherical.lon.degree", - ) + components = wcs.world_axis_object_components + assert len(components) == 3 + assert_celestial_component(components[0], 1) + assert components[1][:2] == ("spectral", 0) + assert_celestial_component(components[2], 0) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -754,11 +713,11 @@ def test_ellipsis_none_types(): [[True, False, True], [False, True, False], [True, False, True]], ) - assert wcs.world_axis_object_components == [ - ("celestial", 1, "spherical.lat.degree"), - ("world", 0, "value"), - ("celestial", 0, "spherical.lon.degree"), - ] + components = wcs.world_axis_object_components + assert len(components) == 3 + assert_celestial_component(components[0], 1) + assert components[1] == ("world", 0, "value") + assert_celestial_component(components[2], 0) assert wcs.world_axis_object_classes["celestial"][0] is SkyCoord assert wcs.world_axis_object_classes["celestial"][1] == () @@ -948,6 +907,7 @@ def test_dropped_dimensions(): dwd = sub.dropped_world_dimensions wao_classes = dwd.pop("world_axis_object_classes") + wao_components = dwd.pop("world_axis_object_components") validate_info_dict( dwd, { @@ -956,12 +916,11 @@ def test_dropped_dimensions(): "world_axis_names": ["Latitude", "Longitude"], "world_axis_units": ["deg", "deg"], "serialized_classes": False, - "world_axis_object_components": [ - ("celestial", 1, "spherical.lat.degree"), - ("celestial", 0, "spherical.lon.degree"), - ], }, ) + assert len(wao_components) == 2 + assert_celestial_component(wao_components[0], 1) + assert_celestial_component(wao_components[1], 0) assert wao_classes["celestial"][0] is SkyCoord assert wao_classes["celestial"][1] == () @@ -972,6 +931,7 @@ def test_dropped_dimensions(): dwd = sub.dropped_world_dimensions wao_classes = dwd.pop("world_axis_object_classes") + wao_components = dwd.pop("world_axis_object_components") validate_info_dict( dwd, { @@ -980,12 +940,11 @@ def test_dropped_dimensions(): "world_axis_names": ["Latitude", "Longitude"], "world_axis_units": ["deg", "deg"], "serialized_classes": False, - "world_axis_object_components": [ - ("celestial", 1, "spherical.lat.degree"), - ("celestial", 0, "spherical.lon.degree"), - ], }, ) + assert len(wao_components) == 2 + assert_celestial_component(wao_components[0], 1) + assert_celestial_component(wao_components[1], 0) assert wao_classes["celestial"][0] is SkyCoord assert wao_classes["celestial"][1] == () @@ -1017,8 +976,8 @@ def test_dropped_dimensions_4d(cube_4d_fitswcs): assert wao_classes["celestial"][2]["unit"] == (u.deg, u.deg) assert wao_classes["spectral"][0:3] == (u.Quantity, (), {}) - assert wao_components[0] == ("celestial", 0, "spherical.lon.degree") - assert wao_components[1] == ("celestial", 1, "spherical.lat.degree") + assert_celestial_component(wao_components[0], 0) + assert_celestial_component(wao_components[1], 1) assert wao_components[2][0:2] == ("spectral", 0) sub = SlicedLowLevelWCS(cube_4d_fitswcs, np.s_[12, 12]) diff --git a/docs/changes/18160.other.rst b/docs/changes/18160.other.rst deleted file mode 100644 index 349ccab6ab63..000000000000 --- a/docs/changes/18160.other.rst +++ /dev/null @@ -1 +0,0 @@ -The minimum required NumPy version is now 1.24. diff --git a/docs/changes/18164.other.rst b/docs/changes/18164.other.rst deleted file mode 100644 index 4b2b28b37d70..000000000000 --- a/docs/changes/18164.other.rst +++ /dev/null @@ -1 +0,0 @@ -The minimum required Matplotlib version is now 3.8.0. diff --git a/docs/changes/18657.other.rst b/docs/changes/18657.other.rst deleted file mode 100644 index f9065ce260cc..000000000000 --- a/docs/changes/18657.other.rst +++ /dev/null @@ -1 +0,0 @@ -Bundled ``expat`` is updated to version 2.7.3. diff --git a/docs/changes/18689.other.rst b/docs/changes/18689.other.rst deleted file mode 100644 index 3fbbd48b7bf0..000000000000 --- a/docs/changes/18689.other.rst +++ /dev/null @@ -1 +0,0 @@ -Updated the bundled CFITSIO library to 4.6.3. diff --git a/docs/changes/18786.other.rst b/docs/changes/18786.other.rst deleted file mode 100644 index 528deb7001db..000000000000 --- a/docs/changes/18786.other.rst +++ /dev/null @@ -1 +0,0 @@ -Wheels are now provided for Windows arm64. diff --git a/docs/changes/config/17934.bugfix.rst b/docs/changes/config/17934.bugfix.rst deleted file mode 100644 index 15bdf0ce67d7..000000000000 --- a/docs/changes/config/17934.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -``get_config_dir()`` and ``get_cache_dir()`` now emit warnings in all cases -where the ``XDG_CACHE_HOME`` (``XDG_CONFIG_HOME``, respectively) environment -variable doesn't meet internal assumptions and is ignored as a result. diff --git a/docs/changes/constants/18118.feature.rst b/docs/changes/constants/18118.feature.rst deleted file mode 100644 index bb6b06e91d8e..000000000000 --- a/docs/changes/constants/18118.feature.rst +++ /dev/null @@ -1,15 +0,0 @@ -Added CODATA 2022 support in ``astropy.constants``. - -This update affects the following constants while the rest are unchanged from CODATA 2018: - -- ``m_p`` (Proton mass) -- ``m_n`` (Neutron mass) -- ``m_e`` (Electron mass) -- ``u`` (Atomic mass) -- ``eps0`` (Vacuum electric permittivity) -- ``Ryd`` (Rydberg constant) -- ``a0`` (Bohr radius) -- ``muB`` (Bohr magneton) -- ``alpha`` (Fine-structure constant) -- ``mu0`` (Vacuum magnetic permeability) -- ``sigma_T`` (Thomson scattering cross-section) diff --git a/docs/changes/coordinates/18193.api.rst b/docs/changes/coordinates/18193.api.rst deleted file mode 100644 index 37922f4ebc1f..000000000000 --- a/docs/changes/coordinates/18193.api.rst +++ /dev/null @@ -1,5 +0,0 @@ -The functionality of ``astropy.coordinates.concatenate`` and -``astropy.coordinates.concatenate_representations`` is now available using -``np.concatenate``. Hence, these functions are being deprecated, emitting an -``AstropyPendingDeprecationWarning`` starting with astropy 7.2. This will be -followed by a regular deprecation warning in astropy 8.0, and removal in 9.0. diff --git a/docs/changes/coordinates/18193.feature.rst b/docs/changes/coordinates/18193.feature.rst deleted file mode 100644 index 972133429750..000000000000 --- a/docs/changes/coordinates/18193.feature.rst +++ /dev/null @@ -1,7 +0,0 @@ -Allow ``np.concatenate``, ``np.stack`` and similar numpy functions to -be applied on representations and differentials. - -They can also be applied to coordinate frames and ``SkyCoord``, though -with the same limitation as for setting elements of frames and -coordinates: all frame attributes have to be scalars (or arrays with -only identical elements). diff --git a/docs/changes/coordinates/18418.api.rst b/docs/changes/coordinates/18418.api.rst deleted file mode 100644 index cb2442befa33..000000000000 --- a/docs/changes/coordinates/18418.api.rst +++ /dev/null @@ -1,15 +0,0 @@ -The ``matrix_utilities`` module was not included in the ``astropy`` API -documentation, but it was nonetheless explicitly referred to in some of the -other documentation. -This made it unclear if the functions in the module are public or private. -The public matrix utilities ``is_rotation_or_reflection()`` and -``rotation_matrix()`` have been made available from the ``astropy.coordinates`` -namespace and should be imported from there. -Functions not available from the ``astropy.coordinate`` namespace are private -and may be changed or removed without warning. -However, three functions have been explicitly deprecated, despite being -private, as a courtesy to existing users. -``matrix_utilites.angle_axis()`` and ``matrix_utilites.is_rotation()`` are -deprecated without replacement. -``matrix_utilities.is_O3()`` is deprecated and the public -``is_rotation_or_reflection()`` function can be used as a replacement. diff --git a/docs/changes/coordinates/18459.feature.rst b/docs/changes/coordinates/18459.feature.rst deleted file mode 100644 index a41dd90d963e..000000000000 --- a/docs/changes/coordinates/18459.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -The results of ``match_coordinates_3d()``, ``match_coordinates_sky()``, -``search_around_3d()`` and ``search_around_sky()`` and the corresponding -``SkyCoord`` methods now have named attributes. diff --git a/docs/changes/coordinates/18504.bugfix.rst b/docs/changes/coordinates/18504.bugfix.rst deleted file mode 100644 index 1961b61d8dbb..000000000000 --- a/docs/changes/coordinates/18504.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -The ``angle`` argument of the ``rotation_matrix()`` function can now be any -angle-like value, like its docstring states. -Previously some angle-like values (e.g. angle-like strings) were erroneously -rejected. diff --git a/docs/changes/coordinates/18638.api.rst b/docs/changes/coordinates/18638.api.rst deleted file mode 100644 index cf4eed9f5153..000000000000 --- a/docs/changes/coordinates/18638.api.rst +++ /dev/null @@ -1 +0,0 @@ -The undocumented ``earth_orientation`` module has been removed. diff --git a/docs/changes/coordinates/18649.api.rst b/docs/changes/coordinates/18649.api.rst deleted file mode 100644 index bd463b31f187..000000000000 --- a/docs/changes/coordinates/18649.api.rst +++ /dev/null @@ -1,6 +0,0 @@ -``astropy`` prefers reading data required for ``EarthLocation.of_site()`` from -a local cache and tries downloading (and caching) the data from the Internet if -the cache is empty. -As a last resort ``astropy`` has so far read a small bundled data file that -provided data for Greenwich as the single entry, but now ``astropy`` will raise -an error. diff --git a/docs/changes/cosmology/18232.feature.rst b/docs/changes/cosmology/18232.feature.rst deleted file mode 100644 index fb90dddf06cb..000000000000 --- a/docs/changes/cosmology/18232.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -The trait ``astropy.cosmology.traits.CurvatureComponent`` has been added to work with -objects that have attributes and methods related to the global curvature. diff --git a/docs/changes/cosmology/18271.feature.rst b/docs/changes/cosmology/18271.feature.rst deleted file mode 100644 index dfe8bf5fb6fc..000000000000 --- a/docs/changes/cosmology/18271.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The trait ``astropy.cosmology.traits.HubbleParameter`` has been added to work with objects that have attributes and methods related to the Hubble parameter. diff --git a/docs/changes/cosmology/18447.feature.rst b/docs/changes/cosmology/18447.feature.rst deleted file mode 100644 index 643936962987..000000000000 --- a/docs/changes/cosmology/18447.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The trait ``astropy.cosmology.traits.DarkEnergyComponent`` has been added to work with objects that have attributes and methods related to the Dark Energy component. diff --git a/docs/changes/cosmology/18632.feature.rst b/docs/changes/cosmology/18632.feature.rst deleted file mode 100644 index 50450fa1d100..000000000000 --- a/docs/changes/cosmology/18632.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Cosmology methods now exclusively return arrays, not floats or other scalars. diff --git a/docs/changes/cosmology/18760.feature.rst b/docs/changes/cosmology/18760.feature.rst deleted file mode 100644 index 02fec8861b63..000000000000 --- a/docs/changes/cosmology/18760.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -The trait ``astropy.cosmology.traits.DarkMatterComponent`` has been added to work with -objects that have attributes and methods related to dark matter. diff --git a/docs/changes/cosmology/18769.feature.rst b/docs/changes/cosmology/18769.feature.rst deleted file mode 100644 index 89f1548ebefd..000000000000 --- a/docs/changes/cosmology/18769.feature.rst +++ /dev/null @@ -1,6 +0,0 @@ -The trait ``astropy.cosmology.traits.MatterComponent`` has been added to work with -objects that have attributes and methods related to matter density. -The trait ``astropy.cosmology.traits.BaryonComponent`` has been added to work with -objects that have attributes and methods related to baryonic matter. -The trait ``astropy.cosmology.traits.CriticalDensity`` has been added to work with -objects that have attributes and methods related to the critical density. diff --git a/docs/changes/cosmology/18787.feature.rst b/docs/changes/cosmology/18787.feature.rst deleted file mode 100644 index 749a2a64c095..000000000000 --- a/docs/changes/cosmology/18787.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The trait ``astropy.cosmology.traits.PhotonComponent`` has been added to work with objects that have attributes and methods related to photons. diff --git a/docs/changes/cosmology/18794.feature.rst b/docs/changes/cosmology/18794.feature.rst deleted file mode 100644 index 9609b6f1ccba..000000000000 --- a/docs/changes/cosmology/18794.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The trait ``astropy.cosmology.traits.TotalComponent`` has been added to work with objects that have attributes and methods related to the total density component of the universe. diff --git a/docs/changes/io.ascii/18506.feature.rst b/docs/changes/io.ascii/18506.feature.rst deleted file mode 100644 index 6c1bd8a89779..000000000000 --- a/docs/changes/io.ascii/18506.feature.rst +++ /dev/null @@ -1 +0,0 @@ -CDS table reader will find the metadata for gzipped tables in the accompanying ReadMe file. diff --git a/docs/changes/io.fits/18151.feature.rst b/docs/changes/io.fits/18151.feature.rst deleted file mode 100644 index 172428b8d3b5..000000000000 --- a/docs/changes/io.fits/18151.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -Enable color and suggestion-on-typos in all ``argparse`` CLIs for Python 3.14 -(``fitscheck``, ``fitsdiff``, ``fitsheader`` and ``fitsinfo``). diff --git a/docs/changes/io.fits/18379.feature.rst b/docs/changes/io.fits/18379.feature.rst deleted file mode 100644 index 499556d83902..000000000000 --- a/docs/changes/io.fits/18379.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -Allow reading a FITS file hosted on a cloud resource like Amazon S3 via -``Table.read()``. This is done with a new ``fsspec_kwargs`` dict argument -that gets passed through to ``fsspec`` to access cloud resources. diff --git a/docs/changes/io.fits/18487.bugfix.rst b/docs/changes/io.fits/18487.bugfix.rst deleted file mode 100644 index e0d186752f9e..000000000000 --- a/docs/changes/io.fits/18487.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix bug with heap which was not updated after a VLA column is modified. diff --git a/docs/changes/io.fits/18574.bugfix.rst b/docs/changes/io.fits/18574.bugfix.rst deleted file mode 100644 index 4f5561af8cfb..000000000000 --- a/docs/changes/io.fits/18574.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Make ``fitscheck`` verify all HDUs before listing errors. diff --git a/docs/changes/io.fits/18681.bugfix.rst b/docs/changes/io.fits/18681.bugfix.rst deleted file mode 100644 index 19eafaec4390..000000000000 --- a/docs/changes/io.fits/18681.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix calculation of DATASUM/CHECKSUM for heap data in ``BinTableHDU``. diff --git a/docs/changes/io.fits/18717.feature.rst b/docs/changes/io.fits/18717.feature.rst deleted file mode 100644 index 89bb584da63c..000000000000 --- a/docs/changes/io.fits/18717.feature.rst +++ /dev/null @@ -1 +0,0 @@ -It is now possible to check the existence of ``Columns`` in ``ColDefs`` by using the membership operator. diff --git a/docs/changes/io.misc/18267.feature.rst b/docs/changes/io.misc/18267.feature.rst deleted file mode 100644 index 25bda31887fd..000000000000 --- a/docs/changes/io.misc/18267.feature.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added a new ECSV table reading module that supports different backend engines for the -CSV data parsing. In addition to the default "io.ascii" engine, this includes engines -that use the PyArrow and Pandas CSV readers. These can be up to 16 times faster and are -more memory efficient than the native astropy ECSV reader. To get help with this -interface run ``Table.read.help(format="ecsv")``. diff --git a/docs/changes/io.misc/18677.bugfix.rst b/docs/changes/io.misc/18677.bugfix.rst deleted file mode 100644 index 2f8e216197e7..000000000000 --- a/docs/changes/io.misc/18677.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Fixed a bug where writing a table to ECSV fails if meta -contains a value that is a numpy string. diff --git a/docs/changes/io.misc/18712.feature.rst b/docs/changes/io.misc/18712.feature.rst deleted file mode 100644 index c5174e6bd238..000000000000 --- a/docs/changes/io.misc/18712.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -Improve support for compressed file formats in the ECSV and the pyarrow CSV -Table readers. All formats supported by ``astropy.utils.data.get_readable_fileobj()`` -(currently gzip, bzip2, lzma (xz) or lzw (Z)) will now work with these readers. diff --git a/docs/changes/io.registry/17572.api.rst b/docs/changes/io.registry/17572.api.rst deleted file mode 100644 index d085f9b75521..000000000000 --- a/docs/changes/io.registry/17572.api.rst +++ /dev/null @@ -1,2 +0,0 @@ -``UnifiedInputRegistry`` and ``UnifiedOutputRegistry``'s ``delay_doc_updates`` -method's effect is disabled under Python's optimized mode (``-OO`` flag). diff --git a/docs/changes/io.registry/18470.feature.rst b/docs/changes/io.registry/18470.feature.rst deleted file mode 100644 index 7e36b3fd016d..000000000000 --- a/docs/changes/io.registry/18470.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -Allow setting EXTNAME when writing a ``Table`` to a FITS file, e.g. -``tbl.write("filename.fits", name="CAT", append=True)``. diff --git a/docs/changes/io.votable/18151.feature.rst b/docs/changes/io.votable/18151.feature.rst deleted file mode 100644 index e7e99495102d..000000000000 --- a/docs/changes/io.votable/18151.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Enable color and suggestion-on-typos in ``volint`` CLI for Python 3.14 diff --git a/docs/changes/io.votable/18366.api.rst b/docs/changes/io.votable/18366.api.rst deleted file mode 100644 index cd4d73ebd515..000000000000 --- a/docs/changes/io.votable/18366.api.rst +++ /dev/null @@ -1,2 +0,0 @@ -Added a ``config`` property to ``astropy.io.votable.tree.VOTableFile``. -This property can be passed to the ``config`` parameter of constructors that need to know the associated VOTable version, such as ``TimeSys`` and ``CooSys``. diff --git a/docs/changes/io.votable/18366.feature.rst b/docs/changes/io.votable/18366.feature.rst deleted file mode 100644 index 6116cb22e116..000000000000 --- a/docs/changes/io.votable/18366.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Modified the constructor for ``astropy.io.votable.tree.TableElement`` to use the version configuration information from the parent ``VOTableFile`` instance. This allows for better handling of version-specific features and ensures that the table element is created with the correct context regarding the VOTable version. diff --git a/docs/changes/io.votable/18483.bugfix.rst b/docs/changes/io.votable/18483.bugfix.rst deleted file mode 100644 index 556f15f38815..000000000000 --- a/docs/changes/io.votable/18483.bugfix.rst +++ /dev/null @@ -1,6 +0,0 @@ -Updated IVOA UCD1+ controlled vocabulary from version 1.5 to 1.6. This adds -support for new atmospheric observation terms including ``obs.atmos.wind``, -``obs.atmos.humidity``, ``obs.atmos.rain``, ``obs.atmos.turbulence``, -``obs.atmos.turbulence.isoplanatic``, ``obs.atmos.water``, and -``phys.temperature.dew`` which are now recognized when parsing UCDs with -``check_controlled_vocabulary=True``. diff --git a/docs/changes/io.votable/18771.bugfix.rst b/docs/changes/io.votable/18771.bugfix.rst deleted file mode 100644 index a72497f2da29..000000000000 --- a/docs/changes/io.votable/18771.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed a bug in ``add_data_origin_info()`` where ``content`` is ignored for some INFO names. diff --git a/docs/changes/modeling/17304.feature.rst b/docs/changes/modeling/17304.feature.rst deleted file mode 100644 index 3cb0f8096d72..000000000000 --- a/docs/changes/modeling/17304.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -Add support for unit change propagation through the ``|`` (model composition) operator, -using either `~astropy.modeling.compose_models_with_units` or by setting the -``unit_change_composition`` attribute on the model after composition. diff --git a/docs/changes/nddata/18205.feature.rst b/docs/changes/nddata/18205.feature.rst deleted file mode 100644 index 81cbb0ab403d..000000000000 --- a/docs/changes/nddata/18205.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The ``interpret_bit_flags`` function now strips whitespace from flag names. diff --git a/docs/changes/nddata/18392.bugfix.rst b/docs/changes/nddata/18392.bugfix.rst deleted file mode 100644 index 667d8017290d..000000000000 --- a/docs/changes/nddata/18392.bugfix.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed unexpected upcasting to 64 bits when doing arithmetic with Python scalars -on ``numpy`` 2. - -Don't upcast ``NDData`` unnecessarily when doing arithmetic involving a single -unit (consistent with the behaviour when there are no units). Upcasting still -occurs if an operand's unit gets converted to match the other, or where -required by the other operand's dtype. diff --git a/docs/changes/samp/18151.feature.rst b/docs/changes/samp/18151.feature.rst deleted file mode 100644 index f17d9398d05f..000000000000 --- a/docs/changes/samp/18151.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Enable color and suggestion-on-typos in ``samp_hub`` CLI for Python 3.14 diff --git a/docs/changes/samp/18169.bugfix.rst b/docs/changes/samp/18169.bugfix.rst deleted file mode 100644 index a63b7c6b1d8d..000000000000 --- a/docs/changes/samp/18169.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -``SAMPHubServer._call_and_wait`` raises a new ``SAMPProxyTimeoutError`` (derived from ``SAMPProxyError``) exception on timeout. -This allows client code to more easily distinguish timeouts from other kind of exceptions. diff --git a/docs/changes/stats/18676.bugfix.rst b/docs/changes/stats/18676.bugfix.rst deleted file mode 100644 index cde07db43b27..000000000000 --- a/docs/changes/stats/18676.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -``poisson_conf_interval`` ``kraft-burrows-nousek`` no longer fails for large N. diff --git a/docs/changes/stats/20008.bugfix.rst b/docs/changes/stats/20008.bugfix.rst new file mode 100644 index 000000000000..af019f2fda51 --- /dev/null +++ b/docs/changes/stats/20008.bugfix.rst @@ -0,0 +1 @@ +Fixed a bug where ``astropy.stats.histogram()`` raised a ``ValueError`` when both ``range`` and ``weights`` were provided with a scalar number of bins. For scalar bin counts, ``calculate_bin_edges()`` now delegates range filtering directly to ``numpy.histogram_bin_edges()``, avoiding an array shape mismatch between data and weights while reserving manual range filtering for Astropy's string-based bin width estimators. diff --git a/docs/changes/table/17631.api.rst b/docs/changes/table/17631.api.rst deleted file mode 100644 index cb1071846f82..000000000000 --- a/docs/changes/table/17631.api.rst +++ /dev/null @@ -1,2 +0,0 @@ -Add additional detail to the text of the ``ValueError`` that is raised when -``pprint`` cannot parse a column format string. diff --git a/docs/changes/table/18151.feature.rst b/docs/changes/table/18151.feature.rst deleted file mode 100644 index 010ad1a68d32..000000000000 --- a/docs/changes/table/18151.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Enable color and suggestion-on-typos in ``showtable`` CLI for Python 3.14 diff --git a/docs/changes/table/18435.feature.rst b/docs/changes/table/18435.feature.rst deleted file mode 100644 index 4df18223384d..000000000000 --- a/docs/changes/table/18435.feature.rst +++ /dev/null @@ -1,8 +0,0 @@ -Added generic ``from_df`` and ``to_df`` methods to ``astropy.Table`` using -``narwhals``. These methods provide a unified interface for converting between -Astropy Tables and various DataFrame formats (pandas, polars, pyarrow, etc.) -through the narwhals library. The ``to_df`` method converts an Astropy Table -to any supported DataFrame format, while ``from_df`` creates an Astropy Table -from any narwhals-compatible DataFrame. Narwhals is a lightweight compatibility -layer that provides a unified API across different DataFrame libraries, allowing -seamless interoperability without requiring all DataFrame libraries as dependencies. diff --git a/docs/changes/table/18641.feature.rst b/docs/changes/table/18641.feature.rst deleted file mode 100644 index 981f049aa238..000000000000 --- a/docs/changes/table/18641.feature.rst +++ /dev/null @@ -1,10 +0,0 @@ -Setting the ``units`` or ``descriptions`` of ``QTable`` and ``Table`` -has been made more flexible for tables with optional columns that may -or may not appear in the data. This applies to directly creating a table -as well as reading formatted data with the ``read()`` method. - -In both cases you can supply ``units`` and ``description`` arguments as a -``dict`` that specifies the units and descriptions for column names in -the table. Previously, if the input table did not contain a column that -was specified in the ``units`` or ``description`` dict, a ``ValueError`` -was raised. Now, such columns are simply ignored. diff --git a/docs/changes/table/18680.api.rst b/docs/changes/table/18680.api.rst deleted file mode 100644 index 692cdf9682fc..000000000000 --- a/docs/changes/table/18680.api.rst +++ /dev/null @@ -1,5 +0,0 @@ -Selecting a table index in the ``.loc``, ``.iloc``, or ``.loc_indices`` properties by -passing the index identifier as the first element of the item is deprecated and is -planned for removal in astropy 9.0. For example, if a table ``t`` has two indices on -columns ``"a"`` and ``"b"`` respectively, then ``t.loc["b", 2]`` (to find table rows -where ``t["b"] == 2``) is deprecated. This is replaced by ``t.loc.with_index("b")[2]``. diff --git a/docs/changes/table/18680.feature.rst b/docs/changes/table/18680.feature.rst deleted file mode 100644 index b2f38cdd5737..000000000000 --- a/docs/changes/table/18680.feature.rst +++ /dev/null @@ -1,10 +0,0 @@ -A new method has been added for accessing a table index for tables with multiple -indices. You can now select the index with the ``with_index(index_id)`` method of the -``.loc``, ``.iloc``, and ``.loc_indices`` properties. For example, for a table ``t`` -which has two indices on columns ``"a"`` and ``"b"`` respectively, -``t.loc.with_index("b")[2]`` will use index ``"b"`` to find all the table rows where -``t["b"] == 2``. Doing this query using the previous syntax ``t.loc["b", 2]`` is -deprecated and this functionality is planned for removal in astropy 9.0. - -In addition, support has been added for using ``.loc``, ``.iloc``, and ``.loc_indices`` -with an index based on two or more key columns. Previously this raised a ``ValueError``. diff --git a/docs/changes/table/18694.bugfix.rst b/docs/changes/table/18694.bugfix.rst deleted file mode 100644 index 01dc5cd2368a..000000000000 --- a/docs/changes/table/18694.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fix a bug when slicing a table that has a multi-column index. Previously, after slicing -the table then ``tbl.indices`` would show duplicates of the multi-column index, one for -each column in the index. The underlying indices on the index columns were incorrectly -distinct objects instead of the expected reference to a single index object. diff --git a/docs/changes/table/18725.bugfix.rst b/docs/changes/table/18725.bugfix.rst deleted file mode 100644 index 8ce0574de93a..000000000000 --- a/docs/changes/table/18725.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Fix bugs when indexing a ``QTable`` with a ``Quantity`` column. Previously, after adding -the index then indexed item access via with a ``Quantity`` or slicing was failing. diff --git a/docs/changes/table/18752.bugfix.rst b/docs/changes/table/18752.bugfix.rst deleted file mode 100644 index 6e07dbe517b6..000000000000 --- a/docs/changes/table/18752.bugfix.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fix a bug where the ECSV writer was not correctly quoting column names if the first name -starts with the "#" character or any names contain leading/trailing whitespace. In this -situation, all column names are now surrounded by double quotes per the ECSV standard. -Likewise the ECSV reader was incorrectly stripping surrounding whitespace from column -names, leading to a consistency check failure when reading. diff --git a/docs/changes/table/20031.bugfix.rst b/docs/changes/table/20031.bugfix.rst new file mode 100644 index 000000000000..c80efa7fb013 --- /dev/null +++ b/docs/changes/table/20031.bugfix.rst @@ -0,0 +1,3 @@ +Fixed issue with table groups for zero-length tables in ``astropy.table``. Previously +operations like ``len(tbl.group_by("a").groups)`` or ``len(tbl.groups)`` would fail or +give inconsistent results if the table ``tbl`` had no rows. diff --git a/docs/changes/tests/17874.api.rst b/docs/changes/tests/17874.api.rst deleted file mode 100644 index 017b9bb4b2e3..000000000000 --- a/docs/changes/tests/17874.api.rst +++ /dev/null @@ -1,10 +0,0 @@ -API changes towards a future deprecation of astropy test runner: - -* ``astropy.tests.runner.keyword`` is removed from public API. - It is used internally as a decorator within astropy test runner and - its exposure as public API was a mistake. In the future, it will be - removed without any deprecation. -* ``astropy.test``, ``astropy.tests.runner.TestRunnerBase``, and ``astropy.tests.runner.TestRunner`` - are now pending deprecation (``AstropyPendingDeprecationWarning``). - This will also affect downstream ``packagename.test`` generated using ``TestRunner``. - They may start to emit ``AstropyDeprecationWarning`` in v8.0 (but no earlier). diff --git a/docs/changes/time/18193.feature.rst b/docs/changes/time/18193.feature.rst deleted file mode 100644 index 802f223d077f..000000000000 --- a/docs/changes/time/18193.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -Allow ``np.concatenate``, ``np.stack`` and similar numpy functions to -be applied on ``Time`` and ``TimeDelta`` instances. diff --git a/docs/changes/time/18330.feature.rst b/docs/changes/time/18330.feature.rst deleted file mode 100644 index 0f015ca0eaf3..000000000000 --- a/docs/changes/time/18330.feature.rst +++ /dev/null @@ -1,6 +0,0 @@ -Add a new time format ``galex`` for the GALEX satellite. - -In GALEX data, due to uncertainty in the spacecraft clock, the absolute time is only accurate to -about 1-10 seconds while the relative time within an observation is better than 0.005 s or so, -except on days with leap seconds, where relative times can be wrong by up to 1 s. -See question 101.2 in https://www.galex.caltech.edu/researcher/faq.html diff --git a/docs/changes/units/18500.bugfix.rst b/docs/changes/units/18500.bugfix.rst deleted file mode 100644 index ba8905400edd..000000000000 --- a/docs/changes/units/18500.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -The string representations of the liter with the different ``astropy`` unit -formatters are now more consistent with each other. -This change only affects converting units to strings, it has no effect on -parsing strings to units. diff --git a/docs/changes/units/18586.feature.rst b/docs/changes/units/18586.feature.rst deleted file mode 100644 index 4afcc897649d..000000000000 --- a/docs/changes/units/18586.feature.rst +++ /dev/null @@ -1,5 +0,0 @@ -Some unit formats have deprecated units and converting such units to strings -emits a warning. -The new ``deprecations`` parameter of the unit ``to_string()`` methods allows -automatically converting deprecated units (if possible), silencing the warnings -or raising them as errors instead. diff --git a/docs/changes/units/18723.bugfix.rst b/docs/changes/units/18723.bugfix.rst deleted file mode 100644 index 66ae4bab7fd6..000000000000 --- a/docs/changes/units/18723.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -So far only the ``"cds"`` unit format has been capable of parsing the string -``"as"`` as the attosecond, but now the other unit formats recognize that -string too. diff --git a/docs/changes/units/18776.bugfix.rst b/docs/changes/units/18776.bugfix.rst deleted file mode 100644 index 39f82a1f6ed1..000000000000 --- a/docs/changes/units/18776.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -The ``"ogip"`` unit formatter can now parse strings that include signed -fractions in the exponent, e.g. ``u.Unit("m**(-1/2)", format="ogip")``. diff --git a/docs/changes/utils/18053.api.rst b/docs/changes/utils/18053.api.rst deleted file mode 100644 index ecd2983a5ab1..000000000000 --- a/docs/changes/utils/18053.api.rst +++ /dev/null @@ -1,2 +0,0 @@ -The ``isiterable()`` utility is deprecated. -``numpy.iterable()`` can be used as a drop-in replacement. diff --git a/docs/changes/utils/18173.bugfix.rst b/docs/changes/utils/18173.bugfix.rst deleted file mode 100644 index 0644cf1c1d35..000000000000 --- a/docs/changes/utils/18173.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -If ``numpy.msort()`` is called with a ``Masked`` array then ``astropy`` no -longer erroneously hides the deprecation warning (with ``numpy`` versions -1.24-1.26). diff --git a/docs/changes/utils/18193.bugfix.rst b/docs/changes/utils/18193.bugfix.rst deleted file mode 100644 index e4b7824b043b..000000000000 --- a/docs/changes/utils/18193.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -For ``numpy < 2.0``, applying ``np.atleast_*d`` to iterables of most astropy -classes will now return a list of instances instead of a tuple, to match the -behaviour for arrays. For numpy >= 2.0, tuples continue to be returned. diff --git a/docs/changes/utils/18518.api.rst b/docs/changes/utils/18518.api.rst deleted file mode 100644 index c65008132210..000000000000 --- a/docs/changes/utils/18518.api.rst +++ /dev/null @@ -1,5 +0,0 @@ -``astropy.utils.metadata.MergeStrategy`` no longer modifies the ``merge()`` -methods of its subclasses at runtime to re-raise all exceptions as -``MergeConflictError``. -This does not affect the functionality of ``MergeStrategy`` subclasses within -the ``astropy`` metadata merging machinery. diff --git a/docs/changes/utils/20064.bugfix.rst b/docs/changes/utils/20064.bugfix.rst new file mode 100644 index 000000000000..251aa3a852fd --- /dev/null +++ b/docs/changes/utils/20064.bugfix.rst @@ -0,0 +1,2 @@ +Fixed an issue where, in a multi-threaded context, astropy's logger could race with +imports, leading to an exception being raised. diff --git a/docs/changes/visualization/18151.feature.rst b/docs/changes/visualization/18151.feature.rst deleted file mode 100644 index 31825add207b..000000000000 --- a/docs/changes/visualization/18151.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Enable color and suggestion-on-typos in ``fits2bitmap`` CLI for Python 3.14 diff --git a/docs/changes/visualization/18312.feature.rst b/docs/changes/visualization/18312.feature.rst deleted file mode 100644 index 458b10d2ace7..000000000000 --- a/docs/changes/visualization/18312.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -Added ``show_decimal_unit`` to ``set_major_formatter`` to control whether -or not units are shown in decimal mode. diff --git a/docs/changes/visualization/18324.api.rst b/docs/changes/visualization/18324.api.rst deleted file mode 100644 index b2e26b83c16c..000000000000 --- a/docs/changes/visualization/18324.api.rst +++ /dev/null @@ -1,4 +0,0 @@ -A warning is now emitted for each axis name which is -invalid in ``set_ticklabel_position``, ``set_axislabel_position``, -and ``set_ticks_position``. This is a deprecation warning, -and in future invalid axis names will result in an error. diff --git a/docs/changes/visualization/18443.feature.rst b/docs/changes/visualization/18443.feature.rst deleted file mode 100644 index 2cc1a9fcf05f..000000000000 --- a/docs/changes/visualization/18443.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -Added the methods ``set_visible()`` and ``set_position()`` to control the visibility and position of ticks, tick labels, and axis labels in a single call. - -Also added ``get_ticks_visible()``, ``get_ticklabel_visible()``, and ``get_axislabel_visible()`` methods to get the visibility state of each coordinate element. diff --git a/docs/changes/visualization/18590.bugfix.rst b/docs/changes/visualization/18590.bugfix.rst deleted file mode 100644 index 8b33b322a2fe..000000000000 --- a/docs/changes/visualization/18590.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Fixed an image-normalization bug where the interval on a ``ImageNormalize`` -instance could be ignored when plotting. diff --git a/docs/changes/visualization/18602.feature.rst b/docs/changes/visualization/18602.feature.rst deleted file mode 100644 index 30789a7eb150..000000000000 --- a/docs/changes/visualization/18602.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -Added an image interval option (``SymmetricInterval``) for specifying a -symmetric extent about a midpoint, and the extent that contains both the image -minimum and maximum can be automatically determined. diff --git a/docs/changes/visualization/18792.api.rst b/docs/changes/visualization/18792.api.rst deleted file mode 100644 index 1495c6e2eb46..000000000000 --- a/docs/changes/visualization/18792.api.rst +++ /dev/null @@ -1,3 +0,0 @@ -A warning is now emitted if arguments are given to the getter method -``get_axislabel_visibility_rule``. This is a deprecation warning, and in -future, giving arguments to this method will result in an error. diff --git a/docs/changes/wcs/18151.feature.rst b/docs/changes/wcs/18151.feature.rst deleted file mode 100644 index a0b4e7806733..000000000000 --- a/docs/changes/wcs/18151.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Enable color and suggestion-on-typos in all ``wcslint`` CLI for Python 3.14 diff --git a/docs/changes/wcs/18338.feature.rst b/docs/changes/wcs/18338.feature.rst deleted file mode 100644 index 1d69a33362da..000000000000 --- a/docs/changes/wcs/18338.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -Added a ``perserve_units`` keyword argument to ``WCS`` to optionally request -that units are not converted to SI (the default behavior is for celestial axes -to have units converted to degrees, and spectral axes to m or Hz). diff --git a/docs/changes/wcs/18730.bugfix.rst b/docs/changes/wcs/18730.bugfix.rst deleted file mode 100644 index ac008d1b53bb..000000000000 --- a/docs/changes/wcs/18730.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed a bug that caused world_to_array_index to return lists instead of Numpy arrays. diff --git a/docs/conf.py b/docs/conf.py index d152b2b720bf..a37ed7ecbe19 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -117,7 +117,7 @@ "pyerfa": ("https://pyerfa.readthedocs.io/en/stable/", None), "pytest": ("https://docs.pytest.org/en/stable/", None), "ipython": ("https://ipython.readthedocs.io/en/stable/", None), - "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None), + "pandas": ("https://pandas.pydata.org/docs/", None), "sphinx_automodapi": ( "https://sphinx-automodapi.readthedocs.io/en/stable/", None, @@ -395,6 +395,11 @@ def _custom_edit_url( dtype, target = line.split(None, 1) nitpick_ignore.append((dtype, target.strip())) +# NumPy frequently relocates its private typing namespace between releases +# (e.g. numpy._typing.ArrayLike -> numpy._typing._array_like.ArrayLike), so +# match the whole private namespace rather than tracking individual paths. +nitpick_ignore_regex = [("py:obj", r"numpy\._typing\..*")] + suppress_warnings = [ "config.cache", # our rebuild is okay ] @@ -407,11 +412,14 @@ def _custom_edit_url( "https://www.usno.navy.mil/USNO/time/gps/usno-gps-time-transfer", "https://aa.usno.navy.mil/publications/docs/Circular_179.php", "http://data.astropy.org", + "https://astropy-dei.orgmycology.com/", # 403 Client Error: Forbidden "https://doi.org/", # CI blocked by service provider + "https://zenodo.org/", # 403 Client Error: Forbidden "https://ui.adsabs.harvard.edu", # CI blocked by service provider "https://hst-docs.stsci.edu", # CI blocked by service provider "https://www.tandfonline.com/", # 403 Client Error: Forbidden "https://stackoverflow.com/", # 403 Client Error: Forbidden + "https://docutils.sourceforge.io", # 403 Client Error: Forbidden "https://ieeexplore.ieee.org/", # 418 Client Error: I'm a teapot "https://pyfits.readthedocs.io/en/v3.2.1/", # defunct page in CHANGES.rst "https://pkgs.dev.azure.com/astropy-project", # defunct page in CHANGES.rst diff --git a/docs/coordinates/example_gallery_index.rst b/docs/coordinates/example_gallery_index.rst index 92d11984d85a..115e29749892 100644 --- a/docs/coordinates/example_gallery_index.rst +++ b/docs/coordinates/example_gallery_index.rst @@ -1,16 +1,16 @@ -.. _astropy-coordinates-example-gallery: - -Example Gallery -*************** - -This gallery of examples shows a variety of relatively small snippets or -examples of tasks that can be done with the ``astropy.coordinates`` sub-package. - -.. toctree:: - :maxdepth: 1 - - example_gallery_plot_galactocentric_frame - example_gallery_plot_mars_coordinate_frame - example_gallery_plot_obs_planning - example_gallery_plot_sgr_coordinate_frame - example_gallery_rv_to_gsr +.. _astropy-coordinates-example-gallery: + +Example Gallery +*************** + +This gallery of examples shows a variety of relatively small snippets or +examples of tasks that can be done with the ``astropy.coordinates`` sub-package. + +.. toctree:: + :maxdepth: 1 + + example_gallery_plot_galactocentric_frame + example_gallery_plot_mars_coordinate_frame + example_gallery_plot_obs_planning + example_gallery_plot_sgr_coordinate_frame + example_gallery_rv_to_gsr diff --git a/docs/coordinates/example_gallery_plot_galactocentric_frame.rst b/docs/coordinates/example_gallery_plot_galactocentric_frame.rst index 9090c311002a..00e2a4555a95 100644 --- a/docs/coordinates/example_gallery_plot_galactocentric_frame.rst +++ b/docs/coordinates/example_gallery_plot_galactocentric_frame.rst @@ -1,174 +1,174 @@ -.. _sphx_glr_generated_examples_coordinates_plot_galactocentric-frame.py: - -Transforming positions and velocities to and from a Galactocentric frame -======================================================================== - -.. - EXAMPLE START - Transforming positions and velocities to and from a Galactocentric frames - -This example shows a few examples of how to use and customize the -`~astropy.coordinates.Galactocentric` frame to transform Heliocentric sky -positions, distance, proper motions, and radial velocities to a Galactocentric, -Cartesian frame, and the same in reverse. - -The main configurable parameters of the `~astropy.coordinates.Galactocentric` -frame control the position and velocity of the solar system barycenter within -the Galaxy. These are specified by setting the ICRS coordinates of the -Galactic center, the distance to the Galactic center (the sun-galactic center -line is always assumed to be the x-axis of the Galactocentric frame), and the -Cartesian 3-velocity of the sun in the Galactocentric frame. We will first -demonstrate how to customize these values, then show how to set the solar motion -instead by inputting the proper motion of Sgr A*. - -Note that, for brevity, we may refer to the solar system barycenter as just "the -sun" in the examples below. - -Let's first define a barycentric coordinate and velocity in the ICRS frame. -We will use the data for the star HD 39881 from the -`Simbad `_ database: - - ->>> import astropy.coordinates as coord ->>> from astropy import units as u ->>> c1 = coord.SkyCoord( -... ra=89.014303 * u.degree, -... dec=13.924912 * u.degree, -... distance=(37.59 * u.mas).to(u.pc, u.parallax()), -... pm_ra_cosdec=372.72 * (u.mas / u.yr), -... pm_dec=-483.69 * (u.mas / u.yr), -... radial_velocity=0.37 * (u.km / u.s), -... frame="icrs", -... ) - -This is a high proper-motion star; suppose we'd like to transform its position -and velocity to a Galactocentric frame to see if it has a large 3D velocity -as well. To use the Astropy default solar position and motion parameters, we -can do the following: - ->>> gc1 = c1.transform_to(coord.Galactocentric) - -From here, we can access the components of the resulting -`~astropy.coordinates.Galactocentric` instance to see the 3D Cartesian -velocity components: - ->>> print(gc1.v_x, gc1.v_y, gc1.v_z) # doctest: +FLOAT_CMP -30.254684717897074 km / s 171.29916086104885 km / s 18.19390627095307 km / s - -The default parameters for the `~astropy.coordinates.Galactocentric` frame -are detailed in the linked documentation, but we can modify the most commonly -changed values using the keywords ``galcen_distance``, ``galcen_v_sun``, and -``z_sun`` which set the sun-Galactic center distance, the 3D velocity vector -of the sun, and the height of the sun above the Galactic midplane, -respectively. The velocity of the sun can be specified as an -`~astropy.units.Quantity` object with velocity units and is interpreted as a -Cartesian velocity, as in the example below. Note that, as with the positions, -the Galactocentric frame is a right-handed system (i.e., the Sun is at negative -x values) so ``v_x`` is opposite of the Galactocentric radial velocity: - ->>> v_sun = [11.1, 244, 7.25] * (u.km / u.s) # [vx, vy, vz] ->>> gc_frame = coord.Galactocentric( -... galcen_distance=8 * u.kpc, galcen_v_sun=v_sun, z_sun=0 * u.pc -... ) - -We can then transform to this frame instead, with our custom parameters: - ->>> gc2 = c1.transform_to(gc_frame) ->>> print(gc2.v_x, gc2.v_y, gc2.v_z) # doctest: +FLOAT_CMP -28.427958360720748 km / s 169.69916086104888 km / s 17.70831652451455 km / s - -It is sometimes useful to specify the solar motion using the -`proper motion of Sgr A* `_ -instead of Cartesian velocity components. With an assumed distance, we can convert -proper motion components to Cartesian velocity components using `astropy.units`: - ->>> galcen_distance = 8 * u.kpc ->>> pm_gal_sgrA = [-6.379, -0.202] * (u.mas / u.yr) # from Reid & Brunthaler 2004 ->>> vy, vz = -(galcen_distance * pm_gal_sgrA).to(u.km / u.s, u.dimensionless_angles()) - -We still have to assume a line-of-sight velocity for the Galactic center, -which we will again take to be 11 km/s: - ->>> vx = 11.1 * (u.km / u.s) ->>> v_sun2 = u.Quantity([vx, vy, vz]) # List of Quantity -> a single Quantity ->>> gc_frame2 = coord.Galactocentric( -... galcen_distance=galcen_distance, galcen_v_sun=v_sun2, z_sun=0 * u.pc -... ) ->>> gc3 = c1.transform_to(gc_frame2) ->>> print(gc3.v_x, gc3.v_y, gc3.v_z) # doctest: +FLOAT_CMP -28.427958360720748 km / s 167.61484955608267 km / s 18.118916793584443 km / s - -The transformations also work in the opposite direction. This can be useful -for transforming simulated or theoretical data to observable quantities. As -an example, we will generate 4 theoretical circular orbits at different -Galactocentric radii with the same circular velocity, and transform them to -Heliocentric coordinates: - -.. plot:: - :include-source: - - >>> import matplotlib.pyplot as plt - >>> import numpy as np - >>> import astropy.coordinates as coord - >>> from astropy import units as u - >>> ring_distances = np.arange(10, 26, 5) * u.kpc - >>> circ_velocity = 220 * (u.km / u.s) - >>> phi_grid = np.linspace(90, 270, 512) * u.degree # grid of azimuths - >>> ring_rep = coord.CylindricalRepresentation( - ... rho=ring_distances[:, np.newaxis], - ... phi=phi_grid[np.newaxis], - ... z=np.zeros_like(ring_distances)[:, np.newaxis], - ... ) - >>> angular_velocity = (-circ_velocity / ring_distances).to( - ... u.mas / u.yr, u.dimensionless_angles() - ... ) - >>> ring_dif = coord.CylindricalDifferential( - ... d_rho=np.zeros(phi_grid.shape)[np.newaxis] * (u.km / u.s), - ... d_phi=angular_velocity[:, np.newaxis], - ... d_z=np.zeros(phi_grid.shape)[np.newaxis] * (u.km / u.s), - ... ) - >>> ring_rep = ring_rep.with_differentials(ring_dif) - >>> gc_rings = coord.SkyCoord(ring_rep, frame=coord.Galactocentric) - - First, let's visualize the geometry in Galactocentric coordinates. Here are - the positions and velocities of the rings; note that in the velocity plot, - the velocities of the 4 rings are identical and thus overlaid under the same - curve: - - >>> fig, axes = plt.subplots(1, 2, figsize=(12, 6)) - >>> axes[0].plot(gc_rings.x.T, gc_rings.y.T, marker="None", linewidth=3) # doctest: +IGNORE_OUTPUT - >>> axes[0].text(-8.0, 0, r"$\odot$", fontsize=20) # doctest: +IGNORE_OUTPUT - >>> axes[0].set_xlim(-30, 30) # doctest: +IGNORE_OUTPUT - >>> axes[0].set_ylim(-30, 30) # doctest: +IGNORE_OUTPUT - >>> axes[0].set_xlabel("$x$ [kpc]") # doctest: +IGNORE_OUTPUT - >>> axes[0].set_ylabel("$y$ [kpc]") # doctest: +IGNORE_OUTPUT - >>> axes[0].set_title("Positions") # doctest: +IGNORE_OUTPUT - >>> axes[1].plot(gc_rings.v_x.T, gc_rings.v_y.T, marker="None", linewidth=3) # doctest: +IGNORE_OUTPUT - >>> axes[1].set_xlim(-250, 250) # doctest: +IGNORE_OUTPUT - >>> axes[1].set_ylim(-250, 250) # doctest: +IGNORE_OUTPUT - >>> axes[1].set_xlabel(f"$v_x$ [{(u.km / u.s).to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT - >>> axes[1].set_ylabel(f"$v_y$ [{(u.km / u.s).to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT - >>> axes[1].set_title("Velocities") # doctest: +IGNORE_OUTPUT - >>> fig.tight_layout() - - Now we can transform to Galactic coordinates and visualize the rings in - observable coordinates: - - >>> gal_rings = gc_rings.transform_to(coord.Galactic) - >>> fig, ax = plt.subplots(1, 1, figsize=(8, 6)) - >>> for i in range(len(ring_distances)): - ... ax.plot( - ... gal_rings[i].l.degree, - ... gal_rings[i].pm_l_cosb.value, - ... label=str(ring_distances[i]), - ... marker="None", - ... linewidth=3, - ... ) # doctest: +IGNORE_OUTPUT - >>> ax.set_xlim(360, 0) # doctest: +IGNORE_OUTPUT - >>> ax.set_xlabel("$l$ [deg]") # doctest: +IGNORE_OUTPUT - >>> ax.set_ylabel(rf'$\mu_l \, \cos b$ [{(u.mas/u.yr).to_string("latex_inline")}]') # doctest: +IGNORE_OUTPUT - >>> ax.legend() # doctest: +IGNORE_OUTPUT - >>> plt.draw() - -.. - EXAMPLE END +.. _sphx_glr_generated_examples_coordinates_plot_galactocentric-frame.py: + +Transforming positions and velocities to and from a Galactocentric frame +======================================================================== + +.. + EXAMPLE START + Transforming positions and velocities to and from a Galactocentric frames + +This example shows a few examples of how to use and customize the +`~astropy.coordinates.Galactocentric` frame to transform Heliocentric sky +positions, distance, proper motions, and radial velocities to a Galactocentric, +Cartesian frame, and the same in reverse. + +The main configurable parameters of the `~astropy.coordinates.Galactocentric` +frame control the position and velocity of the solar system barycenter within +the Galaxy. These are specified by setting the ICRS coordinates of the +Galactic center, the distance to the Galactic center (the sun-galactic center +line is always assumed to be the x-axis of the Galactocentric frame), and the +Cartesian 3-velocity of the sun in the Galactocentric frame. We will first +demonstrate how to customize these values, then show how to set the solar motion +instead by inputting the proper motion of Sgr A*. + +Note that, for brevity, we may refer to the solar system barycenter as just "the +sun" in the examples below. + +Let's first define a barycentric coordinate and velocity in the ICRS frame. +We will use the data for the star HD 39881 from the +`Simbad `_ database: + + +>>> import astropy.coordinates as coord +>>> from astropy import units as u +>>> c1 = coord.SkyCoord( +... ra=89.014303 * u.degree, +... dec=13.924912 * u.degree, +... distance=(37.59 * u.mas).to(u.pc, u.parallax()), +... pm_ra_cosdec=372.72 * (u.mas / u.yr), +... pm_dec=-483.69 * (u.mas / u.yr), +... radial_velocity=0.37 * (u.km / u.s), +... frame="icrs", +... ) + +This is a high proper-motion star; suppose we'd like to transform its position +and velocity to a Galactocentric frame to see if it has a large 3D velocity +as well. To use the Astropy default solar position and motion parameters, we +can do the following: + +>>> gc1 = c1.transform_to(coord.Galactocentric) + +From here, we can access the components of the resulting +`~astropy.coordinates.Galactocentric` instance to see the 3D Cartesian +velocity components: + +>>> print(gc1.v_x, gc1.v_y, gc1.v_z) # doctest: +FLOAT_CMP +30.254684717897074 km / s 171.29916086104885 km / s 18.19390627095307 km / s + +The default parameters for the `~astropy.coordinates.Galactocentric` frame +are detailed in the linked documentation, but we can modify the most commonly +changed values using the keywords ``galcen_distance``, ``galcen_v_sun``, and +``z_sun`` which set the sun-Galactic center distance, the 3D velocity vector +of the sun, and the height of the sun above the Galactic midplane, +respectively. The velocity of the sun can be specified as an +`~astropy.units.Quantity` object with velocity units and is interpreted as a +Cartesian velocity, as in the example below. Note that, as with the positions, +the Galactocentric frame is a right-handed system (i.e., the Sun is at negative +x values) so ``v_x`` is opposite of the Galactocentric radial velocity: + +>>> v_sun = [11.1, 244, 7.25] * (u.km / u.s) # [vx, vy, vz] +>>> gc_frame = coord.Galactocentric( +... galcen_distance=8 * u.kpc, galcen_v_sun=v_sun, z_sun=0 * u.pc +... ) + +We can then transform to this frame instead, with our custom parameters: + +>>> gc2 = c1.transform_to(gc_frame) +>>> print(gc2.v_x, gc2.v_y, gc2.v_z) # doctest: +FLOAT_CMP +28.427958360720748 km / s 169.69916086104888 km / s 17.70831652451455 km / s + +It is sometimes useful to specify the solar motion using the +`proper motion of Sgr A* `_ +instead of Cartesian velocity components. With an assumed distance, we can convert +proper motion components to Cartesian velocity components using `astropy.units`: + +>>> galcen_distance = 8 * u.kpc +>>> pm_gal_sgrA = [-6.379, -0.202] * (u.mas / u.yr) # from Reid & Brunthaler 2004 +>>> vy, vz = -(galcen_distance * pm_gal_sgrA).to(u.km / u.s, u.dimensionless_angles()) + +We still have to assume a line-of-sight velocity for the Galactic center, +which we will again take to be 11 km/s: + +>>> vx = 11.1 * (u.km / u.s) +>>> v_sun2 = u.Quantity([vx, vy, vz]) # List of Quantity -> a single Quantity +>>> gc_frame2 = coord.Galactocentric( +... galcen_distance=galcen_distance, galcen_v_sun=v_sun2, z_sun=0 * u.pc +... ) +>>> gc3 = c1.transform_to(gc_frame2) +>>> print(gc3.v_x, gc3.v_y, gc3.v_z) # doctest: +FLOAT_CMP +28.427958360720748 km / s 167.61484955608267 km / s 18.118916793584443 km / s + +The transformations also work in the opposite direction. This can be useful +for transforming simulated or theoretical data to observable quantities. As +an example, we will generate 4 theoretical circular orbits at different +Galactocentric radii with the same circular velocity, and transform them to +Heliocentric coordinates: + +.. plot:: + :include-source: + + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> import astropy.coordinates as coord + >>> from astropy import units as u + >>> ring_distances = np.arange(10, 26, 5) * u.kpc + >>> circ_velocity = 220 * (u.km / u.s) + >>> phi_grid = np.linspace(90, 270, 512) * u.degree # grid of azimuths + >>> ring_rep = coord.CylindricalRepresentation( + ... rho=ring_distances[:, np.newaxis], + ... phi=phi_grid[np.newaxis], + ... z=np.zeros_like(ring_distances)[:, np.newaxis], + ... ) + >>> angular_velocity = (-circ_velocity / ring_distances).to( + ... u.mas / u.yr, u.dimensionless_angles() + ... ) + >>> ring_dif = coord.CylindricalDifferential( + ... d_rho=np.zeros(phi_grid.shape)[np.newaxis] * (u.km / u.s), + ... d_phi=angular_velocity[:, np.newaxis], + ... d_z=np.zeros(phi_grid.shape)[np.newaxis] * (u.km / u.s), + ... ) + >>> ring_rep = ring_rep.with_differentials(ring_dif) + >>> gc_rings = coord.SkyCoord(ring_rep, frame=coord.Galactocentric) + + First, let's visualize the geometry in Galactocentric coordinates. Here are + the positions and velocities of the rings; note that in the velocity plot, + the velocities of the 4 rings are identical and thus overlaid under the same + curve: + + >>> fig, axes = plt.subplots(1, 2, figsize=(12, 6)) + >>> axes[0].plot(gc_rings.x.T, gc_rings.y.T, marker="None", linewidth=3) # doctest: +IGNORE_OUTPUT + >>> axes[0].text(-8.0, 0, r"$\odot$", fontsize=20) # doctest: +IGNORE_OUTPUT + >>> axes[0].set_xlim(-30, 30) # doctest: +IGNORE_OUTPUT + >>> axes[0].set_ylim(-30, 30) # doctest: +IGNORE_OUTPUT + >>> axes[0].set_xlabel("$x$ [kpc]") # doctest: +IGNORE_OUTPUT + >>> axes[0].set_ylabel("$y$ [kpc]") # doctest: +IGNORE_OUTPUT + >>> axes[0].set_title("Positions") # doctest: +IGNORE_OUTPUT + >>> axes[1].plot(gc_rings.v_x.T, gc_rings.v_y.T, marker="None", linewidth=3) # doctest: +IGNORE_OUTPUT + >>> axes[1].set_xlim(-250, 250) # doctest: +IGNORE_OUTPUT + >>> axes[1].set_ylim(-250, 250) # doctest: +IGNORE_OUTPUT + >>> axes[1].set_xlabel(f"$v_x$ [{(u.km / u.s).to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT + >>> axes[1].set_ylabel(f"$v_y$ [{(u.km / u.s).to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT + >>> axes[1].set_title("Velocities") # doctest: +IGNORE_OUTPUT + >>> fig.tight_layout() + + Now we can transform to Galactic coordinates and visualize the rings in + observable coordinates: + + >>> gal_rings = gc_rings.transform_to(coord.Galactic) + >>> fig, ax = plt.subplots(1, 1, figsize=(8, 6)) + >>> for i in range(len(ring_distances)): + ... ax.plot( + ... gal_rings[i].l.degree, + ... gal_rings[i].pm_l_cosb.value, + ... label=str(ring_distances[i]), + ... marker="None", + ... linewidth=3, + ... ) # doctest: +IGNORE_OUTPUT + >>> ax.set_xlim(360, 0) # doctest: +IGNORE_OUTPUT + >>> ax.set_xlabel("$l$ [deg]") # doctest: +IGNORE_OUTPUT + >>> ax.set_ylabel(rf'$\mu_l \, \cos b$ [{(u.mas/u.yr).to_string("latex_inline")}]') # doctest: +IGNORE_OUTPUT + >>> ax.legend() # doctest: +IGNORE_OUTPUT + >>> plt.draw() + +.. + EXAMPLE END diff --git a/docs/coordinates/example_gallery_plot_mars_coordinate_frame.rst b/docs/coordinates/example_gallery_plot_mars_coordinate_frame.rst index 987bb6aa3379..7eee3fab201b 100644 --- a/docs/coordinates/example_gallery_plot_mars_coordinate_frame.rst +++ b/docs/coordinates/example_gallery_plot_mars_coordinate_frame.rst @@ -1,115 +1,115 @@ -.. _sphx_glr_generated_examples_coordinates_plot_mars-coordinate-frame.py: - -Create a new coordinate frame class for Mars -============================================ - -.. - EXAMPLE START - Create a new coordinate frame class for Mars - -This example describes how to subclass and define a custom coordinate frame for a -planetary body which can be described by a geodetic or bodycentric representation, -as discussed in :ref:`astropy:astropy-coordinates-design` and -:ref:`astropy-coordinates-create-geodetic`. - -Note that we use the frame here only to store coordinates. To use it to determine, e.g., -where to point a telescope on Earth to observe Olympus Mons, one would need to add the -frame to the transfer graph, which is beyond the scope of this example. - -To do this, first we need to define a subclass of a -`~astropy.coordinates.BaseGeodeticRepresentation` and -`~astropy.coordinates.BaseBodycentricRepresentation`, then a subclass of -`~astropy.coordinates.BaseCoordinateFrame` using the previous defined -representations. - -.. plot:: - :include-source: - - >>> import matplotlib.pyplot as plt - >>> import numpy as np - >>> from astropy import units as u - >>> from astropy.coordinates.baseframe import BaseCoordinateFrame - >>> from astropy.coordinates.representation import CartesianRepresentation - >>> from astropy.coordinates.representation.geodetic import ( - ... BaseBodycentricRepresentation, - ... BaseGeodeticRepresentation, - ... ) - >>> from astropy.visualization import quantity_support - - The first step is to create a new class, and make it a subclass of - `~astropy.coordinates.BaseGeodeticRepresentation`. - Geodetic latitudes are used and longitudes span from 0 to 360 degrees east positive - It represent a best fit of the Mars spheroid to the martian geoid (areoid): - - >>> class MarsBestFitAeroid(BaseGeodeticRepresentation): - ... """A Spheroidal representation of Mars that minimized deviations with respect to the - ... areoid following - ... Ardalan A. A, R. Karimi, and E. W. Grafarend (2010) - ... https://doi.org/10.1007/s11038-009-9342-7 - ... """ - ... _equatorial_radius = 3395.4280 * u.km - ... _flattening = 0.5227617843759314 * u.percent - - Now let's define a new geodetic representation obtained from MarsBestFitAeroid but - described by planetocentric latitudes: - - >>> class MarsBestFitOcentricAeroid(BaseBodycentricRepresentation): - ... """A Spheroidal planetocentric representation of Mars that minimized deviations with - ... respect to the areoid following - ... Ardalan A. A, R. Karimi, and E. W. Grafarend (2010) - ... https://doi.org/10.1007/s11038-009-9342-7 - ... """ - ... _equatorial_radius = 3395.4280 * u.km - ... _flattening = 0.5227617843759314 * u.percent - - As a comparison we define a new spherical frame representation, we could - have based it on `~astropy.coordinates.BaseBodycentricRepresentation` too: - - >>> class MarsSphere(BaseGeodeticRepresentation): - ... """A Spherical representation of Mars.""" - ... _equatorial_radius = 3395.4280 * u.km - ... _flattening = 0.0 * u.percent - - The new planetary body-fixed reference system will be described using the - previous defined representations: - - >>> class MarsCoordinateFrame(BaseCoordinateFrame): - ... """A reference system for Mars.""" - ... name = "Mars" - - Now we plot the differences between each component of the cartesian - representation with respect to the spherical model, assuming the point on the - surface of the body (``height = 0``): - - >>> mars_sphere = MarsCoordinateFrame( - ... lon=np.linspace(0, 360, 128) * u.deg, - ... lat=np.linspace(-90, 90, 128) * u.deg, - ... representation_type=MarsSphere, - ... ) - >>> mars = MarsCoordinateFrame( - ... lon=np.linspace(0, 360, 128) * u.deg, - ... lat=np.linspace(-90, 90, 128) * u.deg, - ... representation_type=MarsBestFitAeroid, - ... ) - >>> mars_ocentric = MarsCoordinateFrame( - ... lon=np.linspace(0, 360, 128) * u.deg, - ... lat=np.linspace(-90, 90, 128) * u.deg, - ... representation_type=MarsBestFitOcentricAeroid, - ... ) - >>> xyz_sphere = mars_sphere.represent_as(CartesianRepresentation) - >>> xyz = mars.represent_as(CartesianRepresentation) - >>> xyz_ocentric = mars_ocentric.represent_as(CartesianRepresentation) - >>> with quantity_support(): - ... fig, ax = plt.subplots(2, subplot_kw={"projection": "3d"}) - ... ax[0].scatter(*((xyz - xyz_sphere).xyz << u.km)) # doctest: +IGNORE_OUTPUT - ... ax[0].tick_params(labelsize=8) # doctest: +IGNORE_OUTPUT - ... ax[0].set(xlabel="x [km]", ylabel="y [km]", zlabel="z [km]") # doctest: +IGNORE_OUTPUT - ... ax[0].set_title("Mars-odetic spheroid difference from sphere") # doctest: +IGNORE_OUTPUT - ... ax[1].scatter(*((xyz_ocentric - xyz_sphere).xyz << u.km)) # doctest: +IGNORE_OUTPUT - ... ax[1].tick_params(labelsize=8) # doctest: +IGNORE_OUTPUT - ... ax[1].set(xlabel="x [km]", ylabel="y [km]", zlabel="z [km]") # doctest: +IGNORE_OUTPUT - ... ax[1].set_title("Mars-ocentric spheroid difference from sphere") # doctest: +IGNORE_OUTPUT - ... plt.draw() - -.. - EXAMPLE END +.. _sphx_glr_generated_examples_coordinates_plot_mars-coordinate-frame.py: + +Create a new coordinate frame class for Mars +============================================ + +.. + EXAMPLE START + Create a new coordinate frame class for Mars + +This example describes how to subclass and define a custom coordinate frame for a +planetary body which can be described by a geodetic or bodycentric representation, +as discussed in :ref:`astropy:astropy-coordinates-design` and +:ref:`astropy-coordinates-create-geodetic`. + +Note that we use the frame here only to store coordinates. To use it to determine, e.g., +where to point a telescope on Earth to observe Olympus Mons, one would need to add the +frame to the transfer graph, which is beyond the scope of this example. + +To do this, first we need to define a subclass of a +`~astropy.coordinates.BaseGeodeticRepresentation` and +`~astropy.coordinates.BaseBodycentricRepresentation`, then a subclass of +`~astropy.coordinates.BaseCoordinateFrame` using the previous defined +representations. + +.. plot:: + :include-source: + + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from astropy import units as u + >>> from astropy.coordinates.baseframe import BaseCoordinateFrame + >>> from astropy.coordinates.representation import CartesianRepresentation + >>> from astropy.coordinates.representation.geodetic import ( + ... BaseBodycentricRepresentation, + ... BaseGeodeticRepresentation, + ... ) + >>> from astropy.visualization import quantity_support + + The first step is to create a new class, and make it a subclass of + `~astropy.coordinates.BaseGeodeticRepresentation`. + Geodetic latitudes are used and longitudes span from 0 to 360 degrees east positive + It represent a best fit of the Mars spheroid to the martian geoid (areoid): + + >>> class MarsBestFitAeroid(BaseGeodeticRepresentation): + ... """A Spheroidal representation of Mars that minimized deviations with respect to the + ... areoid following + ... Ardalan A. A, R. Karimi, and E. W. Grafarend (2010) + ... https://doi.org/10.1007/s11038-009-9342-7 + ... """ + ... _equatorial_radius = 3395.4280 * u.km + ... _flattening = 0.5227617843759314 * u.percent + + Now let's define a new geodetic representation obtained from MarsBestFitAeroid but + described by planetocentric latitudes: + + >>> class MarsBestFitOcentricAeroid(BaseBodycentricRepresentation): + ... """A Spheroidal planetocentric representation of Mars that minimized deviations with + ... respect to the areoid following + ... Ardalan A. A, R. Karimi, and E. W. Grafarend (2010) + ... https://doi.org/10.1007/s11038-009-9342-7 + ... """ + ... _equatorial_radius = 3395.4280 * u.km + ... _flattening = 0.5227617843759314 * u.percent + + As a comparison we define a new spherical frame representation, we could + have based it on `~astropy.coordinates.BaseBodycentricRepresentation` too: + + >>> class MarsSphere(BaseGeodeticRepresentation): + ... """A Spherical representation of Mars.""" + ... _equatorial_radius = 3395.4280 * u.km + ... _flattening = 0.0 * u.percent + + The new planetary body-fixed reference system will be described using the + previous defined representations: + + >>> class MarsCoordinateFrame(BaseCoordinateFrame): + ... """A reference system for Mars.""" + ... name = "Mars" + + Now we plot the differences between each component of the cartesian + representation with respect to the spherical model, assuming the point on the + surface of the body (``height = 0``): + + >>> mars_sphere = MarsCoordinateFrame( + ... lon=np.linspace(0, 360, 128) * u.deg, + ... lat=np.linspace(-90, 90, 128) * u.deg, + ... representation_type=MarsSphere, + ... ) + >>> mars = MarsCoordinateFrame( + ... lon=np.linspace(0, 360, 128) * u.deg, + ... lat=np.linspace(-90, 90, 128) * u.deg, + ... representation_type=MarsBestFitAeroid, + ... ) + >>> mars_ocentric = MarsCoordinateFrame( + ... lon=np.linspace(0, 360, 128) * u.deg, + ... lat=np.linspace(-90, 90, 128) * u.deg, + ... representation_type=MarsBestFitOcentricAeroid, + ... ) + >>> xyz_sphere = mars_sphere.represent_as(CartesianRepresentation) + >>> xyz = mars.represent_as(CartesianRepresentation) + >>> xyz_ocentric = mars_ocentric.represent_as(CartesianRepresentation) + >>> with quantity_support(): + ... fig, ax = plt.subplots(2, subplot_kw={"projection": "3d"}) + ... ax[0].scatter(*((xyz - xyz_sphere).xyz << u.km)) # doctest: +IGNORE_OUTPUT + ... ax[0].tick_params(labelsize=8) # doctest: +IGNORE_OUTPUT + ... ax[0].set(xlabel="x [km]", ylabel="y [km]", zlabel="z [km]") # doctest: +IGNORE_OUTPUT + ... ax[0].set_title("Mars-odetic spheroid difference from sphere") # doctest: +IGNORE_OUTPUT + ... ax[1].scatter(*((xyz_ocentric - xyz_sphere).xyz << u.km)) # doctest: +IGNORE_OUTPUT + ... ax[1].tick_params(labelsize=8) # doctest: +IGNORE_OUTPUT + ... ax[1].set(xlabel="x [km]", ylabel="y [km]", zlabel="z [km]") # doctest: +IGNORE_OUTPUT + ... ax[1].set_title("Mars-ocentric spheroid difference from sphere") # doctest: +IGNORE_OUTPUT + ... plt.draw() + +.. + EXAMPLE END diff --git a/docs/coordinates/example_gallery_plot_obs_planning.rst b/docs/coordinates/example_gallery_plot_obs_planning.rst index fd361fb81cc1..b5c7a6e9550b 100644 --- a/docs/coordinates/example_gallery_plot_obs_planning.rst +++ b/docs/coordinates/example_gallery_plot_obs_planning.rst @@ -1,154 +1,154 @@ -.. _sphx_glr_generated_examples_coordinates_plot_obs-planning.py: - -Determining and plotting the altitude/azimuth of a celestial object -=================================================================== - -.. - EXAMPLE START - Determining and plotting the altitude/azimuth of a celestial object - -This example demonstrates coordinate transformations and the creation of -visibility curves to assist with observing run planning. - -In this example, we make a `~astropy.coordinates.SkyCoord` instance for M33. -The altitude-azimuth coordinates are then found using -`astropy.coordinates.EarthLocation` and `astropy.time.Time` objects. - -This example is meant to demonstrate the capabilities of the -`astropy.coordinates` package. For more convenient and/or complex observation -planning, consider the `astroplan `_ -package. - -Let's suppose you are planning to visit picturesque Bear Mountain State Park -in New York, USA. You are bringing your telescope with you (of course), and -someone told you M33 is a great target to observe there. You happen to know -you are free at 11:00 PM local time, and you want to know if it will be up. -Astropy can answer that. - -.. plot:: - :include-source: - - >>> import matplotlib.pyplot as plt - >>> import numpy as np - >>> from astropy import units as u - >>> from astropy.coordinates import AltAz, EarthLocation, SkyCoord, get_body, get_sun - >>> from astropy.time import Time - >>> from astropy.visualization import quantity_support - - :meth:`astropy.coordinates.SkyCoord.from_name` uses Simbad to resolve object - names and retrieve coordinates. - - Get the coordinates of M33: - - >>> # m33 = SkyCoord.from_name("M33") - >>> m33 = SkyCoord(23.46206906, 30.66017511, unit="deg") - - Use `astropy.coordinates.EarthLocation` to provide the location of Bear - Mountain and set the time to 11pm Eastern Daylight Time (EDT) on 2012 July 12: - - >>> bear_mountain = EarthLocation(lat=41.3 * u.deg, lon=-74 * u.deg, height=390 * u.m) - >>> utcoffset = -4 * u.hour # EDT - >>> time = Time("2012-7-12 23:00:00") - utcoffset - - :meth:`astropy.coordinates.EarthLocation.get_site_names` can be used to get - locations of major observatories. - - Use `astropy.coordinates` to find the Alt, Az coordinates of M33 at as - observed from Bear Mountain at 11pm on 2012 July 12: - - >>> m33altaz = m33.transform_to(AltAz(obstime=time, location=bear_mountain)) - >>> print(f"M33's Altitude = {m33altaz.alt:.2}") - M33's Altitude = 0.13 deg - - This is helpful since it turns out M33 is barely above the horizon at this - time. It is more informative to find M33's airmass over the course of - the night. - - Find the Alt, Az coordinates of M33 at 100 times evenly spaced between 10 PM - and 7 AM EDT: - - >>> midnight = Time("2012-7-13 00:00:00") - utcoffset - >>> delta_midnight = np.linspace(-2, 10, 100) * u.hour - >>> frame_July13night = AltAz(obstime=midnight + delta_midnight, location=bear_mountain) - >>> m33altazs_July13night = m33.transform_to(frame_July13night) - - Convert Alt, Az to airmass with `~astropy.coordinates.AltAz.secz` attribute: - - >>> m33airmasss_July13night = m33altazs_July13night.secz - - Plot the airmass as a function of time: - - >>> with quantity_support(): - ... fig, ax = plt.subplots(1, 1, figsize=(12, 6)) - ... ax.plot(delta_midnight, m33airmasss_July13night) # doctest: +IGNORE_OUTPUT - ... ax.set_xlim(-2, 10) # doctest: +IGNORE_OUTPUT - ... ax.set_ylim(1, 4) # doctest: +IGNORE_OUTPUT - ... ax.set_xlabel("Hours from EDT Midnight") # doctest: +IGNORE_OUTPUT - ... ax.set_ylabel("Airmass [Sec(z)]") # doctest: +IGNORE_OUTPUT - ... plt.draw() - - Use :func:`~astropy.coordinates.get_sun` to find the location of the Sun at 1000 - evenly spaced times between noon on July 12 and noon on July 13: - - >>> delta_midnight = np.linspace(-12, 12, 1000) * u.hour - >>> times_July12_to_13 = midnight + delta_midnight - >>> frame_July12_to_13 = AltAz(obstime=times_July12_to_13, location=bear_mountain) - >>> sunaltazs_July12_to_13 = get_sun(times_July12_to_13).transform_to(frame_July12_to_13) - - Do the same with :func:`~astropy.coordinates.get_body` to find when the moon is - up. Be aware that this will need to download a 10 MB file from the internet - to get a precise location of the moon. - - >>> moon_July12_to_13 = get_body("moon", times_July12_to_13) - >>> moonaltazs_July12_to_13 = moon_July12_to_13.transform_to(frame_July12_to_13) - - Find the Alt, Az coordinates of M33 at those same times: - - >>> m33altazs_July12_to_13 = m33.transform_to(frame_July12_to_13) - - Make a figure illustrating nighttime and the altitudes of M33 and - the Sun over that time: - - >>> with quantity_support(): - ... fig, ax = plt.subplots(1, 1, figsize=(12, 6)) - ... ax.plot(delta_midnight, sunaltazs_July12_to_13.alt, color="r", label="Sun") # doctest: +IGNORE_OUTPUT - ... ax.plot( - ... delta_midnight, moonaltazs_July12_to_13.alt, color=[0.75] * 3, ls="--", label="Moon" - ... ) # doctest: +IGNORE_OUTPUT - ... mappable = ax.scatter( - ... delta_midnight, - ... m33altazs_July12_to_13.alt, - ... c=m33altazs_July12_to_13.az.value, - ... label="M33", - ... lw=0, - ... s=8, - ... cmap="viridis", - ... ) - ... ax.fill_between( - ... delta_midnight, - ... 0 * u.deg, - ... 90 * u.deg, - ... sunaltazs_July12_to_13.alt < (-0 * u.deg), - ... color="0.5", - ... zorder=0, - ... ) # doctest: +IGNORE_OUTPUT - ... ax.fill_between( - ... delta_midnight, - ... 0 * u.deg, - ... 90 * u.deg, - ... sunaltazs_July12_to_13.alt < (-18 * u.deg), - ... color="k", - ... zorder=0, - ... ) # doctest: +IGNORE_OUTPUT - ... fig.colorbar(mappable).set_label("Azimuth [deg]") # doctest: +IGNORE_OUTPUT - ... ax.legend(loc="upper left") # doctest: +IGNORE_OUTPUT - ... ax.set_xlim(-12 * u.hour, 12 * u.hour) # doctest: +IGNORE_OUTPUT - ... ax.set_xticks((np.arange(13) * 2 - 12) * u.hour) # doctest: +IGNORE_OUTPUT - ... ax.set_ylim(0 * u.deg, 90 * u.deg) # doctest: +IGNORE_OUTPUT - ... ax.set_xlabel("Hours from EDT Midnight") # doctest: +IGNORE_OUTPUT - ... ax.set_ylabel("Altitude [deg]") # doctest: +IGNORE_OUTPUT - ... ax.grid(visible=True) # doctest: +IGNORE_OUTPUT - ... plt.draw() - -.. - EXAMPLE END +.. _sphx_glr_generated_examples_coordinates_plot_obs-planning.py: + +Determining and plotting the altitude/azimuth of a celestial object +=================================================================== + +.. + EXAMPLE START + Determining and plotting the altitude/azimuth of a celestial object + +This example demonstrates coordinate transformations and the creation of +visibility curves to assist with observing run planning. + +In this example, we make a `~astropy.coordinates.SkyCoord` instance for M33. +The altitude-azimuth coordinates are then found using +`astropy.coordinates.EarthLocation` and `astropy.time.Time` objects. + +This example is meant to demonstrate the capabilities of the +`astropy.coordinates` package. For more convenient and/or complex observation +planning, consider the `astroplan `_ +package. + +Let's suppose you are planning to visit picturesque Bear Mountain State Park +in New York, USA. You are bringing your telescope with you (of course), and +someone told you M33 is a great target to observe there. You happen to know +you are free at 11:00 PM local time, and you want to know if it will be up. +Astropy can answer that. + +.. plot:: + :include-source: + + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from astropy import units as u + >>> from astropy.coordinates import AltAz, EarthLocation, SkyCoord, get_body, get_sun + >>> from astropy.time import Time + >>> from astropy.visualization import quantity_support + + :meth:`astropy.coordinates.SkyCoord.from_name` uses Simbad to resolve object + names and retrieve coordinates. + + Get the coordinates of M33: + + >>> # m33 = SkyCoord.from_name("M33") + >>> m33 = SkyCoord(23.46206906, 30.66017511, unit="deg") + + Use `astropy.coordinates.EarthLocation` to provide the location of Bear + Mountain and set the time to 11pm Eastern Daylight Time (EDT) on 2012 July 12: + + >>> bear_mountain = EarthLocation(lat=41.3 * u.deg, lon=-74 * u.deg, height=390 * u.m) + >>> utcoffset = -4 * u.hour # EDT + >>> time = Time("2012-7-12 23:00:00") - utcoffset + + :meth:`astropy.coordinates.EarthLocation.get_site_names` can be used to get + locations of major observatories. + + Use `astropy.coordinates` to find the Alt, Az coordinates of M33 at as + observed from Bear Mountain at 11pm on 2012 July 12: + + >>> m33altaz = m33.transform_to(AltAz(obstime=time, location=bear_mountain)) + >>> print(f"M33's Altitude = {m33altaz.alt:.2}") + M33's Altitude = 0.13 deg + + This is helpful since it turns out M33 is barely above the horizon at this + time. It is more informative to find M33's airmass over the course of + the night. + + Find the Alt, Az coordinates of M33 at 100 times evenly spaced between 10 PM + and 7 AM EDT: + + >>> midnight = Time("2012-7-13 00:00:00") - utcoffset + >>> delta_midnight = np.linspace(-2, 10, 100) * u.hour + >>> frame_July13night = AltAz(obstime=midnight + delta_midnight, location=bear_mountain) + >>> m33altazs_July13night = m33.transform_to(frame_July13night) + + Convert Alt, Az to airmass with `~astropy.coordinates.AltAz.secz` attribute: + + >>> m33airmasss_July13night = m33altazs_July13night.secz + + Plot the airmass as a function of time: + + >>> with quantity_support(): + ... fig, ax = plt.subplots(1, 1, figsize=(12, 6)) + ... ax.plot(delta_midnight, m33airmasss_July13night) # doctest: +IGNORE_OUTPUT + ... ax.set_xlim(-2, 10) # doctest: +IGNORE_OUTPUT + ... ax.set_ylim(1, 4) # doctest: +IGNORE_OUTPUT + ... ax.set_xlabel("Hours from EDT Midnight") # doctest: +IGNORE_OUTPUT + ... ax.set_ylabel("Airmass [Sec(z)]") # doctest: +IGNORE_OUTPUT + ... plt.draw() + + Use :func:`~astropy.coordinates.get_sun` to find the location of the Sun at 1000 + evenly spaced times between noon on July 12 and noon on July 13: + + >>> delta_midnight = np.linspace(-12, 12, 1000) * u.hour + >>> times_July12_to_13 = midnight + delta_midnight + >>> frame_July12_to_13 = AltAz(obstime=times_July12_to_13, location=bear_mountain) + >>> sunaltazs_July12_to_13 = get_sun(times_July12_to_13).transform_to(frame_July12_to_13) + + Do the same with :func:`~astropy.coordinates.get_body` to find when the moon is + up. Be aware that this will need to download a 10 MB file from the internet + to get a precise location of the moon. + + >>> moon_July12_to_13 = get_body("moon", times_July12_to_13) + >>> moonaltazs_July12_to_13 = moon_July12_to_13.transform_to(frame_July12_to_13) + + Find the Alt, Az coordinates of M33 at those same times: + + >>> m33altazs_July12_to_13 = m33.transform_to(frame_July12_to_13) + + Make a figure illustrating nighttime and the altitudes of M33 and + the Sun over that time: + + >>> with quantity_support(): + ... fig, ax = plt.subplots(1, 1, figsize=(12, 6)) + ... ax.plot(delta_midnight, sunaltazs_July12_to_13.alt, color="r", label="Sun") # doctest: +IGNORE_OUTPUT + ... ax.plot( + ... delta_midnight, moonaltazs_July12_to_13.alt, color=[0.75] * 3, ls="--", label="Moon" + ... ) # doctest: +IGNORE_OUTPUT + ... mappable = ax.scatter( + ... delta_midnight, + ... m33altazs_July12_to_13.alt, + ... c=m33altazs_July12_to_13.az.value, + ... label="M33", + ... lw=0, + ... s=8, + ... cmap="viridis", + ... ) + ... ax.fill_between( + ... delta_midnight, + ... 0 * u.deg, + ... 90 * u.deg, + ... sunaltazs_July12_to_13.alt < (-0 * u.deg), + ... color="0.5", + ... zorder=0, + ... ) # doctest: +IGNORE_OUTPUT + ... ax.fill_between( + ... delta_midnight, + ... 0 * u.deg, + ... 90 * u.deg, + ... sunaltazs_July12_to_13.alt < (-18 * u.deg), + ... color="k", + ... zorder=0, + ... ) # doctest: +IGNORE_OUTPUT + ... fig.colorbar(mappable).set_label("Azimuth [deg]") # doctest: +IGNORE_OUTPUT + ... ax.legend(loc="upper left") # doctest: +IGNORE_OUTPUT + ... ax.set_xlim(-12 * u.hour, 12 * u.hour) # doctest: +IGNORE_OUTPUT + ... ax.set_xticks((np.arange(13) * 2 - 12) * u.hour) # doctest: +IGNORE_OUTPUT + ... ax.set_ylim(0 * u.deg, 90 * u.deg) # doctest: +IGNORE_OUTPUT + ... ax.set_xlabel("Hours from EDT Midnight") # doctest: +IGNORE_OUTPUT + ... ax.set_ylabel("Altitude [deg]") # doctest: +IGNORE_OUTPUT + ... ax.grid(visible=True) # doctest: +IGNORE_OUTPUT + ... plt.draw() + +.. + EXAMPLE END diff --git a/docs/coordinates/example_gallery_plot_sgr_coordinate_frame.rst b/docs/coordinates/example_gallery_plot_sgr_coordinate_frame.rst index 5f752001ba84..935ad8819db6 100644 --- a/docs/coordinates/example_gallery_plot_sgr_coordinate_frame.rst +++ b/docs/coordinates/example_gallery_plot_sgr_coordinate_frame.rst @@ -1,237 +1,237 @@ -.. _sphx_glr_generated_examples_coordinates_plot_sgr-coordinate-frame.py: - -Create a new coordinate class (for the Sagittarius stream) -========================================================== - -.. - EXAMPLE START - Create a new coordinate class (for the Sagittarius stream) - -This document describes in detail how to subclass and define a custom spherical -coordinate frame, as discussed in :ref:`astropy:astropy-coordinates-design` and -the docstring for `~astropy.coordinates.BaseCoordinateFrame`. In this example, -we will define a coordinate system defined by the plane of orbit of the -Sagittarius Dwarf Galaxy (hereafter Sgr; as defined in Majewski et al. 2003). -The Sgr coordinate system is often referred to in terms of two angular -coordinates, :math:`\Lambda,B`. - -To do this, we need to define a subclass of -`~astropy.coordinates.BaseCoordinateFrame` that knows the names and units of the -coordinate system angles in each of the supported representations. In this case -we support `~astropy.coordinates.SphericalRepresentation` with "Lambda" and -"Beta". Then we have to define the transformation from this coordinate system to -some other built-in system. Here we will use Galactic coordinates, represented -by the `~astropy.coordinates.Galactic` class. - -.. seealso:: - - The `gala package `_ - Defines a number of Astropy coordinate frames for - stellar stream coordinate systems. - - Majewski et al. 2003 - "A Two Micron All Sky Survey View of the Sagittarius - Dwarf Galaxy. I. Morphology of the Sagittarius Core and Tidal Arms", - https://arxiv.org/abs/astro-ph/0304198 - - Law & Majewski 2010 - "The Sagittarius Dwarf Galaxy: A Model for Evolution in a - Triaxial Milky Way Halo", https://arxiv.org/abs/1003.1132 - - David Law's Sgr info page - https://www.stsci.edu/~dlaw/Sgr/ - -.. plot:: - :include-source: - - >>> import matplotlib.pyplot as plt - >>> import numpy as np - >>> import astropy.coordinates as coord - >>> from astropy import units as u - >>> from astropy.coordinates import frame_transform_graph, rotation_matrix - - The first step is to create a new class, which we'll call - ``Sagittarius`` and make it a subclass of - `~astropy.coordinates.BaseCoordinateFrame`: - - >>> class Sagittarius(coord.BaseCoordinateFrame): - ... """A Heliocentric spherical coordinate system defined by the orbit - ... of the Sagittarius dwarf galaxy, as described in - ... https://ui.adsabs.harvard.edu/abs/2003ApJ...599.1082M - ... and further explained in - ... https://www.stsci.edu/~dlaw/Sgr/. - ... - ... Parameters - ... ---------- - ... representation : `~astropy.coordinates.BaseRepresentation` or None - ... A representation object or None to have no data (or use the other keywords) - ... Lambda : `~astropy.coordinates.Angle`, optional, must be keyword - ... The longitude-like angle corresponding to Sagittarius' orbit. - ... Beta : `~astropy.coordinates.Angle`, optional, must be keyword - ... The latitude-like angle corresponding to Sagittarius' orbit. - ... distance : `~astropy.units.Quantity`, optional, must be keyword - ... The Distance for this object along the line-of-sight. - ... pm_Lambda_cosBeta : `~astropy.units.Quantity`, optional, must be keyword - ... The proper motion along the stream in ``Lambda`` (including the - ... ``cos(Beta)`` factor) for this object (``pm_Beta`` must also be given). - ... pm_Beta : `~astropy.units.Quantity`, optional, must be keyword - ... The proper motion in Declination for this object (``pm_ra_cosdec`` must - ... also be given). - ... radial_velocity : `~astropy.units.Quantity`, optional, keyword-only - ... The radial velocity of this object. - ... """ - ... default_representation = coord.SphericalRepresentation - ... default_differential = coord.SphericalCosLatDifferential - ... frame_specific_representation_info = { - ... coord.SphericalRepresentation: [ - ... coord.RepresentationMapping("lon", "Lambda"), - ... coord.RepresentationMapping("lat", "Beta"), - ... coord.RepresentationMapping("distance", "distance"), - ... ] - ... } - - Breaking this down line-by-line, we define the class as a subclass of - `~astropy.coordinates.BaseCoordinateFrame`. Then we include a descriptive - docstring. The final lines are class-level attributes that specify the - default representation for the data, default differential for the velocity - information, and mappings from the attribute names used by representation - objects to the names that are to be used by the ``Sagittarius`` frame. In this - case we override the names in the spherical representations but do not do - anything with other representations like cartesian or cylindrical. - - Next we have to define the transformation from this coordinate system to some - other built-in coordinate system; we will use Galactic coordinates. We can do - this by defining functions that return transformation matrices, or by simply - defining a function that accepts a coordinate and returns a new coordinate in - the new system. Because the transformation to the Sagittarius coordinate - stem is just a spherical rotation from Galactic coordinates, we will - define a function that returns this matrix. We will start by constructing the - transformation matrix using pre-determined Euler angles and the - ``rotation_matrix`` helper function: - - >>> SGR_PHI = (180 + 3.75) * u.degree # Euler angles (from Law & Majewski 2010) - >>> SGR_THETA = (90 - 13.46) * u.degree - >>> SGR_PSI = (180 + 14.111534) * u.degree - - Generate the rotation matrix using the x-convention (see Goldstein): - - >>> SGR_MATRIX = ( - ... np.diag([1.0, 1.0, -1.0]) - ... @ rotation_matrix(SGR_PSI, "z") - ... @ rotation_matrix(SGR_THETA, "x") - ... @ rotation_matrix(SGR_PHI, "z") - ... ) - - Since we already constructed the transformation (rotation) matrix above, and - the inverse of a rotation matrix is just its transpose, the required - transformation functions are very simple: - - >>> @frame_transform_graph.transform( - ... coord.StaticMatrixTransform, coord.Galactic, Sagittarius - ... ) - ... def galactic_to_sgr(): - ... """Compute the Galactic spherical to heliocentric Sgr transformation matrix.""" - ... return SGR_MATRIX - - The decorator ``@frame_transform_graph.transform(coord.StaticMatrixTransform, coord.Galactic, Sagittarius)`` - registers this function on the - ``frame_transform_graph`` as a coordinate transformation. Inside the function, - we return the previously defined rotation matrix. - - We then register the inverse transformation by using the transpose of the - rotation matrix (which is faster to compute than the inverse): - - >>> @frame_transform_graph.transform( - ... coord.StaticMatrixTransform, Sagittarius, coord.Galactic - ... ) - ... def sgr_to_galactic(): - ... """Compute the heliocentric Sgr to spherical Galactic transformation matrix.""" - ... return SGR_MATRIX.swapaxes(-2, -1) - - Now that we have registered these transformations between ``Sagittarius`` and - `~astropy.coordinates.Galactic`, we can transform between *any* coordinate - system and ``Sagittarius`` (as long as the other system has a path to - transform to `~astropy.coordinates.Galactic`). For example, to transform from - ICRS coordinates to ``Sagittarius``, we would do: - - >>> icrs = coord.SkyCoord(280.161732 * u.degree, 11.91934 * u.degree, frame="icrs") - >>> sgr = icrs.transform_to(Sagittarius) - >>> print(sgr) - - - Or, to transform from the ``Sagittarius`` frame to ICRS coordinates (in this - case, a line along the ``Sagittarius`` x-y plane): - - >>> sgr = coord.SkyCoord( - ... Lambda=np.linspace(0, 2 * np.pi, 128) * u.radian, - ... Beta=np.zeros(128) * u.radian, - ... frame="sagittarius", - ... ) - >>> icrs = sgr.transform_to(coord.ICRS) - >>> print(icrs) # doctest: +ELLIPSIS - - - As an example, we will now plot the points in both coordinate systems: - - >>> fig, axes = plt.subplots(2, 1, figsize=(8, 10), subplot_kw={"projection": "aitoff"}) - >>> axes[0].set_title("Sagittarius") # doctest: +IGNORE_OUTPUT - >>> axes[0].plot( - ... sgr.Lambda.wrap_at(180 * u.deg).radian, - ... sgr.Beta.radian, - ... linestyle="none", - ... marker=".", - ... ) # doctest: +IGNORE_OUTPUT - >>> axes[0].grid(visible=True) # doctest: +IGNORE_OUTPUT - >>> axes[1].set_title("ICRS") # doctest: +IGNORE_OUTPUT - >>> axes[1].plot( - ... icrs.ra.wrap_at(180 * u.deg).radian, icrs.dec.radian, linestyle="none", marker="." - ... ) # doctest: +IGNORE_OUTPUT - >>> axes[1].grid(visible=True) # doctest: +IGNORE_OUTPUT - - This particular transformation is just a spherical rotation, which is a - special case of an Affine transformation with no vector offset. The - transformation of velocity components is therefore natively supported as - well: - - >>> sgr = coord.SkyCoord( - ... Lambda=np.linspace(0, 2 * np.pi, 128) * u.radian, - ... Beta=np.zeros(128) * u.radian, - ... pm_Lambda_cosBeta=np.random.uniform(-5, 5, 128) * (u.mas / u.yr), - ... pm_Beta=np.zeros(128) * (u.mas / u.yr), - ... frame="sagittarius", - ... ) - >>> icrs = sgr.transform_to(coord.ICRS) - >>> print(icrs) # doctest: +ELLIPSIS - - >>> fig, axes = plt.subplots(3, 1, figsize=(8, 10), sharex=True) - >>> axes[0].set_title("Sagittarius") # doctest: +IGNORE_OUTPUT - >>> axes[0].plot( - ... sgr.Lambda.degree, sgr.pm_Lambda_cosBeta.value, linestyle="none", marker="." - ... ) # doctest: +IGNORE_OUTPUT - >>> axes[0].set_xlabel(r"$\Lambda$ [deg]") # doctest: +IGNORE_OUTPUT - >>> axes[0].set_ylabel( - ... rf"$\mu_\Lambda \, \cos B$ [{sgr.pm_Lambda_cosBeta.unit.to_string('latex_inline')}]" - ... ) # doctest: +IGNORE_OUTPUT - >>> axes[0].grid(visible=True) # doctest: +IGNORE_OUTPUT - >>> axes[1].set_title("ICRS") # doctest: +IGNORE_OUTPUT - >>> axes[1].plot(icrs.ra.degree, icrs.pm_ra_cosdec.value, linestyle="none", marker=".") # doctest: +IGNORE_OUTPUT - >>> axes[1].set_ylabel( - ... rf"$\mu_\alpha \, \cos\delta$ [{icrs.pm_ra_cosdec.unit.to_string('latex_inline')}]" - ... ) # doctest: +IGNORE_OUTPUT - >>> axes[1].grid(visible=True) # doctest: +IGNORE_OUTPUT - >>> axes[2].set_title("ICRS") # doctest: +IGNORE_OUTPUT - >>> axes[2].plot(icrs.ra.degree, icrs.pm_dec.value, linestyle="none", marker=".") # doctest: +IGNORE_OUTPUT - >>> axes[2].set_xlabel("RA [deg]") # doctest: +IGNORE_OUTPUT - >>> axes[2].set_ylabel(rf"$\mu_\delta$ [{icrs.pm_dec.unit.to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT - >>> axes[2].grid(visible=True) # doctest: +IGNORE_OUTPUT - >>> plt.draw() - -.. - EXAMPLE END +.. _sphx_glr_generated_examples_coordinates_plot_sgr-coordinate-frame.py: + +Create a new coordinate class (for the Sagittarius stream) +========================================================== + +.. + EXAMPLE START + Create a new coordinate class (for the Sagittarius stream) + +This document describes in detail how to subclass and define a custom spherical +coordinate frame, as discussed in :ref:`astropy:astropy-coordinates-design` and +the docstring for `~astropy.coordinates.BaseCoordinateFrame`. In this example, +we will define a coordinate system defined by the plane of orbit of the +Sagittarius Dwarf Galaxy (hereafter Sgr; as defined in Majewski et al. 2003). +The Sgr coordinate system is often referred to in terms of two angular +coordinates, :math:`\Lambda,B`. + +To do this, we need to define a subclass of +`~astropy.coordinates.BaseCoordinateFrame` that knows the names and units of the +coordinate system angles in each of the supported representations. In this case +we support `~astropy.coordinates.SphericalRepresentation` with "Lambda" and +"Beta". Then we have to define the transformation from this coordinate system to +some other built-in system. Here we will use Galactic coordinates, represented +by the `~astropy.coordinates.Galactic` class. + +.. seealso:: + + The `gala package `_ + Defines a number of Astropy coordinate frames for + stellar stream coordinate systems. + + Majewski et al. 2003 + "A Two Micron All Sky Survey View of the Sagittarius + Dwarf Galaxy. I. Morphology of the Sagittarius Core and Tidal Arms", + https://arxiv.org/abs/astro-ph/0304198 + + Law & Majewski 2010 + "The Sagittarius Dwarf Galaxy: A Model for Evolution in a + Triaxial Milky Way Halo", https://arxiv.org/abs/1003.1132 + + David Law's Sgr info page + https://www.stsci.edu/~dlaw/Sgr/ + +.. plot:: + :include-source: + + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> import astropy.coordinates as coord + >>> from astropy import units as u + >>> from astropy.coordinates import frame_transform_graph, rotation_matrix + + The first step is to create a new class, which we'll call + ``Sagittarius`` and make it a subclass of + `~astropy.coordinates.BaseCoordinateFrame`: + + >>> class Sagittarius(coord.BaseCoordinateFrame): + ... """A Heliocentric spherical coordinate system defined by the orbit + ... of the Sagittarius dwarf galaxy, as described in + ... https://ui.adsabs.harvard.edu/abs/2003ApJ...599.1082M + ... and further explained in + ... https://www.stsci.edu/~dlaw/Sgr/. + ... + ... Parameters + ... ---------- + ... representation : `~astropy.coordinates.BaseRepresentation` or None + ... A representation object or None to have no data (or use the other keywords) + ... Lambda : `~astropy.coordinates.Angle`, optional, must be keyword + ... The longitude-like angle corresponding to Sagittarius' orbit. + ... Beta : `~astropy.coordinates.Angle`, optional, must be keyword + ... The latitude-like angle corresponding to Sagittarius' orbit. + ... distance : `~astropy.units.Quantity`, optional, must be keyword + ... The Distance for this object along the line-of-sight. + ... pm_Lambda_cosBeta : `~astropy.units.Quantity`, optional, must be keyword + ... The proper motion along the stream in ``Lambda`` (including the + ... ``cos(Beta)`` factor) for this object (``pm_Beta`` must also be given). + ... pm_Beta : `~astropy.units.Quantity`, optional, must be keyword + ... The proper motion in Declination for this object (``pm_ra_cosdec`` must + ... also be given). + ... radial_velocity : `~astropy.units.Quantity`, optional, keyword-only + ... The radial velocity of this object. + ... """ + ... default_representation = coord.SphericalRepresentation + ... default_differential = coord.SphericalCosLatDifferential + ... frame_specific_representation_info = { + ... coord.SphericalRepresentation: [ + ... coord.RepresentationMapping("lon", "Lambda"), + ... coord.RepresentationMapping("lat", "Beta"), + ... coord.RepresentationMapping("distance", "distance"), + ... ] + ... } + + Breaking this down line-by-line, we define the class as a subclass of + `~astropy.coordinates.BaseCoordinateFrame`. Then we include a descriptive + docstring. The final lines are class-level attributes that specify the + default representation for the data, default differential for the velocity + information, and mappings from the attribute names used by representation + objects to the names that are to be used by the ``Sagittarius`` frame. In this + case we override the names in the spherical representations but do not do + anything with other representations like cartesian or cylindrical. + + Next we have to define the transformation from this coordinate system to some + other built-in coordinate system; we will use Galactic coordinates. We can do + this by defining functions that return transformation matrices, or by simply + defining a function that accepts a coordinate and returns a new coordinate in + the new system. Because the transformation to the Sagittarius coordinate + stem is just a spherical rotation from Galactic coordinates, we will + define a function that returns this matrix. We will start by constructing the + transformation matrix using pre-determined Euler angles and the + ``rotation_matrix`` helper function: + + >>> SGR_PHI = (180 + 3.75) * u.degree # Euler angles (from Law & Majewski 2010) + >>> SGR_THETA = (90 - 13.46) * u.degree + >>> SGR_PSI = (180 + 14.111534) * u.degree + + Generate the rotation matrix using the x-convention (see Goldstein): + + >>> SGR_MATRIX = ( + ... np.diag([1.0, 1.0, -1.0]) + ... @ rotation_matrix(SGR_PSI, "z") + ... @ rotation_matrix(SGR_THETA, "x") + ... @ rotation_matrix(SGR_PHI, "z") + ... ) + + Since we already constructed the transformation (rotation) matrix above, and + the inverse of a rotation matrix is just its transpose, the required + transformation functions are very simple: + + >>> @frame_transform_graph.transform( + ... coord.StaticMatrixTransform, coord.Galactic, Sagittarius + ... ) + ... def galactic_to_sgr(): + ... """Compute the Galactic spherical to heliocentric Sgr transformation matrix.""" + ... return SGR_MATRIX + + The decorator ``@frame_transform_graph.transform(coord.StaticMatrixTransform, coord.Galactic, Sagittarius)`` + registers this function on the + ``frame_transform_graph`` as a coordinate transformation. Inside the function, + we return the previously defined rotation matrix. + + We then register the inverse transformation by using the transpose of the + rotation matrix (which is faster to compute than the inverse): + + >>> @frame_transform_graph.transform( + ... coord.StaticMatrixTransform, Sagittarius, coord.Galactic + ... ) + ... def sgr_to_galactic(): + ... """Compute the heliocentric Sgr to spherical Galactic transformation matrix.""" + ... return SGR_MATRIX.swapaxes(-2, -1) + + Now that we have registered these transformations between ``Sagittarius`` and + `~astropy.coordinates.Galactic`, we can transform between *any* coordinate + system and ``Sagittarius`` (as long as the other system has a path to + transform to `~astropy.coordinates.Galactic`). For example, to transform from + ICRS coordinates to ``Sagittarius``, we would do: + + >>> icrs = coord.SkyCoord(280.161732 * u.degree, 11.91934 * u.degree, frame="icrs") + >>> sgr = icrs.transform_to(Sagittarius) + >>> print(sgr) + + + Or, to transform from the ``Sagittarius`` frame to ICRS coordinates (in this + case, a line along the ``Sagittarius`` x-y plane): + + >>> sgr = coord.SkyCoord( + ... Lambda=np.linspace(0, 2 * np.pi, 128) * u.radian, + ... Beta=np.zeros(128) * u.radian, + ... frame="sagittarius", + ... ) + >>> icrs = sgr.transform_to(coord.ICRS) + >>> print(icrs) # doctest: +ELLIPSIS + + + As an example, we will now plot the points in both coordinate systems: + + >>> fig, axes = plt.subplots(2, 1, figsize=(8, 10), subplot_kw={"projection": "aitoff"}) + >>> axes[0].set_title("Sagittarius") # doctest: +IGNORE_OUTPUT + >>> axes[0].plot( + ... sgr.Lambda.wrap_at(180 * u.deg).radian, + ... sgr.Beta.radian, + ... linestyle="none", + ... marker=".", + ... ) # doctest: +IGNORE_OUTPUT + >>> axes[0].grid(visible=True) # doctest: +IGNORE_OUTPUT + >>> axes[1].set_title("ICRS") # doctest: +IGNORE_OUTPUT + >>> axes[1].plot( + ... icrs.ra.wrap_at(180 * u.deg).radian, icrs.dec.radian, linestyle="none", marker="." + ... ) # doctest: +IGNORE_OUTPUT + >>> axes[1].grid(visible=True) # doctest: +IGNORE_OUTPUT + + This particular transformation is just a spherical rotation, which is a + special case of an Affine transformation with no vector offset. The + transformation of velocity components is therefore natively supported as + well: + + >>> sgr = coord.SkyCoord( + ... Lambda=np.linspace(0, 2 * np.pi, 128) * u.radian, + ... Beta=np.zeros(128) * u.radian, + ... pm_Lambda_cosBeta=np.random.uniform(-5, 5, 128) * (u.mas / u.yr), + ... pm_Beta=np.zeros(128) * (u.mas / u.yr), + ... frame="sagittarius", + ... ) + >>> icrs = sgr.transform_to(coord.ICRS) + >>> print(icrs) # doctest: +ELLIPSIS + + >>> fig, axes = plt.subplots(3, 1, figsize=(8, 10), sharex=True) + >>> axes[0].set_title("Sagittarius") # doctest: +IGNORE_OUTPUT + >>> axes[0].plot( + ... sgr.Lambda.degree, sgr.pm_Lambda_cosBeta.value, linestyle="none", marker="." + ... ) # doctest: +IGNORE_OUTPUT + >>> axes[0].set_xlabel(r"$\Lambda$ [deg]") # doctest: +IGNORE_OUTPUT + >>> axes[0].set_ylabel( + ... rf"$\mu_\Lambda \, \cos B$ [{sgr.pm_Lambda_cosBeta.unit.to_string('latex_inline')}]" + ... ) # doctest: +IGNORE_OUTPUT + >>> axes[0].grid(visible=True) # doctest: +IGNORE_OUTPUT + >>> axes[1].set_title("ICRS") # doctest: +IGNORE_OUTPUT + >>> axes[1].plot(icrs.ra.degree, icrs.pm_ra_cosdec.value, linestyle="none", marker=".") # doctest: +IGNORE_OUTPUT + >>> axes[1].set_ylabel( + ... rf"$\mu_\alpha \, \cos\delta$ [{icrs.pm_ra_cosdec.unit.to_string('latex_inline')}]" + ... ) # doctest: +IGNORE_OUTPUT + >>> axes[1].grid(visible=True) # doctest: +IGNORE_OUTPUT + >>> axes[2].set_title("ICRS") # doctest: +IGNORE_OUTPUT + >>> axes[2].plot(icrs.ra.degree, icrs.pm_dec.value, linestyle="none", marker=".") # doctest: +IGNORE_OUTPUT + >>> axes[2].set_xlabel("RA [deg]") # doctest: +IGNORE_OUTPUT + >>> axes[2].set_ylabel(rf"$\mu_\delta$ [{icrs.pm_dec.unit.to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT + >>> axes[2].grid(visible=True) # doctest: +IGNORE_OUTPUT + >>> plt.draw() + +.. + EXAMPLE END diff --git a/docs/coordinates/example_gallery_rv_to_gsr.rst b/docs/coordinates/example_gallery_rv_to_gsr.rst index 1669cb874d65..35d38c5063bf 100644 --- a/docs/coordinates/example_gallery_rv_to_gsr.rst +++ b/docs/coordinates/example_gallery_rv_to_gsr.rst @@ -1,103 +1,103 @@ -.. _sphx_glr_generated_examples_coordinates_rv-to-gsr.py: - -Convert a radial velocity to the Galactic Standard of Rest (GSR) -================================================================ - -.. - EXAMPLE START - Convert a radial velocity to the Galactic Standard of Rest (GSR) - -Radial or line-of-sight velocities of sources are often reported in a -Heliocentric or Solar-system barycentric reference frame. A common -transformation incorporates the projection of the Sun's motion along the -line-of-sight to the target, hence transforming it to a Galactic rest frame -instead (sometimes referred to as the Galactic Standard of Rest, GSR). This -transformation depends on the assumptions about the orientation of the Galactic -frame relative to the bary- or Heliocentric frame. It also depends on the -assumed solar velocity vector. Here we will demonstrate how to perform this -transformation using a sky position and barycentric radial-velocity. - -Use the latest convention for the Galactocentric coordinates: - ->>> import astropy.coordinates as coord ->>> coord.galactocentric_frame_defaults.set("latest") # doctest: +IGNORE_OUTPUT - -For this example, let's work with the coordinates and barycentric radial -velocity of the star HD 155967, as obtained from -`Simbad `_: - ->>> from astropy import units as u ->>> icrs = coord.SkyCoord( -... ra=258.58356362 * u.deg, -... dec=14.55255619 * u.deg, -... radial_velocity=-16.1 * u.km / u.s, -... frame="icrs", -... ) - -Next, we need to decide on the velocity of the Sun in the assumed GSR frame. -We will use the same velocity vector as used in the -`~astropy.coordinates.Galactocentric` frame, and convert it to a -`~astropy.coordinates.CartesianRepresentation` object using the -``.to_cartesian()`` method of the -`~astropy.coordinates.CartesianDifferential` object ``galcen_v_sun``: - ->>> v_sun = coord.Galactocentric().galcen_v_sun.to_cartesian() - -We now need to get a unit vector in the assumed Galactic frame from the sky -position in the ICRS frame above. We will use this unit vector to project the -solar velocity onto the line-of-sight: - ->>> gal = icrs.transform_to(coord.Galactic) ->>> cart_data = gal.data.to_cartesian() ->>> unit_vector = cart_data / cart_data.norm() - -Now we project the solar velocity using this unit vector: - ->>> v_proj = v_sun.dot(unit_vector) - -Finally, we add the projection of the solar velocity to the radial velocity -to get a GSR radial velocity: - ->>> rv_gsr = icrs.radial_velocity + v_proj ->>> print(rv_gsr) # doctest: +FLOAT_CMP -123.30460087379765 km / s - -We could wrap this in a function so we can control the solar velocity and -reuse the above code: - ->>> def rv_to_gsr(c, v_sun=None): -... """Transform a barycentric radial velocity to the Galactic Standard of Rest -... (GSR). -... -... Parameters -... ---------- -... c : `~astropy.coordinates.BaseCoordinateFrame` subclass instance -... The radial velocity, associated with a sky coordinates, to be -... transformed. -... v_sun : `~astropy.units.Quantity`, optional -... The 3D velocity of the solar system barycenter in the GSR frame. -... Defaults to the same solar motion as in the -... `~astropy.coordinates.Galactocentric` frame. -... -... Returns -... ------- -... v_gsr : `~astropy.units.Quantity` -... The input radial velocity transformed to a GSR frame. -... """ -... if v_sun is None: -... v_sun = coord.Galactocentric().galcen_v_sun.to_cartesian() -... -... gal = c.transform_to(coord.Galactic) -... cart_data = gal.data.to_cartesian() -... unit_vector = cart_data / cart_data.norm() -... -... v_proj = v_sun.dot(unit_vector) -... -... return c.radial_velocity + v_proj - ->>> rv_gsr = rv_to_gsr(icrs) ->>> print(rv_gsr) # doctest: +FLOAT_CMP -123.30460087379765 km / s - -.. - EXAMPLE END +.. _sphx_glr_generated_examples_coordinates_rv-to-gsr.py: + +Convert a radial velocity to the Galactic Standard of Rest (GSR) +================================================================ + +.. + EXAMPLE START + Convert a radial velocity to the Galactic Standard of Rest (GSR) + +Radial or line-of-sight velocities of sources are often reported in a +Heliocentric or Solar-system barycentric reference frame. A common +transformation incorporates the projection of the Sun's motion along the +line-of-sight to the target, hence transforming it to a Galactic rest frame +instead (sometimes referred to as the Galactic Standard of Rest, GSR). This +transformation depends on the assumptions about the orientation of the Galactic +frame relative to the bary- or Heliocentric frame. It also depends on the +assumed solar velocity vector. Here we will demonstrate how to perform this +transformation using a sky position and barycentric radial-velocity. + +Use the latest convention for the Galactocentric coordinates: + +>>> import astropy.coordinates as coord +>>> coord.galactocentric_frame_defaults.set("latest") # doctest: +IGNORE_OUTPUT + +For this example, let's work with the coordinates and barycentric radial +velocity of the star HD 155967, as obtained from +`Simbad `_: + +>>> from astropy import units as u +>>> icrs = coord.SkyCoord( +... ra=258.58356362 * u.deg, +... dec=14.55255619 * u.deg, +... radial_velocity=-16.1 * u.km / u.s, +... frame="icrs", +... ) + +Next, we need to decide on the velocity of the Sun in the assumed GSR frame. +We will use the same velocity vector as used in the +`~astropy.coordinates.Galactocentric` frame, and convert it to a +`~astropy.coordinates.CartesianRepresentation` object using the +``.to_cartesian()`` method of the +`~astropy.coordinates.CartesianDifferential` object ``galcen_v_sun``: + +>>> v_sun = coord.Galactocentric().galcen_v_sun.to_cartesian() + +We now need to get a unit vector in the assumed Galactic frame from the sky +position in the ICRS frame above. We will use this unit vector to project the +solar velocity onto the line-of-sight: + +>>> gal = icrs.transform_to(coord.Galactic) +>>> cart_data = gal.data.to_cartesian() +>>> unit_vector = cart_data / cart_data.norm() + +Now we project the solar velocity using this unit vector: + +>>> v_proj = v_sun.dot(unit_vector) + +Finally, we add the projection of the solar velocity to the radial velocity +to get a GSR radial velocity: + +>>> rv_gsr = icrs.radial_velocity + v_proj +>>> print(rv_gsr) # doctest: +FLOAT_CMP +123.30460087379765 km / s + +We could wrap this in a function so we can control the solar velocity and +reuse the above code: + +>>> def rv_to_gsr(c, v_sun=None): +... """Transform a barycentric radial velocity to the Galactic Standard of Rest +... (GSR). +... +... Parameters +... ---------- +... c : `~astropy.coordinates.BaseCoordinateFrame` subclass instance +... The radial velocity, associated with a sky coordinates, to be +... transformed. +... v_sun : `~astropy.units.Quantity`, optional +... The 3D velocity of the solar system barycenter in the GSR frame. +... Defaults to the same solar motion as in the +... `~astropy.coordinates.Galactocentric` frame. +... +... Returns +... ------- +... v_gsr : `~astropy.units.Quantity` +... The input radial velocity transformed to a GSR frame. +... """ +... if v_sun is None: +... v_sun = coord.Galactocentric().galcen_v_sun.to_cartesian() +... +... gal = c.transform_to(coord.Galactic) +... cart_data = gal.data.to_cartesian() +... unit_vector = cart_data / cart_data.norm() +... +... v_proj = v_sun.dot(unit_vector) +... +... return c.radial_velocity + v_proj + +>>> rv_gsr = rv_to_gsr(icrs) +>>> print(rv_gsr) # doctest: +FLOAT_CMP +123.30460087379765 km / s + +.. + EXAMPLE END diff --git a/docs/coordinates/frames.rst b/docs/coordinates/frames.rst index bbc7069dc69c..9ab579b04f3a 100644 --- a/docs/coordinates/frames.rst +++ b/docs/coordinates/frames.rst @@ -176,9 +176,8 @@ if it is present, it can be accessed from the ``data`` attribute:: -All of the above methods can also accept array data (in the form of -class:`~astropy.units.Quantity`, or other Python sequences) to create arrays of -coordinates:: +All of the above methods can also accept array data (in the form of |Quantity|, +or other Python sequences) to create arrays of coordinates:: >>> ICRS(ra=[1.5, 2.5]*u.deg, dec=[3.5, 4.5]*u.deg) # doctest: +FLOAT_CMP >> from astropy.coordinates import EarthLocation - >>> EarthLocation._get_site_registry(force_download=True) #doctest: +REMOTE_DATA +IGNORE_OUTPUT + >>> EarthLocation._get_site_registry(refresh_cache=True) #doctest: +REMOTE_DATA +IGNORE_OUTPUT .. _astropy-coordinates: diff --git a/docs/coordinates/skycoord.rst b/docs/coordinates/skycoord.rst index d2c7bc62819a..bb99f84c2aac 100644 --- a/docs/coordinates/skycoord.rst +++ b/docs/coordinates/skycoord.rst @@ -878,7 +878,7 @@ To change the representation of a coordinate object by setting the >>> c.x Traceback (most recent call last): ... - AttributeError: 'SkyCoord' object has no attribute 'x' + AttributeError: 'SkyCoord' object has no attribute 'x'... >>> c.representation_type = 'spherical' >>> c # doctest: +FLOAT_CMP diff --git a/docs/coordinates/velocities.rst b/docs/coordinates/velocities.rst index fe4c65baae15..937b5e8ead12 100644 --- a/docs/coordinates/velocities.rst +++ b/docs/coordinates/velocities.rst @@ -572,7 +572,7 @@ radial velocity and :math:`v_b` is the barycentric correction returned by the barycentric correction in this way leads to errors of order 3 m/s. The barycentric correction in `~astropy.coordinates.SkyCoord.radial_velocity_correction` is consistent -with the `IDL implementation `_ of +with the IDL implementation of the Wright & Eastmann (2014) paper to a level of 10 mm/s for a source at infinite distance. We do not include the Shapiro delay nor the light travel time correction from equation 28 of that paper. The neglected terms diff --git a/docs/credits.rst b/docs/credits.rst index 3e16da1a6e2a..cabd97afc440 100644 --- a/docs/credits.rst +++ b/docs/credits.rst @@ -15,8 +15,10 @@ Core Package Contributors * Adam Ginsburg * Adam Turner * Adele Plunkett +* Aditya Lohuni * Aditya Sharma * Adrian Price-Whelan +* Adrien Thob * Akash Deshpande * Akeem * Akshat Dixit @@ -27,14 +29,17 @@ Core Package Contributors * Alex Conley * Alex de la Vega * Alex Drlica-Wagner +* Alex Fox * Alex Hagen * Alex Rudy * Alexander Bakanov +* Alexander Hu * Alexandre Beelen * Alexandre R\. Bomfim Junior * Alfio Puglisi * Alpha-Ursae-Minoris * Amit Kumar +* Ana Clara Galvão * Ana Posses * Anany Shrey Jain * Anchit Jain @@ -51,11 +56,13 @@ Core Package Contributors * Anne Archibald * Antetokounpo * Anthony Horton +* Antonio Bento Pereira * Antony Lee * Arfon Smith * Arie Kurniawan * Arne de Laat * Arthur Eigenbrot +* Arthur Sardella * Arthur Xavier Joao Pedro Maia * Aryan Shukla * Asish Panda @@ -65,9 +72,11 @@ Core Package Contributors * Axel Donath * Azalee Bostroem * Bastian Beischer +* Ben Green * Ben Greiner * Benjamin Alan Weaver * Benjamin Roulston +* Benjamin Scully * Benjamin Winkel * Bernardo Sulzbach * Bernie Simon @@ -75,6 +84,8 @@ Core Package Contributors * Bhavya Khandelwal * Bili Dong * Bill Cleveland +* Bill Wolf +* Bodhi Silberling * Bogdan Nicula * Bojan Nikolic * Brandie-M @@ -86,12 +97,15 @@ Core Package Contributors * britgit * Bruce Merry * Bruno Oliveira +* Bruno Sanchez * Bryce Kalmbach * Bryce Nordgren * Caden Gobat * CaioCoutinhoP * Carl Osterwisch * Carl Schaffer +* Caspar van Leeuwen +* Charalampos Stratakis * Chiara Marmo * Chris Beaumont * Chris Hanley @@ -109,6 +123,7 @@ Core Package Contributors * Conor MacBride * Cristian Ardelean * Curtis McCully +* Cyrus * Damien LaRocque * Dan Foreman-Mackey * Dan P\. Cunningham @@ -134,6 +149,7 @@ Core Package Contributors * Demitri Muna * Derek Homeier * Devin Crichton +* Dhruv Yadav * Diego Alonso * Diego Asterio de Zaballa * disha @@ -151,10 +167,13 @@ Core Package Contributors * Edward Betts * Edward Slavich * Eero Vaher +* Eesh Saxena * Eli Bressert * Elijah Bernstein-Cooper +* Elise Chavez * Eloy Salinas * Emily Deibert +* Emily Hu * Emir Karamehmetoglu * Emma Hogan * Eric Depagne @@ -163,11 +182,16 @@ Core Package Contributors * Erik Tollerud * Erin Allard * Esteban Pardo Sánchez +* Evan Chen +* Evan Jones * Even Rouault +* Everett Schlawin * Evert Rol +* Fazeel Usmani * Felipe Cybis Pereira * Felipe Gameleira * Felix Yan +* Finn Womack * fockez * Francesc Vilardell * Francesco Biscani @@ -196,10 +220,11 @@ Core Package Contributors * Hannes Breytenbach * Hans Moritz Günther * Harry Ferguson +* Harshada Raut * Heinz-Alexander Fuetterer * Helen Sherwood-Taylor * Hélvio Peixoto -* Henrike F +* Henrike F\. * Henry Schreiner * Himanshu Pathak * homeboy445 @@ -209,10 +234,13 @@ Core Package Contributors * Humna Awan * iamsoto * Igor Lemos +* Ikbar Faiz * ikkamens * Inada Naoki +* J\. Berg * J\. Goutin * J\. Xavier Prochaska +* Jackson Hayward * Jake VanderPlas * Jakob Maljaars * James Davies @@ -234,6 +262,7 @@ Core Package Contributors * Jeff Taylor * Jeffrey McBeth * Jero Bado +* Jett Higgins * jimboH * Jo Bovy * Joanna Power @@ -251,6 +280,7 @@ Core Package Contributors * Jonathan Foster * Jonathan Sick * Jonathan Whitmore +* Joren Hammudoglu * Jörg Dietrich * Jose Sabater * José Sabater Montes @@ -260,6 +290,7 @@ Core Package Contributors * Joseph Schlitz * Jost Migenda * JP Maia +* Juan Escudero Pedrosa * Juan Luis Cano Rodríguez * Juanjo Bazán * Julien Woillez @@ -270,12 +301,14 @@ Core Package Contributors * Karl Gordon * Karl Vyhmeister * Karl Wessel +* Kartavay Verma * Katrin Leinweber * Kelle Cruz * Kevin Gullikson * Kevin Sooley * Kewei Li * Kieran Leschinski +* Kim Searle * Kirill Tchernyshyov * Kris Stern * Kristin Berry @@ -309,8 +342,9 @@ Core Package Contributors * Luke G\. Bouma * Luke Kelley * Luz Paz -* M Atakan Gürkan -* M S R Dinesh +* M Bussonnier +* M\. Atakan Gürkan +* M\. S\. R\. Dinesh * Mabry Cervin * Madhura Parikh * Magali Mebsout @@ -341,7 +375,6 @@ Core Package Contributors * Matthew Petroff * Matthew Pitkin * Matthew Turk -* Matthias Bussonnier * Matthias Stein * Matthieu Bec * Mavani Bhautik @@ -353,6 +386,8 @@ Core Package Contributors * Médéric Boquien * Megan Sosey * Melissa Weber Mendonça +* Mengjia Shang +* Michael Belfrage * Michael Brewer * Michael Droettboom * Michael Hirsch @@ -376,6 +411,7 @@ Core Package Contributors * Miruna Oprescu * Moataz Hisham * Mohan Agrawal +* Mohsin Mehmood * Molly Peeples * Mridul Seth * Mubin Manasia @@ -383,6 +419,7 @@ Core Package Contributors * mzhengxi * Nabil Freij * Nadia Dencheva +* Naksh Yadav * Nathanial Hendler * Nathaniel Starkman * Naveen Selvadurai @@ -402,6 +439,7 @@ Core Package Contributors * Nora Luetzgendorf * odidev * Ole Streicher +* Om S Habib * omahs * Orion Poplawski * orionlee @@ -419,28 +457,36 @@ Core Package Contributors * Pauline Barmby * Perry Greenfield * Peter Cock +* Peter Scicluna * Peter Teuben * Peter Yoachim +* Pierre Sassoulas +* Pieter Eendebak * Piyush Sharma * Porter Averett * Prajwel Joseph * Prasanth Nair * Pratik Patel +* Preshanth Jagannathan * Pritish Chakraborty * Pushkar Kopparla +* R\. Virinchi * Rachel Guo * Raghuram Devarakonda * Ralf Gommers +* Raphael Erik Hviding * Rashid Khan * Rasmus Handberg * Ravi Kumar * Ray Plante +* Reem Hamraz * Régis Terrier * Ricardo Fonseca * Ricardo Ogando -* Richard R +* Richard R\. * Ricky O'Steen * Rik van Lieshout +* RinZ27 * Ritiek Malhotra * Ritwick DSouza * Roban Hultman Kramer @@ -452,6 +498,7 @@ Core Package Contributors * Rohit Kapoor * Rohit Patil * Roland Weber +* Romain Thomas * Roman Tolesnikov * Roy Smart * Rui Xue @@ -491,7 +538,9 @@ Core Package Contributors * Shilpi Jain * Shivan Sornarajah * Shivansh Mishra +* Shreeharsh Shinde * Shresth Verma +* Shreya Vernekar * Shreyas Bapat * Sigurd Næss * Simon Alinder @@ -500,11 +549,13 @@ Core Package Contributors * Simon Liedtke * Simon Torres * Somia Floret +* Sonu Singh * Sourabh Cheedella * Srikrishna Sekhar * srirajshukla * Stefan Becker * Stefan Nelson +* Stelios Voutsinas * Stephen Bailey * Stephen Portillo * Steve Crawford @@ -513,14 +564,17 @@ Core Package Contributors * Stuart Littlefair * Stuart Mumford * Sudheesh Singanamalla +* Surya K\. * Sushobhana Patra * Suyog Garg * Swapnil Sharma +* Syn Pu * T\. Carl Beery * T\. E\. Pickering * Tanuj Rastogi * Tanvi Pooranmal Meena * Thais Borges +* Thomas Dutkiewicz * Thomas Erben * Thomas J\. Fan * Thomas Robitaille @@ -541,21 +595,27 @@ Core Package Contributors * Tom Kooij * Tomas Babej * Tyler Finethy +* Varun Kasyap Pentamaraju * Varun Nikam * Vatsala Swaroop +* veyron * Víctor Terrón * Víctor Zabalza * Victoria Dye * Vinayak Mehta -* Vishnunarayan K I +* Vishnunarayan K\. I\. +* Vishwas * Vital Fernández * Volodymyr Savchenko * VSN Reddy Janga +* Wang Rui * Wilfred Tyler Gee * William Jamieson * Wolfgang Kerzendorf +* xbreak * xuewc * Yannick Copin +* Yaocheng Chen * Yash Kumar * Yash Nandwana * Yash Sharma @@ -569,10 +629,10 @@ Core Package Contributors * Zhen-Kai Gao * Zhiyuan Ma * Zlatan Vasović +* Περικλής Παντελαίος Other Credits ============= - * Kyle Barbary for designing the Astropy logos and documentation themes. * Andrew Pontzen and the `pynbody `_ team (For code that grew into :mod:`astropy.units`) diff --git a/docs/development/codeguide.rst b/docs/development/codeguide.rst index 8bfff3f1d357..ba7be89eadd8 100644 --- a/docs/development/codeguide.rst +++ b/docs/development/codeguide.rst @@ -434,5 +434,4 @@ the hierarchy. .. _Scipy: https://www.scipy.org/ .. _matplotlib: https://matplotlib.org/ .. _Cython: https://cython.org/ -.. _PyPI: https://pypi.org/project .. _ruff: https://docs.astral.sh/ruff/ diff --git a/docs/development/maintainers/index.rst b/docs/development/maintainers/index.rst index f576ac6b21c2..ce1de3c3bbce 100644 --- a/docs/development/maintainers/index.rst +++ b/docs/development/maintainers/index.rst @@ -1,3 +1,5 @@ +.. _maintainers-index: + Maintaining astropy and affiliated packages ------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index f733c5dc1d69..2170241d9424 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,6 +5,8 @@ :tocdepth: 3 +.. _astropy-docs-index: + ################################################# astropy: A Community Python Library for Astronomy ################################################# diff --git a/docs/index_dev.rst b/docs/index_dev.rst index ab0079910926..c5b078c4afd8 100644 --- a/docs/index_dev.rst +++ b/docs/index_dev.rst @@ -69,7 +69,7 @@ Details development/maintainers/index .. Note:: Parts of this guide were adapted from the - `pandas developer documentation `_. Astropy is grateful to the pandas team for their documentation efforts. + :ref:`pandas developer documentation `. Astropy is grateful to the ``pandas`` team for their documentation efforts. {%else%} diff --git a/docs/install.rst b/docs/install.rst index 6f7275140283..dc436438769e 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -163,7 +163,7 @@ The further dependencies provide more specific features: :meth:`astropy.table.Table.show_in_notebook` to display the Astropy table in Jupyter notebook for ``backend="ipydatagrid"``. -- `xmllint `_: To validate VOTABLE XML files. +- `xmllint `_: To validate VOTABLE XML files. This is a command line tool installed outside of Python. - `pandas `_: To convert diff --git a/docs/io/fits/index.rst b/docs/io/fits/index.rst index e139dd4d2dfb..b6ac1ee6966c 100644 --- a/docs/io/fits/index.rst +++ b/docs/io/fits/index.rst @@ -410,7 +410,7 @@ Working with Image Data This section describes reading and writing image data in the FITS format using the `~astropy.io.fits` package directly. For CCD image data with a unit, you should consider using the :ref:`Unified Image Data` interface with the - :ref:`CCDData class `. This provides the the capability to load data, + :ref:`CCDData class `. This provides the capability to load data, uncertainty and mask from a multi-extension FITS (MEF) file. If an HDU's data is an image, the data attribute of the HDU object will return diff --git a/docs/io/fits/performance.inc.rst b/docs/io/fits/performance.inc.rst index 3ac7bc545a30..cb3827a8dae8 100644 --- a/docs/io/fits/performance.inc.rst +++ b/docs/io/fits/performance.inc.rst @@ -8,6 +8,9 @@ Performance Tips ================ +Use dask for lazy compute +------------------------- + It is possible to set the data array for :class:`~astropy.io.fits.PrimaryHDU` and :class:`~astropy.io.fits.ImageHDU` to a `dask `_ array. If this is written to disk, the dask array will be computed as it is being @@ -21,11 +24,22 @@ written, which will avoid using excessive memory: >>> hdu = fits.PrimaryHDU(data=array) >>> hdu.writeto('test_dask.fits') +Arbitrary padding end of file will degrade performance +------------------------------------------------------ + +As discussed in detail in +`GitHub issue 19296 `_, +arbitrary padding at the end of a FITS file might cause the parser +to inefficiently search for the END card of the next header. +Therefore, we recommend that astropy users to not blindly open +untrusted large FITS files without independently verifying their fidelity +first. + .. TODO: determine whether the following is quantitatively true, and either .. uncomment or remove. -.. Performance Tips -.. ================ +.. Turn off memmap to run faster but use more memory +.. ------------------------------------------------- .. .. By default, :func:`astropy.io.fits.open` will open files using memory-mapping, .. which means that the data is not necessarily read into memory until it is diff --git a/docs/io/fits/usage/cloud.rst b/docs/io/fits/usage/cloud.rst index 82e2b709debd..69d4112f2eee 100644 --- a/docs/io/fits/usage/cloud.rst +++ b/docs/io/fits/usage/cloud.rst @@ -93,7 +93,7 @@ Subsetting FITS files hosted in Amazon S3 cloud storage The FITS file used in the example above also happens to be available via Amazon cloud storage, where it is stored in a `public S3 bucket -`__ at the following location:: +`__ at the following location:: >>> s3_uri = "s3://stpubdata/hst/public/j8pu/j8pu0y010/j8pu0y010_drc.fits" diff --git a/docs/io/fits/usage/unfamiliar.rst b/docs/io/fits/usage/unfamiliar.rst index 707657a2aa1a..ceaf6a8bce7e 100644 --- a/docs/io/fits/usage/unfamiliar.rst +++ b/docs/io/fits/usage/unfamiliar.rst @@ -597,3 +597,106 @@ describes the possible options for constructing a :class:`CompImageHDU` object. .. EXAMPLE END + + +Supported Integer Data Types +---------------------------- + +Not every compression algorithm can be used with every integer data type. The +table below summarizes which combinations work, including the cases where +``astropy`` accepts the input only when the values lie within a more +restricted range. + +.. list-table:: + :header-rows: 1 + :stub-columns: 1 + + * - Compression + - ``int16`` + - ``int32`` + - ``int64`` + - ``uint8`` + - ``uint16`` + - ``uint32`` + - ``uint64`` + * - ``GZIP_1`` + - ✅ + - ✅ + - ⚠️ [1]_ + - ✅ + - ✅ + - ✅ + - ⚠️ [1]_ + * - ``GZIP_2`` + - ✅ + - ✅ + - ⚠️ [1]_ + - ✅ + - ✅ + - ✅ + - ⚠️ [1]_ + * - ``RICE_1`` + - ✅ + - ✅ + - 🟡 [2]_ + - ✅ + - ✅ + - ✅ + - 🟡 [2]_ + * - ``HCOMPRESS_1`` + - ✅ + - ✅ + - 🟡 [2]_ + - ✅ + - ✅ + - ✅ + - 🟡 [2]_ + * - ``PLIO_1`` + - 🟡 [3]_ + - 🟡 [3]_ + - 🟡 [2]_ [3]_ + - ✅ + - ❌ [4]_ + - ❌ [4]_ + - ❌ [4]_ + * - ``NOCOMPRESS`` + - ✅ + - ✅ + - ✅ + - ✅ + - ✅ + - ✅ + - ✅ + +Legend: + +* ✅ Full support: any value within the type's range round-trips losslessly. +* 🟡 Partial support: works only when input values satisfy the numeric + restriction in the corresponding footnote; a ``ValueError`` is raised + otherwise. +* ⚠️ Caveat: round-trips correctly within ``astropy``, but the resulting + file may not be readable by other FITS libraries (see footnote). +* ❌ Not supported: writing the data raises a ``ValueError``. + +.. [1] ``astropy`` writes a standards-compliant file, but ``cfitsio`` and + tools built on top of it (including ``funpack``, ``fitsio``, and DS9) do + not currently support reading 64-bit integer images compressed with + ``GZIP_1`` or ``GZIP_2``. The file round-trips correctly when read by + ``astropy`` itself. + +.. [2] 64-bit integer input is converted to a 32-bit type on write. The + conversion succeeds only if every input value fits in the corresponding + 32-bit range: ``[-2**31, 2**31 - 1]`` for signed and ``[0, 2**32 - 1]`` + for unsigned. Otherwise a ``ValueError`` is raised. When the conversion + succeeds an ``AstropyUserWarning`` is emitted to signal the precision + change. + +.. [3] ``PLIO_1`` is designed for pixel masks and supports only non-negative + integer values up to ``2**24 - 1`` (``16777215``). Negative values or + values above this limit cause a ``ValueError`` at write time. For + ``int64`` input both this restriction and the 32-bit conversion in + footnote [2]_ apply. + +.. [4] ``PLIO_1`` cannot store unsigned 16-, 32-, or 64-bit integers. Use + ``RICE_1``, ``HCOMPRESS_1``, ``GZIP_1``, or ``GZIP_2`` for unsigned data + that does not fit in ``uint8``. diff --git a/docs/io/misc.rst b/docs/io/misc.rst index 587aa3ea2f3a..803fd18eb18c 100644 --- a/docs/io/misc.rst +++ b/docs/io/misc.rst @@ -1,6 +1,6 @@ -************************************************************** -ECVS, HDF5, Parquet, PyArrow CSV, YAML (`astropy.io.misc`) -************************************************************** +********************************************************** +ECSV, HDF5, Parquet, PyArrow CSV, YAML (`astropy.io.misc`) +********************************************************** The `astropy.io.misc` module contains miscellaneous input/output routines that do not fit elsewhere, and are often used by other ``astropy`` sub-packages. For diff --git a/docs/io/unified_table.rst b/docs/io/unified_table.rst index 584b7a47c7e0..c94d6c0679c0 100644 --- a/docs/io/unified_table.rst +++ b/docs/io/unified_table.rst @@ -57,15 +57,12 @@ the file format, for instance ``'ascii.daophot'``: Reading a Table directly from the Internet It is possible to load tables directly from the Internet using URLs. For -example, download tables from Vizier catalogues in CDS format -(``'ascii.cds'``): +example, download an example Spitzer catalog (a :ref:`VOTable `): .. doctest-remote-data:: >>> from astropy.table import Table - >>> t = Table.read("ftp://cdsarc.unistra.fr/pub/cats/VII/253/snrs.dat", - ... readme="ftp://cdsarc.unistra.fr/pub/cats/VII/253/ReadMe", - ... format="ascii.cds") + >>> t = Table.read("http://www.astropy.org/astropy-data/photometry/spitzer_example_catalog.xml", format="votable") For certain file formats the format can be automatically detected, for example, from the filename extension:: diff --git a/docs/io/unified_table_text.rst b/docs/io/unified_table_text.rst index fdc95e616def..3ea7859abad2 100644 --- a/docs/io/unified_table_text.rst +++ b/docs/io/unified_table_text.rst @@ -163,10 +163,10 @@ column use: Pandas ------ -.. _pandas: https://pandas.pydata.org/pandas-docs/stable/index.html +.. _pandas: https://pandas.pydata.org/docs/ ``astropy`` `~astropy.table.Table` supports the ability to read or write tables -using some of the `I/O methods `_ +using some of the :ref:`I/O methods ` available within pandas_. This interface thus provides convenient wrappers to the following functions / methods: @@ -174,10 +174,10 @@ the following functions / methods: :header: "Format name", "Data Description", "Reader", "Writer" :widths: 25, 25, 25, 25 - ``pandas.csv``,`CSV `__,`read_csv() `_,`to_csv() `_ - ``pandas.json``,`JSON `__,`read_json() `_,`to_json() `_ - ``pandas.html``,`HTML `__,`read_html() `_,`to_html() `_ - ``pandas.fwf``,Fixed Width,`read_fwf() `_, + ``pandas.csv``,`CSV `__,:ref:`read_csv() `,:ref:`to_csv() ` + ``pandas.json``,`JSON `__,:ref:`read_json() `,:ref:`to_json() ` + ``pandas.html``,`HTML `__,:ref:`read_html() `,:ref:`to_html() ` + ``pandas.fwf``,Fixed Width,:ref:`read_fwf() `, **Notes**: diff --git a/docs/modeling/new-fitter.rst b/docs/modeling/new-fitter.rst index 0b21f5794276..9e26b95234c0 100644 --- a/docs/modeling/new-fitter.rst +++ b/docs/modeling/new-fitter.rst @@ -64,18 +64,14 @@ entry points can be found in `setuptools' documentation `_ with parameter substitutions corresponding to the field names in the structured diff --git a/docs/table/modify_table.rst b/docs/table/modify_table.rst index cd4f80ca6bfe..2f5d46ccf9c0 100644 --- a/docs/table/modify_table.rst +++ b/docs/table/modify_table.rst @@ -20,7 +20,10 @@ Examples .. EXAMPLE START: Making a Table and Modifying Data -**Make a table** +.. _table-mod-make-a-table: + +Make a table +^^^^^^^^^^^^ :: >>> from astropy.table import Table @@ -28,7 +31,10 @@ Examples >>> arr = np.arange(15).reshape(5, 3) >>> t = Table(arr, names=('a', 'b', 'c'), meta={'keywords': {'key1': 'val1'}}) -**Modify data values** +.. _table-mod-modify-data-values: + +Modify data values +^^^^^^^^^^^^^^^^^^ :: >>> t['a'][:] = [1, -2, 3, -4, 5] # Set all values of column 'a' @@ -73,7 +79,10 @@ the conventions of `~astropy.units.Quantity` by using the .. EXAMPLE END -**Add a column or columns** +.. _table-mod-add-a-column-or-columns: + +Add a column or columns +^^^^^^^^^^^^^^^^^^^^^^^ .. EXAMPLE START: Adding Columns to Tables @@ -115,7 +124,10 @@ convenient to add a |Quantity| to a |QTable| instead, see .. EXAMPLE END -**Remove columns** +.. _table-mod-remove-columns: + +Remove columns +^^^^^^^^^^^^^^ .. EXAMPLE START: Removing Columns from Tables @@ -129,7 +141,10 @@ To remove a column from a table:: .. EXAMPLE END -**Replace a column** +.. _table-mod-replace-a-column: + +Replace a column +^^^^^^^^^^^^^^^^ .. EXAMPLE START: Replacing Columns in Tables @@ -148,7 +163,10 @@ will be done, for example:: .. EXAMPLE END -**Perform a dictionary-style update** +.. _table-mod-perform-a-dictionary-style-update: + +Perform a dictionary-style update +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ It is possible to perform a dictionary-style update, which adds new columns to the table and replaces existing ones:: @@ -204,7 +222,10 @@ you need them to be references you can use the :meth:`~astropy.table.Table.update` method with ``copy=False``, see :ref:`copy_versus_reference` for details. -**Ensure the existence of a column** +.. _table-mod-ensure-the-existence-of-a-column: + +Ensure the existence of a column +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |Table| has a :meth:`~astropy.table.Table.setdefault` method, which is analogous to :meth:`dict.setdefault`. @@ -238,7 +259,10 @@ Either way the (possibly just inserted) column in the table is returned:: Ham False Spam False -**Rename columns** +.. _table-mod-rename-columns: + +Rename columns +^^^^^^^^^^^^^^ .. EXAMPLE START: Renaming Columns in Tables @@ -253,7 +277,10 @@ To rename multiple columns at once:: .. EXAMPLE END -**Add a row of data** +.. _table-mod-add-a-row-of-data: + +Add a row of data +^^^^^^^^^^^^^^^^^ .. EXAMPLE START: Adding a Row of Data to a Table @@ -263,7 +290,10 @@ To add a row:: .. EXAMPLE END -**Remove rows** +.. _table-mod-remove-rows: + +Remove rows +^^^^^^^^^^^ .. EXAMPLE START: Removing Rows of Data from Tables @@ -275,7 +305,10 @@ To remove a row:: .. EXAMPLE END -**Sort by one or more columns** +.. _table-mod-sort-by-one-or-more-columns: + +Sort by one or more columns +^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. EXAMPLE START: Sorting Columns in Tables @@ -286,7 +319,10 @@ To sort columns:: .. EXAMPLE END -**Reverse table rows** +.. _table-mod-reverse-table-rows: + +Reverse table rows +^^^^^^^^^^^^^^^^^^ .. EXAMPLE START: Reversing Table Rows @@ -296,7 +332,10 @@ To reverse the order of table rows:: .. EXAMPLE END -**Modify metadata** +.. _table-mod-modify-metadata: + +Modify metadata +^^^^^^^^^^^^^^^ .. EXAMPLE START: Modifying Metadata in Tables @@ -306,7 +345,10 @@ To modify metadata:: .. EXAMPLE END -**Select or reorder columns** +.. _table-mod-select-or-reorder-columns: + +Select or reorder columns +^^^^^^^^^^^^^^^^^^^^^^^^^ .. EXAMPLE START: Selecting or Reordering Columns in Tables @@ -375,13 +417,19 @@ with float values by internally calling ``t.replace_column('a', [10.5, 20.5, 30.5])``. In general this behavior is more consistent with Python and `pandas `_ behavior. -**Forcing in-place update** +.. _table-mod-forcing-in-place-update: + +Forcing in-place update +----------------------- It is possible to force an in-place update of a column as follows:: t[colname][:] = value -**Finding the source of problems** +.. _table-mod-finding-the-source-of-problems: + +Finding the source of problems +------------------------------ In order to find potential problems related to replacing columns, there is the option `astropy.table.conf.replace_warnings diff --git a/docs/time/index.rst b/docs/time/index.rst index a14c65183d21..a68af35c78bc 100644 --- a/docs/time/index.rst +++ b/docs/time/index.rst @@ -339,6 +339,7 @@ Time from Epoch Formats The formats ``cxcsec``, ``gps``, ``unix``, and ``unix_tai`` are special in that they provide a floating point representation of the elapsed time in seconds +(with the caveat that ``unix`` does not include leap seconds) since a particular reference date. These formats have a intrinsic time scale which is used to compute the elapsed seconds since the reference date. @@ -347,7 +348,7 @@ Format Scale Reference date ============ ====== ======================== ``cxcsec`` TT ``1998-01-01 00:00:00`` ``unix`` UTC ``1970-01-01 00:00:00`` -``unix_tai`` TAI ``1970-01-01 00:00:08`` +``unix_tai`` TAI ``1970-01-01 00:00:00`` ``gps`` TAI ``1980-01-06 00:00:19`` ============ ====== ======================== @@ -1142,12 +1143,11 @@ scale along with the auto-download feature:: request ``UT1-UTC`` for times beyond the range of IERS table data then the nearest available values will be provided. -In the case of the TDB to TT offset, most users need only provide the ``lon`` -and ``lat`` values when creating the |Time| object. If the +In the case of the TDB to TT offset, most users need only provide the +``location`` when creating the |Time| object. If the :attr:`~astropy.time.Time.delta_tdb_tt` attribute is not explicitly set, then the |PyERFA| routine `erfa.dtdb` will be used to compute the TDB to TT -offset. Note that if ``lon`` and ``lat`` are not explicitly initialized, -values of 0.0 degrees for both will be used. +offset. If ``location`` is not specified, the center of the Earth is assumed. Example ~~~~~~~ diff --git a/docs/uncertainty/index.rst b/docs/uncertainty/index.rst index 6553965ac892..39c047b570f0 100644 --- a/docs/uncertainty/index.rst +++ b/docs/uncertainty/index.rst @@ -1,373 +1,373 @@ -.. _astropy-uncertainty: - +.. _astropy-uncertainty: + .. testsetup:: >>> import numpy as np >>> np.random.seed(12345) -******************************************************* -Uncertainties and Distributions (`astropy.uncertainty`) -******************************************************* - -Introduction -============ - -.. note:: This subpackage is still in development. - -``astropy`` provides a |Distribution| object to represent statistical -distributions in a form that acts as a drop-in replacement for a |Quantity| -object or a regular |ndarray|. Used in this manner, |Distribution| provides -uncertainty propagation at the cost of additional computation. It can also more -generally represent sampled distributions for Monte Carlo calculation -techniques, for instance. - -The core object for this feature is the |Distribution|. Currently, all -such distributions are Monte Carlo sampled. While this means each distribution -may take more memory, it allows arbitrarily complex operations to be performed -on distributions while maintaining their correlation structure. Some specific -well-behaved distributions (e.g., the normal distribution) have -analytic forms which may eventually enable a more compact and efficient -representation. In the future, these may provide a coherent uncertainty -propagation mechanism to work with `~astropy.nddata.NDData`. However, this is -not currently implemented. Hence, details of storing uncertainties for -`~astropy.nddata.NDData` objects can be found in the :ref:`astropy_nddata` -section. - -Getting Started -=============== - -To demonstrate a basic use case for distributions, consider the problem of -uncertainty propagation of normal distributions. Assume there are two -measurements you wish to add, each with normal uncertainties. We start -with some initial imports and setup:: - - >>> import numpy as np - >>> from astropy import units as u - >>> from astropy import uncertainty as unc - >>> rng = np.random.default_rng(12345) # ensures reproducible example numbers - -Now we create two |Distribution| objects to represent our distributions:: - - >>> a = unc.normal(1*u.kpc, std=30*u.pc, n_samples=10000) - >>> b = unc.normal(2*u.kpc, std=40*u.pc, n_samples=10000) - -For normal distributions, the centers should add as expected, and the standard -deviations add in quadrature. We can check these results (to the limits of our -Monte Carlo sampling) trivially with |Distribution| arithmetic and attributes:: - - >>> c = a + b - >>> c # doctest: +ELLIPSIS - - >>> c.pdf_mean() # doctest: +FLOAT_CMP +******************************************************* +Uncertainties and Distributions (`astropy.uncertainty`) +******************************************************* + +Introduction +============ + +.. note:: This subpackage is still in development. + +``astropy`` provides a |Distribution| object to represent statistical +distributions in a form that acts as a drop-in replacement for a |Quantity| +object or a regular |ndarray|. Used in this manner, |Distribution| provides +uncertainty propagation at the cost of additional computation. It can also more +generally represent sampled distributions for Monte Carlo calculation +techniques, for instance. + +The core object for this feature is the |Distribution|. Currently, all +such distributions are Monte Carlo sampled. While this means each distribution +may take more memory, it allows arbitrarily complex operations to be performed +on distributions while maintaining their correlation structure. Some specific +well-behaved distributions (e.g., the normal distribution) have +analytic forms which may eventually enable a more compact and efficient +representation. In the future, these may provide a coherent uncertainty +propagation mechanism to work with `~astropy.nddata.NDData`. However, this is +not currently implemented. Hence, details of storing uncertainties for +`~astropy.nddata.NDData` objects can be found in the :ref:`astropy_nddata` +section. + +Getting Started +=============== + +To demonstrate a basic use case for distributions, consider the problem of +uncertainty propagation of normal distributions. Assume there are two +measurements you wish to add, each with normal uncertainties. We start +with some initial imports and setup:: + + >>> import numpy as np + >>> from astropy import units as u + >>> from astropy import uncertainty as unc + >>> rng = np.random.default_rng(12345) # ensures reproducible example numbers + +Now we create two |Distribution| objects to represent our distributions:: + + >>> a = unc.normal(1*u.kpc, std=30*u.pc, n_samples=10000) + >>> b = unc.normal(2*u.kpc, std=40*u.pc, n_samples=10000) + +For normal distributions, the centers should add as expected, and the standard +deviations add in quadrature. We can check these results (to the limits of our +Monte Carlo sampling) trivially with |Distribution| arithmetic and attributes:: + + >>> c = a + b + >>> c # doctest: +ELLIPSIS + + >>> c.pdf_mean() # doctest: +FLOAT_CMP - >>> c.pdf_std().to(u.pc) # doctest: +FLOAT_CMP - - -Indeed these are close to the expectations. While this may seem unnecessary for -the basic Gaussian case, for more complex distributions or arithmetic -operations where error analysis becomes untenable, |Distribution| still powers -through:: - - >>> d = unc.uniform(center=3*u.kpc, width=800*u.pc, n_samples=10000) - >>> e = unc.Distribution(((rng.beta(2,5, 10000)-(2/7))/2 + 3)*u.kpc) - >>> f = (c * d * e) ** (1/3) - >>> f.pdf_mean() # doctest: +FLOAT_CMP + >>> c.pdf_std().to(u.pc) # doctest: +FLOAT_CMP + + +Indeed these are close to the expectations. While this may seem unnecessary for +the basic Gaussian case, for more complex distributions or arithmetic +operations where error analysis becomes untenable, |Distribution| still powers +through:: + + >>> d = unc.uniform(center=3*u.kpc, width=800*u.pc, n_samples=10000) + >>> e = unc.Distribution(((rng.beta(2,5, 10000)-(2/7))/2 + 3)*u.kpc) + >>> f = (c * d * e) ** (1/3) + >>> f.pdf_mean() # doctest: +FLOAT_CMP - >>> f.pdf_std() # doctest: +FLOAT_CMP + >>> f.pdf_std() # doctest: +FLOAT_CMP - >>> from matplotlib import pyplot as plt # doctest: +SKIP - >>> from astropy.visualization import quantity_support # doctest: +SKIP + >>> from matplotlib import pyplot as plt # doctest: +SKIP + >>> from astropy.visualization import quantity_support # doctest: +SKIP >>> with quantity_support(): ... fig, ax = plt.subplots() # doctest: +SKIP ... ax.hist(f.distribution, bins=50) # doctest: +SKIP - -.. plot:: - - import numpy as np - from astropy import units as u - from astropy import uncertainty as unc - from astropy.visualization import quantity_support - from matplotlib import pyplot as plt + +.. plot:: + + import numpy as np + from astropy import units as u + from astropy import uncertainty as unc + from astropy.visualization import quantity_support + from matplotlib import pyplot as plt rng = np.random.default_rng() - a = unc.normal(1*u.kpc, std=30*u.pc, n_samples=10000) - b = unc.normal(2*u.kpc, std=40*u.pc, n_samples=10000) - c = a + b - d = unc.uniform(center=3*u.kpc, width=800*u.pc, n_samples=10000) - e = unc.Distribution(((rng.beta(2,5, 10000)-(2/7))/2 + 3)*u.kpc) - f = (c * d * e) ** (1/3) - with quantity_support(): - fig, ax = plt.subplots() - ax.hist(f.distribution, bins=50) - - -Using `astropy.uncertainty` -=========================== - -Creating Distributions ----------------------- - -.. EXAMPLE START: Creating Distributions Using Arrays or Quantities - -The most direct way to create a distribution is to use an array or |Quantity| + a = unc.normal(1*u.kpc, std=30*u.pc, n_samples=10000) + b = unc.normal(2*u.kpc, std=40*u.pc, n_samples=10000) + c = a + b + d = unc.uniform(center=3*u.kpc, width=800*u.pc, n_samples=10000) + e = unc.Distribution(((rng.beta(2,5, 10000)-(2/7))/2 + 3)*u.kpc) + f = (c * d * e) ** (1/3) + with quantity_support(): + fig, ax = plt.subplots() + ax.hist(f.distribution, bins=50) + + +Using `astropy.uncertainty` +=========================== + +Creating Distributions +---------------------- + +.. EXAMPLE START: Creating Distributions Using Arrays or Quantities + +The most direct way to create a distribution is to use an array or |Quantity| that carries the samples in the *last* dimension:: - >>> import numpy as np - >>> from astropy import units as u - >>> from astropy import uncertainty as unc - >>> rng = np.random.default_rng(123456) # ensures "random" numbers match examples below + >>> import numpy as np + >>> from astropy import units as u + >>> from astropy import uncertainty as unc + >>> rng = np.random.default_rng(123456) # ensures "random" numbers match examples below >>> unc.Distribution(rng.poisson(12, (1000))) # doctest: +IGNORE_OUTPUT - NdarrayDistribution([..., 12,...]) with n_samples=1000 - >>> pq = rng.poisson([1, 5, 30, 400], (1000, 4)).T * u.ct # note the transpose, required to get the sampling on the *last* axis - >>> distr = unc.Distribution(pq) - >>> distr # doctest: +ELLIPSIS - - -Note the distinction for these two distributions: the first is built from an -array and therefore does not have |Quantity| attributes like ``unit``, while the -latter does have these attributes. This is reflected in how they interact with -other objects, for example, the ``NdarrayDistribution`` will not combine with -|Quantity| objects containing units. - -.. EXAMPLE END - -.. EXAMPLE START: Creating Distributions Using Helper Functions - -For commonly used distributions, helper functions exist to make creating them -more convenient. The examples below demonstrate several equivalent ways to -create a normal/Gaussian distribution:: - - >>> center = [1, 5, 30, 400] - >>> n_distr = unc.normal(center*u.kpc, std=[0.2, 1.5, 4, 1]*u.kpc, n_samples=1000) - >>> n_distr = unc.normal(center*u.kpc, var=[0.04, 2.25, 16, 1]*u.kpc**2, n_samples=1000) - >>> n_distr = unc.normal(center*u.kpc, ivar=[25, 0.44444444, 0.625, 1]*u.kpc**-2, n_samples=1000) - >>> n_distr.distribution.shape - (4, 1000) - >>> unc.normal(center*u.kpc, std=[0.2, 1.5, 4, 1]*u.kpc, n_samples=100).distribution.shape - (4, 100) - >>> unc.normal(center*u.kpc, std=[0.2, 1.5, 4, 1]*u.kpc, n_samples=20000).distribution.shape - (4, 20000) - -Additionally, Poisson and uniform |Distribution| creation functions exist:: - - >>> unc.poisson(center*u.count, n_samples=1000) # doctest: +ELLIPSIS - - >>> uwidth = [10, 20, 10, 55]*u.pc - >>> unc.uniform(center=center*u.kpc, width=uwidth, n_samples=1000) # doctest: +ELLIPSIS - - >>> unc.uniform(lower=center*u.kpc - uwidth/2, upper=center*u.kpc + uwidth/2, n_samples=1000) # doctest: +ELLIPSIS - - -.. EXAMPLE END - -Users are free to create their own distribution classes following similar -patterns. - -Using Distributions -------------------- - -.. EXAMPLE START: Accessing Properties of Distributions - -This object now acts much like a |Quantity| or |ndarray| for all but the -non-sampled dimension, but with additional statistical operations that work on -the sampled distributions:: - - >>> distr.shape - (4,) - >>> distr.size - 4 - >>> distr.unit - Unit("ct") - >>> distr.n_samples - 1000 - >>> distr.pdf_mean() # doctest: +FLOAT_CMP + NdarrayDistribution([..., 12,...]) with n_samples=1000 + >>> pq = rng.poisson([1, 5, 30, 400], (1000, 4)).T * u.ct # note the transpose, required to get the sampling on the *last* axis + >>> distr = unc.Distribution(pq) + >>> distr # doctest: +ELLIPSIS + + +Note the distinction for these two distributions: the first is built from an +array and therefore does not have |Quantity| attributes like ``unit``, while the +latter does have these attributes. This is reflected in how they interact with +other objects, for example, the ``NdarrayDistribution`` will not combine with +|Quantity| objects containing units. + +.. EXAMPLE END + +.. EXAMPLE START: Creating Distributions Using Helper Functions + +For commonly used distributions, helper functions exist to make creating them +more convenient. The examples below demonstrate several equivalent ways to +create a normal/Gaussian distribution:: + + >>> center = [1, 5, 30, 400] + >>> n_distr = unc.normal(center*u.kpc, std=[0.2, 1.5, 4, 1]*u.kpc, n_samples=1000) + >>> n_distr = unc.normal(center*u.kpc, var=[0.04, 2.25, 16, 1]*u.kpc**2, n_samples=1000) + >>> n_distr = unc.normal(center*u.kpc, ivar=[25, 0.44444444, 0.625, 1]*u.kpc**-2, n_samples=1000) + >>> n_distr.distribution.shape + (4, 1000) + >>> unc.normal(center*u.kpc, std=[0.2, 1.5, 4, 1]*u.kpc, n_samples=100).distribution.shape + (4, 100) + >>> unc.normal(center*u.kpc, std=[0.2, 1.5, 4, 1]*u.kpc, n_samples=20000).distribution.shape + (4, 20000) + +Additionally, Poisson and uniform |Distribution| creation functions exist:: + + >>> unc.poisson(center*u.count, n_samples=1000) # doctest: +ELLIPSIS + + >>> uwidth = [10, 20, 10, 55]*u.pc + >>> unc.uniform(center=center*u.kpc, width=uwidth, n_samples=1000) # doctest: +ELLIPSIS + + >>> unc.uniform(lower=center*u.kpc - uwidth/2, upper=center*u.kpc + uwidth/2, n_samples=1000) # doctest: +ELLIPSIS + + +.. EXAMPLE END + +Users are free to create their own distribution classes following similar +patterns. + +Using Distributions +------------------- + +.. EXAMPLE START: Accessing Properties of Distributions + +This object now acts much like a |Quantity| or |ndarray| for all but the +non-sampled dimension, but with additional statistical operations that work on +the sampled distributions:: + + >>> distr.shape + (4,) + >>> distr.size + 4 + >>> distr.unit + Unit("ct") + >>> distr.n_samples + 1000 + >>> distr.pdf_mean() # doctest: +FLOAT_CMP - >>> distr.pdf_std() # doctest: +FLOAT_CMP + >>> distr.pdf_std() # doctest: +FLOAT_CMP - >>> distr.pdf_var() # doctest: +FLOAT_CMP + >>> distr.pdf_var() # doctest: +FLOAT_CMP - >>> distr.pdf_median() - - >>> distr.pdf_mad() # Median absolute deviation # doctest: +FLOAT_CMP + >>> distr.pdf_median() + + >>> distr.pdf_mad() # Median absolute deviation # doctest: +FLOAT_CMP - >>> distr.pdf_smad() # Median absolute deviation, rescaled to match std for normal # doctest: +FLOAT_CMP + >>> distr.pdf_smad() # Median absolute deviation, rescaled to match std for normal # doctest: +FLOAT_CMP - >>> distr.pdf_percentiles([10, 50, 90]) + >>> distr.pdf_percentiles([10, 50, 90]) - >>> distr.pdf_percentiles([.1, .5, .9]*u.dimensionless_unscaled) + >>> distr.pdf_percentiles([.1, .5, .9]*u.dimensionless_unscaled) - -If need be, the underlying array can then be accessed from the ``distribution`` -attribute:: - + +If need be, the underlying array can then be accessed from the ``distribution`` +attribute:: + >>> distr.distribution - >>> distr.distribution.shape - (4, 1000) - -.. EXAMPLE END - -.. EXAMPLE START: Interaction Between Quantity Objects and Distributions - -A |Quantity| distribution interacts naturally with non-|Distribution| -|Quantity| objects, assuming the |Quantity| is a Dirac delta distribution:: - - >>> distr_in_kpc = distr * u.kpc/u.count # for the sake of round numbers in examples - >>> distrplus = distr_in_kpc + [2000,0,0,500]*u.pc - >>> distrplus.pdf_median() - - >>> distrplus.pdf_var() # doctest: +FLOAT_CMP + >>> distr.distribution.shape + (4, 1000) + +.. EXAMPLE END + +.. EXAMPLE START: Interaction Between Quantity Objects and Distributions + +A |Quantity| distribution interacts naturally with non-|Distribution| +|Quantity| objects, assuming the |Quantity| is a Dirac delta distribution:: + + >>> distr_in_kpc = distr * u.kpc/u.count # for the sake of round numbers in examples + >>> distrplus = distr_in_kpc + [2000,0,0,500]*u.pc + >>> distrplus.pdf_median() + + >>> distrplus.pdf_var() # doctest: +FLOAT_CMP - -It also operates as expected with other distributions (but see below for a -discussion of covariances):: - + +It also operates as expected with other distributions (but see below for a +discussion of covariances):: + >>> means = [2000, 0, 0, 500] >>> sigmas = [1000, .01, 3000, 10] >>> another_distr = unc.Distribution((rng.normal(means, sigmas, (1000,4))).T * u.pc) - >>> combined_distr = distr_in_kpc + another_distr - >>> combined_distr.pdf_median() # doctest: +FLOAT_CMP + >>> combined_distr = distr_in_kpc + another_distr + >>> combined_distr.pdf_median() # doctest: +FLOAT_CMP - >>> combined_distr.pdf_var() # doctest: +FLOAT_CMP + >>> combined_distr.pdf_var() # doctest: +FLOAT_CMP - -.. EXAMPLE END - -Covariance in Distributions and Discrete Sampling Effects ---------------------------------------------------------- - -One of the main applications for distributions is uncertainty propagation, which -critically requires proper treatment of covariance. This comes naturally in the -Monte Carlo sampling approach used by the |Distribution| class, as long as -proper care is taken with sampling error. - -.. EXAMPLE START: Covariance in Distributions - -To start with a basic example, two un-correlated distributions should produce -an un-correlated joint distribution plot: - -.. plot:: - :context: close-figs - :include-source: - - >>> import numpy as np - >>> from astropy import units as u - >>> from astropy import uncertainty as unc - >>> from matplotlib import pyplot as plt # doctest: +SKIP - >>> n1 = unc.normal(center=0., std=1, n_samples=10000) - >>> n2 = unc.normal(center=0., std=2, n_samples=10000) - >>> fig, ax = plt.subplots() # doctest: +SKIP - >>> ax.scatter(n1.distribution, n2.distribution, s=2, lw=0, alpha=.5) # doctest: +SKIP - >>> ax.set(xlim=(-4, 4), ylim=(-4, 4)) # doctest: +SKIP - -Indeed, the distributions are independent. If we instead construct a covariant -pair of Gaussians, it is immediately apparent: - -.. plot:: - :context: close-figs - :include-source: - + +.. EXAMPLE END + +Covariance in Distributions and Discrete Sampling Effects +--------------------------------------------------------- + +One of the main applications for distributions is uncertainty propagation, which +critically requires proper treatment of covariance. This comes naturally in the +Monte Carlo sampling approach used by the |Distribution| class, as long as +proper care is taken with sampling error. + +.. EXAMPLE START: Covariance in Distributions + +To start with a basic example, two un-correlated distributions should produce +an un-correlated joint distribution plot: + +.. plot:: + :context: close-figs + :include-source: + + >>> import numpy as np + >>> from astropy import units as u + >>> from astropy import uncertainty as unc + >>> from matplotlib import pyplot as plt # doctest: +SKIP + >>> n1 = unc.normal(center=0., std=1, n_samples=10000) + >>> n2 = unc.normal(center=0., std=2, n_samples=10000) + >>> fig, ax = plt.subplots() # doctest: +SKIP + >>> ax.scatter(n1.distribution, n2.distribution, s=2, lw=0, alpha=.5) # doctest: +SKIP + >>> ax.set(xlim=(-4, 4), ylim=(-4, 4)) # doctest: +SKIP + +Indeed, the distributions are independent. If we instead construct a covariant +pair of Gaussians, it is immediately apparent: + +.. plot:: + :context: close-figs + :include-source: + >>> rng = np.random.default_rng(357) - >>> ncov = rng.multivariate_normal([0, 0], [[1, .5], [.5, 2]], size=10000) - >>> n1 = unc.Distribution(ncov[:, 0]) - >>> n2 = unc.Distribution(ncov[:, 1]) - >>> plt.scatter(n1.distribution, n2.distribution, s=2, lw=0, alpha=.5) # doctest: +SKIP - >>> plt.xlim(-4, 4) # doctest: +SKIP - >>> plt.ylim(-4, 4) # doctest: +SKIP - -Most importantly, the proper correlated structure is preserved or generated as -expected by appropriate arithmetic operations. For example, ratios of -uncorrelated normal distribution gain covariances if the axes are not -independent, as in this simulation of iron, hydrogen, and oxygen abundances in -a hypothetical collection of stars: - -.. plot:: - :context: close-figs - :include-source: - - >>> fe_abund = unc.normal(center=-2, std=.25, n_samples=10000) - >>> o_abund = unc.normal(center=-6., std=.5, n_samples=10000) - >>> h_abund = unc.normal(center=-0.7, std=.1, n_samples=10000) - >>> feh = fe_abund - h_abund - >>> ofe = o_abund - fe_abund - >>> plt.scatter(ofe.distribution, feh.distribution, s=2, lw=0, alpha=.5) # doctest: +SKIP - >>> plt.xlabel('[Fe/H]') # doctest: +SKIP - >>> plt.ylabel('[O/Fe]') # doctest: +SKIP - -This demonstrates that the correlations naturally arise from the variables, but -there is no need to explicitly account for it: the sampling process naturally -recovers correlations that are present. - -.. EXAMPLE END - -.. EXAMPLE START: Preserving Covariance in Distributions - -An important note of warning, however, is that the covariance is only preserved -if the sampling axes are exactly matched sample by sample. If they are not, all -covariance information is (silently) lost: - -.. plot:: - :context: close-figs - :include-source: - - >>> n2_wrong = unc.Distribution(ncov[::-1, 1]) #reverse the sampling axis order - >>> plt.scatter(n1.distribution, n2_wrong.distribution, s=2, lw=0, alpha=.5) # doctest: +SKIP - >>> plt.xlim(-4, 4) # doctest: +SKIP - >>> plt.ylim(-4, 4) # doctest: +SKIP - -Moreover, an insufficiently sampled distribution may give poor estimates or -hide correlations. The example below is the same as the covariant Gaussian -example above, but with 200x fewer samples: - - -.. plot:: - :context: close-figs - :include-source: - - >>> ncov = rng.multivariate_normal([0, 0], [[1, .5], [.5, 2]], size=50) - >>> n1 = unc.Distribution(ncov[:, 0]) - >>> n2 = unc.Distribution(ncov[:, 1]) - >>> plt.scatter(n1.distribution, n2.distribution, s=5, lw=0) # doctest: +SKIP - >>> plt.xlim(-4, 4) # doctest: +SKIP - >>> plt.ylim(-4, 4) # doctest: +SKIP - >>> np.cov(n1.distribution, n2.distribution) # doctest: +FLOAT_CMP + >>> ncov = rng.multivariate_normal([0, 0], [[1, .5], [.5, 2]], size=10000) + >>> n1 = unc.Distribution(ncov[:, 0]) + >>> n2 = unc.Distribution(ncov[:, 1]) + >>> plt.scatter(n1.distribution, n2.distribution, s=2, lw=0, alpha=.5) # doctest: +SKIP + >>> plt.xlim(-4, 4) # doctest: +SKIP + >>> plt.ylim(-4, 4) # doctest: +SKIP + +Most importantly, the proper correlated structure is preserved or generated as +expected by appropriate arithmetic operations. For example, ratios of +uncorrelated normal distribution gain covariances if the axes are not +independent, as in this simulation of iron, hydrogen, and oxygen abundances in +a hypothetical collection of stars: + +.. plot:: + :context: close-figs + :include-source: + + >>> fe_abund = unc.normal(center=-2, std=.25, n_samples=10000) + >>> o_abund = unc.normal(center=-6., std=.5, n_samples=10000) + >>> h_abund = unc.normal(center=-0.7, std=.1, n_samples=10000) + >>> feh = fe_abund - h_abund + >>> ofe = o_abund - fe_abund + >>> plt.scatter(ofe.distribution, feh.distribution, s=2, lw=0, alpha=.5) # doctest: +SKIP + >>> plt.xlabel('[Fe/H]') # doctest: +SKIP + >>> plt.ylabel('[O/Fe]') # doctest: +SKIP + +This demonstrates that the correlations naturally arise from the variables, but +there is no need to explicitly account for it: the sampling process naturally +recovers correlations that are present. + +.. EXAMPLE END + +.. EXAMPLE START: Preserving Covariance in Distributions + +An important note of warning, however, is that the covariance is only preserved +if the sampling axes are exactly matched sample by sample. If they are not, all +covariance information is (silently) lost: + +.. plot:: + :context: close-figs + :include-source: + + >>> n2_wrong = unc.Distribution(ncov[::-1, 1]) #reverse the sampling axis order + >>> plt.scatter(n1.distribution, n2_wrong.distribution, s=2, lw=0, alpha=.5) # doctest: +SKIP + >>> plt.xlim(-4, 4) # doctest: +SKIP + >>> plt.ylim(-4, 4) # doctest: +SKIP + +Moreover, an insufficiently sampled distribution may give poor estimates or +hide correlations. The example below is the same as the covariant Gaussian +example above, but with 200x fewer samples: + + +.. plot:: + :context: close-figs + :include-source: + + >>> ncov = rng.multivariate_normal([0, 0], [[1, .5], [.5, 2]], size=50) + >>> n1 = unc.Distribution(ncov[:, 0]) + >>> n2 = unc.Distribution(ncov[:, 1]) + >>> plt.scatter(n1.distribution, n2.distribution, s=5, lw=0) # doctest: +SKIP + >>> plt.xlim(-4, 4) # doctest: +SKIP + >>> plt.ylim(-4, 4) # doctest: +SKIP + >>> np.cov(n1.distribution, n2.distribution) # doctest: +FLOAT_CMP array([[0.95534365, 0.35220031], [0.35220031, 1.99511743]]) - -The covariance structure is much less apparent by eye, and this is reflected -in significant discrepancies between the input and output covariance matrix. -In general this is an intrinsic trade-off using sampled distributions: a smaller -number of samples is computationally more efficient, but leads to larger -uncertainties in any of the relevant quantities. These tend to be of order -:math:`\sqrt{n_{\rm samples}}` in any derived quantity, but that depends on the -complexity of the distribution in question. - -.. EXAMPLE END - -.. note that if this section gets too long, it should be moved to a separate - doc page - see the top of performance.inc.rst for the instructions on how to do - that -.. include:: performance.inc.rst - -Reference/API -============= - -.. automodapi:: astropy.uncertainty + +The covariance structure is much less apparent by eye, and this is reflected +in significant discrepancies between the input and output covariance matrix. +In general this is an intrinsic trade-off using sampled distributions: a smaller +number of samples is computationally more efficient, but leads to larger +uncertainties in any of the relevant quantities. These tend to be of order +:math:`\sqrt{n_{\rm samples}}` in any derived quantity, but that depends on the +complexity of the distribution in question. + +.. EXAMPLE END + +.. note that if this section gets too long, it should be moved to a separate + doc page - see the top of performance.inc.rst for the instructions on how to do + that +.. include:: performance.inc.rst + +Reference/API +============= + +.. automodapi:: astropy.uncertainty diff --git a/docs/units/format.rst b/docs/units/format.rst index 356916754800..5f64facc02c9 100644 --- a/docs/units/format.rst +++ b/docs/units/format.rst @@ -209,7 +209,7 @@ following formats: - ``"latex"``: Writes units out using LaTeX math syntax using the `IAU Style Manual - `_ + `_ recommendations for unit presentation. This format is automatically used when printing a unit in the |IPython| notebook:: @@ -224,7 +224,7 @@ following formats: - ``"latex_inline"``: Writes units out using LaTeX math syntax using the `IAU Style Manual - `_ + `_ recommendations for unit presentation, using negative powers instead of fractions, as required by some journals (e.g., `Apj and AJ `_). diff --git a/docs/units/index.rst b/docs/units/index.rst index 3e6b04cde614..5d008f165f0c 100644 --- a/docs/units/index.rst +++ b/docs/units/index.rst @@ -215,7 +215,7 @@ See Also `_. - `IAU Style Manual - `_. + `_. - `A table of astronomical unit equivalencies `_. diff --git a/docs/wcs/index.rst b/docs/wcs/index.rst index f8805463f64c..0ea51a293a37 100644 --- a/docs/wcs/index.rst +++ b/docs/wcs/index.rst @@ -22,7 +22,7 @@ For historical reasons and to support legacy software, `astropy.wcs` maintains two separate application interfaces. The ``High-Level API`` should be used by most applications. It abstracts out the underlying object and works transparently with other packages which support the -`Common Python Interface for WCS `_, +`Common Python Interface for WCS `_, allowing for a more flexible approach to the problem and avoiding the `limitations of the FITS WCS standard `_. diff --git a/docs/whatsnew/7.2.rst b/docs/whatsnew/7.2.rst index 868264185949..44c26caa3688 100644 --- a/docs/whatsnew/7.2.rst +++ b/docs/whatsnew/7.2.rst @@ -18,21 +18,16 @@ In particular, this release includes: * :ref:`whatsnew-7.2-cosmology` * :ref:`whatsnew-7.2-fits-wcs-units` * :ref:`whatsnew-7.2-cls-concat-stack` -* ... In addition to these major changes, Astropy v7.2 includes a large number of smaller improvements and bug fixes, which are described in the :ref:`changelog`. By the numbers: -* X issues have been closed since v7.1 -* X pull requests have been merged since v7.1 -* X distinct people have contributed code - -Full change log -=============== - -To see a detailed list of all changes in version v7.2, including changes in -API, please see the :ref:`changelog`. +* 858 commits have been added since 7.1 +* 166 issues have been closed since 7.1 +* 359 pull requests have been merged since 7.1 +* 50 people have contributed since 7.1 +* 22 of which are new contributors .. _whatsnew-7.2-faster-ecsv-tab-rdr: @@ -167,8 +162,8 @@ astropy 9.0. Here the index identifier was the first element of the item, e.g., .. _whatsnew-7.2-cosmology: -Cosmology -========= +Cosmology traits +================ The :mod:`~astropy.cosmology.traits` module provides reusable components, called :term:`traits `, that encapsulate specific cosmological properties or @@ -325,3 +320,68 @@ that the existing functions allowed one to concatenate scalars together with one-dimensional arrays, while this is not allowed with `~numpy.concatenate`. Instead, like for arrays, one has to explicitly ensure arrays, e.g., by using ``np.concatenate(np.atleast_1d(coords))``. + +Full change log +=============== + +To see a detailed list of all changes in version v7.2, including changes in +API, please see the :ref:`changelog`. + +Contributors to the 7.2 release +=============================== + +The people who have contributed to the code for this release are: + +.. hlist:: + :columns: 4 + + - Adrien Thob * + - Albert Y. Shih + - Alex Fox * + - Antonio Bento Pereira * + - Benjamin Scully * + - Brigitta Sipőcz + - Caspar van Leeuwen * + - Chiara Marmo + - Clément Robert + - Eero Vaher + - Elise Chavez * + - Emily Hu * + - Evan Jones * + - Everett Schlawin * + - Finn Womack * + - Hans Moritz Günther + - Harshada Raut * + - Igor Lemos + - Jackson Hayward * + - James Turner + - Kim Searle * + - Larry Bradley + - Leo Singer + - Manon Marchand + - Marten van Kerkwijk + - Matthieu Bec + - Maximilian Linhoff + - Michael Belfrage * + - Mihai Cara + - Mohsin Mehmood * + - Nathaniel Starkman + - P\. L\. Lim + - Peter Scicluna * + - Pieter Eendebak * + - Preshanth Jagannathan * + - Raphael Erik Hviding * + - Sam Lee + - Shreeharsh Shinde * + - Simon Conseil + - Sonu Singh * + - Stelios Voutsinas * + - Stuart Mumford + - Thomas Robitaille + - Tiago Gomes + - Tim Jenness + - Tom Aldcroft + - Varun Nikam + - William Jamieson + +Where a * indicates that this release contains their first contribution to astropy. diff --git a/pyproject.toml b/pyproject.toml index b5dbbd186438..e34b6c02c120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,11 +43,11 @@ keywords = [ "ascii", ] dependencies = [ - "numpy>=1.24", + "astropy-iers-data>=0.2026.6.22.1.23.34", + "numpy>=1.24, <2.7", + "packaging>=22.0.0", "pyerfa>=2.0.1.1", # For >=2.0.1.7, adjust structured_units.rst and doctest-requires - "astropy-iers-data>=0.2025.10.27.0.39.10", "PyYAML>=6.0.0", - "packaging>=22.0.0", ] [project.optional-dependencies] @@ -55,6 +55,7 @@ dependencies = [ recommended = [ "scipy>=1.9.2", "matplotlib>=3.8.0", + "pyparsing>=2.4.0", "narwhals>=1.42.0", # keep in sync with dependency-groups.dataframe ] # Optional IPython-related behavior is in many places in Astropy. IPython is a complex @@ -88,7 +89,7 @@ all = [ "beautifulsoup4>=4.9.3", # imposed by pandas==2.0 "html5lib>=1.1", "bleach>=3.2.1", - "sortedcontainers>=1.5.7", # (older versions may work) + "sortedcontainers>=2.1.0", # imposed by testing with hypothesis "pytz>=2016.10", # (older versions may work) "jplephem>=2.17.0", "mpmath>=1.2.1", @@ -119,6 +120,14 @@ test_all = [ "sgp4>=2.3", "array-api-strict>=1.0", "array-api-strict<2.4;python_version<'3.12'", # PYTHON_LT_3_12 + + # 0.2.2 works on *some* platforms we support, though not all + # 1.0.0 would be a more universal lower bound, but it also + # requires numpy>=2.4. Because dependency trees are resolved + # with *all* extras and groups (whether they are to be installed or not), + # and we want to preserve the ability to test against NUMPY_LT_2_4, + # we effectively cannot upgrade this lower bound until we drop NUMPY_LT_2_4 + "numpy-quaddtype>=0.2.2", ] typing = [ "pandas-stubs>=2.0.0", @@ -126,14 +135,14 @@ typing = [ ] docs = [ "astropy[recommended]", # installs the [recommended] dependencies - "sphinx>=8.2.0", # keep in sync with docs/conf.py + "sphinx>=8.2.0,<9", # keep in sync with docs/conf.py "sphinx-astropy[confv2]>=1.9.1", "pytest>=8.0.0", "sphinx-changelog>=1.2.0", "sphinx_design>=0.6.1", "Jinja2>=3.1.3", "sphinxcontrib-globalsubs >= 0.1.1", - "matplotlib>=3.9.1", # https://github.com/matplotlib/matplotlib/issues/28234 + "matplotlib!=3.9.0", # https://github.com/matplotlib/matplotlib/issues/28234 ] # These group together all the dependencies needed for developing in Astropy. dev = [ @@ -166,7 +175,6 @@ volint = "astropy.io.votable.volint:main" wcslint = "astropy.wcs.wcslint:main" [build-system] -# keep in sync with .github/workflows/publish.yml requires = ["setuptools>=77.0.0", "setuptools_scm>=8.0.0", "cython>=3.0.0, <4", @@ -245,6 +253,13 @@ filterwarnings = [ "error", # https://github.com/astropy/astropy/issues/18126 "ignore:'_UnionGenericAlias' is deprecated and slated for removal in Python 3.17:DeprecationWarning", # not PYTHON_LT_3_14 + "ignore:The chararray class is deprecated and will be removed in a future release.:DeprecationWarning", # not NUMPY_LT_2_5 + # Weird warning from the vectorized do_format in Angle.to_string. low numpy/python? + "ignore:invalid value encountered in do_format:RuntimeWarning", + # https://github.com/astropy/astropy/issues/19511 + "ignore:Implicitly cleaning up =2.12.0", "asdf-coordinates-schemas>=0.2.0", + + # https://github.com/astropy/astropy/issues/19651 + "pandas<3" ] diff --git a/tox.ini b/tox.ini index 869ebfa86a12..e89c1353077a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{311,312,313,313t,dev}-test{,-recdeps,-alldeps,-oldestdeps,-devdeps,-devinfra,-predeps,-numpy124,-numpy125,-mpl380}{,-cov}{,-clocale}{,-fitsio}{,-noscipy} + py{311,312,313,314t,dev}-test{,-recdeps,-alldeps,-oldestdeps,-devdeps,-devinfra,-predeps,-numpy124,-numpy125,-mpl380}{,-cov}{,-clocale}{,-fitsio}{,-noscipy} # Only these two exact tox environments have corresponding figure hash files. py311-test-image-mpl380-cov py311-test-image-mpldev-cov @@ -14,7 +14,7 @@ minversion = 4.22 # for dependency_groups # keep in sync with pyproject.toml [testenv] # Pass through the following environment variables which are needed for the CI -passenv = HOME,WINDIR,LC_ALL,LC_CTYPE,CC,CI,IS_CRON,ARCH_ON_CI,PY_COLORS +passenv = HOME,WINDIR,LC_ALL,LC_CTYPE,CC,CI,IS_CRON,ARCH_ON_CI,PY_COLORS,PYTHON_GIL,PYTHON_CONTEXT_AWARE_WARNINGS setenv = NUMPY_WARN_IF_NO_MEM_POLICY = 1 @@ -66,7 +66,9 @@ deps = numpy124: numpy==1.24.* numpy125: numpy==1.25.* + # matplotlib 3.8 + pyparsing 3.3 = DeprecationWarning mpl380: matplotlib==3.8.0 + mpl380: pyparsing==3.2.5 image: latex image, devinfra: scipy @@ -103,6 +105,7 @@ deps = devdeps-!noscipy: sgp4>=2.3 devdeps-!noscipy: array-api-strict>=1.0 py311-devdeps-!noscipy: array-api-strict<2.4 + devdeps-!noscipy: numpy-quaddtype>=0.2.2 # The following indicates which [project.optional-dependencies] from pyproject.toml will be installed. # test_all does not work here due to upstream bug https://github.com/tox-dev/tox/issues/3433 @@ -116,10 +119,10 @@ dependency-groups = commands = {list_dependencies_command} - !cov-!double: pytest --pyargs astropy {toxinidir}/docs {env:MPLFLAGS} {posargs} - cov-!double: pytest --pyargs astropy {toxinidir}/docs {env:MPLFLAGS} --cov astropy --cov-config={toxinidir}/pyproject.toml --cov-report xml:{toxinidir}/coverage.xml {posargs} + !cov-!double: pytest --strict-markers --pyargs astropy {toxinidir}/docs {env:MPLFLAGS} {posargs} + cov-!double: pytest --strict-markers --pyargs astropy {toxinidir}/docs {env:MPLFLAGS} --cov astropy --cov-config={toxinidir}/pyproject.toml --cov-report xml:{toxinidir}/coverage.xml {posargs} - double: pytest --keep-duplicates --pyargs astropy {toxinidir}/docs astropy {toxinidir}/docs {env:MPLFLAGS} {posargs} + double: pytest --keep-duplicates --strict-markers --pyargs astropy {toxinidir}/docs astropy {toxinidir}/docs {env:MPLFLAGS} {posargs} pip_pre = devdeps: true