├── astrodendro ├── io │ ├── tests │ │ ├── __init__.py │ │ ├── data │ │ │ ├── dendro.fits │ │ │ ├── dendro.hdf5 │ │ │ ├── dendro_old.fits │ │ │ └── dendro_old.hdf5 │ │ ├── test_fits.py │ │ └── test_hdf5.py │ ├── handler.py │ ├── __init__.py │ ├── hdf5.py │ ├── fits.py │ └── util.py ├── tests │ ├── __init__.py │ ├── sample-data-hl.npz │ ├── benchmark_data │ │ ├── 2d.fits │ │ ├── 2d1.fits │ │ ├── 2d2.fits │ │ ├── 2d3.fits │ │ ├── 3d.fits │ │ ├── 3d1.fits │ │ ├── 3d2.fits │ │ └── 3d3.fits │ ├── build_benchmark.py │ ├── test_viewer.py │ ├── _testdata.py │ ├── benchmark.py │ ├── test_is_independent.py │ ├── test_recursion.py │ ├── test_pruning.py │ ├── test_flux.py │ ├── test_index.py │ ├── test_io.py │ ├── test_compute.py │ ├── test_structure.py │ └── test_analysis.py ├── __init__.py ├── structure_collection.py ├── progressbar.py ├── pruning.py ├── scatter.py ├── flux.py ├── plot.py └── viewer.py ├── .coveragerc ├── docs ├── _templates │ └── autosummary │ │ ├── base.rst │ │ ├── class.rst │ │ └── module.rst ├── schematic_tree.png ├── L1551_scuba_850mu.fits ├── scatter_screenshot.png ├── viewer_screenshot.png ├── PerA_Extn2MASS_F_Gal.fits ├── algorithm │ ├── simple_final.png │ ├── simple_step1.png │ ├── simple_step2.png │ ├── simple_step3.png │ ├── simple_step4.png │ ├── min_value_final.png │ ├── large_delta_final.png │ ├── large_delta_step1.png │ ├── large_delta_step2.png │ ├── small_delta_final.png │ ├── small_delta_step1.png │ ├── small_delta_step2.png │ └── small_delta_step3.png ├── schematic_structure_1.png ├── schematic_structure_2.png ├── wcsaxes_docs_screenshot.png ├── scatter_selected_viewer_screenshot.png ├── diagrams │ └── README.md ├── installing.rst ├── index.rst ├── migration.rst ├── advanced.rst ├── Makefile ├── algorithm.rst ├── using.rst ├── conf.py ├── plotting.rst └── catalog.rst ├── .readthedocs.yml ├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── main.yml │ └── update-changelog.yaml ├── MANIFEST.in ├── .gitignore ├── README.md ├── LICENSE ├── tox.ini ├── pyproject.toml └── CHANGES.md /astrodendro/io/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /astrodendro/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = astrodendro/test/* 3 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/base.rst: -------------------------------------------------------------------------------- 1 | {% extends "autosummary_core/base.rst" %} 2 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/class.rst: -------------------------------------------------------------------------------- 1 | {% extends "autosummary_core/class.rst" %} 2 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/module.rst: -------------------------------------------------------------------------------- 1 | {% extends "autosummary_core/module.rst" %} 2 | -------------------------------------------------------------------------------- /docs/schematic_tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/schematic_tree.png -------------------------------------------------------------------------------- /docs/L1551_scuba_850mu.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/L1551_scuba_850mu.fits -------------------------------------------------------------------------------- /docs/scatter_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/scatter_screenshot.png -------------------------------------------------------------------------------- /docs/viewer_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/viewer_screenshot.png -------------------------------------------------------------------------------- /docs/PerA_Extn2MASS_F_Gal.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/PerA_Extn2MASS_F_Gal.fits -------------------------------------------------------------------------------- /docs/algorithm/simple_final.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/simple_final.png -------------------------------------------------------------------------------- /docs/algorithm/simple_step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/simple_step1.png -------------------------------------------------------------------------------- /docs/algorithm/simple_step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/simple_step2.png -------------------------------------------------------------------------------- /docs/algorithm/simple_step3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/simple_step3.png -------------------------------------------------------------------------------- /docs/algorithm/simple_step4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/simple_step4.png -------------------------------------------------------------------------------- /docs/schematic_structure_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/schematic_structure_1.png -------------------------------------------------------------------------------- /docs/schematic_structure_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/schematic_structure_2.png -------------------------------------------------------------------------------- /docs/wcsaxes_docs_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/wcsaxes_docs_screenshot.png -------------------------------------------------------------------------------- /docs/algorithm/min_value_final.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/min_value_final.png -------------------------------------------------------------------------------- /astrodendro/io/tests/data/dendro.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/io/tests/data/dendro.fits -------------------------------------------------------------------------------- /astrodendro/io/tests/data/dendro.hdf5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/io/tests/data/dendro.hdf5 -------------------------------------------------------------------------------- /astrodendro/tests/sample-data-hl.npz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/tests/sample-data-hl.npz -------------------------------------------------------------------------------- /docs/algorithm/large_delta_final.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/large_delta_final.png -------------------------------------------------------------------------------- /docs/algorithm/large_delta_step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/large_delta_step1.png -------------------------------------------------------------------------------- /docs/algorithm/large_delta_step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/large_delta_step2.png -------------------------------------------------------------------------------- /docs/algorithm/small_delta_final.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/small_delta_final.png -------------------------------------------------------------------------------- /docs/algorithm/small_delta_step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/small_delta_step1.png -------------------------------------------------------------------------------- /docs/algorithm/small_delta_step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/small_delta_step2.png -------------------------------------------------------------------------------- /docs/algorithm/small_delta_step3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/algorithm/small_delta_step3.png -------------------------------------------------------------------------------- /astrodendro/io/tests/data/dendro_old.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/io/tests/data/dendro_old.fits -------------------------------------------------------------------------------- /astrodendro/io/tests/data/dendro_old.hdf5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/io/tests/data/dendro_old.hdf5 -------------------------------------------------------------------------------- /astrodendro/tests/benchmark_data/2d.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/tests/benchmark_data/2d.fits -------------------------------------------------------------------------------- /astrodendro/tests/benchmark_data/2d1.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/tests/benchmark_data/2d1.fits -------------------------------------------------------------------------------- /astrodendro/tests/benchmark_data/2d2.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/tests/benchmark_data/2d2.fits -------------------------------------------------------------------------------- /astrodendro/tests/benchmark_data/2d3.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/tests/benchmark_data/2d3.fits -------------------------------------------------------------------------------- /astrodendro/tests/benchmark_data/3d.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/tests/benchmark_data/3d.fits -------------------------------------------------------------------------------- /astrodendro/tests/benchmark_data/3d1.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/tests/benchmark_data/3d1.fits -------------------------------------------------------------------------------- /astrodendro/tests/benchmark_data/3d2.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/tests/benchmark_data/3d2.fits -------------------------------------------------------------------------------- /astrodendro/tests/benchmark_data/3d3.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/astrodendro/tests/benchmark_data/3d3.fits -------------------------------------------------------------------------------- /docs/scatter_selected_viewer_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrograms/astrodendro/HEAD/docs/scatter_selected_viewer_screenshot.png -------------------------------------------------------------------------------- /astrodendro/io/handler.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | IOHandler = namedtuple("IOHandler", "identify import_dendro export_dendro") 4 | -------------------------------------------------------------------------------- /docs/diagrams/README.md: -------------------------------------------------------------------------------- 1 | The diagrams in this directory were created with InkScape, which can be 2 | freely downloaded from [http://inkscape.org/](http://inkscape.org/). 3 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-20.04 5 | tools: 6 | python: '3.8' 7 | 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | 15 | formats: [] 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: ".github/workflows" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | actions: 9 | patterns: 10 | - "*" 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGES.rst 3 | include setup.cfg 4 | include LICENSE.rst 5 | include pyproject.toml 6 | 7 | recursive-include astrodendro *.pyx *.c *.pxd 8 | recursive-include docs * 9 | recursive-include licenses * 10 | recursive-include scripts * 11 | 12 | prune build 13 | prune docs/_build 14 | prune docs/api 15 | 16 | global-exclude *.pyc *.o 17 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - pre-commit-ci 5 | categories: 6 | - title: Bug Fixes 7 | labels: 8 | - bug 9 | - title: New Features 10 | labels: 11 | - enhancement 12 | - title: Documentation 13 | labels: 14 | - Documentation 15 | - title: Other Changes 16 | labels: 17 | - "*" 18 | -------------------------------------------------------------------------------- /astrodendro/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | from .dendrogram import Dendrogram, periodic_neighbours # noqa 4 | from .structure import Structure # noqa 5 | from .analysis import ppv_catalog, pp_catalog # noqa 6 | from .plot import DendrogramPlotter # noqa 7 | from .viewer import BasicDendrogramViewer # noqa 8 | 9 | from .version import version as __version__ # noqa 10 | -------------------------------------------------------------------------------- /astrodendro/io/tests/test_fits.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, division 2 | 3 | import os 4 | from ..fits import dendro_import_fits 5 | 6 | DATA = os.path.join(os.path.dirname(__file__), 'data') 7 | 8 | 9 | def test_import_old(): 10 | # Check that we are backward-compatible 11 | dendro_import_fits(os.path.join(DATA, 'dendro_old.fits')) 12 | 13 | 14 | def test_import(): 15 | dendro_import_fits(os.path.join(DATA, 'dendro.fits')) 16 | -------------------------------------------------------------------------------- /astrodendro/io/tests/test_hdf5.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, division 2 | 3 | import os 4 | from ..hdf5 import dendro_import_hdf5 5 | 6 | DATA = os.path.join(os.path.dirname(__file__), 'data') 7 | 8 | 9 | def test_import_old(): 10 | # Check that we are backward-compatible 11 | dendro_import_hdf5(os.path.join(DATA, 'dendro_old.hdf5')) 12 | 13 | 14 | def test_import(): 15 | dendro_import_hdf5(os.path.join(DATA, 'dendro.hdf5')) 16 | -------------------------------------------------------------------------------- /astrodendro/structure_collection.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | from matplotlib.collections import LineCollection 4 | 5 | __all__ = ['StructureCollection'] 6 | 7 | 8 | class StructureCollection(LineCollection): 9 | 10 | @property 11 | def structures(self): 12 | return self._structures 13 | 14 | @structures.setter 15 | def structures(self, values): 16 | self._structures = values 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | 9 | test: 10 | uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@v2 11 | with: 12 | envs: | 13 | - linux: codestyle 14 | - linux: py38-test-oldestdeps 15 | - macos: py39-test 16 | - windows: py39-test 17 | - linux: py310-test 18 | - macos: py311-test-viewer 19 | - windows: py312-test 20 | - linux: py313-test-devdeps 21 | coverage: 'codecov' 22 | 23 | publish: 24 | needs: test 25 | uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish_pure_python.yml@v2 26 | with: 27 | test_extras: test 28 | test_command: pytest --pyargs astrodendro 29 | secrets: 30 | pypi_token: ${{ secrets.pypi_token }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled files 2 | *.py[cod] 3 | *.a 4 | *.o 5 | *.so 6 | __pycache__ 7 | 8 | # Ignore .c files by default to avoid including generated code. If you want to 9 | # add a non-generated .c extension, use `git add -f filename.c`. 10 | *.c 11 | 12 | # Other generated files 13 | */version.py 14 | */cython_version.py 15 | htmlcov 16 | .coverage 17 | MANIFEST 18 | .ipynb_checkpoints 19 | 20 | # Sphinx 21 | docs/api 22 | docs/_build 23 | 24 | # Eclipse editor project files 25 | .project 26 | .pydevproject 27 | .settings 28 | 29 | # Pycharm editor project files 30 | .idea 31 | 32 | # Floobits project files 33 | .floo 34 | .flooignore 35 | 36 | # Visual Studio Code project files 37 | .vscode 38 | 39 | # Packages/installer info 40 | *.egg 41 | *.egg-info 42 | dist 43 | build 44 | eggs 45 | .eggs 46 | parts 47 | bin 48 | var 49 | sdist 50 | develop-eggs 51 | .installed.cfg 52 | distribute-*.tar.gz 53 | 54 | # Other 55 | .cache 56 | .tox 57 | .*.sw[op] 58 | *~ 59 | .project 60 | .pydevproject 61 | .settings 62 | pip-wheel-metadata/ 63 | 64 | # Mac OSX 65 | .DS_Store 66 | 67 | .tmp 68 | -------------------------------------------------------------------------------- /astrodendro/tests/build_benchmark.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from astropy.io import fits 4 | 5 | from astrodendro import Dendrogram 6 | 7 | BENCHMARKS = {'2d1.fits': {'min_value': 2.5, 'min_npix': 20}, 8 | '2d2.fits': {'min_value': 2.5}, 9 | '2d3.fits': {'min_value': 2.5, 'min_delta': 1}, 10 | '3d1.fits': {'min_value': 4.5, 'min_npix': 20}, 11 | '3d2.fits': {'min_value': 4.5}, 12 | '3d3.fits': {'min_value': 4.5, 'min_delta': 1}} 13 | 14 | 15 | def main(): 16 | 17 | data = fits.getdata(os.path.join('benchmark_data', '2d.fits')) 18 | for outfile in '2d1.fits 2d2.fits 2d3.fits'.split(): 19 | d = Dendrogram.compute(data, verbose=True, **BENCHMARKS[outfile]) 20 | d.save_to(os.path.join('benchmark_data', outfile)) 21 | 22 | data = fits.getdata(os.path.join('benchmark_data', '3d.fits')) 23 | for outfile in '3d1.fits 3d2.fits 3d3.fits'.split(): 24 | d = Dendrogram.compute(data, verbose=True, **BENCHMARKS[outfile]) 25 | d.save_to(os.path.join('benchmark_data', outfile)) 26 | 27 | 28 | if __name__ == "__main__": 29 | main() 30 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yaml: -------------------------------------------------------------------------------- 1 | # This workflow takes the GitHub release notes an updates the changelog on the 2 | # main branch with the body of the release notes, thereby keeping a log in 3 | # the git repo of the changes. 4 | 5 | name: "Update Changelog" 6 | 7 | on: 8 | release: 9 | types: [released] 10 | 11 | jobs: 12 | update: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 18 | with: 19 | ref: main 20 | 21 | - name: Update Changelog 22 | uses: stefanzweifel/changelog-updater-action@a938690fad7edf25368f37e43a1ed1b34303eb36 # v1.12.0 23 | with: 24 | release-notes: ${{ github.event.release.body }} 25 | latest-version: ${{ github.event.release.name }} 26 | path-to-changelog: CHANGES.md 27 | 28 | - name: Commit updated CHANGELOG 29 | uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0 30 | with: 31 | branch: main 32 | commit_message: Update CHANGELOG 33 | file_pattern: CHANGES.md 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | About 2 | ----- 3 | 4 | The aim of this module is to provide an easy way to compute dendrograms of observed or simulated Astronomical data in Python 5 | 6 | Documentation 7 | ------------- 8 | 9 | For information on installing and using ``astrodendro``, please visit [https://dendrograms.readthedocs.io/](https://dendrograms.readthedocs.io/) 10 | 11 | Reporting issues 12 | ---------------- 13 | 14 | Please report issues via the [GitHub issue tracker](https://github.com/dendrograms/astrodendro/issues) 15 | 16 | Credits 17 | ------- 18 | 19 | This package was developed by: 20 | 21 | * [Braden MacDonald](https://github.com/bradenmacdonald) 22 | * [Chris Beaumont](https://github.com/ChrisBeaumont) 23 | * [Thomas Robitaille](https://github.com/astrofrog) 24 | * [Adam Ginsburg](https://github.com/keflavich) 25 | * [Erik Rosolowsky](https://github.com/low-sky) 26 | 27 | Build and coverage status 28 | ------------------------- 29 | 30 | [![Build Status](https://travis-ci.org/dendrograms/astrodendro.svg?branch=master)](https://travis-ci.org/dendrograms/astrodendro) 31 | [![Coverage Status](https://coveralls.io/repos/dendrograms/astrodendro/badge.svg?branch=master)](https://coveralls.io/r/dendrograms/astrodendro?branch=master) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Computing Astronomical Dendrograms 2 | Copyright (c) 2013 Thomas P. Robitaille, Chris Beaumont, Braden MacDonald, 3 | and Erik Rosolowsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /docs/installing.rst: -------------------------------------------------------------------------------- 1 | Installing ``astrodendro`` 2 | ========================== 3 | 4 | Requirements 5 | ------------ 6 | 7 | This package has the following depdenencies: 8 | 9 | * `Python `_ 2.6 or later (Python 3.x is supported) 10 | * `Numpy `_ 1.4.1 or later 11 | * `Astropy `_ 0.2.0 or later, optional (needed for reading/writing FITS files and for analysis code) 12 | * `h5py `_ 0.2.0 or later, optional (needed for reading/writing HDF5 files) 13 | 14 | Installation 15 | ------------ 16 | 17 | To install the latest stable release, you can type:: 18 | 19 | pip install astrodendro 20 | 21 | or you can download the latest tar file from 22 | `PyPI `_ and install it using:: 23 | 24 | python setup.py install 25 | 26 | Developer version 27 | ----------------- 28 | 29 | If you want to install the latest developer version of the dendrogram code, you 30 | can do so from the git repository:: 31 | 32 | git clone https://github.com/dendrograms/astrodendro.git 33 | cd astrodendro 34 | python setup.py install 35 | 36 | You may need to add the ``--user`` option to the last line if you do not have 37 | root access. -------------------------------------------------------------------------------- /astrodendro/tests/test_viewer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | 4 | import matplotlib.pyplot as plt 5 | from ..dendrogram import Dendrogram 6 | from matplotlib.backend_bases import MouseEvent 7 | 8 | 9 | DATA = np.array([[1, 3, 4, 4, 1, 4], 10 | [1, 2, 3, 2, 1, 3], 11 | [2, 1, 1, 3, 1, 2], 12 | [1, 1, 1, 1, 1, 1], 13 | [2, 3, 2, 1, 1, 2], 14 | [2, 3, 5, 3, 1, 1]]) 15 | 16 | 17 | def test_viewer(capsys): 18 | 19 | original_backend = plt.get_backend() 20 | 21 | try: 22 | plt.switch_backend('qtagg') 23 | except ImportError: 24 | pytest.skip("This test requires Qt to be installed") 25 | 26 | d = Dendrogram.compute(DATA) 27 | viewer = d.viewer() 28 | 29 | plt.show(block=False) 30 | 31 | cb = viewer.fig.canvas.callbacks 32 | 33 | cb.process('button_press_event', MouseEvent('button_press_event', viewer.fig.canvas, 660, 520, 1)) 34 | cb.process('button_press_event', MouseEvent('button_press_event', viewer.fig.canvas, 890, 800, 1)) 35 | cb.process('button_press_event', MouseEvent('button_press_event', viewer.fig.canvas, 700, 700, 1)) 36 | 37 | plt.switch_backend(original_backend) 38 | 39 | captured = capsys.readouterr() 40 | assert captured.out == "" 41 | assert captured.err == "" 42 | -------------------------------------------------------------------------------- /astrodendro/tests/_testdata.py: -------------------------------------------------------------------------------- 1 | """ 2 | Realistic data for unit tests and benchmarks 3 | This module provides a 107x107x602 pixel data cube where all the flux values 4 | greater than 1.4 represent real data from L1448 13co, but all other (lesser) 5 | values are randomly generated. 6 | """ 7 | import os 8 | try: 9 | import gzip # noqa 10 | except AttributeError: 11 | # gzip tries to import from the "io" package, which causes an error 12 | # if the user's current directory is the "astrodendro" directory 13 | # which has its own "io" package. 14 | raise RuntimeError("This test cannot be run from a folder with an 'io'" 15 | " subfolder. Please change to a different directory" 16 | " and re-run this test.") 17 | 18 | import numpy as np 19 | 20 | # First, load the data cube from the file: 21 | _datafile_path = os.path.join(os.path.dirname(__file__), 'sample-data-hl.npz') 22 | 23 | # data file contains saved flux_values and indices from L1448 13co, 24 | # a data cube which originally had a shape of (107, 107, 602) 25 | # but filtered to only include data points above value of 1.4 26 | 27 | arrays = np.load(_datafile_path) 28 | _flux_values = arrays['flux_values'] 29 | _indices = arrays['coords'] 30 | 31 | # Create a new data cube filled with random values no greater than 1.4 32 | # (these will later be filtered out, but that filtering should be part of 33 | # the tests and benchmark) 34 | 35 | data = np.random.normal(0, 0.25, (107, 107, 602)) 36 | 37 | for i in range(_flux_values.size): 38 | data[tuple(_indices[i])] = _flux_values[i] 39 | -------------------------------------------------------------------------------- /astrodendro/io/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | from pathlib import Path 4 | 5 | from .fits import FITSHandler 6 | from .hdf5 import HDF5Handler 7 | 8 | 9 | IO_FORMATS = { 10 | 'fits': FITSHandler, 11 | 'hdf5': HDF5Handler 12 | } 13 | 14 | 15 | def _valid(formats): 16 | return " and ".join(["'{0}'".format(key) for key in formats]) 17 | 18 | 19 | def load_dendrogram(filename, format=None): 20 | if isinstance(filename, Path): 21 | filename = str(filename) 22 | if format is not None: 23 | return IO_FORMATS[format].import_dendro(filename) 24 | else: 25 | for handler in IO_FORMATS.values(): 26 | if handler.identify(filename, mode='r'): 27 | return handler.import_dendro(filename) 28 | raise IOError("Could not automatically identify file format - use the " 29 | "format= option to specify which format to use (valid " 30 | "options are {0})".format(_valid(IO_FORMATS))) 31 | 32 | 33 | def save_dendrogram(dendrogram, filename, format=None): 34 | if isinstance(filename, Path): 35 | filename = str(filename) 36 | if format is not None: 37 | return IO_FORMATS[format].export_dendro(dendrogram, filename) 38 | else: 39 | for handler in IO_FORMATS.values(): 40 | if handler.identify(filename, mode='w'): 41 | return handler.export_dendro(dendrogram, filename) 42 | raise IOError("Could not automatically identify file format - use the " 43 | "format= option to specify which format to use (valid " 44 | "options are {0})".format(_valid(IO_FORMATS))) 45 | -------------------------------------------------------------------------------- /astrodendro/tests/benchmark.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | import timeit 4 | import os 5 | 6 | from ._testdata import data 7 | 8 | from .. import Dendrogram 9 | 10 | 11 | def benchmark_compute(): 12 | print("Data loaded. Starting dendrogram computations...") 13 | 14 | def test1(): 15 | Dendrogram.compute(data, min_npix=4, min_value=1.4, min_delta=0.3) 16 | print(" Completed an iteration of test1.") 17 | 18 | def test2(): 19 | Dendrogram.compute(data, min_npix=8, min_value=1.4) 20 | print(" Completed an iteration of test2.") 21 | 22 | num = 3 23 | 24 | t1 = timeit.timeit(test1, number=num) / num 25 | t2 = timeit.timeit(test2, number=num) / num 26 | 27 | print("test1 average over {num} computations: {result:.3} s".format(num=num, result=t1)) 28 | print("test2 average over {num} computations: {result:.3} s".format(num=num, result=t2)) 29 | 30 | print("Total average compute time: {0:.3}s".format((t1 + t2) / 2)) 31 | 32 | 33 | def benchmark_hdf5(): 34 | print("\nGenerating complex dendrogram for HDF5 import/export...") 35 | d = Dendrogram.compute(data, min_npix=2, min_value=1.4, min_delta=0.01) 36 | print("Dendrogram generated. Testing import and export...") 37 | 38 | filename = '.astrodendro-hdf5-benchmark.hdf5' 39 | if os.path.exists(filename): 40 | os.remove(filename) 41 | num = 2 42 | 43 | def testHDF5(): 44 | print('Exporting...') 45 | d.save_to(filename) 46 | print('Importing...') 47 | Dendrogram.load_from(filename) 48 | os.remove(filename) 49 | 50 | t = timeit.timeit(testHDF5, number=num) / num 51 | 52 | print("Total average export+import time: {0:.3}s".format(t)) 53 | 54 | 55 | if __name__ == '__main__': 56 | try: 57 | benchmark_compute() 58 | benchmark_hdf5() 59 | except KeyboardInterrupt: 60 | print("Cancelled.") 61 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310,311}-test{,-alldeps,-devdeps,-viewer}{,-cov} 4 | build_docs 5 | linkcheck 6 | codestyle 7 | requires = 8 | setuptools >= 30.3.0 9 | pip >= 19.3.1 10 | isolated_build = true 11 | 12 | [testenv] 13 | passenv = 14 | DISPLAY 15 | setenv = 16 | MPLBACKEND=agg 17 | devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/astropy/simple https://pypi.anaconda.org/scientific-python-nightly-wheels/ 18 | changedir = .tmp/{envname} 19 | description = 20 | run tests 21 | alldeps: with all optional dependencies 22 | devdeps: with the latest developer version of key dependencies 23 | oldestdeps: with the oldest supported version of key dependencies 24 | cov: and test coverage 25 | 26 | deps = 27 | cov: coverage 28 | devdeps: numpy>=0.0.dev0 29 | devdeps: astropy>=0.0.dev0 30 | oldestdeps: astropy==5.0.* 31 | oldestdeps: h5py==3.0.* 32 | oldestdeps: matplotlib==3.3.* 33 | oldestdeps: numpy==1.20.* 34 | oldestdeps: pillow==8.0.* 35 | viewer: PyQt6 36 | 37 | extras = 38 | test 39 | alldeps: all 40 | 41 | commands = 42 | pip freeze 43 | !cov: pytest --pyargs astrodendro {toxinidir}/docs {posargs} 44 | cov: pytest --pyargs astrodendro {toxinidir}/docs --cov astrodendro --cov-config={toxinidir}/setup.cfg {posargs} 45 | cov: coverage xml -o {toxinidir}/coverage.xml 46 | 47 | [testenv:build_docs] 48 | changedir = docs 49 | description = invoke sphinx-build to build the HTML docs 50 | extras = docs 51 | commands = 52 | pip freeze 53 | sphinx-build -W -b html . _build/html 54 | 55 | [testenv:linkcheck] 56 | changedir = docs 57 | description = check the links in the HTML docs 58 | extras = docs 59 | commands = 60 | pip freeze 61 | sphinx-build -W -b linkcheck . _build/html 62 | 63 | [testenv:codestyle] 64 | skip_install = true 65 | changedir = . 66 | description = check code style, e.g. with flake8 67 | deps = flake8 68 | commands = flake8 astrodendro --count --max-line-length=200 69 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | 4 | requires = [ 5 | "setuptools>=61.2", 6 | "setuptools-scm", 7 | ] 8 | 9 | [project] 10 | name = "astrodendro" 11 | description = "Python package for computation of astronomical dendrograms" 12 | readme.content-type = "text/markdown" 13 | readme.file = "README.md" 14 | license.text = "MIT" 15 | authors = [ 16 | { name = "Thomas Robitaille", email = "thomas.robitaille@gmail.com" }, 17 | { name = "Chris Beaumont" }, 18 | { name = "Adam Ginsburg" }, 19 | { name = "Braden MacDonald" }, 20 | { name = "and Erik Rosolowsky" }, 21 | ] 22 | requires-python = ">=3.8" 23 | classifiers = [ 24 | "Programming Language :: Python :: 3" 25 | ] 26 | dynamic = [ 27 | "version", 28 | ] 29 | dependencies = [ 30 | "astropy>=5", 31 | "h5py>=3", 32 | "matplotlib>=3.3", 33 | "numpy>=1.20", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | docs = [ 38 | "aplpy", 39 | "numpydoc", 40 | "sphinx<7", 41 | "sphinx-astropy", 42 | ] 43 | test = [ 44 | "pytest", 45 | "pytest-cov", 46 | ] 47 | 48 | [project.urls] 49 | homepage = "https://www.dendrograms.org/" 50 | documentation = "https://dendrograms.readthedocs.io/en/stable/" 51 | repository = "https://github.com/dendrograms/astrodendro" 52 | 53 | [tool.setuptools] 54 | zip-safe = false 55 | license-files = [ 56 | "LICENSE", 57 | ] 58 | include-package-data = false 59 | 60 | [tool.setuptools.packages.find] 61 | namespaces = false 62 | 63 | [tool.setuptools.package-data] 64 | "astrodendro.tests" = [ 65 | "*.npz", 66 | "benchmark_data/*.fits", 67 | ] 68 | "astrodendro.io.tests" = [ 69 | "data/*", 70 | ] 71 | 72 | [tool.setuptools_scm] 73 | write_to = "astrodendro/version.py" 74 | 75 | [tool.coverage.run] 76 | omit = [ 77 | "astrodendro/conftest.py", 78 | "astrodendro/tests/*", 79 | "astrodendro/*/tests/*", 80 | "astrodendro/extern/*", 81 | "astrodendro/version*", 82 | "*/astrodendro/conftest.py", 83 | "*/astrodendro/tests/*", 84 | "*/astrodendro/*/tests/*", 85 | "*/astrodendro/extern/*", 86 | "*/astrodendro/version*", 87 | ] 88 | 89 | [tool.coverage.report] 90 | exclude_lines = [ 91 | "pragma: no cover", 92 | "except ImportError", 93 | "raise AssertionError", 94 | "raise NotImplementedError", 95 | "def main\\(.*\\):", 96 | "pragma: py{ignore_python_version}", 97 | "def _ipython_key_completions_", 98 | ] 99 | -------------------------------------------------------------------------------- /astrodendro/tests/test_is_independent.py: -------------------------------------------------------------------------------- 1 | # These tests ensure that the ``is_independent`` function is working correctly 2 | 3 | from .build_benchmark import BENCHMARKS 4 | import pytest 5 | import numpy as np 6 | 7 | from ..dendrogram import Dendrogram 8 | 9 | 10 | class TestCustomMerge(object): 11 | 12 | def setup_class(self): 13 | self.data = np.array([0, 3.3, 5.5, 2.1, 1.0, 6.0, 4.4, 1.5, 4.1, 0.5]) 14 | 15 | def test_reference(self): 16 | 17 | d = Dendrogram.compute(self.data) 18 | 19 | branches = [s for s in d.all_structures if s.is_branch] 20 | leaves = [s for s in d.all_structures if s.is_leaf] 21 | 22 | assert len(branches) == 2 23 | assert len(leaves) == 3 24 | 25 | def test_position_criterion(self): 26 | 27 | def position(structure, index=None, value=None): 28 | return (np.any(structure.indices()[0] == 6) or 29 | np.any(structure.indices()[0] == 8)) 30 | 31 | d = Dendrogram.compute(self.data, is_independent=position) 32 | 33 | branches = [s for s in d.all_structures if s.is_branch] 34 | leaves = [s for s in d.all_structures if s.is_leaf] 35 | 36 | assert len(branches) == 1 37 | assert len(leaves) == 2 38 | 39 | # Check that leaf that used to contain pixels 1, 2, and 3 is now just 40 | # part of the main branch. 41 | assert np.all(branches[0].indices(subtree=False) == np.array([0, 1, 2, 3, 4, 7, 9])) 42 | 43 | 44 | # Try and reproduce the benchmark tests using a function instead of arguments 45 | 46 | 47 | @pytest.mark.parametrize(('filename'), BENCHMARKS.keys()) 48 | def test_benchmark(filename): 49 | 50 | from astropy.io import fits 51 | import os 52 | 53 | path = os.path.join(os.path.dirname(__file__), 54 | 'benchmark_data', filename) 55 | 56 | p = BENCHMARKS[filename] 57 | data = fits.getdata(path, 1) 58 | 59 | # Now define a function that will test the criteria 60 | def is_independent_test(structure, index=None, value=None): 61 | if value is None: 62 | value = structure.vmin 63 | if 'min_delta' in p: 64 | if structure.vmax - value < p['min_delta']: 65 | return False 66 | if 'min_npix' in p: 67 | if len(structure.values()) < p['min_npix']: 68 | return False 69 | return True 70 | 71 | d1 = Dendrogram.compute(data, 72 | is_independent=is_independent_test, 73 | min_value=p['min_value'] if 'min_value' in p else "min") 74 | d2 = Dendrogram.load_from(path) 75 | 76 | assert d1 == d2 77 | -------------------------------------------------------------------------------- /astrodendro/io/hdf5.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | import os 4 | 5 | from astropy import log 6 | 7 | from .util import parse_dendrogram 8 | from .handler import IOHandler 9 | 10 | HDF5_SIGNATURE = b'\x89HDF\r\n\x1a\n' 11 | 12 | 13 | def is_hdf5(filename, mode='r'): 14 | if mode == 'r' and os.path.exists(filename): 15 | fileobj = open(filename, 'rb') 16 | sig = fileobj.read(8) 17 | return sig == HDF5_SIGNATURE 18 | elif filename.lower().endswith(('.hdf5', '.h5')): 19 | return True 20 | else: 21 | return False 22 | 23 | 24 | def dendro_export_hdf5(d, filename): 25 | """Export the dendrogram 'd' to the HDF5 file 'filename'""" 26 | import h5py 27 | f = h5py.File(filename, 'w') 28 | 29 | f.attrs['n_dim'] = d.n_dim 30 | 31 | f.create_dataset('newick', data=d.to_newick()) 32 | 33 | ds = f.create_dataset('index_map', data=d.index_map, compression=True) 34 | ds.attrs['CLASS'] = 'IMAGE' 35 | ds.attrs['IMAGE_VERSION'] = '1.2' 36 | ds.attrs['IMAGE_MINMAXRANGE'] = [d.index_map.min(), d.index_map.max()] 37 | 38 | ds = f.create_dataset('data', data=d.data, compression=True) 39 | ds.attrs['CLASS'] = 'IMAGE' 40 | ds.attrs['IMAGE_VERSION'] = '1.2' 41 | ds.attrs['IMAGE_MINMAXRANGE'] = [d.data.min(), d.data.max()] 42 | 43 | for key in d.params.keys(): 44 | f.attrs[key] = d.params[key] 45 | 46 | try: 47 | f.create_dataset('wcs_header', data=d.wcs.to_header_string()) 48 | except AttributeError: 49 | pass 50 | 51 | f.close() 52 | 53 | 54 | def dendro_import_hdf5(filename): 55 | """Import 'filename' and construct a dendrogram from it""" 56 | import h5py 57 | from astropy.wcs.wcs import WCS 58 | 59 | log.debug('Loading HDF5 file from disk...') 60 | with h5py.File(filename, 'r') as h5f: 61 | newick = h5f['newick'][()].decode("utf-8") # str 62 | data = h5f['data'][:] # numpy array 63 | index_map = h5f['index_map'][:] # numpy array 64 | params = {} 65 | if 'min_value' in h5f.attrs: 66 | params['min_value'] = h5f.attrs['min_value'] 67 | params['min_delta'] = h5f.attrs['min_delta'] 68 | params['min_npix'] = h5f.attrs['min_npix'] 69 | 70 | try: 71 | wcs = WCS(h5f['wcs_header'][()]) 72 | except KeyError: 73 | wcs = None 74 | 75 | log.debug('Parsing dendrogram...') 76 | return parse_dendrogram(newick, data, index_map, params, wcs) 77 | 78 | 79 | HDF5Handler = IOHandler(identify=is_hdf5, 80 | export_dendro=dendro_export_hdf5, 81 | import_dendro=dendro_import_hdf5) 82 | -------------------------------------------------------------------------------- /astrodendro/tests/test_recursion.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | from .. import Dendrogram 4 | import sys 5 | 6 | import numpy as np 7 | import matplotlib.pyplot as plt 8 | plt.switch_backend('Agg') 9 | 10 | 11 | class TestRecursionLimit(object): 12 | """ 13 | Test that we can efficiently compute deep dendrogram trees 14 | without hitting the recursion limit. 15 | Note: plot() uses recursion but we should be able to *compute* 16 | dendrograms without using deep recursion, even if we aren't 17 | yet able to plot them without using recursion. 18 | """ 19 | 20 | def setup_method(self, method): 21 | self._oldlimit = sys.getrecursionlimit() 22 | sys.setrecursionlimit(100) # Reduce recursion limit dramatically (default is 1000) 23 | size = 10000 # number of leaves desired in the dendrogram 24 | self._make_data(size) 25 | 26 | def _make_data(self, size): 27 | data1 = np.arange(size * 2) # first row 28 | data2 = np.arange(size * 2) # second row 29 | data2[::2] += 2 30 | data1[-1] = 0 # set the last pixel in the first row to zero, to trigger a deep ancestor search 31 | data = np.vstack((data1, data2)) 32 | self.data = data 33 | self.size = size 34 | 35 | # result looks like this: 36 | # [[ 0, 1, 2, 3, 4, 5, ...], 37 | # [ 2, 1, 4, 3, 6, 5, ...]] 38 | # Notice every second column has a local maximum 39 | # so there are [size] local maxima in the array 40 | 41 | def test_compute(self): 42 | d = Dendrogram.compute(self.data) 43 | assert len(d.leaves) == self.size, "We expect {n} leaves, not {a}.".format(n=self.size, a=len(d.leaves)) 44 | 45 | def test_computing_level(self): 46 | d = Dendrogram.compute(self.data) 47 | 48 | # Now pick a structure near the middle of the dendrogram: 49 | mid_structure = d.structure_at((0, self.size // 2)) 50 | 51 | # Compute its level: 52 | sys.setrecursionlimit(100000) 53 | _ = mid_structure.level 54 | 55 | # Check that .level satisfies the recurrence relation: 56 | # 0 if root else parent.level + 1 57 | for structure in d.all_structures: 58 | if structure.parent is None: 59 | assert structure.level == 0 60 | else: 61 | assert structure.level == structure.parent.level + 1 62 | 63 | def test_plot(self): 64 | sys.setrecursionlimit(self._oldlimit) 65 | ax = plt.gca() 66 | sys.setrecursionlimit(150) 67 | 68 | d = Dendrogram.compute(self.data) 69 | p = d.plotter() 70 | p.plot_tree(ax) 71 | 72 | def teardown_method(self, method): 73 | sys.setrecursionlimit(self._oldlimit) 74 | -------------------------------------------------------------------------------- /astrodendro/io/fits.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | import os 4 | 5 | import numpy as np 6 | 7 | from .util import parse_dendrogram 8 | from .handler import IOHandler 9 | 10 | # Import and export 11 | 12 | # FITS file signature as per RFC 4047 13 | FITS_SIGNATURE = (b"\x53\x49\x4d\x50\x4c\x45\x20\x20\x3d\x20\x20\x20\x20\x20" 14 | b"\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20" 15 | b"\x20\x54") 16 | 17 | 18 | def is_fits(filename, mode='r'): 19 | if mode == 'r' and os.path.exists(filename): 20 | fileobj = open(filename, 'rb') 21 | sig = fileobj.read(30) 22 | return sig == FITS_SIGNATURE 23 | elif filename.lower().endswith(('.fits', '.fits.gz', '.fit', '.fit.gz')): 24 | return True 25 | else: 26 | return False 27 | 28 | 29 | def dendro_export_fits(d, filename): 30 | """Export the dendrogram 'd' to the FITS file 'filename'""" 31 | from astropy.io import fits 32 | 33 | try: 34 | primary_hdu = fits.PrimaryHDU(header=d.wcs.to_header()) 35 | except AttributeError: 36 | primary_hdu = fits.PrimaryHDU() 37 | 38 | primary_hdu.header["MIN_NPIX"] = (d.params['min_npix'], 39 | "Minimum number of pixels in a leaf.") 40 | primary_hdu.header["MIN_DELT"] = (d.params['min_delta'], 41 | "Minimum branch height.") 42 | primary_hdu.header["MIN_VAL"] = (d.params['min_value'], 43 | "Minimum intensity value.") 44 | 45 | hdus = [primary_hdu, 46 | fits.ImageHDU(d.data, name='data'), 47 | fits.ImageHDU(d.index_map, name='index_map'), 48 | fits.ImageHDU(np.array([ord(x) for x in d.to_newick()]), name='newick')] 49 | 50 | hdulist = fits.HDUList(hdus) 51 | 52 | hdulist.writeto(filename, overwrite=True) 53 | 54 | 55 | def dendro_import_fits(filename): 56 | """Import 'filename' and construct a dendrogram from it""" 57 | from astropy.io import fits 58 | from astropy.wcs.wcs import WCS 59 | 60 | with fits.open(filename) as hdus: 61 | try: 62 | wcs = WCS(hdus[0].header) 63 | except AttributeError: 64 | wcs = None 65 | data = hdus[1].data 66 | index_map = hdus[2].data 67 | newick = ''.join(chr(x) for x in hdus[3].data.flat) 68 | 69 | if 'MIN_NPIX' in hdus[0].header: 70 | 71 | params = {"min_npix": hdus[0].header['MIN_NPIX'], 72 | "min_value": hdus[0].header['MIN_VAL'], 73 | "min_delta": hdus[0].header['MIN_DELT']} 74 | 75 | else: 76 | 77 | params = {} 78 | 79 | return parse_dendrogram(newick, data, index_map, params, wcs) 80 | 81 | 82 | FITSHandler = IOHandler(identify=is_fits, 83 | export_dendro=dendro_export_fits, 84 | import_dendro=dendro_import_fits) 85 | -------------------------------------------------------------------------------- /astrodendro/tests/test_pruning.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from ..pruning import all_true, min_npix, min_peak, contains_seeds, min_sum, min_delta 4 | from ..structure import Structure 5 | from ..dendrogram import Dendrogram 6 | from .test_index import assert_permuted_fancyindex 7 | 8 | 9 | data = np.array([[0, 0, 0, 0], 10 | [0, 2, 1, 0], 11 | [0, 1, 0, 0], 12 | [0, 0, 0, 0]]) 13 | st = Structure(zip(*np.where(data)), data[np.where(data)]) 14 | 15 | 16 | def test_npix(): 17 | assert min_npix(3)(st) 18 | assert not min_npix(4)(st) 19 | 20 | 21 | def test_min_delta(): 22 | assert min_delta(1)(st) 23 | assert not min_delta(1.1)(st) 24 | assert min_delta(1.1)(st, value=0) 25 | 26 | 27 | def test_min_peak(): 28 | assert min_peak(2)(st) 29 | assert not min_peak(2.1)(st) 30 | 31 | 32 | def test_min_sum(): 33 | assert min_sum(4)(st) 34 | assert not min_sum(4.1)(st) 35 | 36 | 37 | def test_contains_seeds(): 38 | assert contains_seeds(np.where(data == 2))(st) 39 | assert contains_seeds(np.where(data == 1))(st) 40 | assert contains_seeds(([1], [1]))(st) 41 | assert not contains_seeds(np.where(data == 0))(st) 42 | 43 | 44 | def test_all_true(): 45 | c1 = min_npix(3) 46 | c2 = min_peak(2) 47 | c3 = min_npix(4) 48 | assert all_true((c1, c2))(st) 49 | assert not all_true((c1, c2, c3))(st) 50 | 51 | 52 | def test_multi_ravel(): 53 | from ..pruning import _ravel_multi_index 54 | 55 | x = _ravel_multi_index([[0, 1], [0, 1]], [3, 3]) 56 | np.testing.assert_array_equal(x, [0, 4]) 57 | 58 | x = _ravel_multi_index([[1, 0], [0, 1]], [4, 2]) 59 | np.testing.assert_array_equal(x, [2, 1]) 60 | 61 | x = _ravel_multi_index([[0], [9]], [5, 3], mode='clip') 62 | np.testing.assert_array_equal(x, [2]) 63 | 64 | x = _ravel_multi_index([[0], [9]], [5, 3], mode='wrap') 65 | np.testing.assert_array_equal(x, [0]) 66 | 67 | 68 | def compare_dendrograms(d1, d2): 69 | 70 | assert len(d1) == len(d2) 71 | assert (np.sort([leaf.vmax for leaf in d1.all_structures]) == np.sort([leaf.vmax for leaf in d2.all_structures])).all() 72 | assert (np.sort([leaf.vmin for leaf in d1.all_structures]) == np.sort([leaf.vmin for leaf in d2.all_structures])).all() 73 | 74 | for s in d1.all_structures: 75 | ind1 = np.where(d1.index_map == s.idx) 76 | idx2 = d2.index_map[ind1][0] 77 | ind2 = np.where(d2.index_map == idx2) 78 | assert_permuted_fancyindex(ind1, ind2) 79 | 80 | 81 | class TestPostPruning(object): 82 | 83 | def setup_class(self): 84 | from ._testdata import data 85 | self.data = data 86 | 87 | def test_min_delta(self): 88 | d1 = Dendrogram.compute(self.data, min_delta=1.0, min_npix=8, min_value=1.4) 89 | 90 | d2 = Dendrogram.compute(self.data, min_npix=8, min_value=1.4) 91 | d2.prune(min_delta=1.0) 92 | 93 | compare_dendrograms(d1, d2) 94 | 95 | def test_min_npix(self): 96 | d1 = Dendrogram.compute(self.data, min_npix=8, min_delta=0.3, min_value=1.4) 97 | 98 | d2 = Dendrogram.compute(self.data, min_delta=0.3, min_value=1.4) 99 | d2.prune(min_npix=8) 100 | 101 | compare_dendrograms(d1, d2) 102 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Astronomical Dendrograms in Python 2 | ================================== 3 | 4 | The ``astrodendro`` package provides an easy way to compute dendrograms of 5 | observed or simulated Astronomical data in Python. 6 | 7 | About dendrograms 8 | ----------------- 9 | 10 | The easiest way to think of a dendrogram is to think of a tree that represents 11 | the hierarchy of the structures in your data. If you consider a 12 | two-dimensional map of a hierarchical structure that looks like: 13 | 14 | .. image:: schematic_structure_1.png 15 | :width: 300px 16 | :align: center 17 | 18 | the equivalent dendrogram/tree representation would look like: 19 | 20 | .. image:: schematic_tree.png 21 | :width: 400px 22 | :align: center 23 | 24 | A dendrogram is composed of two types of structures: *branches*, which are 25 | structures which split into multiple sub-structures, and *leaves*, which are 26 | structures that have no sub-structure. Branches can split up into branches and 27 | leaves, which allows hierarchical structures to be adequately represented. The 28 | term *trunk* is used to refer to a structure that has no parent structure. 29 | 30 | Mapping these terms back onto the structure gives the following: 31 | 32 | .. image:: schematic_structure_2.png 33 | :width: 300px 34 | :align: center 35 | 36 | For an example of use of dendrograms on real data, see `Goodman, A. et al 37 | (2009) `_. 38 | 39 | Documentation 40 | ------------- 41 | 42 | .. toctree:: 43 | :maxdepth: 2 44 | 45 | installing.rst 46 | algorithm.rst 47 | using.rst 48 | plotting.rst 49 | catalog.rst 50 | advanced.rst 51 | migration.rst 52 | 53 | Reporting issues and getting help 54 | --------------------------------- 55 | 56 | Please help us improve this package by reporting issues via `GitHub 57 | `_. You can also open an 58 | issue if you need help with using the package. 59 | 60 | Developers 61 | ---------- 62 | 63 | This package was developed by: 64 | 65 | * Thomas Robitaille 66 | * Chris Beaumont 67 | * Adam Ginsburg 68 | * Braden MacDonald 69 | * Erik Rosolowsky 70 | 71 | Acknowledgments 72 | --------------- 73 | 74 | Thanks to the following users for using early versions of this package and 75 | providing valuable feedback: 76 | 77 | * Katharine Johnston 78 | 79 | Citing astrodendro 80 | ------------------ 81 | 82 | If you make use of this package in a publication, please consider adding the 83 | following acknowledgment: 84 | 85 | *This research made use of astrodendro, a Python package to compute dendrograms 86 | of Astronomical data (http://www.dendrograms.org/)* 87 | 88 | If you make use of the analysis code (:doc:`catalog`) or read/write FITS files, 89 | please also consider adding an acknowledgment for Astropy (see 90 | ``_ for the latest recommended citation). 91 | 92 | Public API 93 | ---------- 94 | 95 | .. toctree:: 96 | :maxdepth: 1 97 | 98 | .. automodapi:: astrodendro 99 | :no-inheritance-diagram: 100 | 101 | .. automodapi:: astrodendro.analysis 102 | :no-inheritance-diagram: 103 | 104 | .. automodapi:: astrodendro.pruning 105 | :no-inheritance-diagram: 106 | 107 | .. automodapi:: astrodendro.structure_collection 108 | :no-inheritance-diagram: 109 | -------------------------------------------------------------------------------- /astrodendro/tests/test_flux.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | 4 | from astropy import units as u 5 | 6 | from ..flux import compute_flux 7 | 8 | 9 | COMBINATIONS = \ 10 | [ 11 | (np.array([1, 2, 3]) * u.Jy, u.Jy, {}, 6 * u.Jy), 12 | (np.array([1, 2, 3]) * u.mJy, u.Jy, {}, 0.006 * u.Jy), 13 | (np.array([1, 2, 3]) * u.erg / u.cm ** 2 / u.s / u.Hz, u.Jy, {}, 6e23 * u.Jy), 14 | (np.array([1, 2, 3]) * u.erg / u.cm ** 2 / u.s / u.micron, u.Jy, {'wavelength': 2 * u.micron}, 8005538284.75565 * u.Jy), 15 | (np.array([1, 2, 3]) * u.Jy / u.arcsec ** 2, u.Jy, {'spatial_scale': 2 * u.arcsec}, 24. * u.Jy), 16 | (np.array([1, 2, 3]) * u.Jy / u.beam, u.Jy, {'spatial_scale': 2 * u.arcsec, 'beam_major': 1 * u.arcsec, 'beam_minor': 0.5 * u.arcsec}, 42.36166269526079 * u.Jy), 17 | (np.array([1, 2, 3]) * u.K, u.Jy, {'spatial_scale': 2 * u.arcsec, 'beam_major': 1 * u.arcsec, 'beam_minor': 0.5 * u.arcsec, 'wavelength': 2 * u.mm}, 0.38941636582186634 * u.Jy), 18 | (np.array([1, 2, 3]) * u.K, u.Jy, {'spatial_scale': 2 * u.arcsec, 'beam_major': 1 * u.arcsec, 'beam_minor': 0.5 * u.arcsec, 'wavelength': 100 * u.GHz}, 0.17331365650395836 * u.Jy), 19 | ] 20 | 21 | 22 | @pytest.mark.parametrize(('input_quantities', 'output_unit', 'keywords', 'output'), COMBINATIONS) 23 | def test_compute_flux(input_quantities, output_unit, keywords, output): 24 | q = compute_flux(input_quantities, output_unit, **keywords) 25 | np.testing.assert_allclose(q.value, output.value, rtol=1e-6) 26 | assert q.unit == output.unit 27 | 28 | 29 | def test_monochromatic_wav_missing(): 30 | with pytest.raises(ValueError, match='wavelength is needed to convert from erg'): 31 | compute_flux(np.array([1, 2, 3]) * u.erg / u.cm ** 2 / u.s / u.micron, u.Jy) 32 | 33 | 34 | def test_monochromatic_wav_invalid_units(): 35 | with pytest.raises(ValueError, match='wavelength should be a physical length'): 36 | compute_flux(np.array([1, 2, 3]) * u.erg / u.cm ** 2 / u.s / u.micron, u.Jy, wavelength=3 * u.L) 37 | 38 | 39 | def test_surface_brightness_scale_missing(): 40 | with pytest.raises(ValueError, match='spatial_scale is needed to convert from Jy'): 41 | compute_flux(np.array([1, 2, 3]) * u.Jy / u.arcsec ** 2, u.Jy) 42 | 43 | 44 | def test_surface_brightness_invalid_units(): 45 | with pytest.raises(ValueError, match='spatial_scale should be an angle'): 46 | compute_flux(np.array([1, 2, 3]) * u.Jy / u.arcsec ** 2, u.Jy, spatial_scale=3 * u.m) 47 | 48 | 49 | def test_per_beam_scale_missing(): 50 | 51 | with pytest.raises(ValueError, match='spatial_scale is needed to convert from Jy / beam to Jy'): 52 | compute_flux(np.array([1, 2, 3]) * u.Jy / u.beam, u.Jy, beam_major=3 * u.arcsec, beam_minor=2. * u.arcsec) 53 | 54 | with pytest.raises(ValueError, match='beam_major is needed to convert from Jy / beam to Jy'): 55 | compute_flux(np.array([1, 2, 3]) * u.Jy / u.beam, u.Jy, spatial_scale=3 * u.arcsec, beam_minor=2. * u.arcsec) 56 | 57 | with pytest.raises(ValueError, match='beam_minor is needed to convert from Jy / beam to Jy'): 58 | compute_flux(np.array([1, 2, 3]) * u.Jy / u.beam, u.Jy, spatial_scale=3 * u.arcsec, beam_major=2. * u.arcsec) 59 | 60 | 61 | def test_per_beam_invalid_units(): 62 | 63 | with pytest.raises(ValueError, match='beam_major should be an angle'): 64 | compute_flux(np.array([1, 2, 3]) * u.Jy / u.beam, u.Jy, spatial_scale=3 * u.arcsec, beam_major=3 * u.m, beam_minor=2. * u.arcsec) 65 | 66 | with pytest.raises(ValueError, match='beam_minor should be an angle'): 67 | compute_flux(np.array([1, 2, 3]) * u.Jy / u.beam, u.Jy, spatial_scale=3 * u.arcsec, beam_major=3 * u.arcsec, beam_minor=2. * u.m) 68 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## v0.3.1 - 2024-11-15 2 | 3 | 4 | ### What's Changed 5 | 6 | #### Bug Fixes 7 | 8 | * Fix compatibility of interactive viewer with recent versions of Matplotlib by @astrofrog in https://github.com/dendrograms/astrodendro/pull/202 9 | 10 | #### Other Changes 11 | 12 | * Bump actions/checkout from 4.2.0 to 4.2.2 in /.github/workflows in the actions group by @dependabot in https://github.com/dendrograms/astrodendro/pull/201 13 | 14 | ### New Contributors 15 | 16 | * @dependabot made their first contribution in https://github.com/dendrograms/astrodendro/pull/201 17 | 18 | **Full Changelog**: https://github.com/dendrograms/astrodendro/compare/v0.3.0...v0.3.1 19 | 20 | ## v0.3.0 - 2024-11-15 21 | 22 | 23 | ### What's Changed 24 | 25 | * update "Iterable" import for Python versions > 3.9 by @nbrunett in https://github.com/dendrograms/astrodendro/pull/184 26 | * Fix viewer in Python 3 by @indebetouw in https://github.com/dendrograms/astrodendro/pull/181 27 | * Remove np.int by @keflavich in https://github.com/dendrograms/astrodendro/pull/179 28 | * h5py removed .value after v3 by @indebetouw in https://github.com/dendrograms/astrodendro/pull/182 29 | * Update package infrastructure by @astrofrog in https://github.com/dendrograms/astrodendro/pull/186 30 | * Update plot.py to pass subtree to get_lines by @tonywong94 in https://github.com/dendrograms/astrodendro/pull/168 31 | * Fix for changes between astropy 5.2.2 and 5.3.1 by @ajrigby in https://github.com/dendrograms/astrodendro/pull/192 32 | * Fix tests/continuous integration by @astrofrog in https://github.com/dendrograms/astrodendro/pull/193 33 | * WCSAxes import fix by @Parkerwise in https://github.com/dendrograms/astrodendro/pull/198 34 | * Update infrastructure and fix compatibility with Numpy 2.0 by @astrofrog in https://github.com/dendrograms/astrodendro/pull/200 35 | 36 | ### New Contributors 37 | 38 | * @nbrunett made their first contribution in https://github.com/dendrograms/astrodendro/pull/184 39 | * @indebetouw made their first contribution in https://github.com/dendrograms/astrodendro/pull/181 40 | * @tonywong94 made their first contribution in https://github.com/dendrograms/astrodendro/pull/168 41 | * @ajrigby made their first contribution in https://github.com/dendrograms/astrodendro/pull/192 42 | * @Parkerwise made their first contribution in https://github.com/dendrograms/astrodendro/pull/198 43 | 44 | **Full Changelog**: https://github.com/dendrograms/astrodendro/compare/v0.2.0...v0.3.0 45 | 46 | ## 0.2.0 (2016-09-29) 47 | 48 | - Make sure that calling structure_at with an array, list, or tuple all behave the same. [#98] 49 | 50 | - Added support for linked scatter plots and multiple selections. [#104, #105, #109, #136] 51 | 52 | - Added support for custom functions to define what a 'neighbor' is. [#108] 53 | 54 | - Fixed a bug that caused the interactive viewer when showing a dendrogram loaded from a file. [#106, #110] 55 | 56 | - Added a 'prune' method to prune dendrograms after computing them. [#111] 57 | 58 | - Added support for brightness temperatures in Kelvin. [#112] 59 | 60 | - Cache/memoize catalog statistics. [#115] 61 | 62 | - Make sure that periodic boundaries (e.g. longitude) are properly supported. [#121] 63 | 64 | - Added progress bar for catalog computation. [#127] 65 | 66 | - Better support for image WCS. [#126, #137] 67 | 68 | - Improve the performance of dendrogram loading. [#131] 69 | 70 | - Include dendrogram parameters in HDF5 files. [#142, #145] 71 | 72 | - Give HDUs names in FITS output. [#144] 73 | 74 | 75 | ## 0.1.0 (2013-11-09) 76 | 77 | Initial release 78 | -------------------------------------------------------------------------------- /astrodendro/tests/test_index.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | import numpy as np 4 | from ..dendrogram import Dendrogram, TreeIndex 5 | 6 | 7 | def assert_permuted_fancyindex(x, y): 8 | """ Assert that two fancy indices (tuples of integer ndarrays) 9 | are permutations of each other 10 | """ 11 | if not isinstance(x, tuple) or not (isinstance(x[0], np.ndarray)): 12 | raise TypeError("First argument not a fancy index: %s" % x) 13 | 14 | if not isinstance(y, tuple) or not (isinstance(y[0], np.ndarray)): 15 | raise TypeError("Second argument not a fancy index: %s" % y) 16 | 17 | dtype = [('%i' % i, 'i') for i in range(len(x))] 18 | x = np.array(list(zip(*x)), dtype=dtype) 19 | y = np.array(list(zip(*y)), dtype=dtype) 20 | np.testing.assert_array_equal(np.sort(x), 21 | np.sort(y)) 22 | 23 | 24 | def assert_identical_fancyindex(x, y): 25 | for xx, yy in zip(x, y): 26 | np.testing.assert_array_equal(xx, yy) 27 | 28 | 29 | class TestIndex(object): 30 | 31 | def setup_method(self, method): 32 | pass 33 | 34 | def assert_valid_index(self, d, index): 35 | """Assert that a dendrogram index is correct""" 36 | 37 | # subtree=False is a permutation of np.where(index_map == x) 38 | for s in d.all_structures: 39 | ind = index.indices(s.idx, subtree=False) 40 | expected = np.where(d.index_map == s.idx) 41 | assert_permuted_fancyindex(ind, expected) 42 | 43 | # subtree=True is the same, but includes descendents 44 | for s in d.all_structures: 45 | ind = index.indices(s.idx, subtree=True) 46 | expected = [np.where(d.index_map == ss.idx) for 47 | ss in [s] + s.descendants] 48 | expected = tuple(np.hstack(i) for i in zip(*expected)) 49 | assert_permuted_fancyindex(ind, expected) 50 | 51 | def test_single_trunk(self): 52 | data = np.array([[1, 1, 1, 1], 53 | [1, 5, 1, 1], 54 | [1, 4, 3, 5], 55 | [1, 1, 1, 4]]) 56 | d = Dendrogram.compute(data) 57 | assert len(d) == 3 58 | self.assert_valid_index(d, TreeIndex(d)) 59 | 60 | def test_two_trunk(self): 61 | data = np.array([[1, 1, 1, 1], 62 | [1, 5, 1, 1], 63 | [1, 4, 1, 5], 64 | [1, 1, 1, 4]]) 65 | d = Dendrogram.compute(data, min_value=2) 66 | assert len(d.trunk) == 2 67 | self.assert_valid_index(d, TreeIndex(d)) 68 | 69 | def test_single_structure(self): 70 | data = np.array([[1, 1, 1, 1], 71 | [1, 5, 1, 1], 72 | [1, 4, 1, 1], 73 | [1, 1, 1, 1]]) 74 | d = Dendrogram.compute(data) 75 | assert len(d) == 1 76 | self.assert_valid_index(d, TreeIndex(d)) 77 | 78 | def test_cube(self): 79 | np.random.seed(42) 80 | data = np.random.random((5, 5, 5)) 81 | 82 | d = Dendrogram.compute(data) 83 | self.assert_valid_index(d, TreeIndex(d)) 84 | 85 | def test_1d(self): 86 | np.random.seed(42) 87 | data = np.random.random(5) 88 | 89 | d = Dendrogram.compute(data) 90 | assert len(d) > 0 91 | self.assert_valid_index(d, TreeIndex(d)) 92 | 93 | def test_4d(self): 94 | np.random.seed(42) 95 | data = np.random.random((3, 3, 3, 3)) 96 | 97 | d = Dendrogram.compute(data) 98 | assert len(d) > 0 99 | self.assert_valid_index(d, TreeIndex(d)) 100 | -------------------------------------------------------------------------------- /astrodendro/progressbar.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | """ 3 | progressbar.py 4 | 5 | A Python module with a ProgressBar class which can be used to represent a 6 | task's progress in the form of a progress bar and it can be formated in a 7 | basic way. 8 | 9 | Here is some basic usage with the default options: 10 | 11 | >>> from progressbar import ProgressBar 12 | >>> p = ProgressBar() 13 | >>> print p 14 | [>............] 0% 15 | >>> p + 1 16 | >>> print p 17 | [=>...........] 10% 18 | >>> p + 9 19 | >>> print p 20 | [============>] 0% 21 | 22 | And here another example with different options: 23 | 24 | >>> from progressbar import ProgressBar 25 | >>> custom_options = { 26 | ... 'end': 100, 27 | ... 'width': 20, 28 | ... 'fill': '#', 29 | ... 'format': '%(progress)s%% [%(fill)s%(blank)s]' 30 | ... } 31 | >>> p = ProgressBar(**custom_options) 32 | >>> print p 33 | 0% [....................] 34 | >>> p + 5 35 | >>> print p 36 | 5% [#...................] 37 | >>> p + 9 38 | >>> print p 39 | 100% [####################] 40 | 41 | Source: https://github.com/ikame/progressbar 42 | 43 | """ 44 | import sys 45 | 46 | 47 | class ProgressBar(object): 48 | """ProgressBar class holds the options of the progress bar. 49 | The options are: 50 | start State from which start the progress. For example, if start is 51 | 5 and the end is 10, the progress of this state is 50% 52 | end State in which the progress has terminated. 53 | width -- 54 | fill String to use for "filled" used to represent the progress 55 | blank String to use for "filled" used to represent remaining space. 56 | format Format 57 | incremental 58 | """ 59 | 60 | def __init__(self, start=0, end=10, width=12, fill='=', blank='.', pformat='[%(fill)s>%(blank)s] %(progress)s%%', incremental=True): 61 | super(ProgressBar, self).__init__() 62 | 63 | self.start = start 64 | self.end = end 65 | self.width = width 66 | self.fill = fill 67 | self.blank = blank 68 | self.pformat = pformat 69 | self.incremental = incremental 70 | self.step = 100 / float(width) # fix 71 | self.reset() 72 | 73 | def __add__(self, increment): 74 | increment = self._get_progress(increment) 75 | if 100 > self.progress + increment: 76 | self.progress += increment 77 | else: 78 | self.progress = 100 79 | return self 80 | 81 | def __str__(self): 82 | progressed = int(self.progress / self.step) # fix 83 | fill = progressed * self.fill 84 | blank = (self.width - progressed) * self.blank 85 | return self.pformat % {'fill': fill, 'blank': blank, 'progress': int(self.progress)} 86 | 87 | __repr__ = __str__ 88 | 89 | def _get_progress(self, increment): 90 | return float(increment * 100) / self.end 91 | 92 | def reset(self): 93 | """Resets the current progress to the start point""" 94 | self.progress = self._get_progress(self.start) 95 | return self 96 | 97 | 98 | class AnimatedProgressBar(ProgressBar): 99 | """Extends ProgressBar to allow you to use it straighforward on a script. 100 | Accepts an extra keyword argument named `stdout` (by default use sys.stdout) 101 | and may be any file-object to which send the progress status. 102 | """ 103 | 104 | def __init__(self, *args, **kwargs): 105 | super(AnimatedProgressBar, self).__init__(*args, **kwargs) 106 | self.stdout = kwargs.get('stdout', sys.stdout) 107 | 108 | def show_progress(self): 109 | if hasattr(self.stdout, 'isatty') and self.stdout.isatty(): 110 | self.stdout.write('\r') 111 | else: 112 | self.stdout.write('\n') 113 | self.stdout.write(str(self)) 114 | self.stdout.flush() 115 | -------------------------------------------------------------------------------- /docs/migration.rst: -------------------------------------------------------------------------------- 1 | Migration guide for previous users of ``astrodendro`` 2 | ===================================================== 3 | 4 | The ``astrodendro`` package has been in development for a couple of years, and 5 | we have recently undertaken an effort to prepare the package for a first 6 | release, which involved tidying up the programming interface to the package, 7 | and re-writing large sections. This means that the present version of 8 | ``astrodendro`` will likely not work with scripts you had if you were using the 9 | original astrodendro packages from @astrofrog and @brandenmacdonald's 10 | repositories. 11 | 12 | This page summarizes the main changes in the new code, and how to adapt your 13 | code to ensure that it will work correctly. This only covers changes that will 14 | *break* your code, but you are encouraged to look through the rest of the 15 | documentation to read about new features! Also, only the main 16 | backward-incompatible changes are mentioned, but for any questions on changes 17 | not mentioned here, please open an issue on `GitHub 18 | `_. 19 | 20 | Computing a dendrogram 21 | ---------------------- 22 | 23 | Rather than computing a dendrogram using:: 24 | 25 | d = Dendrogram(data) 26 | d.compute(...) 27 | 28 | you should now use:: 29 | 30 | d = Dendrogram.compute(data) 31 | 32 | In addition, the following options for ``compute`` have been renamed: 33 | 34 | * ``minimum_flux`` is now ``min_value`` (since we expect dendrograms to be used 35 | not only for images, but also e.g. density fields). 36 | 37 | * ``minimum_delta`` is now ``min_delta`` 38 | 39 | * ``minimum_npix`` is now ``min_npix`` 40 | 41 | Dendrogram methods and attributes 42 | --------------------------------- 43 | 44 | The following dendrogram methods have changed: 45 | 46 | * ``get_leaves()`` has now been replaced by a ``leaves`` attribute (it is no 47 | longer a method.) 48 | 49 | * the ``to_hdf5()`` and ``from_hdf5()`` methods have been replaced by 50 | :meth:`~astrodendro.dendrogram.Dendrogram.save_to` 51 | 52 | ``Leaf`` and ``Branch`` classes 53 | ------------------------------- 54 | 55 | The ``Leaf`` and ``Branch`` classes no longer exist, and have been replaced by 56 | a single :class:`~astrodendro.structure.Structure` class that instead has 57 | ``is_leaf`` and ``is_branch`` attributes. Thus, if you were checking if 58 | something was a leaf by doing e.g.:: 59 | 60 | if type(s) == Leaf: 61 | # code here 62 | 63 | or:: 64 | 65 | if isinstance(s, Leaf): 66 | # code here 67 | 68 | then you will instead need to use:: 69 | 70 | if s.is_leaf: 71 | # code here 72 | 73 | Leaf and branch attributes 74 | -------------------------- 75 | 76 | The following leaf and branch attributes have changed: 77 | 78 | * ``f`` has been replaced by a method called :meth:`~astrodendro.structure.Structure.values` that can take a 79 | ``subtree=`` option that indicates whether pixels in sub-structures should be 80 | included. 81 | 82 | * ``coords`` has been replaced by a method called :meth:`~astrodendro.structure.Structure.indices` that can take a 83 | ``subtree=`` option that indicates whether pixels in sub-structures should be 84 | included. 85 | 86 | * ``height`` now has a different definition - it is ``vmax`` for a leaf, or the 87 | smallest ``vmin`` of the children for a branch - this is used when plotting 88 | the dendrogram, to know at what height to plot the structure. 89 | 90 | Interactive visualization 91 | ------------------------- 92 | 93 | Visualizing the results of the dendrogram is now much easier, and does not 94 | require the additional ``astrocube`` package. To launch the interactive viewer 95 | (which requires only Matplotlib), once the dendrogram has been computed, you can do: 96 | 97 | >>> d.viewer() 98 | 99 | and the interactive viewer will launch. It will however no longer have the 100 | option to re-compute the dendrogram from the window, and will also no longer 101 | have an IPython terminal. For the latter, we recommend you consider using the 102 | `Glue `_ package. 103 | 104 | -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: astrodendro.dendrogram 2 | 3 | Advanced topics 4 | =============== 5 | 6 | Specifying a custom structure merging strategy 7 | ---------------------------------------------- 8 | 9 | By default, the decision about whether a leaf remains independent (i.e., 10 | whether it remains a leaf or its pixels get incorporated into another branch) 11 | when merged is made based on the ``min_delta`` and ``min_npix`` parameters, but 12 | in some cases, you may want to use more specialized criteria. For example, you 13 | may want only leaves overlapping with a certain position, or you may want 14 | leaves with a certain spatial or velocity extent, or a minimum peak value, to 15 | be considered independent structures. 16 | 17 | In order to accomodate this, the 18 | :meth:`~astrodendro.dendrogram.Dendrogram.compute` method can optionally take 19 | an ``is_independent`` argument which should be a function with the following 20 | call signature:: 21 | 22 | def is_independent(structure, index=None, value=None): 23 | ... 24 | 25 | 26 | where ``structure`` is the :class:`~astrodendro.structure.Structure` object 27 | that is being considered, and ``index`` and ``value`` are the pixel index and 28 | value of the pixel that is linking the structure to the rest of the tree. These 29 | last two values are only set when calling the ``is_independent`` function 30 | during the tree computation, but the ``is_independent`` function is also used 31 | at the end of the computation to prune leaves that are not attached to the 32 | tree, and in this case ``index`` and ``value`` are not set. 33 | 34 | The following example compares the dendrogram obtained with and without a 35 | custom ``is_independent`` function: 36 | 37 | .. plot:: 38 | :include-source: 39 | 40 | import matplotlib.pyplot as plt 41 | from astropy.io import fits 42 | from astrodendro import Dendrogram 43 | 44 | image = fits.getdata('PerA_Extn2MASS_F_Gal.fits') 45 | 46 | fig = plt.figure(figsize=(15,5)) 47 | 48 | # Default merging strategy 49 | 50 | d1 = Dendrogram.compute(image, min_value=2.0) 51 | p1 = d1.plotter() 52 | 53 | ax1 = fig.add_subplot(1, 3, 1) 54 | p1.plot_tree(ax1, color='black') 55 | ax1.hlines(3.5, *ax1.get_xlim(), color='b', linestyle='--') 56 | ax1.set_xlabel("Structure") 57 | ax1.set_ylabel("Flux") 58 | ax1.set_title("Default merging") 59 | 60 | # Require minimum peak value 61 | # this is equivalent to 62 | # custom_independent = astrodendro.pruning.min_peak(3.5) 63 | def custom_independent(structure, index=None, value=None): 64 | peak_index, peak_value = structure.get_peak() 65 | return peak_value > 3.5 66 | 67 | d2 = Dendrogram.compute(image, min_value=2.0, 68 | is_independent=custom_independent) 69 | p2 = d2.plotter() 70 | 71 | ax2 = fig.add_subplot(1, 3, 2) 72 | p2.plot_tree(ax2, color='black') 73 | ax2.hlines(3.5, *ax2.get_xlim(), color='b', linestyle='--') 74 | ax2.set_xlabel("Structure") 75 | ax2.set_ylabel("Flux") 76 | ax2.set_title("Custom merging") 77 | 78 | # For comparison, this is what changing the min_value does: 79 | d3 = Dendrogram.compute(image, min_value=3.5) 80 | p3 = d3.plotter() 81 | 82 | ax3 = fig.add_subplot(1, 3, 3) 83 | p3.plot_tree(ax3, color='black') 84 | ax3.hlines(3.5, *ax3.get_xlim(), color='b', linestyle='--') 85 | ax3.set_xlabel("Structure") 86 | ax3.set_ylabel("Flux") 87 | ax3.set_title("min_value=3.5 merging") 88 | ax3.set_ylim(*ax2.get_ylim()) 89 | 90 | Several pre-implemented functions suitable for use as ``is_independent`` tests 91 | are provided in :mod:`astrodendro.pruning`. In addition, the 92 | :meth:`astrodendro.pruning.all_true` function can be used to combine several 93 | criteria. For example, the following code builds a dendrogram where each leaf 94 | contains a pixel whose value >=20, and whose pixels sum to >= 100:: 95 | 96 | from astrodendro.pruning import all_true, min_peak, min_sum 97 | 98 | custom_independent = all_true((min_peak(20), min_sum(100))) 99 | Dendrogram.compute(image, is_independent=custom_independent) 100 | 101 | 102 | Handling custom adjacency logic 103 | ------------------------------- 104 | By default, neighbours to a given pixel are considered to be the adjacent 105 | pixels in the array. However, not all data are like this. For example, 106 | all-sky cartesian maps are periodic along the X axis. 107 | 108 | You can specify custom neighbour logic by providing a ``neighbours`` function 109 | to :meth:`Dendrogram.compute`. For example, the :func:`periodic_neighbours` 110 | utility will wrap neighbours across array edges. To correctly compute dendrograms for all-sky Cartesian maps:: 111 | 112 | periodic_axis = 1 # data wraps along longitude axis 113 | Dendrogram.compute(data, neighbours=periodic_neighbours(periodic_axis)) 114 | -------------------------------------------------------------------------------- /astrodendro/pruning.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | """ 3 | The pruning module provides several functions to perform common 4 | pruning via the ``is_independent`` keyword in the Dendrogram 5 | :meth:`~astrodendro.dendrogram.Dendrogram.compute` method. 6 | 7 | Examples:: 8 | 9 | #prune unless leaf peak value >= 5 10 | Dendrogram.compute(data, is_independent=min_peak(5)) 11 | 12 | #prune unless leaf contains 10 pixels 13 | Dendrogram.compute(data, is_independent=min_npix(10)) 14 | 15 | #apply both criteria 16 | is_independent = all_true((min_peak(5), min_npix(10))) 17 | Dendrogram.compute(data, is_independent=is_independent) 18 | """ 19 | 20 | import numpy as np 21 | 22 | 23 | def _ravel_multi_index(multi_index, dims, mode='raise'): 24 | # partial implementation of ravel_multi_index, 25 | # for compatibility with numpy <= 1.5 26 | # does not implement order kwarg 27 | ndim = len(dims) 28 | 29 | if len(multi_index) != len(dims): 30 | raise ValueError("parameter multi_index must be " 31 | "a sequence of length %i" % ndim) 32 | 33 | indices = [np.asarray(m) for m in multi_index] 34 | if mode == 'raise': 35 | for i, d in zip(indices, dims): 36 | if ((i < 0) | (i >= d)).any(): 37 | raise ValueError("invalid entry in coordinates array") 38 | elif mode == 'clip': 39 | indices = [np.clip(i, 0, d - 1) for i, d in zip(indices, dims)] 40 | else: # mode == 'wrap' 41 | indices = [i % d for i, d in zip(indices, dims)] 42 | 43 | result = np.zeros(len(multi_index[0]), dtype=int) 44 | offset = 1 45 | for i, d in list(zip(indices, dims))[::-1]: 46 | result += (i * offset).ravel() 47 | offset *= d 48 | 49 | return result 50 | 51 | 52 | if not hasattr(np, 'ravel_multi_index'): 53 | np.ravel_multi_index = _ravel_multi_index 54 | 55 | 56 | def all_true(funcs): 57 | """Combine several ``is_independent`` functions into one 58 | 59 | Parameters 60 | ---------- 61 | funcs : list-like 62 | A list of ``is_independent`` functions 63 | 64 | Returns 65 | ------- 66 | combined_func : function 67 | A new function which returns true of all the input functions are true 68 | """ 69 | def result(*args, **kwargs): 70 | return all(f(*args, **kwargs) for f in funcs) 71 | return result 72 | 73 | 74 | def min_delta(delta): 75 | """ 76 | Minimum delta criteria 77 | 78 | Parameters 79 | ---------- 80 | delta : float 81 | The minimum height of a leaf above its merger level 82 | 83 | """ 84 | def result(structure, index=None, value=None): 85 | if value is None: 86 | if structure.parent is not None: 87 | return (structure.height - structure.parent.height) >= delta 88 | 89 | return (structure.vmax - structure.vmin) >= delta 90 | return (structure.vmax - value) >= delta 91 | return result 92 | 93 | 94 | def min_sum(sum): 95 | """ 96 | Minimum sum criteria 97 | 98 | Parameters 99 | ---------- 100 | sum : float 101 | The minimum sum of the pixel values in a leaf 102 | """ 103 | def result(structure, index=None, value=None): 104 | return np.nansum(structure.values()) >= sum 105 | return result 106 | 107 | 108 | def min_peak(peak): 109 | """ 110 | Minimum peak criteria 111 | 112 | Parameters 113 | ---------- 114 | peak : float 115 | The minimum peak pixel value in a leaf 116 | """ 117 | def result(structure, index=None, value=None): 118 | return structure.vmax >= peak 119 | return result 120 | 121 | 122 | def min_npix(npix): 123 | """ 124 | Minimum npix criteria 125 | 126 | Parameters 127 | ---------- 128 | npix : int 129 | The minimum number of pixels in a leaf 130 | """ 131 | def result(structure, index=None, value=None): 132 | return len(structure.values()) >= npix 133 | return result 134 | 135 | 136 | def contains_seeds(seeds): 137 | """ 138 | Critieria that leaves contain at least one of a list of seed positions 139 | 140 | Parameters 141 | ---------- 142 | seeds : tuple of array-like 143 | seed locations. The ith array in the tuple lists the ith coordinate 144 | for each seed. This is the format returned, e.g., by np.where 145 | """ 146 | shp = [np.asarray(s).max() + 2 for s in seeds] 147 | rav = np.ravel_multi_index(seeds, shp) 148 | 149 | def result(structure, index=None, value=None): 150 | sid = structure.indices() 151 | if len(sid) != len(seeds): 152 | raise TypeError("Dimensions of seeds and data do not agree") 153 | rav2 = np.ravel_multi_index(sid, shp, mode='clip') 154 | return np.intersect1d(rav, rav2).size > 0 155 | 156 | return result 157 | -------------------------------------------------------------------------------- /astrodendro/scatter.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | from matplotlib.widgets import Lasso 3 | from matplotlib import path 4 | import matplotlib 5 | import numpy as np 6 | 7 | 8 | class Scatter(object): 9 | 10 | """ 11 | Scatter is an optional viewer that plugs into a SelectionHub. 12 | It displays catalog properties in a scatter plot. 13 | Users can select scatter points directly by clicking and dragging 14 | a lasso around points of interest. These selected points' 15 | corresponding structures will then be highlighted in all other 16 | viewers. 17 | 18 | Example use: 19 | 20 | >>> from astrodendro.scatter import Scatter 21 | # ... code to create a dendrogram (d) and catalog ... 22 | >>> dv = d.viewer() 23 | >>> ds = Scatter(d, dv.hub, catalog, 'radius', 'v_rms') 24 | >>> dv.show() 25 | 26 | To set logarithmic scaling on the x, y axes or both, the following 27 | convenience methods are defined: 28 | 29 | >>> ds.set_loglog() 30 | >>> ds.set_semilogx() 31 | >>> ds.set_semilogy() 32 | # These can be unset by passing a `log=False` keyword, i.e. 33 | >>> ds.set_loglog(False) 34 | 35 | For more information on using Scatter, see the online 36 | documentation. 37 | 38 | """ 39 | 40 | def __init__(self, dendrogram, hub, catalog, xaxis, yaxis): 41 | 42 | self.hub = hub 43 | self.dendrogram = dendrogram 44 | self.structures = list(self.dendrogram.all_structures) 45 | 46 | self.fig = plt.figure() 47 | self.axes = plt.subplot(1, 1, 1) 48 | 49 | self.catalog = catalog 50 | self.xdata = catalog[xaxis] 51 | self.ydata = catalog[yaxis] 52 | 53 | self.xys = np.column_stack((self.xdata, self.ydata)) 54 | 55 | self.x_column_name = xaxis 56 | self.y_column_name = yaxis 57 | 58 | self.lines2d = {} # selection_id -> matplotlib.lines.Line2D 59 | 60 | # This is a workaround for a (likely) bug in matplotlib.widgets. Lasso crashes without this fix. 61 | if matplotlib.get_backend() == 'MacOSX': 62 | self.fig.canvas.supports_blit = False 63 | 64 | self._draw_plot() 65 | self.hub.add_callback(self.update_selection) 66 | 67 | self.cid = self.fig.canvas.mpl_connect('button_press_event', self.onpress) 68 | 69 | # If things are already selected in the hub, go select them! 70 | for selection_id in self.hub.selections: 71 | self.update_selection(selection_id) 72 | 73 | def _draw_plot(self): 74 | 75 | self.axes.plot(self.xdata, self.ydata, 'o', color='w', mec='k', zorder=-5) 76 | 77 | self.axes.set_xlabel(self.x_column_name) 78 | self.axes.set_ylabel(self.y_column_name) 79 | 80 | self.fig.canvas.draw() 81 | 82 | # This is a closure - we have to pass the input key to callback somehow. 83 | def callback_generator(self, event): 84 | 85 | input_key = event.button 86 | 87 | def callback(verts): 88 | p = path.Path(verts) 89 | 90 | # `p.contains_points` has undesirable behavior that makes it necessary to explicitly exclude `nan` data. 91 | indices = np.where(p.contains_points(self.xys) & 92 | ~np.isnan(self.xdata) & 93 | ~np.isnan(self.ydata))[0] 94 | selected_structures = [self.dendrogram[i] for i in indices] 95 | 96 | if len(selected_structures) == 0: 97 | selected_structures = [None] 98 | 99 | self.hub.select(input_key, selected_structures, subtree=False) 100 | 101 | self.fig.canvas.draw_idle() 102 | del self.lasso 103 | 104 | return callback 105 | 106 | def onpress(self, event): 107 | if event.canvas.toolbar.mode != '': 108 | return 109 | if event.inaxes is None: 110 | return 111 | self.lasso = Lasso(event.inaxes, (event.xdata, event.ydata), self.callback_generator(event)) 112 | 113 | def update_selection(self, selection_id): 114 | """Highlight seleted structures""" 115 | 116 | if selection_id in self.lines2d: 117 | if self.lines2d[selection_id] is not None: 118 | self.lines2d[selection_id].remove() 119 | del self.lines2d[selection_id] 120 | 121 | structures = self.hub.selections[selection_id] 122 | struct = structures[0] 123 | 124 | if struct is None: 125 | self.fig.canvas.draw() 126 | return 127 | if self.hub.select_subtree[selection_id]: 128 | selected_indices = [leaf.idx for leaf in struct.descendants + [struct]] 129 | else: 130 | selected_indices = [leaf.idx for leaf in structures] 131 | 132 | self.lines2d[selection_id] = self.axes.plot( 133 | self.xdata[selected_indices], 134 | self.ydata[selected_indices], 135 | 'o', color=self.hub.colors[selection_id], zorder=struct.height)[0] 136 | 137 | self.fig.canvas.draw() 138 | 139 | def set_loglog(self, log=True): 140 | """ Convenience function to make the plot logarithmic """ 141 | 142 | if log: 143 | self.axes.set_xscale('log') 144 | self.axes.set_yscale('log') 145 | else: 146 | self.axes.set_xscale('linear') 147 | self.axes.set_yscale('linear') 148 | self.fig.canvas.draw() 149 | 150 | def set_semilogx(self, log=True): 151 | if log: 152 | self.axes.set_xscale('log') 153 | else: 154 | self.axes.set_xscale('linear') 155 | self.fig.canvas.draw() 156 | 157 | def set_semilogy(self, log=True): 158 | if log: 159 | self.axes.set_yscale('log') 160 | else: 161 | self.axes.set_yscale('linear') 162 | self.fig.canvas.draw() 163 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* _generated 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/astrodendro.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/astrodendro.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/astrodendro" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/astrodendro" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /astrodendro/tests/test_io.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | " Test import and export of dendrograms " 4 | 5 | import pytest 6 | import numpy as np 7 | 8 | from .. import Dendrogram 9 | from .test_index import assert_permuted_fancyindex 10 | 11 | from astropy.wcs import WCS 12 | 13 | 14 | class TestIO(object): 15 | 16 | def setup_method(self, method): 17 | n = np.nan 18 | self.data = np.array([[[n, n, n, n, n, n, n, n], 19 | [n, 4, n, n, n, n, n, n], 20 | [n, n, n, 1, n, n, 0, 5], 21 | [3, n, n, 2, 3, 2, 0, n]], 22 | [[n, n, n, n, n, n, n, n], 23 | [1, n, n, n, n, n, n, n], 24 | [1, n, 1, 1, 0, n, 0, 1], 25 | [2, n, n, 1, 3, 1, n, 1]], 26 | [[n, 2, 3, 4, n, n, n, n], 27 | [1, 1, n, n, n, n, n, n], 28 | [n, n, n, n, n, n, 0, 1], 29 | [n, n, n, 1, 0, 1, 0, n]]]) 30 | 31 | def compare_dendrograms(self, d1, d2): 32 | " Helper method that ensures d1 and d2 are equivalent " 33 | # Do we get the same number of structures? 34 | assert len(d1) == len(d2) 35 | # Do we recover the data exactly? 36 | np.testing.assert_array_equal(d1.data, d2.data) 37 | # Now check that the structures are the same: 38 | for s in d2: 39 | idx = s.idx 40 | structure1, structure2 = d1[idx], d2[idx] 41 | assert_permuted_fancyindex(structure1.indices(subtree=False), 42 | structure2.indices(subtree=False)) 43 | assert np.all(np.sort(structure1.values(subtree=False)) == 44 | np.sort(structure2.values(subtree=False))) 45 | assert isinstance(structure1, type(structure2)) 46 | # Compare the coordinates and data values of all peak pixels: 47 | assert structure1.get_peak(subtree=True) == \ 48 | structure2.get_peak(subtree=True) 49 | 50 | assert structure2._tree_index is not None 51 | 52 | # Below are the actual tests for each import/export format: 53 | 54 | def test_hdf5(self, tmp_path): 55 | test_filename = tmp_path / 'astrodendro-test.hdf5' 56 | d1 = Dendrogram.compute(self.data, verbose=False) 57 | d1.save_to(test_filename, format='hdf5') 58 | d2 = Dendrogram.load_from(test_filename, format='hdf5') 59 | self.compare_dendrograms(d1, d2) 60 | 61 | def test_fits(self, tmp_path): 62 | test_filename = tmp_path / 'astrodendro-test.fits' 63 | d1 = Dendrogram.compute(self.data, verbose=False) 64 | d1.save_to(test_filename, format='fits') 65 | d2 = Dendrogram.load_from(test_filename, format='fits') 66 | self.compare_dendrograms(d1, d2) 67 | 68 | def test_hdf5_auto(self, tmp_path): 69 | 70 | test_filename = tmp_path / 'astrodendro-test.hdf5' 71 | test_filename_noext = tmp_path / 'astrodendro-test' 72 | 73 | d1 = Dendrogram.compute(self.data, verbose=False) 74 | 75 | # recognize from extension 76 | d1.save_to(test_filename) 77 | 78 | # no way to tell 79 | with pytest.raises(IOError): 80 | d1.save_to(test_filename_noext) 81 | 82 | # no way to tell, so have to explicitly give format 83 | d1.save_to(test_filename_noext, format='hdf5') 84 | 85 | # recognize from extension 86 | Dendrogram.load_from(test_filename) 87 | 88 | # recognize from signature 89 | Dendrogram.load_from(test_filename_noext) 90 | 91 | def test_fits_auto(self, tmp_path): 92 | 93 | test_filename = tmp_path / 'astrodendro-test.fits' 94 | test_filename_noext = tmp_path / 'astrodendro-test' 95 | 96 | d1 = Dendrogram.compute(self.data, verbose=False) 97 | 98 | # recognize from extension 99 | d1.save_to(test_filename) 100 | 101 | # no way to tell 102 | with pytest.raises(IOError): 103 | d1.save_to(test_filename_noext) 104 | 105 | # no way to tell, so have to explicitly give format 106 | d1.save_to(test_filename_noext, format='fits') 107 | 108 | # recognize from extension 109 | Dendrogram.load_from(test_filename) 110 | 111 | # recognize from signature 112 | Dendrogram.load_from(test_filename_noext) 113 | 114 | def test_hdf5_with_wcs(self, tmp_path): 115 | test_filename = tmp_path / 'astrodendro-test-wcs.hdf5' 116 | test_wcs = WCS(header=dict(cdelt1=1, crval1=0, crpix1=1, 117 | cdelt2=2, crval2=0, crpix2=1, 118 | cdelt3=3, crval3=0, crpix3=1)) 119 | 120 | d1 = Dendrogram.compute(self.data, verbose=False, wcs=test_wcs) 121 | d1.save_to(test_filename, format='hdf5') 122 | d2 = Dendrogram.load_from(test_filename, format='hdf5') 123 | 124 | assert d2.wcs.to_header_string() == d1.wcs.to_header_string() 125 | 126 | def test_fits_with_wcs(self, tmp_path): 127 | test_filename = tmp_path / 'astrodendro-test-wcs.fits' 128 | test_wcs = WCS(header=dict(cdelt1=1, crval1=0, crpix1=1, 129 | cdelt2=2, crval2=0, crpix2=1, 130 | cdelt3=3, crval3=0, crpix3=1)) 131 | d1 = Dendrogram.compute(self.data, verbose=False, wcs=test_wcs) 132 | d1.save_to(test_filename, format='fits') 133 | d2 = Dendrogram.load_from(test_filename, format='fits') 134 | 135 | assert d2.wcs.to_header_string() == d1.wcs.to_header_string() 136 | 137 | @pytest.mark.parametrize('ext', ('fits', 'hdf5')) 138 | def test_reload_retains_dendro_reference(self, ext, tmp_path): 139 | # regression test for issue 106 140 | 141 | d1 = Dendrogram.compute(self.data, verbose=False) 142 | 143 | test_filename = tmp_path / f'astrodendro-test.{ext}' 144 | 145 | d1.save_to(test_filename) 146 | d2 = Dendrogram.load_from(test_filename) 147 | 148 | for s in d1: 149 | np.testing.assert_array_equal(d2[s.idx].get_mask(subtree=True), 150 | d1[s.idx].get_mask(subtree=True)) 151 | -------------------------------------------------------------------------------- /docs/algorithm.rst: -------------------------------------------------------------------------------- 1 | An Illustrated Description of the Core Algorithm 2 | ================================================ 3 | 4 | This page contains an explanation of the algorithm behind the Python dendrogram 5 | code. This is demonstrated with a step by step example of how the algorithm 6 | constructs the tree structure of a very simple one-dimensional dataset. Even 7 | though this dataset is very simple, what is described applies to datasets with 8 | any number of dimensions. 9 | 10 | Basic example 11 | ------------- 12 | 13 | The following diagram shows a one-dimensional dataset (with flux versus 14 | position) in the solid black line, with the corresponding dendrogram for that 15 | dataset overplotted in green: 16 | 17 | .. image:: algorithm/simple_final.png 18 | :align: center 19 | 20 | In the rest of this document, we will refer to the individual points in this 21 | dataset as *pixels*. 22 | 23 | The way the algorithm works is to construct the tree starting from the 24 | brightest pixels in the dataset, and progressively adding fainter and fainter 25 | pixels. We will illustrate this by showing the current value being considered, 26 | with the following blue dashed line: 27 | 28 | .. image:: algorithm/simple_step1.png 29 | :align: center 30 | 31 | Let's now start moving this line down, starting from the peak pixel in the 32 | dataset. We create our first structure from this pixel. We then move to the 33 | pixel with the next largest value, and each time, we decide whether to join the 34 | pixel to an existing structure, or create a new structure. We only start a new 35 | structure if the value of the pixel is greater than its immediate neighbors, 36 | and therefore is a local maximum. The first structure being constructed is 37 | shown below: 38 | 39 | .. image:: algorithm/simple_step2.png 40 | :align: center 41 | 42 | We have now found a local maximum, so rather than add this pixel to the first 43 | structure, we create a new structure. As we move further down, both structures 44 | keep growing, until we reach a pixel that is not a local maximum, and is 45 | adjacent to both existing structures: 46 | 47 | .. image:: algorithm/simple_step3.png 48 | :align: center 49 | 50 | At this point, we merge the structures into a branch, which is shown by a green 51 | horizontal line. As we move down further, the branch continues to grow, and we 52 | very quickly find two more local maxima which cause new structures to be 53 | created: 54 | 55 | .. image:: algorithm/simple_step4.png 56 | :align: center 57 | 58 | These structures eventually merge, and we end up with a single tree: 59 | 60 | .. image:: algorithm/simple_final.png 61 | :align: center 62 | 63 | Accounting for noise 64 | -------------------- 65 | 66 | Setting a minimum value (``min_value``) 67 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 68 | 69 | Most real-life datasets are likely to contain some level of noise, and below a 70 | certain value of the noise, there is no point in expanding the tree since it 71 | will not be measuring anything physical; new branches will be 'noise spikes'. 72 | By default, the minimum value is set to negative infinity, which means all 73 | pixels are added to the tree. However, you will very likely want to change this 74 | so that only significant features above the noise are included. 75 | 76 | Let's go back to the original data. We have left the outline of the complete 77 | tree for reference. We now set a minimum value, which we show below with the 78 | purple line. This is controlled by the ``min_value`` option in 79 | :meth:`~astrodendro.dendrogram.Dendrogram.compute`. 80 | 81 | .. image:: algorithm/min_value_final.png 82 | :align: center 83 | 84 | The effect on the tree is simply to get rid of (or *prune*) any structure 85 | peaking below this minimum. In this case, the peak on the right is no longer 86 | part of the tree since it is below the minimum specified value. 87 | 88 | Setting a minimum significance for structures (``min_delta``) 89 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 90 | 91 | If our data are noisy, we also want to avoid including *local* maxima that - while 92 | above the minimum absolute value specified above - are only identified because of noise, 93 | so we can also define a minimum height required for a structure to be retained. 94 | This is the ``min_delta`` parameter in 95 | :meth:`~astrodendro.dendrogram.Dendrogram.compute`. We show the value 96 | corresponding to the current value being considered plus this minimum height: 97 | 98 | .. image:: algorithm/small_delta_step1.png 99 | :align: center 100 | 101 | In this case, ``min_delta`` is set to 0.01. As we now move down in flux as 102 | before, the structure first appears red. This indicates that the structure is 103 | not yet part of the tree: 104 | 105 | .. image:: algorithm/small_delta_step2.png 106 | :align: center 107 | 108 | Once the height of the structure exceeds the minimum specified, the structure 109 | can now be considered part of the tree: 110 | 111 | .. image:: algorithm/small_delta_step3.png 112 | :align: center 113 | 114 | In this case, all structures that are above the minimum value are also all 115 | large enough to be significant, so the tree is the same as before: 116 | 117 | .. image:: algorithm/small_delta_final.png 118 | :align: center 119 | 120 | We can now repeat this experiment, but this time, with a larger minimum height 121 | for structures to be retained (``min_delta=0.025``). Once we reach the point 122 | where the second peak would have been merged, we can see that it is not high 123 | enough above the merging point to be considered an independent structure: 124 | 125 | .. image:: algorithm/large_delta_step1.png 126 | :align: center 127 | 128 | and the pixels are then simply added to the first structure, rather than 129 | creating a branch: 130 | 131 | .. image:: algorithm/large_delta_step2.png 132 | :align: center 133 | 134 | We can now see that the final tree looks a little different to the original 135 | one, because the second largest peak was deemed insignificant: 136 | 137 | .. image:: algorithm/large_delta_final.png 138 | :align: center 139 | 140 | Additional options 141 | ------------------ 142 | 143 | In addition to the minimum height of a structure, it is also possible to 144 | specify the minimum number of pixels that a structure should contain in order 145 | to remain an independent structure (``min_npix``), and in the future, it will 146 | be possible to specify arbitrary criteria, such as the proximity to a given 147 | point or set of coordinates. 148 | -------------------------------------------------------------------------------- /astrodendro/io/util.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from astropy.utils.console import ProgressBar 4 | from astropy import log 5 | 6 | 7 | def parse_newick(string): 8 | 9 | items = {} 10 | 11 | # Find maximum level 12 | current_level = 0 13 | max_level = 0 14 | log.debug('String loading...') 15 | for i, c in enumerate(string): 16 | if c == '(': 17 | current_level += 1 18 | if c == ')': 19 | current_level -= 1 20 | max_level = max(max_level, current_level) 21 | 22 | # Loop through levels and construct tree 23 | log.debug('Tree loading...') 24 | for level in range(max_level, 0, -1): 25 | 26 | pairs = [] 27 | 28 | current_level = 0 29 | for i, c in enumerate(string): 30 | if c == '(': 31 | current_level += 1 32 | if current_level == level: 33 | start = i 34 | if c == ')': 35 | if current_level == level: 36 | pairs.append((start, i)) 37 | current_level -= 1 38 | 39 | for pair in pairs[::-1]: 40 | 41 | # Extract start and end of branch definition 42 | start, end = pair 43 | 44 | # Find the ID of the branch 45 | colon = string.find(":", end) 46 | branch_id = string[end + 1:colon] 47 | if branch_id == '': 48 | branch_id = 'trunk' 49 | else: 50 | branch_id = int(branch_id) 51 | 52 | # Add branch definition to overall definition 53 | items[branch_id] = eval("{%s}" % string[start + 1:end]) 54 | 55 | # Remove branch definition from string 56 | string = string[:start] + string[end + 1:] 57 | 58 | def collect(d): 59 | for item in d: 60 | if item in items: 61 | collect(items[item]) 62 | d[item] = (items[item], d[item]) 63 | return 64 | 65 | collect(items['trunk']) 66 | 67 | return items['trunk'] 68 | 69 | 70 | def parse_dendrogram(newick, data, index_map, params, wcs=None): 71 | from ..dendrogram import Dendrogram 72 | from ..structure import Structure 73 | 74 | d = Dendrogram() 75 | d.ndim = len(data.shape) 76 | 77 | d._structures_dict = {} 78 | d.data = data 79 | d.index_map = index_map 80 | d.params = params 81 | d.wcs = wcs 82 | 83 | try: 84 | flux_by_structure, indices_by_structure = _fast_reader(d.index_map, data) 85 | except ImportError: 86 | flux_by_structure, indices_by_structure = _slow_reader(d.index_map, data) 87 | 88 | def _construct_tree(repr): 89 | structures = [] 90 | for idx in repr: 91 | idx = int(idx) 92 | structure_indices = indices_by_structure[idx] 93 | f = flux_by_structure[idx] 94 | if type(repr[idx]) is tuple: 95 | sub_structures_repr = repr[idx][0] # Parsed representation of sub structures 96 | sub_structures = _construct_tree(sub_structures_repr) 97 | for i in sub_structures: 98 | d._structures_dict[i.idx] = i 99 | branch = Structure(structure_indices, f, children=sub_structures, idx=idx, dendrogram=d) 100 | # Correct merge levels - complicated because of the 101 | # order in which we are building the tree. 102 | # What we do is look at the heights of this branch's 103 | # 1st child as stored in the newick representation, and then 104 | # work backwards to compute the merge level of this branch 105 | d._structures_dict[idx] = branch 106 | structures.append(branch) 107 | else: 108 | leaf = Structure(structure_indices, f, idx=idx, dendrogram=d) 109 | structures.append(leaf) 110 | d._structures_dict[idx] = leaf 111 | return structures 112 | 113 | log.debug('Parsing newick and constructing tree...') 114 | d.trunk = _construct_tree(parse_newick(newick)) 115 | # To make the structure.level property fast, we ensure all the items in the 116 | # trunk have their level cached as "0" 117 | for structure in d.trunk: 118 | structure._level = 0 # See the @property level() definition in structure.py 119 | 120 | d._index() 121 | return d 122 | 123 | 124 | def _fast_reader(index_map, data): 125 | """ 126 | Use scipy.ndimage.find_objects to quickly identify subsets of the data 127 | to increase speed of dendrogram loading 128 | """ 129 | 130 | flux_by_structure, indices_by_structure = {}, {} 131 | 132 | from scipy import ndimage 133 | idxs = np.unique(index_map[index_map > -1]) 134 | 135 | # ndimage ignores 0 and -1, but we want index 0 136 | object_slices = ndimage.find_objects(index_map+1) 137 | 138 | # find_objects returns a tuple that includes many None values that we 139 | # need to get rid of. 140 | object_slices = [x for x in object_slices if x is not None] 141 | 142 | index_cube = np.indices(index_map.shape) 143 | 144 | # Need to have same length, otherwise assumptions above are wrong 145 | assert len(idxs) == len(object_slices) 146 | log.debug('Creating index maps for {0} indices...'.format(len(idxs))) 147 | 148 | p = ProgressBar(len(object_slices)) 149 | for idx, sl in zip(idxs, object_slices): 150 | match = index_map[sl] == idx 151 | sl2 = (slice(None),) + sl 152 | match_inds = index_cube[sl2][:, match] 153 | coords = list(zip(*match_inds)) 154 | dd = data[sl][match].tolist() 155 | flux_by_structure[idx] = dd 156 | indices_by_structure[idx] = coords 157 | p.update() 158 | 159 | return flux_by_structure, indices_by_structure 160 | 161 | 162 | def _slow_reader(index_map, data): 163 | """ 164 | Loop over each valid pixel in the index_map and add its coordinates and 165 | data to the flux_by_structure and indices_by_structure dicts 166 | 167 | This is slower than _fast_reader but faster than that implementation would 168 | be without find_objects. The bottleneck is doing `index_map == idx` N 169 | times. 170 | """ 171 | flux_by_structure, indices_by_structure = {}, {} 172 | # Do a fast iteration through d.data, adding the indices and data values 173 | # to the two dictionaries declared above: 174 | indices = np.array(np.where(index_map > -1)).transpose() 175 | 176 | log.debug('Creating index maps for {0} coordinates...'.format(len(indices))) 177 | for coord in ProgressBar(indices): 178 | coord = tuple(coord) 179 | idx = index_map[coord] 180 | if idx in flux_by_structure: 181 | flux_by_structure[idx].append(data[coord]) 182 | indices_by_structure[idx].append(coord) 183 | else: 184 | flux_by_structure[idx] = [data[coord]] 185 | indices_by_structure[idx] = [coord] 186 | 187 | return flux_by_structure, indices_by_structure 188 | -------------------------------------------------------------------------------- /astrodendro/flux.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import numpy as np 4 | 5 | from astropy import units as u 6 | from astropy.constants import si 7 | 8 | 9 | class UnitMetadataWarning(UserWarning): 10 | pass 11 | 12 | 13 | def quantity_sum(quantities): 14 | """ 15 | In Astropy 0.3, np.sum will do the right thing for quantities, but in the mean time we need a workaround. 16 | """ 17 | return np.sum(quantities.value) * quantities.unit 18 | 19 | 20 | def compute_flux(input_quantities, output_unit, wavelength=None, spatial_scale=None, 21 | velocity_scale=None, beam_major=None, beam_minor=None): 22 | """ 23 | Given a set of flux values in arbitrary units, find the total flux in a 24 | specific set of units. 25 | 26 | Parameters 27 | ---------- 28 | input_quantities : :class:`~astropy.units.quantity.Quantity` instance 29 | A `astropy.units.quantity.Quantity` instance containing an array of 30 | flux values to be summed. 31 | output_unit : :class:`~astropy.units.core.Unit` instance 32 | The final unit to give the total flux in (should be equivalent to Jy) 33 | wavelength : :class:`~astropy.units.quantity.Quantity` instance 34 | The wavelength of the data (required if converting e.g. 35 | ergs/cm^2/s/micron to Jy) 36 | spatial_scale : :class:`~astropy.units.quantity.Quantity` instance 37 | The pixel scale of the data (should be an angle) 38 | velocity_scale : :class:`~astropy.units.quantity.Quantity` instance 39 | The pixel scale of the data (should be a velocity) 40 | beam_major : :class:`~astropy.units.quantity.Quantity` instance 41 | The beam major full width at half_maximum (FWHM) 42 | beam_minor : :class:`~astropy.units.quantity.Quantity` instance 43 | The beam minor full width at half_maximum (FWHM) 44 | """ 45 | 46 | # Start off by finding the total flux in Jy 47 | 48 | if input_quantities.unit.is_equivalent(u.Jy): # Fnu 49 | 50 | # Simply sum up the values and convert to output unit 51 | total_flux = quantity_sum(input_quantities).to(u.Jy) 52 | 53 | elif input_quantities.unit.is_equivalent(u.erg / u.cm ** 2 / u.s / u.m): # Flambda 54 | 55 | if wavelength is not None and not wavelength.unit.is_equivalent(u.m): 56 | raise ValueError("wavelength should be a physical length") 57 | 58 | # Find the frequency 59 | if wavelength is None: 60 | raise ValueError("wavelength is needed to convert from {0} to Jy".format(input_quantities.unit)) 61 | 62 | # Find frequency 63 | nu = si.c / wavelength 64 | 65 | # Convert input quantity to Fnu in Jy 66 | q = (input_quantities * wavelength / nu).to(u.Jy) 67 | 68 | # Find total flux in Jy 69 | total_flux = quantity_sum(q) 70 | 71 | elif input_quantities.unit.is_equivalent(u.MJy / u.sr): # surface brightness (Fnu) 72 | 73 | if spatial_scale is not None and not spatial_scale.unit.is_equivalent(u.degree): 74 | raise ValueError("spatial_scale should be an angle") 75 | 76 | if spatial_scale is None: 77 | raise ValueError("spatial_scale is needed to convert from {0} to Jy".format(input_quantities.unit)) 78 | 79 | # Find the area of a pixel as a solid angle 80 | pixel_area = (spatial_scale ** 2) 81 | 82 | # Convert input quantity to Fnu in Jy 83 | q = (input_quantities * pixel_area).to(u.Jy) 84 | 85 | # Find total flux in Jy 86 | total_flux = quantity_sum(q) 87 | 88 | elif input_quantities.unit.is_equivalent(u.Jy / u.beam): 89 | 90 | if spatial_scale is not None and not spatial_scale.unit.is_equivalent(u.degree): 91 | raise ValueError("spatial_scale should be an angle") 92 | 93 | if spatial_scale is None: 94 | raise ValueError("spatial_scale is needed to convert from {0} to Jy".format(input_quantities.unit)) 95 | 96 | if beam_major is not None and not beam_major.unit.is_equivalent(u.degree): 97 | raise ValueError("beam_major should be an angle") 98 | 99 | if beam_major is None: 100 | raise ValueError("beam_major is needed to convert from {0} to Jy".format(input_quantities.unit)) 101 | 102 | if beam_minor is not None and not beam_minor.unit.is_equivalent(u.degree): 103 | raise ValueError("beam_minor should be an angle") 104 | 105 | if beam_minor is None: 106 | raise ValueError("beam_minor is needed to convert from {0} to Jy".format(input_quantities.unit)) 107 | 108 | # Find the beam area 109 | beams_per_pixel = spatial_scale ** 2 / (beam_minor * beam_major * 1.1331) * u.beam 110 | 111 | # Convert input quantity to Fnu in Jy 112 | q = (input_quantities * beams_per_pixel).to(u.Jy) 113 | 114 | # Find total flux in Jy 115 | total_flux = quantity_sum(q) 116 | 117 | elif input_quantities.unit.is_equivalent(u.K): 118 | 119 | if spatial_scale is not None and not spatial_scale.unit.is_equivalent(u.degree): 120 | raise ValueError("spatial_scale should be an angle") 121 | 122 | if spatial_scale is None: 123 | raise ValueError("spatial_scale is needed to convert from {0} to Jy".format(input_quantities.unit)) 124 | 125 | if beam_major is not None and not beam_major.unit.is_equivalent(u.degree): 126 | raise ValueError("beam_major should be an angle") 127 | 128 | if beam_major is None: 129 | raise ValueError("beam_major is needed to convert from {0} to Jy".format(input_quantities.unit)) 130 | 131 | if beam_minor is not None and not beam_minor.unit.is_equivalent(u.degree): 132 | raise ValueError("beam_minor should be an angle") 133 | 134 | if beam_minor is None: 135 | raise ValueError("beam_minor is needed to convert from {0} to Jy".format(input_quantities.unit)) 136 | 137 | if wavelength is not None and not wavelength.unit.is_equivalent(u.m, equivalencies=u.spectral()): 138 | raise ValueError("wavelength should be a physical length") 139 | 140 | # Find the frequency 141 | if wavelength is None: 142 | raise ValueError("wavelength is needed to convert from {0} to Jy".format(input_quantities.unit)) 143 | 144 | warnings.warn("'Kelvin' units interpreted as main beam brightness temperature.", 145 | UnitMetadataWarning) 146 | 147 | # Find frequency 148 | nu = wavelength.to(u.Hz, equivalencies=u.spectral()) 149 | 150 | # Angular area of beam. Conversion between 2D Gaussian FWHM and effective area comes from 151 | # https://github.com/radio-astro-tools/radio_beam/blob/bc906c38a65e85c6a894ee81519a642665e50f7c/radio_beam/beam.py#L8 152 | omega_beam = np.pi * 2 / (8*np.log(2)) * beam_major * beam_minor 153 | 154 | # Find the beam area 155 | beams_per_pixel = spatial_scale ** 2 / omega_beam * u.beam 156 | 157 | # Convert input quantity to Fnu in Jy 158 | # Implicitly, this equivalency gives the Janskys in a single beam, so we make this explicit by dividing out a beam 159 | jansky_per_beam = input_quantities.to(u.Jy, 160 | equivalencies=u.brightness_temperature(nu, beam_area=omega_beam)) / u.beam 161 | 162 | q = jansky_per_beam * beams_per_pixel 163 | 164 | # Find total flux in Jy 165 | total_flux = quantity_sum(q) 166 | 167 | else: 168 | 169 | raise ValueError("Flux units {0} not yet supported".format(input_quantities.unit)) 170 | 171 | if not output_unit.is_equivalent(u.Jy): 172 | raise ValueError("output_unit has to be equivalent to Jy") 173 | else: 174 | return total_flux.to(output_unit) 175 | -------------------------------------------------------------------------------- /docs/using.rst: -------------------------------------------------------------------------------- 1 | Computing and exploring dendrograms 2 | =================================== 3 | 4 | For a graphical description of the actual algorithm used to compute 5 | dendrograms, see :doc:`algorithm`. 6 | 7 | Computing a Dendrogram 8 | ---------------------- 9 | 10 | Dendrograms can be computed from an n-dimensional array using: 11 | 12 | >>> from astrodendro import Dendrogram 13 | >>> d = Dendrogram.compute(array) 14 | 15 | where ``array`` is a Numpy array and ``d`` is then an instance of the 16 | :class:`~astrodendro.dendrogram.Dendrogram` class, which can be used to access 17 | the computed dendrogram (see `Exploring the Dendrogram`_ below). Where the 18 | ``array`` comes from is not important - for example it can be read in from a 19 | FITS file, from an HDF5 file, or it can be generated in memory. If you are 20 | interested in making a dendrogram from data in a FITS file, you can do: 21 | 22 | >>> from astropy.io import fits 23 | >>> array = fits.getdata('observations.fits') 24 | >>> from astrodendro import Dendrogram 25 | >>> d = Dendrogram.compute(array) 26 | 27 | .. There should probably be a stronger/bolder warning against doing this 28 | example blindly because it will create a LOT of dendro branches. 29 | The computation may take anywhere between less than a second to several 30 | minutes depending on the size and complexity of the data. By default, the 31 | above command will compute a dendrogram where there are as many levels in the 32 | dendrograms as pixels, which is likely not what you are interested in. There 33 | are several options to control the computation of the dendrogram and can be 34 | passed to the :meth:`~astrodendro.dendrogram.Dendrogram.compute` method: 35 | 36 | * ``min_value``: the minimum value to consider in the dataset - any value 37 | lower than this will not be considered in the dendrogram. If you are working 38 | with observations, it is likely that you will want to set this to the 39 | "detection level," for example 3- or 5-sigma, so that only significant 40 | values are included in the dendrogram. By default, all values are used. 41 | 42 | * ``min_delta``: how significant a leaf has to be in order to be considered an 43 | independent entity. The significance is measured from the difference between 44 | its peak flux and the value at which it is being merged into the tree. If 45 | you are working with observational data, then you could set this to, e.g., 46 | 1-sigma, which means that any leaf that is locally less than 1-sigma tall is 47 | combined with its neighboring leaf or branch and is no longer considered a 48 | separate entity. 49 | 50 | * ``min_npix``: the minimum number of pixels/values needed for a leaf to be 51 | considered an independent entity. When the dendrogram is being computed, 52 | and when a leaf is about to be joined onto a branch or another leaf, if the 53 | leaf has fewer than this number of pixels, then it is combined with the 54 | branch or leaf it is being merged with and is no longer considered a 55 | separate entity. By default, this parameter is set to zero, so there is no 56 | minimum number of pixels required for leaves to remain independent entities. 57 | 58 | These options are illustrated graphically in :doc:`algorithm`. 59 | 60 | As an example, we can use a publicly available extinction map of the Perseus 61 | star-formation region from the The COordinated Molecular Probe Line Extinction 62 | Thermal Emission (COMPLETE) Survey of Star Forming Regions 63 | (:download:`PerA_Extn2MASS_F_Gal.fits`, originally obtained from 64 | ``_). The units of the map are magnitudes of 65 | extinction, and we want to make a dendrogram of all structures above a minimum 66 | value of 2 magnitudes, and we only consider leaves with at least 10 pixels and 67 | which have a peak to base difference larger than one magnitude of extinction:: 68 | 69 | >>> from astrodendro import Dendrogram 70 | >>> from astropy.io import fits 71 | >>> image = fits.getdata('PerA_Extn2MASS_F_Gal.fits') 72 | >>> d = Dendrogram.compute(image, min_value=2.0, min_delta=1., min_npix=10) 73 | 74 | By default, the computation will be silent, but for large dendrograms, it can 75 | be useful to have an idea of how long the computation will take:: 76 | 77 | >>> d = Dendrogram.compute(image, min_value=2.0, min_delta=1., min_npix=10, 78 | verbose=True) 79 | Generating dendrogram using 6,386 of 67,921 pixels (9% of data) 80 | [=========================> ] 64% 81 | 82 | The '9% of data' indicates that only 9% of the data are over the `min_value` 83 | threshold. 84 | 85 | Exploring the Dendrogram 86 | ------------------------ 87 | 88 | Once the dendrogram has been computed, you will want to explore/visualize it. 89 | You can access the full tree from the computed dendrogram. Assuming that you 90 | have computed a dendrogram with:: 91 | 92 | >>> d = Dendrogram.compute(array, ...) 93 | 94 | you can now access the full tree from the ``d`` variable. 95 | 96 | The first place to start is the *trunk* of the tree (the ``trunk`` attribute), 97 | which is a `list` of all the structures at the lowest level. Unless you left 98 | ``min_value`` to the default setting, which would mean that all values in the 99 | dataset are used, it's likely that not all structures are connected. So the 100 | ``trunk`` is a collection of items at the lowest level, each of which could be 101 | a leaf or a branch (itself having leaves and branches). In the case of the 102 | Perseus extinction map, we get:: 103 | 104 | >>> d.trunk 105 | [, 106 | , 107 | , 108 | ] 109 | 110 | In the above case, the trunk contains two leaves and two branches. Since 111 | ``trunk`` is just a list, you can access items in it with e.g.:: 112 | 113 | >>> d.trunk[1] 114 | 115 | 116 | Branches have a ``children`` attribute that returns a list of all 117 | sub-structures, which can include branches and leaves. Thus, we can return the 118 | sub-structures of the above branch with:: 119 | 120 | >>> d.trunk[1].children 121 | [, 122 | ] 123 | 124 | which shows that the child branch is composed of two more branches. We can 125 | therefore access the sub-structures of these branch with e.g.:: 126 | 127 | >>> d.trunk[1].children[0].children 128 | [, 129 | ] 130 | 131 | which shows this branch splitting into two leaves. 132 | 133 | We can access the properties of leaves as follows:: 134 | 135 | >>> leaf = d.trunk[1].children[0].children[0] 136 | >>> leaf.indices 137 | (array([143, 142, 142, 142, 139, 141, 141, 141, 143, 140, 140]), 138 | array([116, 114, 115, 116, 115, 114, 115, 116, 115, 115, 114])) 139 | >>> leaf.values 140 | array([ 2.7043395 , 2.57071948, 3.4551146 , 3.29953575, 2.53844047, 141 | 2.59633183, 3.11309052, 2.70936489, 2.81024122, 2.76864815, 142 | 2.52840114], dtype=float32) 143 | 144 | A full list of attributes and methods for leaves and branches (i.e. structures) 145 | is available from the :class:`~astrodendro.structure.Structure` page, while a 146 | list of attributes and methods for the dendrogram itself is available from the 147 | :class:`~astrodendro.dendrogram.Dendrogram` page. 148 | 149 | Saving and loading the dendrogram 150 | --------------------------------- 151 | 152 | A :class:`~astrodendro.dendrogram.Dendrogram` object can be exported to an HDF5 file (requires h5py) or FITS file (requires astropy). To export the 153 | dendrogram to a file, use:: 154 | 155 | >>> d.save_to('my_dendrogram.hdf5') 156 | 157 | or:: 158 | 159 | >>> d.save_to('my_dendrogram.fits') 160 | 161 | and to load and existing dendrogram:: 162 | 163 | >>> d = Dendrogram.load_from('my_other_dendrogram.hdf5') 164 | 165 | or:: 166 | 167 | >>> d = Dendrogram.load_from('my_other_dendrogram.fits') 168 | 169 | If you wish, you can use this to separate the computation and analysis of the 170 | dendrogram into two scripts, to ensure that the dendrogram is only computed 171 | once. For example, you could have a script ``compute.py`` that contains:: 172 | 173 | from astropy.io import fits 174 | from astrodendro import Dendrogram 175 | 176 | array = fits.getdata('observations.fits') 177 | d = Dendrogram.compute(array) 178 | d.save_to('dendrogram.fits') 179 | 180 | and a second file containing:: 181 | 182 | from astrodendro import Dendrogram 183 | d = Dendrogram.load_from('dendrogram.fits') 184 | 185 | # any analysis code here 186 | -------------------------------------------------------------------------------- /astrodendro/plot.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | import numpy as np 4 | 5 | 6 | class DendrogramPlotter(object): 7 | 8 | """ 9 | A class to plot a dendrogram object. 10 | """ 11 | 12 | def __init__(self, dendrogram): 13 | # should we copy to ensure immutability? 14 | self.dendrogram = dendrogram 15 | self._cached_positions = None 16 | self.sort() 17 | 18 | def set_custom_positions(self, custom_position): 19 | """ 20 | Manually set the positon on the structures for plotting. 21 | 22 | Parameters 23 | ---------- 24 | custom_position : function 25 | This should be a function that takes a 26 | `~astrodendro.structure.Structure`returns the position of the 27 | leaves to use for plotting. If the dataset has more than one 28 | dimension, using this may cause lines to cross. If this is used, 29 | then ``sort_key`` and ``reverse`` are ignored. 30 | """ 31 | self._cached_positions = {} 32 | for structure in self.dendrogram.all_structures: 33 | self._cached_positions[structure] = custom_position(structure) 34 | 35 | def sort(self, sort_key=None, reverse=False): 36 | """ 37 | Sort the position of the leaves for plotting. 38 | 39 | Parameters 40 | ---------- 41 | sort_key : function, optional 42 | This should be a function that takes a 43 | `~astrodendro.structure.Structure` and returns a scalar that is 44 | then used to sort the leaves. If not specified, the leaves are 45 | sorted according to their peak value. 46 | reverse : bool, optional 47 | Whether to reverse the sorting 48 | """ 49 | 50 | if sort_key is None: 51 | def sort_key(s): return s.get_peak(subtree=True)[1] 52 | 53 | sorted_trunk_structures = sorted(self.dendrogram.trunk, key=sort_key, reverse=reverse) 54 | 55 | positions = {} 56 | x = 0 # the first index for each trunk structure 57 | for structure in sorted_trunk_structures: 58 | 59 | # Get sorted leaves 60 | sorted_leaves = structure.sorted_leaves(subtree=True, reverse=reverse) 61 | 62 | # Loop over leaves and assign positions 63 | for leaf in sorted_leaves: 64 | positions[leaf] = x 65 | x += 1 66 | 67 | # Sort structures from the top-down 68 | sorted_structures = sorted(structure.descendants, key=lambda s: s.level, reverse=True) + [structure] 69 | 70 | # Loop through structures and assing position of branches as the mean 71 | # of the leaves 72 | for structure in sorted_structures: 73 | if not structure.is_leaf: 74 | positions[structure] = np.mean([positions[child] for child in structure.children]) 75 | 76 | self._cached_positions = positions 77 | 78 | def plot_tree(self, ax, structure=None, subtree=True, autoscale=True, **kwargs): 79 | """ 80 | Plot the dendrogram tree or a substructure. 81 | 82 | Parameters 83 | ---------- 84 | ax : :class:`~matplotlib.axes.Axes` instance 85 | The Axes inside which to plot the dendrogram 86 | structure : int or `~astrodendro.structure.Structure`, optional 87 | If specified, only plot this structure. This can be either the 88 | structure object itself, or the ID (``idx``) of the structure. 89 | subtree : bool, optional 90 | If a structure is specified, by default the whole subtree will be 91 | plotted, but this can be disabled with this option. 92 | autoscale : bool, optional 93 | Whether to automatically adapt the window limits to the tree 94 | 95 | Notes 96 | ----- 97 | Any additional keyword arguments are passed to 98 | `~matplotlib.collections.LineCollection` and can be used to control the 99 | appearance of the plot. 100 | """ 101 | 102 | # Get the lines for the dendrogram 103 | lines = self.get_lines(structures=structure, **kwargs) 104 | 105 | # Add the lines to the axes 106 | ax.add_collection(lines) 107 | 108 | # Auto-scale axes (doesn't happen by default with ``add_collection``) 109 | if autoscale: 110 | ax.margins(0.05) 111 | ax.autoscale_view(True, True, True) 112 | 113 | def plot_contour(self, ax, structure=None, subtree=True, slice=None, **kwargs): 114 | """ 115 | Plot a contour outlining all pixels in the dendrogram, or a specific. 116 | structure. 117 | 118 | Parameters 119 | ---------- 120 | ax : :class:`~matplotlib.axes.Axes` instance 121 | The Axes inside which to plot the dendrogram 122 | structure : int or `~astrodendro.structure.Structure`, optional 123 | If specified, only plot this structure. This can be either the 124 | structure object itself, or the ID (``idx``) of the structure. 125 | subtree : bool, optional 126 | If a structure is specified, by default the whole subtree will be 127 | plotted, but this can be disabled with this option. 128 | slice : int, optional 129 | If dealing with a 3-d cube, the slice at which to plot the contour. 130 | If not set, the slice containing the peak of the structure will be 131 | shown 132 | 133 | Notes 134 | ----- 135 | Any additional keyword arguments are passed to 136 | `~matplotlib.axes.Axes.contour` and can be used to control the 137 | appearance of the plot. 138 | 139 | """ 140 | if self.dendrogram.data.ndim not in [2, 3]: 141 | raise ValueError("plot_data can only be used with 2- or 3-dimensional data") 142 | 143 | if structure is None: 144 | mask = self.dendrogram.data > self.dendrogram.params['min_value'] 145 | else: 146 | if type(structure) is int: 147 | structure = self.dendrogram[structure] 148 | mask = structure.get_mask(subtree=subtree) 149 | if self.dendrogram.data.ndim == 3: 150 | if slice is None: 151 | peak_index = structure.get_peak(subtree=subtree) 152 | slice = peak_index[0][0] 153 | mask = mask[slice, :, :] 154 | 155 | # fix a common mistake when trying to set the color of contours 156 | if 'color' in kwargs and 'colors' not in kwargs: 157 | kwargs['colors'] = kwargs['color'] 158 | 159 | ax.contour(mask, levels=[0.5], **kwargs) 160 | 161 | def get_lines(self, structures=None, subtree=True, **kwargs): 162 | """ 163 | Get a collection of lines to draw the dendrogram. 164 | 165 | Parameters 166 | ---------- 167 | structures : :class:`~astrodendro.structure.Structure` 168 | The structures to plot. If not set, the whole tree will be plotted. 169 | subtree : bool, optional 170 | If a structure is specified, by default the whole subtree will be 171 | retrieved, but this can be disabled with this option. 172 | 173 | Returns 174 | ------- 175 | lines : :class:`~astrodendro.structure_collection.StructureCollection` 176 | The lines (sub-class of LineCollection) which can be directly used in Matplotlib 177 | 178 | Notes 179 | ----- 180 | Any additional keyword arguments are passed to the 181 | `~matplotlib.collections.LineCollection` class. 182 | """ 183 | 184 | if self._cached_positions is None: 185 | raise Exception("Leaves have not yet been sorted") 186 | 187 | # Case 1: no structures are selected 188 | if structures is None: 189 | structures = list(self.dendrogram.all_structures) 190 | # Case 2: one structure is selected, and subtree is True 191 | else: 192 | if subtree: 193 | if isinstance(structures, int): 194 | structures = [structures] 195 | if type(structures[0]) is int: 196 | structure = self.dendrogram[structures[0]] 197 | else: 198 | structure = structures[0] 199 | structures = structure.descendants + [structure] 200 | # Case 3: subtree is False (do nothing special to `structures`) 201 | 202 | lines = [] 203 | mapping = [] 204 | for s in structures: 205 | x = self._cached_positions[s] 206 | bot = s.parent.height if s.parent is not None else s.vmin 207 | top = s.height 208 | lines.append(([x, bot], [x, top])) 209 | mapping.append(s) 210 | if s.is_branch: 211 | pc = [self._cached_positions[c] for c in s.children] 212 | lines.append(([min(pc), top], [max(pc), top])) 213 | mapping.append(s) 214 | 215 | from .structure_collection import StructureCollection 216 | sc = StructureCollection(lines, **kwargs) 217 | sc.structures = mapping 218 | return sc 219 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # astrodendro documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Jun 13 14:25:14 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['matplotlib.sphinxext.plot_directive', 29 | 'sphinx.ext.autodoc', 30 | 'sphinx.ext.intersphinx', 31 | 'numpydoc', 32 | 'sphinx_automodapi.automodapi', 33 | 'sphinx_automodapi.smart_resolver' 34 | ] 35 | 36 | autosummary_generate = True 37 | numpydoc_show_class_members = False 38 | autoclass_content = 'class' 39 | 40 | plot_template = """ 41 | {{ source_code }} 42 | 43 | {{ only_html }} 44 | 45 | {% for img in images %} 46 | .. image:: {{ build_dir }}/{{ img.basename }}.png 47 | {%- for option in options %} 48 | {{ option }} 49 | {% endfor %} 50 | {% endfor %} 51 | 52 | {{ only_latex }} 53 | 54 | {% for img in images %} 55 | .. image:: {{ build_dir }}/{{ img.basename }}.pdf 56 | {% endfor %} 57 | 58 | {{ only_texinfo }} 59 | 60 | {% for img in images %} 61 | .. image:: {{ build_dir }}/{{ img.basename }}.png 62 | {%- for option in options %} 63 | {{ option }} 64 | {% endfor %} 65 | 66 | {% endfor %} 67 | 68 | """ 69 | 70 | # Add any paths that contain templates here, relative to this directory. 71 | templates_path = ['_templates'] 72 | 73 | # The suffix of source filenames. 74 | source_suffix = '.rst' 75 | 76 | # The encoding of source files. 77 | #source_encoding = 'utf-8-sig' 78 | 79 | # The master toctree document. 80 | master_doc = 'index' 81 | 82 | # General information about the project. 83 | project = u'astrodendro' 84 | copyright = u'2013, Thomas Robitaille, Chris Beaumont, Braden McDonald, and Erik Rosolowsky' 85 | 86 | # The version info for the project you're documenting, acts as replacement for 87 | # |version| and |release|, also used in various other places throughout the 88 | # built documents. 89 | # 90 | # The short X.Y version. 91 | version = '0.3.0.dev' 92 | # The full version, including alpha/beta/rc tags. 93 | release = '0.3.0.dev' 94 | 95 | # The language for content autogenerated by Sphinx. Refer to documentation 96 | # for a list of supported languages. 97 | #language = None 98 | 99 | # There are two options for replacing |today|: either, you set today to some 100 | # non-false value, then it is used: 101 | #today = '' 102 | # Else, today_fmt is used as the format for a strftime call. 103 | #today_fmt = '%B %d, %Y' 104 | 105 | # List of patterns, relative to source directory, that match files and 106 | # directories to ignore when looking for source files. 107 | exclude_patterns = ['_build', '_templates'] 108 | 109 | # The reST default role (used for this markup: `text`) to use for all documents. 110 | #default_role = None 111 | 112 | # If true, '()' will be appended to :func: etc. cross-reference text. 113 | #add_function_parentheses = True 114 | 115 | # If true, the current module name will be prepended to all description 116 | # unit titles (such as .. function::). 117 | #add_module_names = True 118 | 119 | # If true, sectionauthor and moduleauthor directives will be shown in the 120 | # output. They are ignored by default. 121 | #show_authors = False 122 | 123 | # The name of the Pygments (syntax highlighting) style to use. 124 | pygments_style = 'sphinx' 125 | 126 | # A list of ignored prefixes for module index sorting. 127 | #modindex_common_prefix = [] 128 | 129 | 130 | # -- Options for HTML output --------------------------------------------------- 131 | 132 | # The theme to use for HTML and HTML Help pages. See the documentation for 133 | # a list of builtin themes. 134 | html_theme = 'default' 135 | 136 | # Theme options are theme-specific and customize the look and feel of a theme 137 | # further. For a list of options available for each theme, see the 138 | # documentation. 139 | #html_theme_options = {} 140 | 141 | # Add any paths that contain custom themes here, relative to this directory. 142 | #html_theme_path = [] 143 | 144 | # The name for this set of Sphinx documents. If None, it defaults to 145 | # " v documentation". 146 | #html_title = None 147 | 148 | # A shorter title for the navigation bar. Default is the same as html_title. 149 | #html_short_title = None 150 | 151 | # The name of an image file (relative to this directory) to place at the top 152 | # of the sidebar. 153 | #html_logo = None 154 | 155 | # The name of an image file (within the static path) to use as favicon of the 156 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 157 | # pixels large. 158 | #html_favicon = None 159 | 160 | # Add any paths that contain custom static files (such as style sheets) here, 161 | # relative to this directory. They are copied after the builtin static files, 162 | # so a file named "default.css" will overwrite the builtin "default.css". 163 | # html_static_path = ['_static'] 164 | html_static_path = [] 165 | 166 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 167 | # using the given strftime format. 168 | #html_last_updated_fmt = '%b %d, %Y' 169 | 170 | # If true, SmartyPants will be used to convert quotes and dashes to 171 | # typographically correct entities. 172 | #html_use_smartypants = True 173 | 174 | # Custom sidebar templates, maps document names to template names. 175 | #html_sidebars = {} 176 | 177 | # Additional templates that should be rendered to pages, maps page names to 178 | # template names. 179 | #html_additional_pages = {} 180 | 181 | # If false, no module index is generated. 182 | #html_domain_indices = True 183 | 184 | # If false, no index is generated. 185 | #html_use_index = True 186 | 187 | # If true, the index is split into individual pages for each letter. 188 | #html_split_index = False 189 | 190 | # If true, links to the reST sources are added to the pages. 191 | #html_show_sourcelink = True 192 | 193 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 194 | #html_show_sphinx = True 195 | 196 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 197 | #html_show_copyright = True 198 | 199 | # If true, an OpenSearch description file will be output, and all pages will 200 | # contain a tag referring to it. The value of this option must be the 201 | # base URL from which the finished HTML is served. 202 | #html_use_opensearch = '' 203 | 204 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 205 | #html_file_suffix = None 206 | 207 | # Output file base name for HTML help builder. 208 | htmlhelp_basename = 'astrodendrodoc' 209 | 210 | 211 | # -- Options for LaTeX output -------------------------------------------------- 212 | 213 | latex_elements = { 214 | # The paper size ('letterpaper' or 'a4paper'). 215 | #'papersize': 'letterpaper', 216 | 217 | # The font size ('10pt', '11pt' or '12pt'). 218 | #'pointsize': '10pt', 219 | 220 | # Additional stuff for the LaTeX preamble. 221 | #'preamble': '', 222 | } 223 | 224 | # Grouping the document tree into LaTeX files. List of tuples 225 | # (source start file, target name, title, author, documentclass [howto/manual]). 226 | latex_documents = [ 227 | ('index', 'astrodendro.tex', u'astrodendro Documentation', 228 | u'Thomas Robitaille, Braden McDonald, Chris Beaumont, Erik Rosolowsky', 'manual'), 229 | ] 230 | 231 | # The name of an image file (relative to this directory) to place at the top of 232 | # the title page. 233 | #latex_logo = None 234 | 235 | # For "manual" documents, if this is true, then toplevel headings are parts, 236 | # not chapters. 237 | #latex_use_parts = False 238 | 239 | # If true, show page references after internal links. 240 | #latex_show_pagerefs = False 241 | 242 | # If true, show URL addresses after external links. 243 | #latex_show_urls = False 244 | 245 | # Documents to append as an appendix to all manuals. 246 | #latex_appendices = [] 247 | 248 | # If false, no module index is generated. 249 | #latex_domain_indices = True 250 | 251 | 252 | # -- Options for manual page output -------------------------------------------- 253 | 254 | # One entry per manual page. List of tuples 255 | # (source start file, name, description, authors, manual section). 256 | man_pages = [ 257 | ('index', 'astrodendro', u'astrodendro Documentation', 258 | [u'Thomas Robitaille, Braden McDonald, Chris Beaumont, Erik Rosolowsky'], 1) 259 | ] 260 | 261 | # If true, show URL addresses after external links. 262 | #man_show_urls = False 263 | 264 | 265 | # -- Options for Texinfo output ------------------------------------------------ 266 | 267 | # Grouping the document tree into Texinfo files. List of tuples 268 | # (source start file, target name, title, author, 269 | # dir menu entry, description, category) 270 | texinfo_documents = [ 271 | ('index', 'astrodendro', u'astrodendro Documentation', 272 | u'Thomas Robitaille, Braden McDonald, Chris Beaumont, Erik Rosolowsky', 'astrodendro', 'One line description of project.', 273 | 'Miscellaneous'), 274 | ] 275 | 276 | # Documents to append as an appendix to all manuals. 277 | #texinfo_appendices = [] 278 | 279 | # If false, no module index is generated. 280 | #texinfo_domain_indices = True 281 | 282 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 283 | #texinfo_show_urls = 'footnote' 284 | 285 | 286 | # Example configuration for intersphinx: refer to the Python standard library. 287 | intersphinx_mapping = { 288 | 'python': ('https://docs.python.org/3/', 289 | (None, 'http://data.astropy.org/intersphinx/python3.inv')), 290 | 'numpy': ('https://numpy.org/doc/stable/', 291 | (None, 'http://data.astropy.org/intersphinx/numpy.inv')), 292 | 'matplotlib': ('https://matplotlib.org/stable/', 293 | (None, 'http://data.astropy.org/intersphinx/matplotlib.inv')), 294 | 'astropy': ('https://docs.astropy.org/en/stable/', None) 295 | } 296 | 297 | nitpicky = True -------------------------------------------------------------------------------- /docs/plotting.rst: -------------------------------------------------------------------------------- 1 | Plotting Dendrograms 2 | ==================== 3 | 4 | Once you have computed a dendrogram, you will likely want to plot it as well as 5 | over-plot the structures on your original image. 6 | 7 | Interactive Visualization 8 | ------------------------- 9 | 10 | One you have computed your dendrogram, the easiest way to view it interactively 11 | is to use the :meth:`~astrodendro.dendrogram.Dendrogram.viewer` method:: 12 | 13 | d = Dendrogram.compute(...) 14 | v = d.viewer() 15 | v.show() 16 | 17 | This will launch an interactive window showing the original data, and the 18 | dendrogram itself. Note that the viewer is only available for 2 or 3-d 19 | datasets. The main window will look like this: 20 | 21 | .. image:: viewer_screenshot.png 22 | :width: 100% 23 | 24 | Within the viewer, you can: 25 | 26 | **Highlight structures:** either click on structures in the dendrogram to 27 | highlight them, which will also show them in the image view on the left, or 28 | click on pixels in the image and have the corresponding structure be 29 | highlighted in the dendrogram plot. Clicking on a branch in the dendrogram plot 30 | or in the image will highlight that branch and all sub-structures. 31 | 32 | Multiple structures can be highlighted in different colors using the three 33 | mouse buttons: Mouse button 1 (Left-click or "regular" click), button 2 34 | (Middle-click or "alt+click"), and button 3 (Right-click/"ctrl+click"). 35 | Each selection is independent of the other two; any of the three can be 36 | selected either by clicking on the image or the dendrogram. 37 | 38 | **Change the image stretch:** use the ``vmin`` and ``vmax`` sliders above the 39 | image to change the lower and upper level of the image stretch. 40 | 41 | **Change slice in a 3-d cube:** if you select a structure in the dendrogram for 42 | a 3-d cube, the cube will automatically change to the slice containing the peak 43 | pixel of the structure (including sub-structures). However, you can also change 44 | slice manually by using the ``slice`` slider. 45 | 46 | **View the selected structure ID:** in a computed dendrogram, every structure 47 | has a unique integer ID (the ``.idx`` attribute) that can be used to recognize 48 | the identify the structure when computing catalogs or making plots manually 49 | (see below). 50 | 51 | **Display astronomical coordinates:** 52 | If your data has an associated WCS object (for example, if you loaded your data 53 | from a FITS file with astronomical coordinate information), the interactive viewer 54 | will display the coordinates using ``wcsaxes``:: 55 | 56 | from astropy.io.fits import getdata 57 | from astropy import wcs 58 | 59 | data, header = getdata('astrodendro/docs/PerA_Extn2MASS_F_Gal.fits', header=True) 60 | wcs = wcs.WCS(header) 61 | d = astrodendro.Dendrogram.compute(data, wcs=wcs) 62 | v = d.viewer() 63 | v.show() 64 | 65 | .. image:: wcsaxes_docs_screenshot.png 66 | 67 | Note that this functionality requires that the ``wcsaxes`` package is installed. 68 | Installation instructions can be found here: 69 | http://wcsaxes.readthedocs.org/en/latest/ 70 | 71 | **Linked scatter plots:** 72 | If you have built a catalog (see :doc:`catalog`), you can also 73 | display a scatterplot of two catalog columns, linked to the viewer. 74 | The available catalog columns can be accessed as ```catalog.colnames```. 75 | Selections in the main viewer update the colors of the points in this plot:: 76 | 77 | from astrodendro.scatter import Scatter 78 | ... code to create a dendrogram (d) and catalog ... 79 | dv = d.viewer() 80 | ds = Scatter(d, dv.hub, catalog, 'radius', 'v_rms') 81 | dv.show() 82 | 83 | The catalog properties of dendrogram structures will be plotted here. You can 84 | select structures directly from the scatter plot by clicking and dragging a 85 | lasso, and the selected structures will be highlighted in other plots: 86 | 87 | .. image:: scatter_screenshot.png 88 | :width: 50% 89 | 90 | .. image:: scatter_selected_viewer_screenshot.png 91 | :width: 80% 92 | 93 | To set logarithmic scaling on either the x axis, the y axis, or both, 94 | the following convenience methods are defined:: 95 | 96 | ds.set_semilogx() 97 | ds.set_semilogy() 98 | ds.set_loglog() 99 | 100 | # To unset logarithmic scaling, pass `log=False` to the above methods, i.e. 101 | ds.set_loglog(False) 102 | 103 | Making plots for publications 104 | ----------------------------- 105 | 106 | While the viewer is useful for exploring the dendrogram, it does not allow one 107 | to produce publication-quality plots. For this, you can use the non-interactive 108 | plotting interface. To do this, you can first use the 109 | :meth:`~astrodendro.dendrogram.Dendrogram.plotter` method to provide a plotting 110 | tool:: 111 | 112 | d = Dendrogram.compute(...) 113 | p = d.plotter() 114 | 115 | and then use this to make the plot you need. The following complete example 116 | shows how to make a plot of the dendrogram of the extinction map of the Perseus 117 | region (introduced in :doc:`using`) using 118 | :meth:`~astrodendro.plot.DendrogramPlotter.plot_tree`, highlighting two of the 119 | main branches: 120 | 121 | .. plot:: 122 | :include-source: 123 | 124 | import matplotlib.pyplot as plt 125 | from astropy.io import fits 126 | from astrodendro import Dendrogram 127 | 128 | image = fits.getdata('PerA_Extn2MASS_F_Gal.fits') 129 | d = Dendrogram.compute(image, min_value=2.0, min_delta=1., min_npix=10) 130 | p = d.plotter() 131 | 132 | fig = plt.figure() 133 | ax = fig.add_subplot(1, 1, 1) 134 | 135 | # Plot the whole tree 136 | p.plot_tree(ax, color='black') 137 | 138 | # Highlight two branches 139 | p.plot_tree(ax, structure=8, color='red', lw=2, alpha=0.5) 140 | p.plot_tree(ax, structure=24, color='orange', lw=2, alpha=0.5) 141 | 142 | # Add axis labels 143 | ax.set_xlabel("Structure") 144 | ax.set_ylabel("Flux") 145 | 146 | You can find out the structure ID you need either from the interactive viewer 147 | presented above, or programmatically by accessing the ``idx`` attribute of a 148 | Structure. 149 | 150 | A :meth:`~astrodendro.plot.DendrogramPlotter.plot_contour` method is also 151 | provided to outline the contours of structures. Calling 152 | :meth:`~astrodendro.plot.DendrogramPlotter.plot_contour` without any arguments 153 | results in a contour corresponding to the value of ``min_value`` used being 154 | shown. 155 | 156 | .. plot:: 157 | :include-source: 158 | 159 | import matplotlib.pyplot as plt 160 | from astropy.io import fits 161 | from astrodendro import Dendrogram 162 | 163 | image = fits.getdata('PerA_Extn2MASS_F_Gal.fits') 164 | d = Dendrogram.compute(image, min_value=2.0, min_delta=1., min_npix=10) 165 | p = d.plotter() 166 | 167 | fig = plt.figure() 168 | ax = fig.add_subplot(1, 1, 1) 169 | ax.imshow(image, origin='lower', interpolation='nearest', cmap=plt.cm.Blues, vmax=4.0) 170 | 171 | # Show contour for ``min_value`` 172 | p.plot_contour(ax, color='black') 173 | 174 | # Highlight two branches 175 | p.plot_contour(ax, structure=8, lw=3, colors='red') 176 | p.plot_contour(ax, structure=24, lw=3, colors='orange') 177 | 178 | Plotting contours of structures in third-party packages 179 | ------------------------------------------------------- 180 | 181 | In some cases you may want to plot the contours in third party packages such as 182 | `APLpy `_ or `DS9 183 | `_. For these cases, the best 184 | approach is to output FITS files with a mask of the structures to plot (one 185 | mask file per contour color you want to show). 186 | 187 | Let's first take the plot above and make a contour plot in APLpy outlining all the leaves. We can use the :meth:`~astrodendro.structure.Structure.get_mask` method to retrieve the footprint of a given structure: 188 | 189 | .. plot:: 190 | :include-source: 191 | 192 | import aplpy 193 | import numpy as np 194 | import matplotlib.pyplot as plt 195 | from astropy.io import fits 196 | from astrodendro import Dendrogram 197 | 198 | hdu = fits.open('PerA_Extn2MASS_F_Gal.fits')[0] 199 | d = Dendrogram.compute(hdu.data, min_value=2.0, min_delta=1., min_npix=10) 200 | 201 | # Create empty mask. For each leaf we do an 'or' operation with the mask so 202 | # that any pixel corresponding to a leaf is set to True. 203 | mask = np.zeros(hdu.data.shape, dtype=bool) 204 | for leaf in d.leaves: 205 | mask = mask | leaf.get_mask() 206 | 207 | # Now we create a FITS HDU object to contain this, with the correct header 208 | mask_hdu = fits.PrimaryHDU(mask.astype('short'), hdu.header) 209 | 210 | # We then use APLpy to make the final plot 211 | fig = aplpy.FITSFigure(hdu, figsize=(8, 6)) 212 | fig.show_colorscale(cmap='Blues', vmax=4.0) 213 | fig.show_contour(mask_hdu, colors='red', linewidths=0.5) 214 | fig.tick_labels.set_xformat('dd') 215 | fig.tick_labels.set_yformat('dd') 216 | 217 | Now let's take the example from `Making plots for publications`_ and try and 218 | reproduce the same plot. As described there, one way to find interesting 219 | structures in the dendrogram is to use the `Interactive Visualization`_ tool. 220 | This tool will give the ID of a structure as an integer (``idx``). 221 | 222 | Because we are starting from this ID rather than a 223 | :class:`~astrodendro.structure.Structure` object, we need to first get the 224 | structure, which can be done with:: 225 | 226 | structure = d[idx] 227 | 228 | where ``d`` is a :class:`~astrodendro.dendrogram.Dendrogram` instance. We also 229 | want to create a different mask for each contour so as to have complete control 230 | over the colors: 231 | 232 | .. plot:: 233 | :include-source: 234 | 235 | import aplpy 236 | from astropy.io import fits 237 | from astrodendro import Dendrogram 238 | 239 | hdu = fits.open('PerA_Extn2MASS_F_Gal.fits')[0] 240 | d = Dendrogram.compute(hdu.data, min_value=2.0, min_delta=1., min_npix=10) 241 | 242 | # Find the structures 243 | structure_08 = d[8] 244 | structure_24 = d[24] 245 | 246 | # Extract the masks 247 | mask_08 = structure_08.get_mask() 248 | mask_24 = structure_24.get_mask() 249 | 250 | # Create FITS HDU objects to contain the masks 251 | mask_hdu_08 = fits.PrimaryHDU(mask_08.astype('short'), hdu.header) 252 | mask_hdu_24 = fits.PrimaryHDU(mask_24.astype('short'), hdu.header) 253 | 254 | # Use APLpy to make the final plot 255 | fig = aplpy.FITSFigure(hdu, figsize=(8, 6)) 256 | fig.show_colorscale(cmap='Blues', vmax=4.0) 257 | fig.show_contour(hdu, levels=[2.0], colors='black', linewidths=0.5) 258 | fig.show_contour(mask_hdu_08, colors='red', linewidths=0.5) 259 | fig.show_contour(mask_hdu_24, colors='orange', linewidths=0.5) 260 | fig.tick_labels.set_xformat('dd') 261 | fig.tick_labels.set_yformat('dd') 262 | -------------------------------------------------------------------------------- /astrodendro/tests/test_compute.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | from .build_benchmark import BENCHMARKS 4 | import numpy as np 5 | import pytest 6 | 7 | from .. import Dendrogram, periodic_neighbours 8 | 9 | 10 | class Test2DimensionalData(object): 11 | 12 | def test_dendrogramWithNan(self): 13 | 14 | n = np.nan 15 | data = np.array([[n, n, n, n, n, n, n, n], 16 | [n, 4, n, n, n, n, n, n], 17 | [n, n, n, 1, n, n, 0, 5], 18 | [3, n, n, 2, 3, 2, 0, n]]) 19 | d = Dendrogram.compute(data) 20 | 21 | ######################################## 22 | # Check the trunk elements: 23 | 24 | leaves = [structure for structure in d.trunk if structure.is_leaf] 25 | branches = [structure for structure in d.trunk if structure not in leaves] 26 | 27 | assert len(leaves) == 2, "We expect two leaves among the lowest structures (the trunk)" 28 | assert len(branches) == 1, "We expect one branch among the lowest structures (the trunk)" 29 | 30 | for leaf in leaves: 31 | assert len(leaf.values(subtree=False)) == 1, "Leaves in the trunk are only expected to contain one point" 32 | assert leaf.parent is None 33 | assert leaf.ancestor == leaf 34 | assert leaf.get_npix() == 1 35 | if leaf.values(subtree=False)[0] == 4: 36 | assert list(zip(*leaf.indices(subtree=False)))[0] == (1, 1) 37 | elif leaf.values(subtree=False)[0] == 3: 38 | assert list(zip(*leaf.indices(subtree=False)))[0] == (3, 0) 39 | else: 40 | self.fail("Invalid value of flux in one of the leaves") 41 | 42 | ######################################## 43 | # Check properties of the branch: 44 | branch = branches[0] 45 | assert branch.parent is None 46 | assert branch.ancestor == branch 47 | assert branch.get_npix(subtree=False) == 1 # only pixel is a 0 48 | assert branch.get_npix(subtree=True) == 7 49 | 50 | assert len(branch.children) == 2 51 | for leaf in branch.children: 52 | assert leaf.is_leaf 53 | assert leaf.ancestor == branch 54 | assert leaf.parent == branch 55 | if 5 in leaf.values(subtree=False): 56 | assert sum(leaf.values(subtree=False)) == 5 57 | elif 3 in leaf.values(subtree=False): 58 | assert sum(leaf.values(subtree=False)) == 1 + 2 + 3 + 2 59 | else: 60 | self.fail("Invalid child of the branch") 61 | 62 | def test_mergeLevelAndHeight(self): 63 | n = np.nan 64 | data = np.array([[n, n, n, n, n, ], 65 | [n, 4, 2, 5, n, ], 66 | [n, n, n, n, 0, ]]) 67 | d = Dendrogram.compute(data) 68 | branch, leaf4, leaf5 = d.trunk[0], d.structure_at((1, 1)), d.structure_at((1, 3)) 69 | assert leaf4.height == 4. 70 | assert leaf5.height == 5. 71 | assert branch.height == 4. 72 | 73 | def test_dendrogramWithConstBackground(self): 74 | # Test a highly artificial array containing a lot of equal pixels 75 | data = np.array([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 76 | [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 77 | [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 78 | [1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 79 | [1, 1, 3, 5, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1], 80 | [1, 1, 2, 3, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1], 81 | [1, 1, 1, 1, 3, 4, 3, 1, 1, 1, 1, 1, 1, 1], 82 | [1, 1, 1, 1, 2, 3, 2, 1, 1, 1, 1, 1, 1, 1], 83 | [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 84 | [1, 1, 1, 1, 2, 3, 2, 1, 1, 1, 1, 1, 1, 1], 85 | [1, 1, 1, 1, 3, 4, 3, 1, 1, 2, 2, 1, 1, 1], 86 | [1, 1, 1, 1, 2, 3, 2, 1, 1, 1, 1, 1, 1, 1], 87 | [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 88 | [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ]) 89 | d = Dendrogram.compute(data) 90 | assert len(d) <= 7 91 | # Some of the '1' valued pixels get included with the leaves and branches, 92 | # hence number of structures is currently 7 and not 6 as expected. 93 | # Fixing this is probably more trouble than it's worth. 94 | leaf_with_twos = d.structure_at((10, 9)) 95 | assert leaf_with_twos.height == 2 96 | 97 | # Check that all structures contain a reference to the dendrogram 98 | for structure in d: 99 | assert structure._dendrogram is d 100 | 101 | 102 | class Test3DimensionalData(object): 103 | def setup_method(self, method): 104 | from ._testdata import data 105 | self.data = data 106 | 107 | def test_dendrogramComputation(self): 108 | d = Dendrogram.compute(self.data, min_npix=8, min_delta=0.3, min_value=1.4) 109 | 110 | # This data with these parameters should produce 55 leaves 111 | assert len(d.leaves) == 55 112 | 113 | # Now check every pixel in the data cube (this takes a while). 114 | st_map = -np.ones(self.data.shape, dtype=int) 115 | for st in d.all_structures: 116 | st_map[st.indices(subtree=False)] = st.idx 117 | 118 | # check that vmin/vmax/peak are correct 119 | for st in d.all_structures: 120 | assert st.vmin == self.data[st.indices(subtree=False)].min() 121 | assert st.vmax == self.data[st.indices(subtree=False)].max() 122 | pk_exp = self.data[st.indices(subtree=True)].max() 123 | 124 | ind, pk = st.get_peak(subtree=True) 125 | assert self.data[ind] == pk 126 | assert pk_exp == pk 127 | 128 | # The "right" way to do this is loop through indices, 129 | # and repeatedly call structure_at(). However, this is quite slow 130 | # structure_at is a thin wrapper around index_map, 131 | # and we compare index_map to st_map instead 132 | np.testing.assert_array_equal(st_map, d.index_map) 133 | 134 | # here, we test a few values of structure_at 135 | for coord in np.indices(self.data.shape).reshape(self.data.ndim, np.prod(self.data.shape)).transpose()[::100]: 136 | coord = tuple(coord) 137 | structure = d.structure_at(coord) 138 | if structure is not None: 139 | assert structure.idx == st_map[coord], "Pixel at {0} is claimed to be part of {1}, but that structure does not contain the coordinate {0}!".format(coord, structure) 140 | else: 141 | assert st_map[coord] == -1 142 | 143 | 144 | class TestNDimensionalData(object): 145 | def test_4dim(self): 146 | " Test 4-dimensional data " 147 | data = np.zeros((5, 5, 5, 5)) # Create a 5x5x5x5 array initialized to zero 148 | # N-dimensional data is hard to conceptualize so I've kept this simple. 149 | # Create a local maximum (value 5) at the centre 150 | data[2, 2, 2, 2] = 5 151 | # add some points around it with value 3. Note that '1:4:2' is equivalent to saying indices '1' and '3' 152 | data[2, 1:4:2, 2, 2] = data[2, 2, 1:4:2, 2] = data[2, 2, 2, 1:4:2] = 3 153 | # Add a trail of points of value 2 connecting one of those 3s to a 4 154 | data[0:3, 0, 2, 2] = 2 # Sets [0, 0, 2, 2], [1, 0, 2, 2], and [2, 0, 2, 2] all equal to 2 -> will connect to the '3' at [2, 1, 2, 2] 155 | data[0, 0, 2, 1] = 4 156 | 157 | # Now dendrogram it: 158 | d = Dendrogram.compute(data, min_value=1) 159 | # We expect two leaves: 160 | leaves = d.leaves 161 | assert len(leaves) == 2 162 | # We expect one branch: 163 | branches = [i for i in d.all_structures if i.is_branch] 164 | assert len(branches) == 1 165 | assert len(d.trunk) == 1 166 | assert d.trunk[0] == branches[0] 167 | 168 | # The maxima of each leaf should be at [2,2,2,2] and [0,3,2,1] 169 | for leaf in leaves: 170 | assert leaf.get_peak() in (((2, 2, 2, 2), 5.), ((0, 0, 2, 1), 4.)) 171 | assert leaves[0].get_peak() != leaves[1].get_peak() 172 | 173 | # Check out a few more properties of the leaf around the global maximum: 174 | leaf = d.structure_at((2, 2, 2, 2)) 175 | assert leaf.vmax == 5 176 | assert leaf.vmin == 2 177 | assert leaf.get_npix() == 1 + 6 + 2 # Contains 1x '5', 6x '3', and 2x '2'. The other '2' should be in the branch 178 | # Check that the only pixel in the branch is a '2' at [0,0,2,2] 179 | assert (list(zip(*branches[0].indices(subtree=False))), branches[0].values(subtree=False)) == ([(0, 0, 2, 2), ], [2., ]) 180 | 181 | 182 | def test_periodic(): 183 | x = np.array([[0, 0, 0, 0, 0, ], 184 | [1, 1, 0, 1, 1], 185 | [0, 0, 0, 0, 0]]) 186 | 187 | d = Dendrogram.compute(x, min_value=0.5, 188 | neighbours=periodic_neighbours(1)) 189 | expected = np.array([[-1, -1, -1, -1, -1], 190 | [0, 0, -1, 0, 0], 191 | [-1, -1, -1, -1, -1]]) 192 | np.testing.assert_array_equal(d.index_map, expected) 193 | 194 | 195 | def test_periodic_left(): 196 | x = np.array([[1, 0, 0, 0, 0], 197 | [1, 0, 0, 0, 1], 198 | [1, 0, 0, 0, 0]]) 199 | d = Dendrogram.compute(x, min_value=0.5, 200 | neighbours=periodic_neighbours(1)) 201 | expected = np.array([[0, -1, -1, -1, -1], 202 | [0, -1, -1, -1, 0], 203 | [0, -1, -1, -1, -1]]) 204 | np.testing.assert_array_equal(d.index_map, expected) 205 | 206 | 207 | def test_periodic_left_narrow(): 208 | x = np.array([[0, 0, 0, 0, 0], 209 | [1, 1, 0, 0, 1], 210 | [0, 0, 0, 0, 0]]) 211 | d = Dendrogram.compute(x, min_value=0.5, 212 | neighbours=periodic_neighbours(1)) 213 | expected = np.array([[-1, -1, -1, -1, -1], 214 | [0, 0, -1, -1, 0], 215 | [-1, -1, -1, -1, -1]]) 216 | np.testing.assert_array_equal(d.index_map, expected) 217 | 218 | 219 | def test_periodic_right(): 220 | x = np.array([[0, 0, 0, 0, 1], 221 | [1, 0, 0, 0, 1], 222 | [0, 0, 0, 0, 1]]) 223 | d = Dendrogram.compute(x, min_value=0.5, 224 | neighbours=periodic_neighbours(1)) 225 | expected = np.array([[-1, -1, -1, -1, 0], 226 | [0, -1, -1, -1, 0], 227 | [-1, -1, -1, -1, 0]]) 228 | np.testing.assert_array_equal(d.index_map, expected) 229 | 230 | 231 | def test_periodic_right_narrow(): 232 | x = np.array([[0, 0, 0, 0, 0], 233 | [1, 0, 0, 1, 1], 234 | [0, 0, 0, 0, 0]]) 235 | d = Dendrogram.compute(x, min_value=0.5, 236 | neighbours=periodic_neighbours(1)) 237 | expected = np.array([[-1, -1, -1, -1, -1], 238 | [0, -1, -1, 0, 0], 239 | [-1, -1, -1, -1, -1]]) 240 | np.testing.assert_array_equal(d.index_map, expected) 241 | 242 | 243 | @pytest.mark.parametrize(('filename'), BENCHMARKS.keys()) 244 | def test_benchmark(filename): 245 | from astropy.io import fits 246 | import os 247 | 248 | path = os.path.join(os.path.dirname(__file__), 249 | 'benchmark_data', filename) 250 | p = BENCHMARKS[filename] 251 | data = fits.getdata(path, 1) 252 | 253 | d1 = Dendrogram.compute(data, **p) 254 | d2 = Dendrogram.load_from(path) 255 | 256 | assert d1 == d2 257 | 258 | # Check that all structures contain a reference to the dendrogram 259 | for structure in d1: 260 | assert structure._dendrogram is d1 261 | -------------------------------------------------------------------------------- /docs/catalog.rst: -------------------------------------------------------------------------------- 1 | Computing Dendrogram Statistics 2 | =============================== 3 | 4 | For 2D position-position (PP) and 3D position-position-velocity (PPV) 5 | observational data, the ``astrodendro.analysis`` module can be used to 6 | compute basic properties for each Dendrogram structure. There are two ways to 7 | compute statistics - on a structure-by-structure basis and as a catalog, both 8 | of which are described below. 9 | 10 | Deriving statistics for individual structures 11 | --------------------------------------------- 12 | 13 | In order to derive statistics for a given structure, you will need to use the 14 | :class:`~astrodendro.analysis.PPStatistic` or the 15 | :class:`~astrodendro.analysis.PPVStatistic` classes, e.g.:: 16 | 17 | >>> from astrodendro.analysis import PPStatistic 18 | >>> stat = PPStatistic(structure) 19 | 20 | where ``structure`` is a :class:`~astrodendro.structure.Structure` instance 21 | from a dendrogram. The resulting object then has methods to compute various 22 | statistics. Using the example data from :doc:`using`:: 23 | 24 | >>> from astrodendro import Dendrogram 25 | >>> from astropy.io import fits 26 | >>> image = fits.getdata('PerA_Extn2MASS_F_Gal.fits') 27 | >>> d = Dendrogram.compute(image, min_value=2.0, min_delta=1., min_npix=10) 28 | 29 | we can get statistics for the first structure in the trunk, which is a leaf:: 30 | 31 | >>> from astrodendro.analysis import PPStatistic 32 | >>> d.trunk[0] 33 | 34 | >>> stat = PPStatistic(d.trunk[0]) 35 | >>> stat.major_sigma # length of major axis on the sky 36 | 37 | >>> stat.minor_sigma # length of minor axis on the sky 38 | 39 | >>> stat.position_angle # position angle on the sky 40 | 41 | 42 | .. note:: The objects returned are Astropy :class:`~astropy.units.quantity.Quantity` 43 | objects, which are Numpy scalars or arrays with units attached. For more 44 | information, see the `Astropy Documentation 45 | `_. In most cases, 46 | you should be able to use these objects directly, but if for any 47 | reason you need to access the underlying value, then you can do so 48 | with the ``value`` and ``unit`` attributes:: 49 | 50 | >>> q = 1.882980574564531 * u.pix 51 | >>> q.unit 52 | Unit("pix") 53 | >>> q.value 54 | 1.882980574564531 55 | 56 | Specifying meta-data when computing statistics 57 | ---------------------------------------------- 58 | 59 | In some cases, meta-data can or should be specified. To demonstrate this, we 60 | will use a different data set which is a small section 61 | (:download:`L1551_scuba_850mu.fits`) of a SCUBA 850 micron map from the `SCUBA 62 | Legacy Catalog 63 | `_. This 64 | map has a pixel scale of 6 arcseconds per pixel and a circular beam with a 65 | full-width at half maximum (FWHM) of 22.9 arcseconds. First, we compute the 66 | dendrogram as usual:: 67 | 68 | >>> from astropy.io import fits 69 | >>> from astrodendro import Dendrogram 70 | >>> image = fits.getdata('L1551_scuba_850mu.fits') 71 | >>> d = Dendrogram.compute(image, min_value=0.1, min_delta=0.02) 72 | 73 | then we set up a Python dictionary containing the required meta-data:: 74 | 75 | >>> from astropy import units as u 76 | >>> metadata = {} 77 | >>> metadata['data_unit'] = u.Jy / u.beam 78 | >>> metadata['spatial_scale'] = 6 * u.arcsec 79 | >>> metadata['beam_major'] = 22.9 * u.arcsec # FWHM 80 | >>> metadata['beam_minor'] = 22.9 * u.arcsec # FWHM 81 | 82 | Note that the meta-data required depends on the units of the data and whether 83 | you are working in position-position or position-position-velocity (see 84 | `Required metadata`_). 85 | 86 | Finally, as before, we use the :class:`~astrodendro.analysis.PPStatistic` class 87 | to extract properties for the first structure:: 88 | 89 | >>> from astrodendro.analysis import PPStatistic 90 | >>> stat = PPStatistic(d.trunk[0], metadata=metadata) 91 | >>> stat.major_sigma 92 | 93 | >>> stat.minor_sigma 94 | 95 | >>> stat.position_angle 96 | 97 | >>> stat.flux 98 | 99 | 100 | Note that the major and minor sigma on the sky of the structures are now in 101 | arcseconds since the spatial scale was specified, and the flux (density) has 102 | been converted from Jy/beam to Jy. Note also that the flux does not include any 103 | kind of background subtraction, and is just a plain sum of the values in the 104 | structure, converted to Jy 105 | 106 | Making a catalog 107 | ---------------- 108 | 109 | In order to produce a catalog of properties for all structures, it is also 110 | possible to make use of the :func:`~astrodendro.analysis.pp_catalog` and 111 | :func:`~astrodendro.analysis.ppv_catalog` functions. We demonstrate this using 112 | the same SCUBA data as used above:: 113 | 114 | >>> from astropy.io import fits 115 | >>> from astrodendro import Dendrogram, pp_catalog 116 | >>> image = fits.getdata('L1551_scuba_850mu.fits') 117 | >>> d = Dendrogram.compute(image, min_value=0.1, min_delta=0.02) 118 | 119 | >>> from astropy import units as u 120 | >>> metadata = {} 121 | >>> metadata['data_unit'] = u.Jy / u.beam 122 | >>> metadata['spatial_scale'] = 6 * u.arcsec 123 | >>> metadata['beam_major'] = 22.9 * u.arcsec 124 | >>> metadata['beam_minor'] = 22.9 * u.arcsec 125 | 126 | >>> cat = pp_catalog(d, metadata) 127 | >>> cat.pprint(show_unit=True, max_lines=10) 128 | _idx flux major_sigma minor_sigma ... radius x_cen y_cen 129 | Jy arcsec arcsec ... arcsec pix pix 130 | ---- --------------- ------------- ------------- ... ------------- ------------- ------------- 131 | 7 0.241196886798 20.3463077838 8.15504176036 ... 12.8811874315 168.053017504 3.98809714744 132 | 51 0.132470059814 14.2778133293 4.81100492125 ... 8.2879810685 163.25495657 9.13394216473 133 | 60 0.0799106574322 9.66298008473 3.47364264736 ... 5.79359471511 169.278409915 15.1884110291 134 | ... ... ... ... ... ... ... ... 135 | 1203 0.183438198239 22.7202518034 4.04690367115 ... 9.58888264776 15.3760934458 100.136384362 136 | 1384 2.06217635837 38.1060171889 19.766115194 ... 27.4446338168 136.429313911 107.190835447 137 | 1504 1.90767291972 8.64476839751 8.09070477357 ... 8.36314946298 68.818705665 120.246719845 138 | 139 | The catalog function returns an Astropy :class:`~astropy.table.table.Table` object. 140 | 141 | Note that :func:`~astrodendro.analysis.pp_catalog` and 142 | :func:`~astrodendro.analysis.ppv_catalog` generate warnings if required 143 | meta-data is missing and sensible defaults can be assumed. If no sensible 144 | defaults can be assumed (e.g. for ``data_unit``) then an exception is raised. 145 | 146 | Unlike clumpfind-style algorithms, there is not a one-to-one mapping between 147 | identifiers and pixels in the map: each pixel may belong to multiple nested 148 | branches in the catalog. 149 | 150 | Available statistics 151 | -------------------- 152 | 153 | For a full list of available statistics for each type of statistic class, see 154 | :class:`~astrodendro.analysis.PPStatistic` and 155 | :class:`~astrodendro.analysis.PPVStatistic`. For more information on the 156 | quantities listed in these pages, consult the paper on `Bias Free Measurements 157 | of Molecular Cloud Properties 158 | `_ or `the original 159 | dendrogram paper `_. In the 160 | terminology of the dendrogram paper, the quantities in 161 | :class:`~astrodendro.analysis.PPStatistic` and 162 | :class:`~astrodendro.analysis.PPVStatistic` adopt the "bijection" paradigm. 163 | 164 | Required metadata 165 | ----------------- 166 | 167 | As described above, the metadata needed by the statistic routines depends on 168 | what statistics are required and on the units of the data. With the exception 169 | of ``wcs``, all meta-data should be specified as `Astropy Quantity 170 | `_ objects (e.g., ``3 * 171 | u.arcsec``): 172 | 173 | * ``data_unit`` is **required** in order to compute the flux, so it is needed 174 | for both the :func:`~astrodendro.analysis.pp_catalog` and 175 | :func:`~astrodendro.analysis.ppv_catalog` functions, as well as for the 176 | ``flux`` attribute of the :class:`~astrodendro.analysis.PPStatistic` and 177 | :class:`~astrodendro.analysis.PPVStatistic` classes. 178 | Note: if ``data_unit`` is given as ``K``, it is interpreted as units of 179 | main beam brightness temperature, following the conventions in the astropy 180 | `Brightness Temperature / Flux Density equivalency `_ . 181 | 182 | * ``spatial_scale`` is **required** if the data are in units of surface 183 | brightness (e.g. ``MJy/sr``, ``Jy/beam``, or ``K``) so as to be able to convert the 184 | surface brightness to the flux in each pixel. Even if the data are not in 185 | units of surface brightness, the ``spatial_scale`` can **optionally** be 186 | specified, causing any derived size (e.g. ``major_sigma``) to be in the 187 | correct units instead of in pixels. 188 | 189 | * ``velocity_scale`` can **optionally** be specified for PPV data, causing 190 | ``v_rms`` to be in the correct units instead of in pixels. 191 | 192 | * ``beam_major`` and ``beam_minor`` are **required** if the data units depend 193 | on the beam (e.g. ``Jy/beam`` or ``K``). 194 | 195 | * ``vaxis`` can **optionally** be specified when using 3-dimensional data to 196 | indicate which dimension corresponds to the velocity. By default, this is 197 | ``0``, which corresponds to the third axis in a FITS file (because the 198 | dimensions are reversed in numpy). 199 | 200 | * ``wavelength`` is **required** if the data are in monochromatic flux 201 | densities per unit wavelength because the fluxes need to be converted to 202 | monochromatic flux densities per unit frequency. It is also required if 203 | the data are in brightness temperature units of ``K``. 204 | 205 | * ``wcs`` can **optionally** be specified and should be a 206 | :class:`~astropy.wcs.WCS` instance. If specified, it allows ``x_cen``, 207 | ``y_cen``, and ``v_cen`` to be in the correct world coordinates rather than 208 | in pixel coordinates. 209 | 210 | Example 211 | ------- 212 | 213 | The following example shows how to combine the plotting functionality in 214 | :doc:`plotting` and the analysis tools shown above, to overlay ellipses 215 | approximating the structures on top of the structures themselves: 216 | 217 | .. plot:: 218 | :include-source: 219 | 220 | from astropy.io import fits 221 | 222 | from astrodendro import Dendrogram 223 | from astrodendro.analysis import PPStatistic 224 | 225 | import matplotlib.pyplot as plt 226 | from matplotlib.patches import Ellipse 227 | 228 | hdu = fits.open('PerA_Extn2MASS_F_Gal.fits')[0] 229 | 230 | d = Dendrogram.compute(hdu.data, min_value=2.0, min_delta=1., min_npix=10) 231 | p = d.plotter() 232 | 233 | fig = plt.figure() 234 | ax = fig.add_subplot(1, 1, 1) 235 | 236 | ax.imshow(hdu.data, origin='lower', interpolation='nearest', 237 | cmap=plt.cm.Blues, vmax=6.0) 238 | 239 | for leaf in d.leaves: 240 | 241 | p.plot_contour(ax, structure=leaf, lw=3, colors='red') 242 | 243 | s = PPStatistic(leaf) 244 | ellipse = s.to_mpl_ellipse(edgecolor='orange', facecolor='none') 245 | 246 | ax.add_patch(ellipse) 247 | 248 | ax.set_xlim(75., 170.) 249 | ax.set_ylim(120., 260.) 250 | 251 | As shown above, the :class:`~astrodendro.analysis.PPStatistic` and 252 | :class:`~astrodendro.analysis.PPVStatistic` classes have a 253 | :meth:`~astrodendro.analysis.SpatialBase.to_mpl_ellipse` method to convert the 254 | first and second moments of the structures into schematic ellipses. 255 | 256 | 257 | -------------------------------------------------------------------------------- /astrodendro/tests/test_structure.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | # The tests here ensure that the Structure class behaves as expected and has 4 | # the correct interface. This is done by setting up a few simple examples and 5 | # ensuring that all the properties and methods are behaving as expected. 6 | 7 | import pytest 8 | import numpy as np 9 | from ..structure import Structure 10 | from .test_index import assert_identical_fancyindex 11 | 12 | 13 | @pytest.mark.parametrize('index', [(0,), (1, 3), (4, 5, 9)]) 14 | def test_init_leaf_scalar(index): 15 | 16 | s = Structure(index, 1.5) 17 | 18 | # Properties 19 | assert s.idx is None 20 | assert s.is_leaf 21 | assert not s.is_branch 22 | assert_identical_fancyindex(s.indices(subtree=False), 23 | tuple(np.atleast_1d(i) for i in index)) 24 | assert np.all(s.indices() == s.indices(subtree=True)) 25 | assert np.all(s.values(subtree=False) == np.array([1.5])) 26 | assert np.all(s.values(subtree=True) == s.values(subtree=False)) 27 | assert s.vmin == 1.5 28 | assert s.vmax == 1.5 29 | assert s.height == 1.5 30 | assert s.level == 0 31 | assert s.ancestor is s 32 | assert s.parent is None 33 | assert s.children == [] 34 | assert s.descendants == [] 35 | 36 | # Methods 37 | for subtree in [False, True]: 38 | assert s.get_npix(subtree=subtree) == 1 39 | assert s.get_peak(subtree=subtree) == (index, 1.5) 40 | 41 | # Footprint 42 | array = np.zeros([20 for i in range(len(index))]) 43 | s._fill_footprint(array, level=2) 44 | assert array[index] == 2 45 | assert np.sum(array) == 2. 46 | 47 | 48 | @pytest.mark.parametrize('index', [[(0,), (1,), (2,)], 49 | [(1, 3), (2, 2), (4, 1)], 50 | [(4, 5, 9), (3, 2, 1), (6, 7, 8)]]) 51 | def test_init_leaf_list(index): 52 | 53 | s = Structure(index, [3.1, 4.2, 5.3]) 54 | 55 | # Properties 56 | assert s.idx is None 57 | assert s.is_leaf 58 | assert not s.is_branch 59 | indices = tuple(np.atleast_1d(i) for i in zip(*index)) 60 | assert_identical_fancyindex(s.indices(subtree=False), indices) 61 | assert_identical_fancyindex(s.indices(subtree=True), indices) 62 | assert np.all(s.values(subtree=False) == np.array([3.1, 4.2, 5.3])) 63 | assert np.all(s.values(subtree=True) == s.values(subtree=False)) 64 | assert s.vmin == 3.1 65 | assert s.vmax == 5.3 66 | assert s.height == 5.3 67 | assert s.level == 0 68 | assert s.ancestor is s 69 | assert s.parent is None 70 | assert s.children == [] 71 | assert s.descendants == [] 72 | 73 | # Methods 74 | for subtree in [False, True]: 75 | assert s.get_npix(subtree=subtree) == 3 76 | assert s.get_peak(subtree=subtree) == (index[2], 5.3) 77 | 78 | # Footprint 79 | array = np.zeros([20 for i in range(len(index[0]))]) 80 | s._fill_footprint(array, level=2) 81 | for i in index: 82 | print(i, array[i]) 83 | assert array[i] == 2 84 | assert np.sum(array) == 6. 85 | 86 | 87 | @pytest.mark.parametrize('index', [(0,), (1, 3), (4, 5, 9)]) 88 | def test_init_branch_scalar(index): 89 | 90 | leaf_index = tuple([10 for i in range(len(index))]) 91 | leaf = Structure(leaf_index, 20.) 92 | leaf_indices = leaf.indices(subtree=False) 93 | 94 | s = Structure(index, 1.5, children=[leaf]) 95 | 96 | # Properties 97 | assert s.idx is None 98 | assert not s.is_leaf 99 | assert s.is_branch 100 | indices = tuple(np.atleast_1d(i) for i in index) 101 | indices_all = tuple(np.hstack(a) 102 | for a in zip(indices, leaf_indices)) 103 | assert_identical_fancyindex(s.indices(subtree=False), indices) 104 | assert_identical_fancyindex(s.indices(subtree=True), indices_all) 105 | assert np.all(s.values(subtree=False) == np.array([1.5])) 106 | assert np.all(s.values(subtree=True) == np.hstack(([1.5], leaf.values(subtree=False)))) 107 | assert s.vmin == 1.5 108 | assert s.vmax == 1.5 109 | assert s.height == 20. 110 | assert s.level == 0 111 | assert s.ancestor is s 112 | assert s.parent is None 113 | assert s.children == [leaf] 114 | assert s.descendants == [leaf] 115 | 116 | # Leaf properties 117 | assert leaf.level == 1 118 | assert leaf.ancestor is s 119 | assert leaf.parent is s 120 | assert leaf.children == [] 121 | assert leaf.descendants == [] 122 | 123 | # Methods 124 | assert s.get_npix(subtree=False) == 1 125 | assert s.get_peak(subtree=False) == (index, 1.5) 126 | assert s.get_npix(subtree=True) == 2 127 | assert s.get_peak(subtree=True) == (leaf_index, 20.) 128 | 129 | # Footprint 130 | array = np.zeros([20 for i in range(len(index))]) 131 | s._fill_footprint(array, level=2) 132 | assert array[index] == 2. 133 | assert array[leaf_index] == 3. 134 | assert np.sum(array) == 5. 135 | 136 | 137 | @pytest.mark.parametrize('index', [[(0,), (1,), (2,)], 138 | [(1, 3), (2, 2), (4, 1)], 139 | [(4, 5, 9), (3, 2, 1), (6, 7, 8)]]) 140 | def test_init_branch_list(index): 141 | 142 | ndim = len(index[0]) 143 | 144 | leaf_index = tuple([10 for i in range(ndim)]) 145 | 146 | leaf = Structure(leaf_index, 20.) 147 | leaf_indices = leaf.indices(subtree=False) 148 | 149 | s = Structure(index, [3.1, 4.2, 5.3], children=[leaf]) 150 | 151 | # Properties 152 | assert s.idx is None 153 | assert not s.is_leaf 154 | assert s.is_branch 155 | indices = tuple(np.atleast_1d(i) for i in zip(*index)) 156 | indices_all = tuple(np.hstack(a) for a in 157 | zip(indices, leaf_indices)) 158 | 159 | assert_identical_fancyindex(s.indices(subtree=False), indices) 160 | assert_identical_fancyindex(s.indices(subtree=True), indices_all) 161 | 162 | assert np.all(s.values(subtree=False) == np.array([3.1, 4.2, 5.3])) 163 | assert np.all(s.values(subtree=True) == np.hstack(([3.1, 4.2, 5.3], leaf.values(subtree=False)))) 164 | assert s.vmin == 3.1 165 | assert s.vmax == 5.3 166 | assert s.height == 20. 167 | assert s.level == 0 168 | assert s.ancestor is s 169 | assert s.parent is None 170 | assert s.children == [leaf] 171 | assert s.descendants == [leaf] 172 | 173 | # Leaf properties 174 | assert leaf.level == 1 175 | assert leaf.ancestor is s 176 | assert leaf.parent is s 177 | assert leaf.children == [] 178 | assert leaf.descendants == [] 179 | 180 | # Methods 181 | assert s.get_npix(subtree=False) == 3 182 | assert s.get_peak(subtree=False) == (index[2], 5.3) 183 | assert s.get_npix(subtree=True) == 4 184 | assert s.get_peak(subtree=True) == (leaf_index, 20.) 185 | 186 | # Footprint 187 | array = np.zeros([20 for i in range(ndim)]) 188 | s._fill_footprint(array, level=2) 189 | for i in index: 190 | assert array[i] == 2. 191 | assert array[leaf_index] == 3. 192 | assert np.sum(array) == 9. 193 | 194 | 195 | @pytest.mark.parametrize('index', [(0,), (1, 3), (4, 5, 9)]) 196 | def test_init_branch_scalar_3_level(index): 197 | 198 | leaf_index = tuple([10 for i in range(len(index))]) 199 | leaf = Structure(leaf_index, 20.) 200 | leaf_indices = leaf.indices(subtree=False) 201 | 202 | branch_index = tuple([9 for i in range(len(index))]) 203 | branch = Structure(branch_index, 15., children=[leaf]) 204 | branch_indices = branch.indices(subtree=False) 205 | 206 | s = Structure(index, 1.5, children=[branch]) 207 | 208 | # Properties 209 | assert s.idx is None 210 | assert not s.is_leaf 211 | assert s.is_branch 212 | 213 | indices = tuple(np.atleast_1d(i) for i in index) 214 | indices_all = tuple(np.hstack(a) 215 | for a in zip(indices, branch_indices, leaf_indices)) 216 | assert_identical_fancyindex(s.indices(subtree=False), indices) 217 | assert_identical_fancyindex(s.indices(subtree=True), indices_all) 218 | 219 | assert np.all(s.values(subtree=False) == np.array([1.5])) 220 | assert np.all(s.values(subtree=True) == np.hstack((s.values(subtree=False), branch.values(subtree=False), leaf.values(subtree=False)))) 221 | assert s.vmin == 1.5 222 | assert s.vmax == 1.5 223 | assert s.height == 15. 224 | assert s.level == 0 225 | assert s.ancestor is s 226 | assert s.parent is None 227 | assert s.children == [branch] 228 | assert set(s.descendants) == set([branch, leaf]) # order should not matter 229 | 230 | # Branch properties 231 | assert branch.level == 1 232 | assert branch.ancestor is s 233 | assert branch.parent is s 234 | assert branch.children == [leaf] 235 | assert branch.descendants == [leaf] 236 | 237 | # Leaf properties 238 | assert leaf.level == 2 239 | assert leaf.ancestor is s 240 | assert leaf.parent is branch 241 | assert leaf.children == [] 242 | assert leaf.descendants == [] 243 | 244 | # Methods 245 | assert s.get_npix(subtree=False) == 1 246 | assert s.get_peak(subtree=False) == (index, 1.5) 247 | assert s.get_npix(subtree=True) == 3 248 | assert s.get_peak(subtree=True) == (leaf_index, 20.) 249 | 250 | # Footprint 251 | array = np.zeros([20 for i in range(len(index))]) 252 | s._fill_footprint(array, level=2) 253 | assert array[index] == 2. 254 | assert array[branch_index] == 3. 255 | assert array[leaf_index] == 4. 256 | assert np.sum(array) == 9. 257 | 258 | 259 | @pytest.mark.parametrize('index', [[(0,), (1,), (2,)], 260 | [(1, 3), (2, 2), (4, 1)], 261 | [(4, 5, 9), (3, 2, 1), (6, 7, 8)]]) 262 | def test_init_branch_list_3_level(index): 263 | 264 | ndim = len(index[0]) 265 | 266 | leaf_index = tuple([10 for i in range(ndim)]) 267 | leaf = Structure(leaf_index, 20.) 268 | leaf_indices = leaf.indices(subtree=False) 269 | 270 | branch_index = tuple([9 for i in range(ndim)]) 271 | branch = Structure(branch_index, 15., children=[leaf]) 272 | branch_indices = branch.indices(subtree=False) 273 | 274 | s = Structure(index, [3.1, 4.2, 5.3], children=[branch]) 275 | 276 | # Properties 277 | assert s.idx is None 278 | assert not s.is_leaf 279 | assert s.is_branch 280 | 281 | indices = tuple(np.atleast_1d(i) for i in zip(*index)) 282 | indices_all = tuple(np.hstack(a) for a in 283 | zip(indices, branch_indices, leaf_indices)) 284 | assert_identical_fancyindex(s.indices(subtree=False), indices) 285 | assert_identical_fancyindex(s.indices(subtree=True), indices_all) 286 | 287 | assert np.all(s.values(subtree=False) == np.array([3.1, 4.2, 5.3])) 288 | assert np.all(s.values(subtree=True) == np.hstack((s.values(subtree=False), branch.values(subtree=False), leaf.values(subtree=False)))) 289 | assert s.vmin == 3.1 290 | assert s.vmax == 5.3 291 | assert s.height == 15. 292 | assert s.level == 0 293 | assert s.ancestor is s 294 | assert s.parent is None 295 | assert s.children == [branch] 296 | assert s.descendants == [branch, leaf] 297 | 298 | # Branch properties 299 | assert branch.level == 1 300 | assert branch.ancestor is s 301 | assert branch.parent is s 302 | assert branch.children == [leaf] 303 | assert branch.descendants == [leaf] 304 | 305 | # Leaf properties 306 | assert leaf.level == 2 307 | assert leaf.ancestor is s 308 | assert leaf.parent is branch 309 | assert leaf.children == [] 310 | assert leaf.descendants == [] 311 | 312 | # Methods 313 | assert s.get_npix(subtree=False) == 3 314 | assert s.get_peak(subtree=False) == (index[2], 5.3) 315 | assert s.get_npix(subtree=True) == 5 316 | assert s.get_peak(subtree=True) == (leaf_index, 20.) 317 | 318 | # Footprint 319 | array = np.zeros([20 for i in range(ndim)]) 320 | s._fill_footprint(array, level=2) 321 | for i in index: 322 | assert array[i] == 2. 323 | assert array[branch_index] == 3. 324 | assert array[leaf_index] == 4. 325 | assert np.sum(array) == 13. 326 | 327 | 328 | def test_add_pixel(): 329 | 330 | s = Structure(1, 10.) 331 | 332 | assert s.get_npix() == 1 333 | assert s.get_peak() == (1, 10) 334 | assert s.vmin == 10. 335 | assert s.vmax == 10. 336 | 337 | s._add_pixel(2, 8.) 338 | 339 | assert s.get_npix() == 2 340 | assert s.get_peak() == (1, 10) 341 | assert s.vmin == 8. 342 | assert s.vmax == 10. 343 | 344 | s._add_pixel(3, 12.) 345 | 346 | assert s.get_npix() == 3 347 | assert s.get_peak() == (3, 12.) 348 | assert s.vmin == 8. 349 | assert s.vmax == 12. 350 | 351 | 352 | def test_sorted_leaves(): 353 | l1 = Structure(1, 10., idx=1) 354 | l2 = Structure(2, 8., idx=2) 355 | s = Structure(3, 5., children=[l1, l2], idx=3) 356 | assert s.sorted_leaves() == [l2, l1] 357 | assert s.sorted_leaves(reverse=True) == [l1, l2] 358 | 359 | def key(x): 360 | return x.idx 361 | assert s.sorted_leaves(sort_key=key) == [l1, l2] 362 | 363 | s2 = Structure(4, 3., children=[s], idx=4) 364 | assert s2.sorted_leaves() == [l2, l1] 365 | assert s2.sorted_leaves(subtree=False) == [] 366 | 367 | # TODO: add newick tests 368 | -------------------------------------------------------------------------------- /astrodendro/viewer.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | from collections import defaultdict 4 | from functools import reduce 5 | 6 | import numpy as np 7 | from .plot import DendrogramPlotter 8 | 9 | 10 | class SelectionHub(object): 11 | 12 | """ 13 | The SelectionHub manages a set of selected Dendrogram structures. 14 | Callback functions can be registered to the Hub, to be notified 15 | when a selection changes. 16 | """ 17 | 18 | def __init__(self): 19 | # map selection IDs -> list of selected dendrogram IDs 20 | self.selections = {} 21 | self.select_subtree = {} # map selection IDs -> bool 22 | self.colors = defaultdict(lambda: 'red') 23 | self.colors[1] = '#e41a1c' 24 | self.colors[2] = '#377eb8' 25 | self.colors[3] = '#4daf4a' 26 | # someday we may provide a UI to update what goes in colordict. 27 | 28 | self._callbacks = [] 29 | 30 | def add_callback(self, method): 31 | """ 32 | Register a new callback function to be invoked 33 | whenever the selection changes 34 | 35 | The callback will be called as method(id), 36 | where id is the modified selection id 37 | """ 38 | self._callbacks.append(method) 39 | 40 | def select(self, id, structures, subtree=True): 41 | """ 42 | Select new structures, and associate 43 | them with a selection ID 44 | 45 | Parameters 46 | ----------- 47 | id : int 48 | structures : list of Dendrogram Structures to select 49 | """ 50 | if not isinstance(structures, list): 51 | structures = [structures] 52 | 53 | self.selections[id] = structures 54 | self.select_subtree[id] = subtree 55 | for cb in self._callbacks: 56 | cb(id) 57 | 58 | 59 | class BasicDendrogramViewer(object): 60 | 61 | def __init__(self, dendrogram): 62 | 63 | if dendrogram.data.ndim not in [2, 3]: 64 | raise ValueError( 65 | "Only 2- and 3-dimensional arrays are supported at this time") 66 | 67 | self.hub = SelectionHub() 68 | self._connect_to_hub() 69 | 70 | self.array = dendrogram.data 71 | self.dendrogram = dendrogram 72 | self.plotter = DendrogramPlotter(dendrogram) 73 | self.plotter.sort(reverse=True) 74 | 75 | # Get the lines as individual elements, and the mapping from line to structure 76 | self.lines = self.plotter.get_lines(edgecolor='k') 77 | 78 | # Define the currently selected subtree 79 | self.selected_lines = {} 80 | self.selected_contour = {} 81 | # The keys in these dictionaries are event button IDs. 82 | 83 | # Initiate plot 84 | import matplotlib.pyplot as plt 85 | self.fig = plt.figure(figsize=(14, 8)) 86 | 87 | ax_image_limits = [0.1, 0.1, 0.4, 0.7] 88 | 89 | if self.dendrogram.wcs is not None: 90 | 91 | if self.array.ndim == 2: 92 | slices = ('x', 'y') 93 | else: 94 | slices = ('x', 'y', 1) 95 | 96 | self.ax_image = self.fig.add_axes(ax_image_limits, projection=self.dendrogram.wcs, slices=slices) 97 | 98 | else: 99 | self.ax_image = self.fig.add_axes(ax_image_limits) 100 | 101 | from matplotlib.widgets import Slider 102 | 103 | self._clim = (np.min(self.array[~np.isnan(self.array) & ~np.isinf(self.array)]), 104 | np.max(self.array[~np.isnan(self.array) & ~np.isinf(self.array)])) 105 | 106 | if self.array.ndim == 2: 107 | 108 | self.slice = None 109 | self.image = self.ax_image.imshow(self.array, origin='lower', interpolation='nearest', vmin=self._clim[0], vmax=self._clim[1], cmap=plt.cm.gray) 110 | 111 | self.slice_slider = None 112 | 113 | else: 114 | 115 | if self.array.shape[0] > 1: 116 | 117 | self.slice = int(round(self.array.shape[0] / 2.)) 118 | 119 | self.slice_slider_ax = self.fig.add_axes([0.1, 0.95, 0.4, 0.03]) 120 | self.slice_slider_ax.set_xticklabels("") 121 | self.slice_slider_ax.set_yticklabels("") 122 | self.slice_slider = Slider(self.slice_slider_ax, "3-d slice", 0, self.array.shape[0], valinit=self.slice, valfmt="%i") 123 | self.slice_slider.on_changed(self.update_slice) 124 | self.slice_slider.drawon = False 125 | 126 | else: 127 | 128 | self.slice = 0 129 | self.slice_slider = None 130 | 131 | self.image = self.ax_image.imshow(self.array[self.slice, :, :], origin='lower', interpolation='nearest', vmin=self._clim[0], vmax=self._clim[1], cmap=plt.cm.gray) 132 | 133 | self.vmin_slider_ax = self.fig.add_axes([0.1, 0.90, 0.4, 0.03]) 134 | self.vmin_slider_ax.set_xticklabels("") 135 | self.vmin_slider_ax.set_yticklabels("") 136 | self.vmin_slider = Slider(self.vmin_slider_ax, "vmin", self._clim[0], self._clim[1], valinit=self._clim[0]) 137 | self.vmin_slider.on_changed(self.update_vmin) 138 | self.vmin_slider.drawon = False 139 | 140 | self.vmax_slider_ax = self.fig.add_axes([0.1, 0.85, 0.4, 0.03]) 141 | self.vmax_slider_ax.set_xticklabels("") 142 | self.vmax_slider_ax.set_yticklabels("") 143 | self.vmax_slider = Slider(self.vmax_slider_ax, "vmax", self._clim[0], self._clim[1], valinit=self._clim[1]) 144 | self.vmax_slider.on_changed(self.update_vmax) 145 | self.vmax_slider.drawon = False 146 | 147 | self.ax_dendrogram = self.fig.add_axes([0.6, 0.3, 0.35, 0.4]) 148 | self.ax_dendrogram.add_collection(self.lines) 149 | 150 | self.selected_label = {} # map selection IDs -> text objects 151 | self.selected_label[1] = self.fig.text(0.6, 0.85, "No structure selected", fontsize=18, 152 | color=self.hub.colors[1]) 153 | self.selected_label[2] = self.fig.text(0.6, 0.8, "No structure selected", fontsize=18, 154 | color=self.hub.colors[2]) 155 | self.selected_label[3] = self.fig.text(0.6, 0.75, "No structure selected", fontsize=18, 156 | color=self.hub.colors[3]) 157 | x = [p.vertices[:, 0] for p in self.lines.get_paths()] 158 | y = [p.vertices[:, 1] for p in self.lines.get_paths()] 159 | xmin = np.min(x) 160 | xmax = np.max(x) 161 | ymin = np.min(y) 162 | ymax = np.max(y) 163 | self.lines.set_picker(2.) 164 | self.lines.set_zorder(0) 165 | dx = xmax - xmin 166 | self.ax_dendrogram.set_xlim(xmin - dx * 0.1, xmax + dx * 0.1) 167 | self.ax_dendrogram.set_ylim(ymin * 0.5, ymax * 2.0) 168 | self.ax_dendrogram.set_yscale('log') 169 | 170 | self.fig.canvas.mpl_connect('pick_event', self.line_picker) 171 | self.fig.canvas.mpl_connect('button_press_event', self.select_from_map) 172 | 173 | def show(self): 174 | import matplotlib.pyplot as plt 175 | plt.show() 176 | 177 | def update_slice(self, pos=None): 178 | if self.array.ndim == 2: 179 | self.image.set_array(self.array) 180 | else: 181 | self.slice = int(round(pos)) 182 | self.image.set_array(self.array[self.slice, :, :]) 183 | 184 | self.update_contours() 185 | 186 | self.fig.canvas.draw() 187 | 188 | def _connect_to_hub(self): 189 | self.hub.add_callback(self._on_selection_change) 190 | 191 | def _on_selection_change(self, selection_id): 192 | self._update_lines(selection_id) 193 | self.update_contours() 194 | self.fig.canvas.draw() 195 | 196 | def update_vmin(self, vmin): 197 | if vmin > self._clim[1]: 198 | self._clim = (self._clim[1], self._clim[1]) 199 | else: 200 | self._clim = (vmin, self._clim[1]) 201 | self.image.set_clim(*self._clim) 202 | self.fig.canvas.draw() 203 | 204 | def update_vmax(self, vmax): 205 | if vmax < self._clim[0]: 206 | self._clim = (self._clim[0], self._clim[0]) 207 | else: 208 | self._clim = (self._clim[0], vmax) 209 | self.image.set_clim(*self._clim) 210 | self.fig.canvas.draw() 211 | 212 | def select_from_map(self, event): 213 | 214 | # Only do this if no tools are currently selected 215 | if event.canvas.toolbar.mode != '': 216 | return 217 | if event.button not in self.selected_label: 218 | return 219 | 220 | if event.inaxes is self.ax_image: 221 | 222 | input_key = event.button 223 | 224 | # Find pixel co-ordinates of click 225 | ix = int(round(event.xdata)) 226 | iy = int(round(event.ydata)) 227 | 228 | if self.array.ndim == 2: 229 | indices = (iy, ix) 230 | else: 231 | indices = (self.slice, iy, ix) 232 | 233 | # Select the structure 234 | structure = self.dendrogram.structure_at(indices) 235 | self.hub.select(input_key, structure) 236 | 237 | # Re-draw 238 | event.canvas.draw() 239 | 240 | def line_picker(self, event): 241 | 242 | # Only do this if no tools are currently selected 243 | if event.canvas.toolbar.mode != '': 244 | return 245 | if event.mouseevent.button not in self.selected_label: 246 | return 247 | 248 | input_key = event.mouseevent.button 249 | 250 | # event.ind gives the indices of the paths that have been selected 251 | 252 | # Find levels of selected paths 253 | peaks = [event.artist.structures[i].get_peak(subtree=True)[1] for i in event.ind] 254 | 255 | # Find position of minimum level (may be duplicates, let Numpy decide) 256 | ind = event.ind[np.argmax(peaks)] 257 | 258 | # Extract structure 259 | structure = event.artist.structures[ind] 260 | 261 | # If 3-d, select the slice 262 | if self.slice_slider is not None: 263 | peak_index = structure.get_peak(subtree=True) 264 | self.slice_slider.set_val(peak_index[0][0]) 265 | 266 | # Select the structure 267 | self.hub.select(input_key, structure) 268 | 269 | # Re-draw 270 | event.canvas.draw() 271 | 272 | def _update_lines(self, selection_id): 273 | structures = self.hub.selections[selection_id] 274 | select_subtree = self.hub.select_subtree[selection_id] 275 | 276 | structure = structures[0] 277 | 278 | # Remove previously selected collection 279 | if selection_id in self.selected_lines: 280 | self.selected_lines[selection_id].remove() 281 | del self.selected_lines[selection_id] 282 | 283 | if structure is None: 284 | self.selected_label[selection_id].set_text("No structure selected") 285 | self.remove_contour(selection_id) 286 | self.fig.canvas.draw() 287 | return 288 | 289 | self.remove_all_contours() 290 | 291 | if len(structures) <= 1: 292 | label_text = "Selected structure: {0}".format(structure.idx) 293 | elif len(structures) <= 3: 294 | label_text = "Selected structures: {0}".format(', '.join([str(structure.idx) for structure in structures])) 295 | else: 296 | label_text = "Selected structures: {0}...".format(', '.join([str(structure.idx) for structure in structures[:3]])) 297 | 298 | self.selected_label[selection_id].set_text(label_text) 299 | 300 | # Get collection for this substructure 301 | self.selected_lines[selection_id] = self.plotter.get_lines( 302 | structures=structures, subtree=select_subtree) 303 | self.selected_lines[selection_id].set_color(self.hub.colors[selection_id]) 304 | self.selected_lines[selection_id].set_linewidth(2) 305 | self.selected_lines[selection_id].set_zorder(structure.height) 306 | 307 | # Add to axes 308 | self.ax_dendrogram.add_collection(self.selected_lines[selection_id]) 309 | 310 | def remove_contour(self, selection_id): 311 | if selection_id in self.selected_contour: 312 | self.selected_contour[selection_id].remove() 313 | del self.selected_contour[selection_id] 314 | 315 | def remove_all_contours(self): 316 | """ Remove all selected contours. """ 317 | for key in list(self.selected_contour): 318 | self.remove_contour(key) 319 | 320 | def update_contours(self): 321 | self.remove_all_contours() 322 | 323 | for selection_id in self.hub.selections.keys(): 324 | structures = self.hub.selections[selection_id] 325 | select_subtree = self.hub.select_subtree[selection_id] 326 | 327 | struct = structures[0] 328 | if struct is None: 329 | continue 330 | 331 | if select_subtree: 332 | mask = struct.get_mask(subtree=True) 333 | else: 334 | mask = reduce(np.add, [structure.get_mask(subtree=True) for structure in structures]) 335 | if self.array.ndim == 3: 336 | mask = mask[self.slice, :, :] 337 | self.selected_contour[selection_id] = self.ax_image.contour( 338 | mask, colors=self.hub.colors[selection_id], 339 | linewidths=2, levels=[0.5], alpha=0.75, zorder=struct.height) 340 | -------------------------------------------------------------------------------- /astrodendro/tests/test_analysis.py: -------------------------------------------------------------------------------- 1 | # Licensed under an MIT open source license - see LICENSE 2 | 3 | import pytest 4 | 5 | import numpy as np 6 | from numpy.testing import assert_allclose 7 | import astropy.units as u 8 | from astropy.wcs import WCS 9 | from astropy.tests.helper import assert_quantity_allclose as assert_allclose_quantity 10 | 11 | from ._testdata import data 12 | from ..analysis import (ScalarStatistic, PPVStatistic, ppv_catalog, 13 | Metadata, PPStatistic, pp_catalog) 14 | from .. import Dendrogram, periodic_neighbours 15 | from ..structure import Structure 16 | 17 | wcs_2d = WCS(header=dict(cdelt1=1, crval1=0, crpix1=1, 18 | cdelt2=2, crval2=0, crpix2=1)) 19 | wcs_3d = WCS(header=dict(cdelt1=1, crval1=0, crpix1=1, 20 | cdelt2=2, crval2=0, crpix2=1, 21 | cdelt3=3, crval3=0, crpix3=1)) 22 | 23 | 24 | def benchmark_stat(): 25 | x = np.array([216, 216, 216, 216, 216, 217, 216, 26 | 216, 216, 217, 216, 217, 218, 216, 27 | 217, 216, 216, 217, 216, 217, 216]) 28 | y = np.array([48, 50, 51, 47, 48, 48, 49, 50, 29 | 46, 46, 47, 47, 47, 48, 48, 49, 30 | 46, 46, 47, 46, 47]) 31 | z = np.array([11, 11, 11, 12, 12, 12, 12, 12, 32 | 13, 13, 13, 13, 13, 13, 13, 13, 33 | 14, 14, 14, 15, 15]) 34 | indices = (z, y, x) 35 | values = data[indices] 36 | return ScalarStatistic(values, indices) 37 | 38 | 39 | def benchmark_values(): 40 | result = {} 41 | result['mom0'] = 45.8145142 42 | result['mom1'] = [12.8093996, 47.6574211, 216.3799896] 43 | result['mom2'] = [ 44 | [1.2831592097072804, -1.1429260442312184, 0.1653071908000249], 45 | [-1.1429260442312184, 2.0107706426889038, -0.2976682802749759], 46 | [0.1653071908000249, -0.2976682802749759, 0.3306051333187136]] 47 | result['mom2_100'] = 1.2831592097072804 48 | result['mom2_010'] = 2.0107706426889038 49 | result['mom2_011'] = 0.8730196376110217 50 | 51 | result['paxis1'] = [0.5833025351190873, 52 | -0.8016446861607931, 0.1308585101314019] 53 | result['paxis2'] = [0.8038500408313988, 54 | 0.5466096691013617, -0.2346124069614796] 55 | result['paxis3'] = [-0.1165472624260408, 56 | -0.2420406304632855, -0.9632409194100563] 57 | result['sig_maj'] = np.sqrt(2.0619485) 58 | result['sig_min'] = np.sqrt(0.27942730) 59 | result['area_exact_pp'] = 21 60 | result['area_exact_ppv'] = 10 61 | 62 | return result 63 | 64 | 65 | class TestScalarStatistic(object): 66 | 67 | def setup_method(self, method): 68 | self.stat = benchmark_stat() 69 | 70 | def test_mom0(self): 71 | stat = self.stat 72 | assert_allclose(stat.mom0(), benchmark_values()['mom0']) 73 | 74 | def test_mom1(self): 75 | stat = self.stat 76 | assert_allclose(stat.mom1(), benchmark_values()['mom1']) 77 | 78 | def test_mom2(self): 79 | stat = self.stat 80 | assert_allclose(stat.mom2(), benchmark_values()['mom2']) 81 | 82 | def test_mom2_along(self): 83 | stat = self.stat 84 | v = benchmark_values() 85 | assert_allclose(stat.mom2_along((1, 0, 0)), v['mom2_100']) 86 | assert_allclose(stat.mom2_along((2, 0, 0)), v['mom2_100']) 87 | assert_allclose(stat.mom2_along((0, 1, 0)), v['mom2_010']) 88 | assert_allclose(stat.mom2_along((0, 1, 1)), v['mom2_011']) 89 | 90 | def test_count(self): 91 | stat = self.stat 92 | assert_allclose(stat.count(), 21) 93 | 94 | def test_sky_paxes(self): 95 | stat = self.stat 96 | v1, v2, v3 = stat.paxes() 97 | v = benchmark_values() 98 | 99 | # doesn't matter if v = vex * -1. 100 | assert_allclose(np.abs(np.dot(v1, v['paxis1'])), 1) 101 | assert_allclose(np.abs(np.dot(v2, v['paxis2'])), 1) 102 | assert_allclose(np.abs(np.dot(v3, v['paxis3'])), 1) 103 | 104 | def test_projected_paxes(self): 105 | stat = self.stat 106 | v = benchmark_values() 107 | v1, v2 = stat.projected_paxes(((0, 1, 0), (0, 0, 1))) 108 | 109 | assert_allclose(stat.mom2_along((0, v1[0], v1[1])), v['sig_maj'] ** 2) 110 | assert_allclose(stat.mom2_along((0, v2[0], v2[1])), v['sig_min'] ** 2) 111 | 112 | def test_projected_paxes_int(self): 113 | ind = (np.array([0, 1, 2]), 114 | np.array([0, 1, 2]), 115 | np.array([0, 1, 2])) 116 | v = np.array([1, 1, 1]) 117 | stat = ScalarStatistic(v, ind) 118 | a, b = stat.projected_paxes(((0, 1, 0), (0, 0, 1))) 119 | assert_allclose(a, [1 / np.sqrt(2), 1 / np.sqrt(2)]) 120 | 121 | 122 | class TestScalar2D(object): 123 | 124 | def setup_method(self, method): 125 | x = np.array([213, 213, 214, 211, 212, 212]) 126 | y = np.array([71, 71, 71, 71, 71, 71]) 127 | z = np.array([46, 45, 45, 44, 46, 43]) 128 | 129 | ind = (z, y, x) 130 | val = data[ind].copy() 131 | ind = (z, x) 132 | val[0] = 0 # we'll replace this with nan in a subclass 133 | 134 | self.stat = ScalarStatistic(val, ind) 135 | 136 | def test_mom0(self): 137 | assert_allclose(self.stat.mom0(), 19.2793083) 138 | 139 | def test_mom1(self): 140 | assert_allclose(self.stat.mom1(), [44.6037369, 212.4050598]) 141 | 142 | def test_mom2(self): 143 | assert_allclose(self.stat.mom2(), 144 | [[1.0321983103326871, 0.3604276691031663], 145 | [0.3604276691031663, 1.0435691076146387]]) 146 | 147 | def test_mom2_along(self): 148 | assert_allclose(self.stat.mom2_along((0, 1)), 1.0435691076146387) 149 | 150 | def test_count(self): 151 | assert_allclose(self.stat.count(), 6) 152 | 153 | def test_sky_paxes(self): 154 | v1, v2 = self.stat.paxes() 155 | v1ex = [0.7015083489012299, 0.7126612353859795] 156 | v2ex = [0.7126612353859795, -0.7015083489012299] 157 | 158 | assert_allclose(np.abs(np.dot(v1, v1ex)), 1) 159 | assert_allclose(np.abs(np.dot(v2, v2ex)), 1) 160 | 161 | 162 | class TestScalarNan(TestScalar2D): 163 | 164 | def setup_method(self, method): 165 | x = np.array([213, 213, 214, 211, 212, 212]) 166 | y = np.array([71, 71, 71, 71, 71, 71]) 167 | z = np.array([46, 45, 45, 44, 46, 43]) 168 | 169 | ind = (z, y, x) 170 | val = data[ind].copy() 171 | ind = (z, x) 172 | # all tests should be the same as superclass, 173 | # since nan should = 0 weight 174 | val[0] = np.nan 175 | 176 | self.stat = ScalarStatistic(val, ind) 177 | 178 | 179 | class TestPPVStatistic(object): 180 | 181 | def setup_method(self, method): 182 | self.stat = benchmark_stat() 183 | self.v = benchmark_values() 184 | 185 | def metadata(self, **kwargs): 186 | result = dict(data_unit=u.Jy, wcs=wcs_3d) 187 | result.update(**kwargs) 188 | return result 189 | 190 | def test_x_cen(self): 191 | 192 | p = PPVStatistic(self.stat, self.metadata()) 193 | assert_allclose(p.x_cen, self.v['mom1'][2]) 194 | 195 | p = PPVStatistic(self.stat, self.metadata(vaxis=2)) 196 | assert_allclose(p.x_cen, self.v['mom1'][1] * 2) 197 | 198 | def test_y_cen(self): 199 | p = PPVStatistic(self.stat, self.metadata()) 200 | assert_allclose(p.y_cen, self.v['mom1'][1] * 2) 201 | 202 | p = PPVStatistic(self.stat, self.metadata(vaxis=2)) 203 | assert_allclose(p.y_cen, self.v['mom1'][0] * 3) 204 | 205 | def test_v_cen(self): 206 | p = PPVStatistic(self.stat, self.metadata()) 207 | assert_allclose(p.v_cen, self.v['mom1'][0] * 3) 208 | 209 | p = PPVStatistic(self.stat, self.metadata(vaxis=2)) 210 | assert_allclose(p.v_cen, self.v['mom1'][2]) 211 | 212 | def test_major_sigma(self): 213 | p = PPVStatistic(self.stat, self.metadata(spatial_scale=2 * u.arcsec)) 214 | assert_allclose_quantity(p.major_sigma, self.v['sig_maj'] * 2 * u.arcsec) 215 | 216 | def test_minor_sigma(self): 217 | p = PPVStatistic(self.stat, self.metadata(spatial_scale=4 * u.arcsec)) 218 | assert_allclose_quantity(p.minor_sigma, self.v['sig_min'] * 4 * u.arcsec) 219 | 220 | def test_radius(self): 221 | p = PPVStatistic(self.stat, self.metadata(spatial_scale=4 * u.arcsec)) 222 | assert_allclose_quantity(p.radius, np.sqrt(self.v['sig_min'] * self.v['sig_maj']) * 4 * u.arcsec) 223 | 224 | def test_area_exact(self): 225 | p = PPVStatistic(self.stat, self.metadata(spatial_scale=4 * u.arcsec)) 226 | assert_allclose_quantity(p.area_exact, (4 * u.arcsec) ** 2 * self.v['area_exact_ppv']) 227 | 228 | def test_area_ellipse(self): 229 | p = PPVStatistic(self.stat, self.metadata(spatial_scale=4 * u.arcsec)) 230 | assert_allclose_quantity(p.area_ellipse, (4 * u.arcsec) ** 2 * self.v['sig_min'] * self.v['sig_maj'] * np.pi * (2.3548 * 0.5) ** 2) 231 | 232 | def test_v_rms(self): 233 | p = PPVStatistic(self.stat, self.metadata()) 234 | assert_allclose_quantity(p.v_rms, np.sqrt(self.v['mom2_100']) * u.pixel) 235 | 236 | p = PPVStatistic(self.stat, self.metadata(vaxis=1, velocity_scale=10 * u.km / u.s)) 237 | assert_allclose_quantity(p.v_rms, np.sqrt(self.v['mom2_010']) * 10 * u.km / u.s) 238 | 239 | def test_position_angle(self): 240 | x = np.array([0, 1, 2]) 241 | y = np.array([1, 1, 1]) 242 | z = np.array([0, 1, 2]) 243 | v = np.array([1, 1, 1]) 244 | 245 | ind = (z, y, x) 246 | stat = ScalarStatistic(v, ind) 247 | p = PPVStatistic(stat, self.metadata()) 248 | assert_allclose_quantity(p.position_angle, 0 * u.degree) 249 | 250 | ind = (z, x, y) 251 | stat = ScalarStatistic(v, ind) 252 | p = PPVStatistic(stat, self.metadata()) 253 | assert_allclose_quantity(p.position_angle, 90 * u.degree) 254 | 255 | def test_units(self): 256 | m = self.metadata(spatial_scale=1 * u.deg, velocity_scale=1 * u.km / u.s, 257 | data_unit=1 * u.K, distance=1 * u.pc) 258 | p = PPVStatistic(self.stat, m) 259 | 260 | assert p.v_rms.unit == u.km / u.s 261 | assert p.major_sigma.unit == u.deg 262 | assert p.minor_sigma.unit == u.deg 263 | assert p.radius.unit == u.deg 264 | 265 | 266 | class TestPPStatistic(object): 267 | 268 | def setup_method(self, method): 269 | self.stat = benchmark_stat() 270 | # this trick essentially collapses along the 0th axis 271 | # should preserve sky_maj, sky_min 272 | self.stat.indices = (self.stat.indices[1], self.stat.indices[2]) 273 | self.v = benchmark_values() 274 | 275 | def metadata(self, **kwargs): 276 | result = dict() 277 | result.update(**kwargs) 278 | return result 279 | 280 | def test_major_sigma(self): 281 | p = PPStatistic(self.stat, self.metadata(spatial_scale=2 * u.arcsec)) 282 | 283 | assert_allclose_quantity(p.major_sigma, self.v['sig_maj'] * 2 * u.arcsec) 284 | 285 | def test_minor_sigma(self): 286 | p = PPStatistic(self.stat, self.metadata(spatial_scale=4 * u.arcsec)) 287 | assert_allclose_quantity(p.minor_sigma, self.v['sig_min'] * 4 * u.arcsec) 288 | 289 | def test_radius(self): 290 | p = PPStatistic(self.stat, self.metadata(spatial_scale=4 * u.arcsec)) 291 | assert_allclose_quantity(p.radius, np.sqrt(self.v['sig_min'] * self.v['sig_maj']) * 4 * u.arcsec) 292 | 293 | def test_area_exact(self): 294 | p = PPStatistic(self.stat, self.metadata(spatial_scale=4 * u.arcsec)) 295 | assert_allclose_quantity(p.area_exact, (4 * u.arcsec) ** 2 * self.v['area_exact_pp']) 296 | 297 | def test_area_ellipse(self): 298 | p = PPStatistic(self.stat, self.metadata(spatial_scale=4 * u.arcsec)) 299 | assert_allclose_quantity(p.area_ellipse, (4 * u.arcsec) ** 2 * self.v['sig_min'] * self.v['sig_maj'] * np.pi * (2.3548 * 0.5) ** 2) 300 | 301 | def test_position_angle(self): 302 | x = np.array([0, 1, 2]) 303 | y = np.array([1, 1, 1]) 304 | v = np.array([1, 1, 1]) 305 | 306 | ind = (y, x) 307 | stat = ScalarStatistic(v, ind) 308 | p = PPStatistic(stat, self.metadata()) 309 | assert_allclose_quantity(p.position_angle, 0 * u.degree) 310 | 311 | ind = (x, y) 312 | stat = ScalarStatistic(v, ind) 313 | p = PPStatistic(stat, self.metadata()) 314 | assert_allclose_quantity(p.position_angle, 90 * u.degree) 315 | 316 | 317 | def test_statistic_dimensionality(): 318 | 319 | d = Dendrogram.compute(np.ones((10, 10))) 320 | 321 | with pytest.raises(ValueError) as exc: 322 | PPVStatistic(d.trunk[0]) 323 | assert exc.value.args[0] == "PPVStatistic can only be used on 3-d datasets" 324 | 325 | PPStatistic(d.trunk[0]) 326 | 327 | d = Dendrogram.compute(np.ones((10, 10, 10))) 328 | 329 | with pytest.raises(ValueError) as exc: 330 | PPStatistic(d.trunk[0]) 331 | assert exc.value.args[0] == "PPStatistic can only be used on 2-d datasets" 332 | 333 | PPVStatistic(d.trunk[0]) 334 | 335 | 336 | class TestCataloger(object): 337 | files = [] 338 | cataloger = None 339 | 340 | def stat(self): 341 | raise NotImplementedError() 342 | 343 | def metadata(self): 344 | raise NotImplementedError() 345 | 346 | def make_catalog(self, s=None, md=None, fields=None): 347 | s = s or [self.stat()] 348 | structures = [Structure(zip(*x.indices), x.values) for x in s] 349 | md = md or self.metadata() 350 | return self.cataloger(structures, md, fields) 351 | 352 | def test_benchmark(self): 353 | c = self.make_catalog() 354 | assert c.dtype.names == tuple(sorted(self.fields)) 355 | assert len(c) == 1 356 | c = self.make_catalog(s=[self.stat()] * 3) 357 | assert len(c) == 3 358 | return c 359 | 360 | def test_field_selection(self): 361 | stat = self.stat() 362 | md = self.metadata() 363 | c = self.cataloger([Structure(zip(*stat.indices), stat.values)], md, fields=['x_cen']) 364 | assert c.dtype.names == ('_idx', 'x_cen',) 365 | 366 | 367 | class TestPPVCataloger(TestCataloger): 368 | fields = ['_idx', 'flux', 369 | 'major_sigma', 'minor_sigma', 'radius', 'area_ellipse', 370 | 'area_exact', 'v_rms', 'position_angle', 'x_cen', 'y_cen', 'v_cen'] 371 | cataloger = staticmethod(ppv_catalog) 372 | 373 | def stat(self): 374 | return benchmark_stat() 375 | 376 | def metadata(self): 377 | return dict(vaxis=1, data_unit=u.Jy, wcs=wcs_3d) 378 | 379 | 380 | class TestPPCataloger(TestCataloger): 381 | fields = ['_idx', 'flux', 382 | 'major_sigma', 'minor_sigma', 'radius', 'area_ellipse', 383 | 'area_exact', 'position_angle', 'x_cen', 'y_cen'] 384 | cataloger = staticmethod(pp_catalog) 385 | 386 | def stat(self): 387 | bs = benchmark_stat() 388 | bs.indices = (bs.indices[1], bs.indices[2]) 389 | return bs 390 | 391 | def metadata(self): 392 | return dict(data_unit=u.Jy, wcs=wcs_2d) 393 | 394 | 395 | def test_wraparound_catalog(): 396 | 397 | x_centered = np.array( 398 | [[0, 1, 0, 0, 0, 0], 399 | [0, 1, 1, 1, 0, 0], 400 | [0, 0, 0, 0, 0, 0]] 401 | ) 402 | # same structure as x_centered, but shifted to the boundary 403 | x_straddling = np.array( 404 | [[0, 0, 0, 0, 0, 1], 405 | [1, 1, 0, 0, 0, 1], 406 | [0, 0, 0, 0, 0, 0]] 407 | ) 408 | 409 | d_centered = Dendrogram.compute(x_centered, min_value=0.5, 410 | neighbours=periodic_neighbours(1)) 411 | 412 | d_straddling = Dendrogram.compute(x_straddling, min_value=0.5, 413 | neighbours=periodic_neighbours(1)) 414 | 415 | assert len(d_centered) == len(d_straddling) 416 | 417 | metadata = {'data_unit': u.Jy} # dummy unit to get the catalog to compute 418 | 419 | catalog_centered = pp_catalog(d_centered, metadata) 420 | catalog_straddling = pp_catalog(d_straddling, metadata) 421 | 422 | assert catalog_centered['major_sigma'][0] == catalog_straddling['major_sigma'][0] 423 | assert catalog_centered['position_angle'][0] == catalog_straddling['position_angle'][0] 424 | assert catalog_centered['radius'][0] == catalog_straddling['radius'][0] 425 | assert catalog_centered['area_exact'][0] == catalog_straddling['area_exact'][0] 426 | 427 | assert catalog_centered['x_cen'][0] == catalog_straddling['x_cen'][0] - 4 # offset by 4 px 428 | assert catalog_centered['y_cen'][0] == catalog_straddling['y_cen'][0] 429 | 430 | # default behavior is to NOT join structures on data edges, let's make sure that we aren't fooling ourselves. 431 | d_broken = Dendrogram.compute(x_straddling, min_value=0.5) 432 | assert len(d_straddling) != len(d_broken) 433 | 434 | 435 | # don't let pytest test abstract class 436 | del TestCataloger 437 | 438 | 439 | def test_metadata_protocol(): 440 | class Foo(object): 441 | x = Metadata('x', 'test') 442 | y = Metadata('y', 'test', default=5) 443 | z = Metadata('z', 'test', strict=True) 444 | 445 | def __init__(self, md): 446 | self.metadata = md 447 | 448 | f = Foo(dict(x=10)) 449 | assert f.x == 10 450 | assert f.y == 5 451 | with pytest.raises(KeyError): 452 | f.z 453 | --------------------------------------------------------------------------------