diff --git a/.github/workflows/joss_paper_draft.yml b/.github/workflows/joss_paper_draft.yml index 7ecd4a66..6d39820d 100644 --- a/.github/workflows/joss_paper_draft.yml +++ b/.github/workflows/joss_paper_draft.yml @@ -1,3 +1,6 @@ +# This modification is heavily based on GitHub user zonca's version linked +# in https://github.com/openjournals/joss/issues/132#issuecomment-890440692 + # The overall name of this action name: Compile draft XGA JOSS Paper @@ -22,16 +25,34 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 - # Then use the JOSS action to build the paper - - name: Build draft PDF - uses: openjournals/openjournals-draft-action@master - # Sets the specific journal to be built for, and where the Markdown file lives + - name: Build TeX and PDF + uses: docker://openjournals/paperdraft:latest with: - journal: joss - paper-path: paper/paper.md + args: '-k paper/paper.md' + # Sets the specific journal to be built for, and where the Markdown file lives + env: + GIT_SHA: $GITHUB_SHA + JOURNAL: joss - name: Upload uses: actions/upload-artifact@v1 with: name: paper - # This is the output path where Pandoc will write the compiled PDF - path: paper/paper.pdf \ No newline at end of file + path: paper/ + +# # Sets up the steps of the job, firstly we check out the master branch +# steps: +# - name: Checkout +# uses: actions/checkout@v2 +# # Then use the JOSS action to build the paper +# - name: Build draft PDF +# uses: openjournals/openjournals-draft-action@master +# # Sets the specific journal to be built for, and where the Markdown file lives +# with: +# journal: joss +# paper-path: paper/paper.md +# - name: Upload +# uses: actions/upload-artifact@v1 +# with: +# name: paper +# # This is the output path where Pandoc will write the compiled PDF +# path: paper/paper.pdf diff --git a/.github/workflows/publish_to_pypi.yml b/.github/workflows/publish_to_pypi.yml index 9c5a3b11..af32bc5b 100644 --- a/.github/workflows/publish_to_pypi.yml +++ b/.github/workflows/publish_to_pypi.yml @@ -41,14 +41,17 @@ jobs: build --user +# - name: Build a binary wheel and source tarball +# run: >- +# python -m +# build +# --sdist +# --wheel +# --outdir dist/ +# . - name: Build a binary wheel and source tarball run: >- - python -m - build - --sdist - --wheel - --outdir dist/ - . + python3 setup.py sdist bdist_wheel # Then the module is published to the real PyPI index - name: Publish to PyPI diff --git a/.gitignore b/.gitignore index 84f19cb3..8b8e9156 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /docs/source/notebooks/advanced_tutorials/random_brightness_profile.xga .coverage .pytest_cache -xga_output \ No newline at end of file +xga_output +.ipynb_checkpoints \ No newline at end of file diff --git a/README.md b/README.md index 4dbae985..47972704 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Documentation Status](https://readthedocs.org/projects/xga/badge/?version=latest)](https://xga.readthedocs.io/en/latest/?badge=latest) [![Coverage Percentage](https://raw.githubusercontent.com/DavidT3/XGA/master/tests/coverage_badge.svg)](https://raw.githubusercontent.com/DavidT3/XGA/master/tests/coverage_badge.svg) -# What is XMM: Generate and Analyse (XGA)? +# What is X-ray: Generate and Analyse (XGA)? XGA is a Python module designed to make it easy to analyse X-ray sources that have been observed by the XMM-Newton Space telescope. It is based around declaring different types of source and sample objects which correspond to real X-ray sources, finding all available data, and then insulating the user from the tedious generation and basic analysis of X-ray data products. @@ -17,6 +17,16 @@ This module also supports more complex analyses for specific object types; the e This is a slightly more complex installation than many Python modules, but shouldn't be too difficult. If you're having issues feel free to contact me. +## Data Required to use XGA +### Cleaned Event Lists +**This is very important** - Currently, to make use of this module, you **must** have access to cleaned XMM-Newton +event lists, as XGA is not yet capable of producing them itself. + +### Region Files +It will be beneficial if you have region files available, as it will allow XGA to remove interloper sources. If you +wish to use existing region files, then they must be in a DS9 compatible format, **point sources** must be **red** and +**extended sources** must be **green**. + ## The Module XGA has been uploaded to PyPi, so you can simply run: ```shell script @@ -34,8 +44,7 @@ python setup.py install XGA depends on two non-Python pieces of software: * XMM's Science Analysis System (SAS) - Version 17.0.0, but other versions should be largely compatible with the software. SAS version 14.0.0 however, does not support features that PSF correction of images depends on. -* HEASoft's XSPEC - Version 12.10.1, **I can't guarantee later versions will work.** -* HEASoft's XSPEC - Version 12.10.1, **I can't guarantee later versions will work.** +* HEASoft's XSPEC - Version 12.10.1, but other versions should be largely compatible even if I have not tested them. All required Python modules can be found in requirements.txt, and should be added to your system during the installation of XGA. @@ -106,7 +115,7 @@ If you encounter a bug, or would like to make a feature request, please use the [issues](https://github.com/DavidT3/XGA/issues) page, it really helps to keep track of everything. However, if you have further questions, or just want to make doubly sure I notice the issue, feel free to send -me an email at david.turner@sussex.ac.uk +me an email at turne540@msu.edu diff --git a/derivations/hydrostatic_mass_models.ipynb b/derivations/hydrostatic_mass_models.ipynb new file mode 100644 index 00000000..19dcc8c5 --- /dev/null +++ b/derivations/hydrostatic_mass_models.ipynb @@ -0,0 +1,497 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2127f69d", + "metadata": {}, + "source": [ + "# Galaxy Cluster Hydrostatic Mass Models" + ] + }, + { + "cell_type": "markdown", + "id": "1d0e5d99", + "metadata": {}, + "source": [ + "This notebook is one of a series where important derivations are presented and explained. In this we derive analytical hydrostatic mass models from common temperature and density profile models used for galaxy clusters." + ] + }, + { + "cell_type": "markdown", + "id": "781f78e9", + "metadata": {}, + "source": [ + "## Import Statements" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "90382133", + "metadata": {}, + "outputs": [], + "source": [ + "from sympy import symbols, simplify, sqrt, diff, solve, latex\n", + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "id": "b979dfc6", + "metadata": {}, + "source": [ + "## Defining gas temperature models" + ] + }, + { + "cell_type": "markdown", + "id": "f5ad4fff", + "metadata": {}, + "source": [ + "Define the full Vikhlinin temperature model, as well as a simplified one presented by Ghirardini in 2019. We also use SymPy to derive the analytical derivatives with radius, which is also necessary for the calculation of hydrostatic mass." + ] + }, + { + "cell_type": "markdown", + "id": "c91e16fe", + "metadata": {}, + "source": [ + "### Full Vikhlinin temperature model" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "66da5921", + "metadata": {}, + "outputs": [], + "source": [ + "r, r_cool, a_cool, t_min, t_0, r_tran, a, b, c = symbols('r r_cool a_cool t_min t_0 r_tran a b c')" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "6facd772", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle \\frac{t_{0} \\left(\\frac{r}{r_{tran}}\\right)^{- a} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + \\frac{t_{min}}{t_{0}}\\right) \\left(\\left(\\frac{r}{r_{tran}}\\right)^{b} + 1\\right)^{- \\frac{c}{b}}}{\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1}$" + ], + "text/plain": [ + "t_0*(r/r_tran)**(-a)*((r/r_cool)**a_cool + t_min/t_0)*((r/r_tran)**b + 1)**(-c/b)/((r/r_cool)**a_cool + 1)" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "power_rad_ratio = (r / r_cool)**a_cool\n", + "t_cool = (power_rad_ratio + (t_min / t_0)) / (power_rad_ratio + 1)\n", + "\n", + "rad_ratio = r / r_tran\n", + "t_outer = rad_ratio**(-a) / (1 + rad_ratio**b)**(c / b)\n", + "full_vikh_temp = t_0 * t_cool * t_outer\n", + "full_vikh_temp" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "57c07352", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle \\frac{\\left(\\frac{r}{r_{tran}}\\right)^{- 3 a} \\left(\\left(\\frac{r}{r_{tran}}\\right)^{b} + 1\\right)^{- \\frac{b + 3 c}{b}} \\left(- a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(\\frac{r}{r_{tran}}\\right)^{2 a} \\left(\\left(\\frac{r}{r_{tran}}\\right)^{b} + 1\\right)^{\\frac{b + 2 c}{b}} \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) - c \\left(\\frac{r}{r_{tran}}\\right)^{2 a + b} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(\\left(\\frac{r}{r_{tran}}\\right)^{b} + 1\\right)^{\\frac{2 c}{b}} \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + \\left(\\frac{r}{r_{tran}}\\right)^{2 a} \\left(- a \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(\\left(\\frac{r}{r_{tran}}\\right)^{b} + 1\\right)^{\\frac{b + 2 c}{b}}\\right)}{r \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right)^{2}}$" + ], + "text/plain": [ + "(r/r_tran)**(-3*a)*((r/r_tran)**b + 1)**(-(b + 3*c)/b)*(-a_cool*(r/r_cool)**a_cool*(r/r_tran)**(2*a)*((r/r_tran)**b + 1)**((b + 2*c)/b)*(t_0*(r/r_cool)**a_cool + t_min) - c*(r/r_tran)**(2*a + b)*((r/r_cool)**a_cool + 1)*((r/r_tran)**b + 1)**(2*c/b)*(t_0*(r/r_cool)**a_cool + t_min) + (r/r_tran)**(2*a)*(-a*(t_0*(r/r_cool)**a_cool + t_min) + a_cool*t_0*(r/r_cool)**a_cool)*((r/r_cool)**a_cool + 1)*((r/r_tran)**b + 1)**((b + 2*c)/b))/(r*((r/r_cool)**a_cool + 1)**2)" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "full_vikh_temp_diff = simplify(diff(full_vikh_temp, r))\n", + "full_vikh_temp_diff" + ] + }, + { + "cell_type": "markdown", + "id": "e5b7dbd2", + "metadata": {}, + "source": [ + "### Simplified Vikhlinin temperature model" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "5fb59491", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle \\frac{t_{0} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + \\frac{t_{min}}{t_{0}}\\right) \\left(\\frac{r^{2}}{r_{tran}^{2}} + 1\\right)^{- \\frac{c}{2}}}{\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1}$" + ], + "text/plain": [ + "t_0*((r/r_cool)**a_cool + t_min/t_0)*(r**2/r_tran**2 + 1)**(-c/2)/((r/r_cool)**a_cool + 1)" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cool_expr = ((t_min / t_0) + (r / r_cool)**a_cool) / (1 + (r / r_cool)**a_cool)\n", + "out_expr = 1 / ((1 + (r / r_tran)**2)**(c / 2))\n", + "simp_vikh_temp = t_0 * cool_expr * out_expr\n", + "simp_vikh_temp" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "fe3fea2b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle \\frac{\\left(\\frac{r^{2} + r_{tran}^{2}}{r_{tran}^{2}}\\right)^{- \\frac{c}{2}} \\left(a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) - a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) - c r^{2} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right)\\right)}{r \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right)^{2}}$" + ], + "text/plain": [ + "((r**2 + r_tran**2)/r_tran**2)**(-c/2)*(a_cool*t_0*(r/r_cool)**a_cool*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1) - a_cool*(r/r_cool)**a_cool*(r**2 + r_tran**2)*(t_0*(r/r_cool)**a_cool + t_min) - c*r**2*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min))/(r*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)**2)" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "simp_vikh_temp_diff = simplify(diff(simp_vikh_temp, r))\n", + "simp_vikh_temp_diff" + ] + }, + { + "cell_type": "markdown", + "id": "aacb94f8", + "metadata": {}, + "source": [ + "## Defining gas density models" + ] + }, + { + "cell_type": "markdown", + "id": "e4f5eb90", + "metadata": {}, + "source": [ + "Define the full Vikhlinin density model, as well as a simplified one presented by Ghirardini in 2019. We also use SymPy to derive the analytical derivatives with radius, which is also necessary for the calculation of hydrostatic mass." + ] + }, + { + "cell_type": "markdown", + "id": "c7bb4573", + "metadata": {}, + "source": [ + "### Full Vikhlinin density model" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "4feb402f", + "metadata": {}, + "outputs": [], + "source": [ + "r_c1, r_c2, r_s, alpha, beta_1, gamma, epsilon, gamma, beta_2, N_1, N_2 = \\\n", + "symbols('r_c1 r_c2 r_s alpha beta_1 gamma epsilon gamma beta_2 N_1 N_2')" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "6e73e320", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle \\sqrt{N_{1}^{2} \\left(\\frac{r}{r_{c1}}\\right)^{- \\alpha} \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{- \\frac{\\epsilon}{\\gamma}} \\left(\\frac{r^{2}}{r_{c1}^{2}} + 1\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} + N_{2}^{2} \\left(\\frac{r^{2}}{r_{c2}^{2}} + 1\\right)^{- 3 \\beta_{2}}}$" + ], + "text/plain": [ + "sqrt(N_1**2*(r/r_c1)**(-alpha)*((r/r_s)**gamma + 1)**(-epsilon/gamma)*(r**2/r_c1**2 + 1)**(alpha/2 - 3*beta_1) + N_2**2*(r**2/r_c2**2 + 1)**(-3*beta_2))" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rc1_rat = r / r_c1\n", + "rc2_rat = r / r_c2\n", + "rs_rat = r / r_s\n", + "\n", + "first_term = rc1_rat**(-alpha) / ((1 + rc1_rat**2)**((3 * beta_1) - (alpha / 2)))\n", + "second_term = 1 / ((1 + rs_rat**gamma)**(epsilon / gamma))\n", + "additive_term = 1 / ((1 + rc2_rat**2)**(3 * beta_2))\n", + "\n", + "full_vikh_dens = sqrt((np.power(N_1, 2) * first_term * second_term) + (np.power(N_2, 2) * additive_term))\n", + "full_vikh_dens" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "50a8bf46", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle \\frac{\\left(\\frac{r}{r_{c1}}\\right)^{- \\alpha} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{- 3 \\beta_{2}} \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{- \\frac{3 \\epsilon + \\gamma}{\\gamma}} \\left(- N_{1}^{2} \\alpha \\left(\\frac{r^{2} + r_{c1}^{2}}{r_{c1}^{2}}\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{3 \\beta_{2}} \\left(r^{2} + r_{c1}^{2}\\right) \\left(r^{2} + r_{c2}^{2}\\right) \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{\\frac{2 \\epsilon + \\gamma}{\\gamma}} - N_{1}^{2} \\epsilon \\left(\\frac{r}{r_{s}}\\right)^{\\gamma} \\left(\\frac{r^{2} + r_{c1}^{2}}{r_{c1}^{2}}\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{3 \\beta_{2}} \\left(r^{2} + r_{c1}^{2}\\right) \\left(r^{2} + r_{c2}^{2}\\right) \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{\\frac{2 \\epsilon}{\\gamma}} + N_{1}^{2} r^{2} \\left(\\frac{r^{2} + r_{c1}^{2}}{r_{c1}^{2}}\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{3 \\beta_{2}} \\left(\\alpha - 6 \\beta_{1}\\right) \\left(r^{2} + r_{c2}^{2}\\right) \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{\\frac{2 \\epsilon + \\gamma}{\\gamma}} - 6 N_{2}^{2} \\beta_{2} r^{2} \\left(\\frac{r}{r_{c1}}\\right)^{\\alpha} \\left(r^{2} + r_{c1}^{2}\\right) \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{\\frac{3 \\epsilon + \\gamma}{\\gamma}}\\right)}{2 r \\sqrt{\\left(\\frac{r}{r_{c1}}\\right)^{- \\alpha} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{- 3 \\beta_{2}} \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{- \\frac{\\epsilon}{\\gamma}} \\left(N_{1}^{2} \\left(\\frac{r^{2} + r_{c1}^{2}}{r_{c1}^{2}}\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{3 \\beta_{2}} + N_{2}^{2} \\left(\\frac{r}{r_{c1}}\\right)^{\\alpha} \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{\\frac{\\epsilon}{\\gamma}}\\right)} \\left(r^{2} + r_{c1}^{2}\\right) \\left(r^{2} + r_{c2}^{2}\\right)}$" + ], + "text/plain": [ + "(r/r_c1)**(-alpha)*((r**2 + r_c2**2)/r_c2**2)**(-3*beta_2)*((r/r_s)**gamma + 1)**(-(3*epsilon + gamma)/gamma)*(-N_1**2*alpha*((r**2 + r_c1**2)/r_c1**2)**(alpha/2 - 3*beta_1)*((r**2 + r_c2**2)/r_c2**2)**(3*beta_2)*(r**2 + r_c1**2)*(r**2 + r_c2**2)*((r/r_s)**gamma + 1)**((2*epsilon + gamma)/gamma) - N_1**2*epsilon*(r/r_s)**gamma*((r**2 + r_c1**2)/r_c1**2)**(alpha/2 - 3*beta_1)*((r**2 + r_c2**2)/r_c2**2)**(3*beta_2)*(r**2 + r_c1**2)*(r**2 + r_c2**2)*((r/r_s)**gamma + 1)**(2*epsilon/gamma) + N_1**2*r**2*((r**2 + r_c1**2)/r_c1**2)**(alpha/2 - 3*beta_1)*((r**2 + r_c2**2)/r_c2**2)**(3*beta_2)*(alpha - 6*beta_1)*(r**2 + r_c2**2)*((r/r_s)**gamma + 1)**((2*epsilon + gamma)/gamma) - 6*N_2**2*beta_2*r**2*(r/r_c1)**alpha*(r**2 + r_c1**2)*((r/r_s)**gamma + 1)**((3*epsilon + gamma)/gamma))/(2*r*sqrt((r/r_c1)**(-alpha)*((r**2 + r_c2**2)/r_c2**2)**(-3*beta_2)*((r/r_s)**gamma + 1)**(-epsilon/gamma)*(N_1**2*((r**2 + r_c1**2)/r_c1**2)**(alpha/2 - 3*beta_1)*((r**2 + r_c2**2)/r_c2**2)**(3*beta_2) + N_2**2*(r/r_c1)**alpha*((r/r_s)**gamma + 1)**(epsilon/gamma)))*(r**2 + r_c1**2)*(r**2 + r_c2**2))" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "full_vikh_dens_diff = simplify(diff(full_vikh_dens, r))\n", + "full_vikh_dens_diff" + ] + }, + { + "cell_type": "markdown", + "id": "e99ef0fb", + "metadata": {}, + "source": [ + "### Simplified Vikhlinin density model" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "5f9682ed", + "metadata": {}, + "outputs": [], + "source": [ + "r_c, beta, N = symbols('r_c beta N')" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "04eae3a1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle N \\sqrt{\\left(\\frac{r}{r_{c}}\\right)^{- \\alpha} \\left(\\frac{r^{2}}{r_{c}^{2}} + 1\\right)^{\\frac{\\alpha}{2} - 3 \\beta} \\left(\\frac{r^{3}}{r_{s}^{3}} + 1\\right)^{- \\frac{\\epsilon}{3}}}$" + ], + "text/plain": [ + "N*sqrt((r/r_c)**(-alpha)*(r**2/r_c**2 + 1)**(alpha/2 - 3*beta)*(r**3/r_s**3 + 1)**(-epsilon/3))" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rc_rat = r / r_c\n", + "rs_rat = r / r_s\n", + "\n", + "first_term = rc_rat**(-alpha) / ((1+rc_rat**2)**((3 * beta) - (alpha / 2)))\n", + "second_term = 1 / ((1 + rs_rat**3)**(epsilon / 3))\n", + "simp_vikh_dens = N * sqrt(first_term * second_term)\n", + "simp_vikh_dens" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "d6b1b621", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle - \\frac{N \\sqrt{\\left(\\frac{r}{r_{c}}\\right)^{- \\alpha} \\left(\\frac{r^{2} + r_{c}^{2}}{r_{c}^{2}}\\right)^{\\frac{\\alpha}{2} - 3 \\beta} \\left(\\frac{r^{3} + r_{s}^{3}}{r_{s}^{3}}\\right)^{- \\frac{\\epsilon}{3}}} \\left(\\alpha r^{3} r_{c}^{2} + \\alpha r_{c}^{2} r_{s}^{3} + 6 \\beta r^{5} + 6 \\beta r^{2} r_{s}^{3} + \\epsilon r^{5} + \\epsilon r^{3} r_{c}^{2}\\right)}{2 r \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right)}$" + ], + "text/plain": [ + "-N*sqrt((r/r_c)**(-alpha)*((r**2 + r_c**2)/r_c**2)**(alpha/2 - 3*beta)*((r**3 + r_s**3)/r_s**3)**(-epsilon/3))*(alpha*r**3*r_c**2 + alpha*r_c**2*r_s**3 + 6*beta*r**5 + 6*beta*r**2*r_s**3 + epsilon*r**5 + epsilon*r**3*r_c**2)/(2*r*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3))" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "simp_vikh_dens_diff = simplify(diff(simp_vikh_dens, r))\n", + "simp_vikh_dens_diff" + ] + }, + { + "cell_type": "markdown", + "id": "cfc7340e", + "metadata": {}, + "source": [ + "## Defining hydrostatic mass models" + ] + }, + { + "cell_type": "markdown", + "id": "d8b62a56", + "metadata": {}, + "source": [ + "Using a slightly different form of the hydrostatic mass equation (the derivation is altered slightly so there are no dln F / dr, just dF/dr terms), we substitute in temperature and density models (as well as their derivatives) to write the analytical mass model that results from those models.\n", + "\n", + "We do this for both the full Vikhlinin models and the simplified versions, we also show an analytical first derivative for the simplified Vikhlinin mass model." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "bfa04414", + "metadata": {}, + "outputs": [], + "source": [ + "k_B, mu, m_u, G = symbols('k_B mu m_u G')" + ] + }, + { + "cell_type": "markdown", + "id": "4d6cd383", + "metadata": {}, + "source": [ + "### Full Vikhlinin hydrostatic mass model" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "57acaf66", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle - \\frac{k_{B} r^{2} \\left(\\frac{t_{0} \\left(\\frac{r}{r_{c1}}\\right)^{- \\alpha} \\left(\\frac{r}{r_{tran}}\\right)^{- a} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{- 3 \\beta_{2}} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + \\frac{t_{min}}{t_{0}}\\right) \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{- \\frac{3 \\epsilon + \\gamma}{\\gamma}} \\left(\\left(\\frac{r}{r_{tran}}\\right)^{b} + 1\\right)^{- \\frac{c}{b}} \\left(- N_{1}^{2} \\alpha \\left(\\frac{r^{2} + r_{c1}^{2}}{r_{c1}^{2}}\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{3 \\beta_{2}} \\left(r^{2} + r_{c1}^{2}\\right) \\left(r^{2} + r_{c2}^{2}\\right) \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{\\frac{2 \\epsilon + \\gamma}{\\gamma}} - N_{1}^{2} \\epsilon \\left(\\frac{r}{r_{s}}\\right)^{\\gamma} \\left(\\frac{r^{2} + r_{c1}^{2}}{r_{c1}^{2}}\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{3 \\beta_{2}} \\left(r^{2} + r_{c1}^{2}\\right) \\left(r^{2} + r_{c2}^{2}\\right) \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{\\frac{2 \\epsilon}{\\gamma}} + N_{1}^{2} r^{2} \\left(\\frac{r^{2} + r_{c1}^{2}}{r_{c1}^{2}}\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{3 \\beta_{2}} \\left(\\alpha - 6 \\beta_{1}\\right) \\left(r^{2} + r_{c2}^{2}\\right) \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{\\frac{2 \\epsilon + \\gamma}{\\gamma}} - 6 N_{2}^{2} \\beta_{2} r^{2} \\left(\\frac{r}{r_{c1}}\\right)^{\\alpha} \\left(r^{2} + r_{c1}^{2}\\right) \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{\\frac{3 \\epsilon + \\gamma}{\\gamma}}\\right)}{2 r \\sqrt{\\left(\\frac{r}{r_{c1}}\\right)^{- \\alpha} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{- 3 \\beta_{2}} \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{- \\frac{\\epsilon}{\\gamma}} \\left(N_{1}^{2} \\left(\\frac{r^{2} + r_{c1}^{2}}{r_{c1}^{2}}\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} \\left(\\frac{r^{2} + r_{c2}^{2}}{r_{c2}^{2}}\\right)^{3 \\beta_{2}} + N_{2}^{2} \\left(\\frac{r}{r_{c1}}\\right)^{\\alpha} \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{\\frac{\\epsilon}{\\gamma}}\\right)} \\left(r^{2} + r_{c1}^{2}\\right) \\left(r^{2} + r_{c2}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right)} + \\frac{\\left(\\frac{r}{r_{tran}}\\right)^{- 3 a} \\left(\\left(\\frac{r}{r_{tran}}\\right)^{b} + 1\\right)^{- \\frac{b + 3 c}{b}} \\sqrt{N_{1}^{2} \\left(\\frac{r}{r_{c1}}\\right)^{- \\alpha} \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{- \\frac{\\epsilon}{\\gamma}} \\left(\\frac{r^{2}}{r_{c1}^{2}} + 1\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} + N_{2}^{2} \\left(\\frac{r^{2}}{r_{c2}^{2}} + 1\\right)^{- 3 \\beta_{2}}} \\left(- a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(\\frac{r}{r_{tran}}\\right)^{2 a} \\left(\\left(\\frac{r}{r_{tran}}\\right)^{b} + 1\\right)^{\\frac{b + 2 c}{b}} \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) - c \\left(\\frac{r}{r_{tran}}\\right)^{2 a + b} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(\\left(\\frac{r}{r_{tran}}\\right)^{b} + 1\\right)^{\\frac{2 c}{b}} \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + \\left(\\frac{r}{r_{tran}}\\right)^{2 a} \\left(- a \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(\\left(\\frac{r}{r_{tran}}\\right)^{b} + 1\\right)^{\\frac{b + 2 c}{b}}\\right)}{r \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right)^{2}}\\right)}{G m_{u} \\mu \\sqrt{N_{1}^{2} \\left(\\frac{r}{r_{c1}}\\right)^{- \\alpha} \\left(\\left(\\frac{r}{r_{s}}\\right)^{\\gamma} + 1\\right)^{- \\frac{\\epsilon}{\\gamma}} \\left(\\frac{r^{2}}{r_{c1}^{2}} + 1\\right)^{\\frac{\\alpha}{2} - 3 \\beta_{1}} + N_{2}^{2} \\left(\\frac{r^{2}}{r_{c2}^{2}} + 1\\right)^{- 3 \\beta_{2}}}}$" + ], + "text/plain": [ + "-k_B*r**2*(t_0*(r/r_c1)**(-alpha)*(r/r_tran)**(-a)*((r**2 + r_c2**2)/r_c2**2)**(-3*beta_2)*((r/r_cool)**a_cool + t_min/t_0)*((r/r_s)**gamma + 1)**(-(3*epsilon + gamma)/gamma)*((r/r_tran)**b + 1)**(-c/b)*(-N_1**2*alpha*((r**2 + r_c1**2)/r_c1**2)**(alpha/2 - 3*beta_1)*((r**2 + r_c2**2)/r_c2**2)**(3*beta_2)*(r**2 + r_c1**2)*(r**2 + r_c2**2)*((r/r_s)**gamma + 1)**((2*epsilon + gamma)/gamma) - N_1**2*epsilon*(r/r_s)**gamma*((r**2 + r_c1**2)/r_c1**2)**(alpha/2 - 3*beta_1)*((r**2 + r_c2**2)/r_c2**2)**(3*beta_2)*(r**2 + r_c1**2)*(r**2 + r_c2**2)*((r/r_s)**gamma + 1)**(2*epsilon/gamma) + N_1**2*r**2*((r**2 + r_c1**2)/r_c1**2)**(alpha/2 - 3*beta_1)*((r**2 + r_c2**2)/r_c2**2)**(3*beta_2)*(alpha - 6*beta_1)*(r**2 + r_c2**2)*((r/r_s)**gamma + 1)**((2*epsilon + gamma)/gamma) - 6*N_2**2*beta_2*r**2*(r/r_c1)**alpha*(r**2 + r_c1**2)*((r/r_s)**gamma + 1)**((3*epsilon + gamma)/gamma))/(2*r*sqrt((r/r_c1)**(-alpha)*((r**2 + r_c2**2)/r_c2**2)**(-3*beta_2)*((r/r_s)**gamma + 1)**(-epsilon/gamma)*(N_1**2*((r**2 + r_c1**2)/r_c1**2)**(alpha/2 - 3*beta_1)*((r**2 + r_c2**2)/r_c2**2)**(3*beta_2) + N_2**2*(r/r_c1)**alpha*((r/r_s)**gamma + 1)**(epsilon/gamma)))*(r**2 + r_c1**2)*(r**2 + r_c2**2)*((r/r_cool)**a_cool + 1)) + (r/r_tran)**(-3*a)*((r/r_tran)**b + 1)**(-(b + 3*c)/b)*sqrt(N_1**2*(r/r_c1)**(-alpha)*((r/r_s)**gamma + 1)**(-epsilon/gamma)*(r**2/r_c1**2 + 1)**(alpha/2 - 3*beta_1) + N_2**2*(r**2/r_c2**2 + 1)**(-3*beta_2))*(-a_cool*(r/r_cool)**a_cool*(r/r_tran)**(2*a)*((r/r_tran)**b + 1)**((b + 2*c)/b)*(t_0*(r/r_cool)**a_cool + t_min) - c*(r/r_tran)**(2*a + b)*((r/r_cool)**a_cool + 1)*((r/r_tran)**b + 1)**(2*c/b)*(t_0*(r/r_cool)**a_cool + t_min) + (r/r_tran)**(2*a)*(-a*(t_0*(r/r_cool)**a_cool + t_min) + a_cool*t_0*(r/r_cool)**a_cool)*((r/r_cool)**a_cool + 1)*((r/r_tran)**b + 1)**((b + 2*c)/b))/(r*((r/r_cool)**a_cool + 1)**2))/(G*m_u*mu*sqrt(N_1**2*(r/r_c1)**(-alpha)*((r/r_s)**gamma + 1)**(-epsilon/gamma)*(r**2/r_c1**2 + 1)**(alpha/2 - 3*beta_1) + N_2**2*(r**2/r_c2**2 + 1)**(-3*beta_2)))" + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "full_vikh_hymm = ((-k_B*r**2) / (full_vikh_dens*mu*m_u*G))*(full_vikh_dens*full_vikh_temp_diff + \n", + " full_vikh_temp*full_vikh_dens_diff)\n", + "# full_vikh_hymm = simplify(full_vikh_hymm)\n", + "full_vikh_hymm" + ] + }, + { + "cell_type": "markdown", + "id": "bd107bc5", + "metadata": {}, + "source": [ + "### Simplified Vikhlinin hydrostatic mass model" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "id": "d51ca89d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle \\frac{k_{B} r \\left(\\frac{r^{2} + r_{tran}^{2}}{r_{tran}^{2}}\\right)^{- \\frac{c}{2}} \\left(\\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) \\left(\\alpha r^{3} r_{c}^{2} + \\alpha r_{c}^{2} r_{s}^{3} + 6 \\beta r^{5} + 6 \\beta r^{2} r_{s}^{3} + \\epsilon r^{5} + \\epsilon r^{3} r_{c}^{2}\\right) + 2 \\left(- a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) + a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + c r^{2} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right)\\right) \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right)\\right)}{2 G m_{u} \\mu \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right)^{2} \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right)}$" + ], + "text/plain": [ + "k_B*r*((r**2 + r_tran**2)/r_tran**2)**(-c/2)*((r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min)*(alpha*r**3*r_c**2 + alpha*r_c**2*r_s**3 + 6*beta*r**5 + 6*beta*r**2*r_s**3 + epsilon*r**5 + epsilon*r**3*r_c**2) + 2*(-a_cool*t_0*(r/r_cool)**a_cool*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1) + a_cool*(r/r_cool)**a_cool*(r**2 + r_tran**2)*(t_0*(r/r_cool)**a_cool + t_min) + c*r**2*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min))*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3))/(2*G*m_u*mu*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)**2*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3))" + ] + }, + "execution_count": 35, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "simp_vikh_hymm = ((-k_B*r**2) / (simp_vikh_dens*mu*m_u*G))*(simp_vikh_dens*simp_vikh_temp_diff + \n", + " simp_vikh_temp*simp_vikh_dens_diff)\n", + "simp_vikh_hymm = simplify(simp_vikh_hymm)\n", + "simp_vikh_hymm" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "id": "80e43741", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle \\frac{k_{B} \\left(\\frac{r^{2} + r_{tran}^{2}}{r_{tran}^{2}}\\right)^{- \\frac{c}{2}} \\left(- 2 a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) \\left(\\alpha r^{3} r_{c}^{2} + \\alpha r_{c}^{2} r_{s}^{3} + 6 \\beta r^{5} + 6 \\beta r^{2} r_{s}^{3} + \\epsilon r^{5} + \\epsilon r^{3} r_{c}^{2}\\right) + 2 \\left(- a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) + a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + c r^{2} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right)\\right) \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right)\\right) \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right) - r^{2} \\left(c + 2\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(\\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) \\left(\\alpha r^{3} r_{c}^{2} + \\alpha r_{c}^{2} r_{s}^{3} + 6 \\beta r^{5} + 6 \\beta r^{2} r_{s}^{3} + \\epsilon r^{5} + \\epsilon r^{3} r_{c}^{2}\\right) + 2 \\left(- a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) + a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + c r^{2} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right)\\right) \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right)\\right) \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right) - r^{2} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(\\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) \\left(\\alpha r^{3} r_{c}^{2} + \\alpha r_{c}^{2} r_{s}^{3} + 6 \\beta r^{5} + 6 \\beta r^{2} r_{s}^{3} + \\epsilon r^{5} + \\epsilon r^{3} r_{c}^{2}\\right) + 2 \\left(- a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) + a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + c r^{2} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right)\\right) \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right)\\right) \\left(5 r^{3} + 3 r r_{c}^{2} + 2 r_{s}^{3}\\right) + \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right) \\left(a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(\\alpha r^{3} r_{c}^{2} + \\alpha r_{c}^{2} r_{s}^{3} + 6 \\beta r^{5} + 6 \\beta r^{2} r_{s}^{3} + \\epsilon r^{5} + \\epsilon r^{3} r_{c}^{2}\\right) + a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) \\left(\\alpha r^{3} r_{c}^{2} + \\alpha r_{c}^{2} r_{s}^{3} + 6 \\beta r^{5} + 6 \\beta r^{2} r_{s}^{3} + \\epsilon r^{5} + \\epsilon r^{3} r_{c}^{2}\\right) + r^{2} \\left(\\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) \\left(3 \\alpha r r_{c}^{2} + 30 \\beta r^{3} + 12 \\beta r_{s}^{3} + 5 \\epsilon r^{3} + 3 \\epsilon r r_{c}^{2}\\right) + 2 \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) \\left(\\alpha r^{3} r_{c}^{2} + \\alpha r_{c}^{2} r_{s}^{3} + 6 \\beta r^{5} + 6 \\beta r^{2} r_{s}^{3} + \\epsilon r^{5} + \\epsilon r^{3} r_{c}^{2}\\right) + 2 \\left(5 r^{3} + 3 r r_{c}^{2} + 2 r_{s}^{3}\\right) \\left(- a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) + a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + c r^{2} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right)\\right)\\right) + \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) \\left(\\alpha r^{3} r_{c}^{2} + \\alpha r_{c}^{2} r_{s}^{3} + 6 \\beta r^{5} + 6 \\beta r^{2} r_{s}^{3} + \\epsilon r^{5} + \\epsilon r^{3} r_{c}^{2}\\right) + 2 \\left(- a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) + a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + c r^{2} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right)\\right) \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right) + 2 \\left(- a_{cool}^{2} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) + a_{cool}^{2} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(r^{2} + r_{tran}^{2}\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + r^{2} \\left(a_{cool} c t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) + a_{cool} c \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) - 2 a_{cool} t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) + 2 a_{cool} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right) + 2 c \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right) \\left(t_{0} \\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + t_{min}\\right)\\right)\\right) \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right)\\right)\\right)}{2 G m_{u} \\mu \\left(r^{2} + r_{tran}^{2}\\right)^{2} \\left(\\left(\\frac{r}{r_{cool}}\\right)^{a_{cool}} + 1\\right)^{3} \\left(r^{5} + r^{3} r_{c}^{2} + r^{2} r_{s}^{3} + r_{c}^{2} r_{s}^{3}\\right)^{2}}$" + ], + "text/plain": [ + "k_B*((r**2 + r_tran**2)/r_tran**2)**(-c/2)*(-2*a_cool*(r/r_cool)**a_cool*(r**2 + r_tran**2)*((r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min)*(alpha*r**3*r_c**2 + alpha*r_c**2*r_s**3 + 6*beta*r**5 + 6*beta*r**2*r_s**3 + epsilon*r**5 + epsilon*r**3*r_c**2) + 2*(-a_cool*t_0*(r/r_cool)**a_cool*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1) + a_cool*(r/r_cool)**a_cool*(r**2 + r_tran**2)*(t_0*(r/r_cool)**a_cool + t_min) + c*r**2*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min))*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3))*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3) - r**2*(c + 2)*((r/r_cool)**a_cool + 1)*((r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min)*(alpha*r**3*r_c**2 + alpha*r_c**2*r_s**3 + 6*beta*r**5 + 6*beta*r**2*r_s**3 + epsilon*r**5 + epsilon*r**3*r_c**2) + 2*(-a_cool*t_0*(r/r_cool)**a_cool*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1) + a_cool*(r/r_cool)**a_cool*(r**2 + r_tran**2)*(t_0*(r/r_cool)**a_cool + t_min) + c*r**2*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min))*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3))*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3) - r**2*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)*((r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min)*(alpha*r**3*r_c**2 + alpha*r_c**2*r_s**3 + 6*beta*r**5 + 6*beta*r**2*r_s**3 + epsilon*r**5 + epsilon*r**3*r_c**2) + 2*(-a_cool*t_0*(r/r_cool)**a_cool*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1) + a_cool*(r/r_cool)**a_cool*(r**2 + r_tran**2)*(t_0*(r/r_cool)**a_cool + t_min) + c*r**2*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min))*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3))*(5*r**3 + 3*r*r_c**2 + 2*r_s**3) + (r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3)*(a_cool*t_0*(r/r_cool)**a_cool*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)*(alpha*r**3*r_c**2 + alpha*r_c**2*r_s**3 + 6*beta*r**5 + 6*beta*r**2*r_s**3 + epsilon*r**5 + epsilon*r**3*r_c**2) + a_cool*(r/r_cool)**a_cool*(r**2 + r_tran**2)*(t_0*(r/r_cool)**a_cool + t_min)*(alpha*r**3*r_c**2 + alpha*r_c**2*r_s**3 + 6*beta*r**5 + 6*beta*r**2*r_s**3 + epsilon*r**5 + epsilon*r**3*r_c**2) + r**2*((r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min)*(3*alpha*r*r_c**2 + 30*beta*r**3 + 12*beta*r_s**3 + 5*epsilon*r**3 + 3*epsilon*r*r_c**2) + 2*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min)*(alpha*r**3*r_c**2 + alpha*r_c**2*r_s**3 + 6*beta*r**5 + 6*beta*r**2*r_s**3 + epsilon*r**5 + epsilon*r**3*r_c**2) + 2*(5*r**3 + 3*r*r_c**2 + 2*r_s**3)*(-a_cool*t_0*(r/r_cool)**a_cool*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1) + a_cool*(r/r_cool)**a_cool*(r**2 + r_tran**2)*(t_0*(r/r_cool)**a_cool + t_min) + c*r**2*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min))) + (r**2 + r_tran**2)*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min)*(alpha*r**3*r_c**2 + alpha*r_c**2*r_s**3 + 6*beta*r**5 + 6*beta*r**2*r_s**3 + epsilon*r**5 + epsilon*r**3*r_c**2) + 2*(-a_cool*t_0*(r/r_cool)**a_cool*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1) + a_cool*(r/r_cool)**a_cool*(r**2 + r_tran**2)*(t_0*(r/r_cool)**a_cool + t_min) + c*r**2*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min))*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3) + 2*(-a_cool**2*t_0*(r/r_cool)**a_cool*(r**2 + r_tran**2)*((r/r_cool)**a_cool + 1) + a_cool**2*(r/r_cool)**a_cool*(r**2 + r_tran**2)*(t_0*(r/r_cool)**a_cool + t_min) + r**2*(a_cool*c*t_0*(r/r_cool)**a_cool*((r/r_cool)**a_cool + 1) + a_cool*c*(r/r_cool)**a_cool*(t_0*(r/r_cool)**a_cool + t_min) - 2*a_cool*t_0*(r/r_cool)**a_cool*((r/r_cool)**a_cool + 1) + 2*a_cool*(r/r_cool)**a_cool*(t_0*(r/r_cool)**a_cool + t_min) + 2*c*((r/r_cool)**a_cool + 1)*(t_0*(r/r_cool)**a_cool + t_min)))*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3)))/(2*G*m_u*mu*(r**2 + r_tran**2)**2*((r/r_cool)**a_cool + 1)**3*(r**5 + r**3*r_c**2 + r**2*r_s**3 + r_c**2*r_s**3)**2)" + ] + }, + "execution_count": 36, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "simplify(diff(simp_vikh_hymm, r))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 04b760a1..89bdcc8b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,3 +1,6 @@ +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors + # Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full @@ -58,8 +61,8 @@ # -- Project information ----------------------------------------------------- -project = 'XMM: Generate and Analyse (XGA)' -copyright = '2021, David J Turner' +project = 'X-ray: Generate and Analyse (XGA)' +copyright = '2022, David J Turner' author = 'David J Turner' # The full version, including alpha/beta/rc tags diff --git a/docs/source/future.rst b/docs/source/future.rst index 57c33283..d94cb7d0 100644 --- a/docs/source/future.rst +++ b/docs/source/future.rst @@ -11,7 +11,7 @@ Planned XGA Features * **Ability to save ScalingRelation objects** - The ability to save ScalingRelation objects to disk in some way, so that code to generate them doesn't need to be run multiple times. -* **Support for other X-ray telescopes** - Support for generation and analysis of data products from other telescopes (I guess than XGA will come to mean 'X-ray: Generate and Analyse', rather than 'XMM: Generate and Analyse'). +* **Support for other X-ray telescopes** - Support for generation and analysis of data products from other telescopes. * **Creating a Docker image for users to download** - Creating a Docker environment with SAS and HEASoft already installed, for ease of use. diff --git a/docs/source/index.rst b/docs/source/index.rst index 60e34776..78ac5e85 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,9 +1,9 @@ -.. XMM: Generate and Analyse (XGA) documentation master file, created by +.. X-ray: Generate and Analyse (XGA) documentation master file, created by sphinx-quickstart on Thu Dec 17 12:18:33 2020. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to XMM: Generate and Analyse (XGA)'s documentation! +Welcome to X-ray: Generate and Analyse (XGA)'s documentation! =========================================================== .. toctree:: :maxdepth: 2 @@ -13,6 +13,7 @@ Welcome to XMM: Generate and Analyse (XGA)'s documentation! Installation Tutorials Advanced Tutorials + XGA Pipelines Under the Hood XGA's Parallelism XGA Classes and Functions @@ -20,3 +21,24 @@ Welcome to XMM: Generate and Analyse (XGA)'s documentation! Publications using XGA Getting Support + +.. code-block:: + :caption: If you make use of XGA in academic work, please cite the following software paper + :name: BibTeX Reference + + @ARTICLE{2022arXiv220201236T, + author = {{Turner}, D.~J. and {Giles}, P.~A. and {Romer}, A.~K. and {Korbina}, V.}, + title = "{XGA: A module for the large-scale scientific exploitation of archival X-ray astronomy data}", + journal = {arXiv e-prints}, + keywords = {Astrophysics - Instrumentation and Methods for Astrophysics, Astrophysics - Cosmology and Nongalactic Astrophysics, Astrophysics - Astrophysics of Galaxies, Astrophysics - High Energy Astrophysical Phenomena}, + year = 2022, + month = feb, + eid = {arXiv:2202.01236}, + pages = {arXiv:2202.01236}, + archivePrefix = {arXiv}, + eprint = {2202.01236}, + primaryClass = {astro-ph.IM}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2022arXiv220201236T}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} + } + diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 961ae853..93a28de0 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -1,7 +1,7 @@ Introduction to XGA =================== -XMM: Generate and Analyse (XGA) is a Python module designed to make it easy to analyse X-ray sources that have been +X-ray: Generate and Analyse (XGA) is a Python module designed to make it easy to analyse X-ray sources that have been observed by the XMM-Newton Space telescope. It is based around declaring different types of source and sample objects which correspond to real X-ray sources, finding all available data, and then insulating the user from the tedious generation and basic analysis of X-ray data products (though with the option to get stuck into the data @@ -21,9 +21,7 @@ to investigate how properties change radially with distance from the centre, and masses of clusters. While XGA is a piece of open source software, I would appreciate it if any work that makes use of it would cite the -Journal of Open Source Software paper that will accompany this package. It is in preparation, but has not yet been -submitted, so if you are going to use this module in a piece of work please get in touch with me so we can work -something out. +paper accompanying this package, which can be found in the :doc:`publications` section. If wish to contribute to XGA, have feature suggestions, or any comments at all, then please go to the "Getting Support" section and submit an issue on GitHub/send me an email, I'll be happy to hear from you! \ No newline at end of file diff --git a/docs/source/notebooks/pipeline_tutorials/LT_pipeline.ipynb b/docs/source/notebooks/pipeline_tutorials/LT_pipeline.ipynb new file mode 100644 index 00000000..0032b438 --- /dev/null +++ b/docs/source/notebooks/pipeline_tutorials/LT_pipeline.ipynb @@ -0,0 +1,1323 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ac38b786", + "metadata": {}, + "source": [ + "# XGA luminosity, temperature, and radius pipeline for galaxy clusters" + ] + }, + { + "cell_type": "markdown", + "id": "05d5080c", + "metadata": {}, + "source": [ + "This notebook serves to demonstrate the use of an XGA pipeline (or 'tool'). These pipelines do not necessarily take advantage of the interactive and highly customisable nature of XGA sources, samples, and general analyses, but will take information on a sample of objects and provide a set output of information without further interaction by the user. \n", + "\n", + "The XGA luminosity, temperature, and radius measurement pipeline for galaxy clusters will take information about the positions, redshifts, and names of a sample of galaxy clusters, and provide the user with a set of overdensity radius, temperature, and luminosity measurements (for those clusters which have available observations). You do not need to know a priori whether a cluster in the input sample has X-ray data available, XGA will determine that for you and simply ignore those clusters which do not.\n", + "\n", + "We explain how the pipeline works, demonstrate its use, and explain the information that it returns and saves to disk (if requested by the user). We also demonstrate that $R_{500}$ measurements using this XGA pipeline are consistent with previous XCS $R_{500}$ measurements. XGA measurements of temperature and luminosity within a specific radius value have been shown to be consistent with multiple previous works, including XCS, LoCuSS, and XXL (Turner et al. in prep). " + ] + }, + { + "cell_type": "markdown", + "id": "dbe4432c", + "metadata": {}, + "source": [ + "## Import Statements" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "de11b9c0", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "from astropy.units import Quantity\n", + "from matplotlib import pyplot as plt\n", + "\n", + "import xga\n", + "xga.NUM_CORES = 80\n", + "from xga.tools import luminosity_temperature_pipeline\n", + "from xga.relations.clusters.RT import arnaud_r500, arnaud_r2500, arnaud_r200" + ] + }, + { + "cell_type": "markdown", + "id": "6c392d4c", + "metadata": {}, + "source": [ + "## How does the pipeline work?" + ] + }, + { + "cell_type": "markdown", + "id": "8b29caa5", + "metadata": {}, + "source": [ + "### Step 1 - Initial aperture and scaling relation" + ] + }, + { + "cell_type": "markdown", + "id": "e4cd87b1", + "metadata": {}, + "source": [ + "Once the pipeline has created an XGA ClusterSample from the data provided, the pipeline will measure an initial temperature and luminosity within the aperture specified by the user (using the `start_aperture` argument). Temperatures are measured from the simultaneous fitting of all observations that are available for a particular cluster, with an absorbed (with tbabs) plasma emission model (apec). See the documentation of the `single_temp_apec` XGA function for more information. The temperature result is used to estimate an $R_{\\Delta}$ (where $\\Delta$ might equal 2500, 500, or 200) from the supplied $R_{\\Delta}$-$T_{\\rm{X}}$ relation.\n", + "\n", + "The scaling relation is what tells the pipeline which overdensity radius you are measuring for, and must be implemented using the XGA ScalingRelation product class. The y-axis label is used to determine which overdensity radius is relevant, and as such any custom relations defined by the user must contain 'R2500', 'R500', or 'R200'. The default relation is the [Arnaud et al. 2005](https://ui.adsabs.harvard.edu/abs/2005A&A...441..893A/abstract) $R_{500}$-$T_{\\rm{X}}$, though XGA also has the corresponding $R_{2500}$ and $R_{200}$ relations available:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "7985c884", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/mnt/pact/dt237/code/PycharmProjects/XGA/xga/products/relation.py:915: UserWarning: Not all of these ScalingRelations have the same y-axis names.\n", + " warn('Not all of these ScalingRelations have the same y-axis names.')\n" + ] + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "(arnaud_r2500 + arnaud_r500 + arnaud_r200).view(figsize=(7, 7))" + ] + }, + { + "cell_type": "markdown", + "id": "583906b5", + "metadata": {}, + "source": [ + "### Step 2 - Iterate until convergence" + ] + }, + { + "cell_type": "markdown", + "id": "8954a203", + "metadata": {}, + "source": [ + "Now that we have used the starting fixed aperture to measure a temperature and estimate a radius, we will iterate and repeat this process, using the new radius estimate to generate/fit a new set of spectra for each cluster, and then using the resulting new temperature measurement to estimate a radius value.\n", + "\n", + "A cluster radius is considered to be converged (which is when it is accepted and will not change anymore) if the new radius estimate is within a certain percentage of the last estimate (the convergence fraction can be set by the user, and the default value is 0.1/10%). \n", + "\n", + "Convergence can only occur once a minimum number of iterations has taken place (the user can set this value, and the default is 3), and the iterative process will exit either when all clusters have 'accepted' radii, or when the maximum number of iterations is reached (again can be set by the user, the default is 10).\n", + "\n", + "It is quite possible that spectral fitting will fail for some of the clusters during one of these iterations. If that is the case then a temperature estimate has not been produced and the pipeline cannot continue to analyse that particular cluster. Such clusters are removed from the XGA sample, though whatever progress they made through the iterations will still be recorded in the radii history dataframe (see step 4)." + ] + }, + { + "cell_type": "markdown", + "id": "41d376c3", + "metadata": {}, + "source": [ + "### Step 3 - Measure results for the final radii" + ] + }, + { + "cell_type": "markdown", + "id": "809c7b85", + "metadata": {}, + "source": [ + "Once the iterative process has concluded, hopefully (but not necessarily) with no clusters dropping out and all of the radii successfully converging, there is another call to the XGA `single_temp_apec` function. As some clusters may only have achieved radius convergence in the very last iteration, they may not yet have temperature/luminosity measurements for those radii. Calling `single_temp_apec` after the iterative process is over ensures that there are measurements in those cases.\n", + "\n", + "Finally, if the user has indicated (through setting `core_excised=True` in the pipeline call) that they wish to also measure core-excised results (where spectra are generated in the [0.15-1]$R_{\\Delta}$ region, and then fit), there will be another call to `single_temp_apec`. If the user sets `core_excised=True` when the scaling relation is for $R_{2500}$, there will be a warning shown at the beginning to ensure that they meant for this behaviour.\n", + "\n", + "**Note** - Setting `core_excised=True` will provide core-excised results _in addition_ to global results, not instead of. " + ] + }, + { + "cell_type": "markdown", + "id": "94b878d0", + "metadata": {}, + "source": [ + "### Step 4 - Record results and radius history" + ] + }, + { + "cell_type": "markdown", + "id": "059e1703", + "metadata": {}, + "source": [ + "Once all spectral generation and fitting are complete, the final results (in the form of a Pandas dataframe) will be put together for the user. Not every entry in the input `sample_data` dataframe will necessarily be present in the output results dataframe; if a cluster was found to have no X-ray data by the ClusterSample declaration, then it will not be included, but objects that failed part way through the iterative process **will** be included (though with NaN entries for the measurements). \n", + "\n", + "A dataframe containing the history of the radii through the various iterative steps will also be produced. Again this will only contain entries for those clusters which have some X-ray data, but will contain clusters that failed part way through the iterative process. For those that failed part way through, all entries for after their failure will be NaN values. The final column in this dataframe indicates whether the radius is considered to be converged or not, as it is possible to have entries for all iteration steps and it still not be converged because it reached the iteration limit set by `max_iter`.\n", + "\n", + "If the pipeline is being used interactively, the ClusterSample created for the analysis will be the first entry in the tuple returned by the pipeline function, the results dataframe will be the second entry, and the radius history dataframe the last. \n", + "\n", + "The user may instead (or additionally) choose to **write the results and radius history dataframes to csv**, by setting the `save_samp_results_path` and `save_rad_history_path` variables with the paths to save the files to." + ] + }, + { + "cell_type": "markdown", + "id": "16393cc4", + "metadata": {}, + "source": [ + "## Demonstrating the pipeline" + ] + }, + { + "cell_type": "markdown", + "id": "1ed721a8", + "metadata": {}, + "source": [ + "In this case we will use the pipeline interactively, and capture the returned ClusterSample, results dataframe, and radius history dataframe, as well as setting the variables necessary to write the results/radius history dataframes to disk. \n", + "\n", + "There are quite a few configuration options, but we will largely leave things on the default settings for this demonstration. To give you an idea of what you can change, take a look at the docstring of the function printed below (you can access this in Jupyter by hitting `shift+tab` while your caret is inside the function brackets, or you can look at it in the XGA API documentation entry for [luminosity_temperature_pipeline](../../xga.tools.rst#xga.tools.clusters.LT.luminosity_temperature_pipeline)):" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "711ec53f", + "metadata": { + "scrolled": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " This is the XGA pipeline for measuring overdensity radii, and the temperatures and luminosities within the\n", + " radii, for a sample of clusters. No knowledge of the overdensity radii of the clusters is required\n", + " beforehand, only the position and redshift of the objects. A name is also required for each of them.\n", + "\n", + " The pipeline works by measuring a temperature from a spectrum generated with radius equal to the\n", + " 'start_aperture', and the using the radius temperature relation ('rad_temp_rel') to infer a value for the\n", + " overdensity radius you are targeting. The cluster's overdensity radius is set equal to the new radius estimate\n", + " and we repeat the process.\n", + "\n", + " A cluster radius measurement is accepted if the 'current' estimate of the radius is considered to be converged\n", + " with the last estimate. For instance if 'convergence_frac' is set to 0.1, convergence occurs when a change of\n", + " less than 10% from the last radius estimate is measured. The radii cannot be assessed for convergence until\n", + " at least 'min_iter' iterations have been passed, and the iterative process will end if the number of iterations\n", + " reaches 'max_iter'.\n", + "\n", + " This pipeline will only work for clusters that we can successfully measure temperatures for, which requires a\n", + " minimum data quality - as such you may find that some do not achieve successful radius measurements with this\n", + " pipeline. In these cases the pipeline should not error, but the failure will be recorded in the results and\n", + " radius history dataframes returned from the function (and optionally written to CSV files).\n", + "\n", + " As with all XGA sources and samples, the XGA luminosity-temperature pipeline DOES NOT require all objects\n", + " passed in the sample_data to have X-ray observations. Those that do not will simply be filtered out.\n", + "\n", + " :param pd.DataFrame sample_data: A dataframe of information on the galaxy clusters. The columns 'ra', 'dec',\n", + " 'name', and 'redshift' are required for this pipeline to work.\n", + " :param Quantity start_aperture: This is the radius used to generate the first set of spectra for each\n", + " cluster, which in turn are fit to produce the first temperature estimate.\n", + " :param bool use_peak: If True then XGA will measure an X-ray peak coordinate and use that as the centre for\n", + " spectrum generation and fitting, and the peak coordinate will be included in the results dataframe/csv.\n", + " If False then the coordinate in sample_data will be used. Default is False.\n", + " :param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default is\n", + " 'hierarchical' (uses XGA's hierarchical clustering peak finder), 'simple' may also be passed in which\n", + " case the brightest unmasked pixel within the source region will be selected.\n", + " :param float convergence_frac: This defines how close a current radii estimate must be to the last\n", + " radii measurement for it to count as converged. The default value is 0.1, which means the current-to-last\n", + " estimate ratio must be between 0.9 and 1.1.\n", + " :param int min_iter: The minimum number of iterations before a radius can converge and be accepted. The\n", + " default is 3.\n", + " :param int max_iter: The maximum number of iterations before the loop exits and the pipeline moves on. This\n", + " makes sure that the loop has an exit condition and won't continue on forever. The default is 10.\n", + " :param ScalingRelation rad_temp_rel: The scaling relation used to convert a cluster temperature measurement\n", + " for into an estimate of an overdensity radius. The y-axis must be radii, and the x-axis must be temperature.\n", + " The pipeline will attempt to determine the overdensity radius you are attempting to measure for by checking\n", + " the name of the y-axis; it must contain 2500, 500, or 200 to indicate the overdensity. The default is the\n", + " R500-Tx Arnaud et al. 2005 relation.\n", + " :param Quantity lum_en: The energy bands in which to measure luminosity. The default is\n", + " Quantity([[0.5, 2.0], [0.01, 100.0]], 'keV'), corresponding to the 0.5-2.0keV and bolometric bands.\n", + " :param bool core_excised: Should final measurements of temperature and luminosity be made with core-excision in\n", + " addition to measurements within the overdensity radius specified by the scaling relation. This will involve\n", + " multiplying the radii by 0.15 to determine the inner radius. Default is False.\n", + " :param str save_samp_results_path: The path to save the final results (temperatures, luminosities, radii) to.\n", + " The default is None, in which case no file will be created. This information is also returned from this\n", + " function.\n", + " :param str save_rad_history_path: The path to save the radii history for all clusters. This specifies what the\n", + " estimated radius was for each cluster at each iteration step, in kpc. The default is None, in which case no\n", + " file will be created. This information is also returned from this function.\n", + " :param Cosmology cosmo: The cosmology to use for sample declaration, and thus for all analysis. The default\n", + " cosmology is a flat LambdaCDM concordance model.\n", + " :param Quantity timeout: This sets the amount of time an XSPEC fit can run before it is timed out, the default\n", + " is 1 hour.\n", + " :param int num_cores: The number of cores that can be used for spectrum generation and fitting. The default is\n", + " 90% of the cores detected on the system.\n", + " :return: The GalaxyCluster sample object used for this analysis, the dataframe of results for all input\n", + " objects (even those for which the pipeline was unsuccessful), and the radius history dataframe for the\n", + " clusters.\n", + " :rtype: Tuple[ClusterSample, pd.DataFrame, pd.DataFrame]\n", + " \n" + ] + } + ], + "source": [ + "print(luminosity_temperature_pipeline.__doc__)" + ] + }, + { + "cell_type": "markdown", + "id": "3429550a", + "metadata": {}, + "source": [ + "### Loading the example sample" + ] + }, + { + "cell_type": "markdown", + "id": "6037dba7", + "metadata": {}, + "source": [ + "In this case we use a subset of a sample of 150 clusters selected from SDSS, analysed by the XMM Cluster Survey (XCS), and that have been found to have well constrained temperature measurements ([Giles et al. 2022](https://arxiv.org/abs/2202.11107)). The subset consists of 40 clusters, selected randomly. \n", + "\n", + "The sample file contains coordinates, which are derived from XCS X-ray centroid measurements using the XAPA source finder. It also contains redshift values, which come from the redMaPPer catalogue that the SDSSRM-XCS sample was selected from initially. The names in the sample combine an XCSSDSS prefix with the 'MEM_MATCH_ID' entry in the original SDSS redMaPPeR catalogue. XCS measurements of $R_{500}$ are present in the sample, though only to provide a point of comparison, **they are not used by the pipeline**. " + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "3d8e907e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "RangeIndex: 40 entries, 0 to 39\n", + "Data columns (total 5 columns):\n", + " # Column Non-Null Count Dtype \n", + "--- ------ -------------- ----- \n", + " 0 name 40 non-null object \n", + " 1 ra 40 non-null float64\n", + " 2 dec 40 non-null float64\n", + " 3 redshift 40 non-null float64\n", + " 4 xcs_r500 40 non-null float64\n", + "dtypes: float64(4), object(1)\n", + "memory usage: 1.7+ KB\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nameradecredshiftxcs_r500
0SDSSXCS-487323.799200-1.0494680.3339121103.195128
1SDSSXCS-17923350.47041019.8936040.314943488.754689
2SDSSXCS-597735.868932-8.8689810.167958778.068206
3SDSSXCS-890126.4894604.2460140.234921960.169165
4SDSSXCS-30950251.92973034.9360560.249875556.325349
\n", + "
" + ], + "text/plain": [ + " name ra dec redshift xcs_r500\n", + "0 SDSSXCS-487 323.799200 -1.049468 0.333912 1103.195128\n", + "1 SDSSXCS-17923 350.470410 19.893604 0.314943 488.754689\n", + "2 SDSSXCS-5977 35.868932 -8.868981 0.167958 778.068206\n", + "3 SDSSXCS-890 126.489460 4.246014 0.234921 960.169165\n", + "4 SDSSXCS-30950 251.929730 34.936056 0.249875 556.325349" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "samp = pd.read_csv('lt_examp_samp.csv')\n", + "samp.info()\n", + "samp.head(5)" + ] + }, + { + "cell_type": "markdown", + "id": "8ae33e24", + "metadata": {}, + "source": [ + "### Calling the pipeline function" + ] + }, + { + "cell_type": "markdown", + "id": "419955aa", + "metadata": {}, + "source": [ + "Here we run the pipeline, telling it to use a 500kpc aperture for the starting measurements, and to produce core-excised measurements as well as global measurements. As already stated we have largely used the default settings for this run, but it is important to note that you can easily select the cosmology that you wish to use for analysis by setting the `cosmo=` argument with an Astropy cosmology object (the default is a flat LambdaCDM concordance model).\n", + "\n", + "We're using the RA and Dec supplied by the user as the central coordinates of our spectra, but it is also possible to set `use_peak=True`, to let XGA identify an X-ray peak coordinate for each of the clusters and use that instead:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "21389db6", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Declaring BaseSource Sample: 100%|██████████████████| 40/40 [00:29<00:00, 1.35it/s]\n", + "Generating products of type(s) ccf: 100%|███████████| 93/93 [00:35<00:00, 2.63it/s]\n", + "Generating products of type(s) image: 100%|█████████| 40/40 [00:05<00:00, 6.95it/s]\n", + "Generating products of type(s) expmap: 100%|████████| 40/40 [00:04<00:00, 8.12it/s]\n", + "Setting up Galaxy Clusters: 100%|███████████████████| 40/40 [00:52<00:00, 1.32s/it]\n", + "Generating products of type(s) image: 100%|█████████| 13/13 [00:00<00:00, 16.05it/s]\n", + "Generating products of type(s) expmap: 100%|████████| 13/13 [00:00<00:00, 18.68it/s]\n", + "/mnt/pact/dt237/code/PycharmProjects/XGA/xga/samples/extended.py:237: UserWarning: Non-fatal warnings occurred during the declaration of some sources, to access them please use the suppressed_warnings property of this sample.\n", + " self._check_source_warnings()\n", + "Generating products of type(s) spectrum: 100%|████| 168/168 [24:19<00:00, 8.69s/it]\n", + "Running XSPEC Fits: 100%|███████████████████████████| 40/40 [04:51<00:00, 7.28s/it]\n", + "/mnt/pact/dt237/code/PycharmProjects/XGA/xga/products/relation.py:620: UserWarning: Some of the x values you have passed are outside the validity range of this relation (1.0-12.0keV).\n", + " warn(\"Some of the x values you have passed are outside the validity range of this relation \"\n", + "Generating products of type(s) spectrum: 100%|████| 168/168 [53:39<00:00, 19.16s/it]\n", + "Running XSPEC Fits: 100%|███████████████████████████| 40/40 [04:24<00:00, 6.61s/it]\n", + "Generating products of type(s) spectrum: 100%|████| 168/168 [50:38<00:00, 18.09s/it]\n", + "Running XSPEC Fits: 100%|███████████████████████████| 40/40 [05:46<00:00, 8.67s/it]\n", + "/mnt/pact/dt237/code/PycharmProjects/XGA/xga/products/relation.py:620: UserWarning: Some of the x values you have passed are outside the validity range of this relation (1.0-12.0keV).\n", + " warn(\"Some of the x values you have passed are outside the validity range of this relation \"\n", + "Generating products of type(s) spectrum: 100%|████| 168/168 [52:58<00:00, 18.92s/it]\n", + "Running XSPEC Fits: 100%|███████████████████████████| 40/40 [05:00<00:00, 7.51s/it]\n", + "/mnt/pact/dt237/code/PycharmProjects/XGA/xga/products/relation.py:620: UserWarning: Some of the x values you have passed are outside the validity range of this relation (1.0-12.0keV).\n", + " warn(\"Some of the x values you have passed are outside the validity range of this relation \"\n", + "Generating products of type(s) spectrum: 100%|████| 168/168 [53:57<00:00, 19.27s/it]\n", + "Running XSPEC Fits: 100%|███████████████████████████| 40/40 [04:58<00:00, 7.47s/it]\n", + "/mnt/pact/dt237/code/PycharmProjects/XGA/xga/products/relation.py:620: UserWarning: Some of the x values you have passed are outside the validity range of this relation (1.0-12.0keV).\n", + " warn(\"Some of the x values you have passed are outside the validity range of this relation \"\n", + "Generating products of type(s) spectrum: 100%|██████| 18/18 [20:05<00:00, 66.96s/it]\n", + "Running XSPEC Fits: 100%|█████████████████████████████| 4/4 [00:58<00:00, 14.55s/it]\n", + "Generating products of type(s) spectrum: 100%|████| 168/168 [52:21<00:00, 18.70s/it]\n", + "Running XSPEC Fits: 100%|███████████████████████████| 40/40 [03:58<00:00, 5.96s/it]\n" + ] + } + ], + "source": [ + "srcs, res, rad_hist = luminosity_temperature_pipeline(samp, Quantity(500, 'kpc'), core_excised=True,\n", + " save_samp_results_path='sdssrm_results.csv',\n", + " save_rad_history_path='sdssrm_radii_history.csv')" + ] + }, + { + "cell_type": "markdown", + "id": "3ea43b0a", + "metadata": {}, + "source": [ + "### Looking at the pipeline output" + ] + }, + { + "cell_type": "markdown", + "id": "532bc304", + "metadata": {}, + "source": [ + "The first thing returned by the pipeline function is the ClusterSample object which was used for the analysis - this contains the GalaxyCluster objects which were not removed from the sample during the iterative process (or could be single GalaxyCluster object if only one was passed through the pipeline).\n", + "\n", + "Here we know it will be a sample, because we are looking at a sample of 40 clusters, and we can just look at some basic summary information:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "56370750", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "-----------------------------------------------------\n", + "Number of Sources - 40\n", + "Redshift Information - True\n", + "Sources with ≥1 detection - 40 [100%]\n", + "-----------------------------------------------------\n", + "\n" + ] + } + ], + "source": [ + "srcs.info()" + ] + }, + { + "cell_type": "markdown", + "id": "45c8d171", + "metadata": {}, + "source": [ + "The next return is the results dataframe, which contains the columns from the original input dataframe, as well as the measured temperatures, luminosities, and radii. As we passed a value to `save_samp_results_path` in the pipeline function call, this dataframe is also being saved as a csv.\n", + "\n", + "**Note** - if we had chosen to set `use_peak=True` when we called the pipeline function, there would be an entry for peak RA and peak Dec in the results dataframe, alongside the temperature, luminosity, and radius values.\n", + "\n", + "First off, we'll take a look at the top five rows of our output results:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "20e65bc6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nameradecredshiftxcs_r500r500Tx500Tx500-Tx500+Lx500_0.5-2.0...Lx500_0.01-100.0+Tx500ceTx500ce-Tx500ce+Lx500ce_0.5-2.0Lx500ce_0.5-2.0-Lx500ce_0.5-2.0+Lx500ce_0.01-100.0Lx500ce_0.01-100.0-Lx500ce_0.01-100.0+
0SDSSXCS-487323.799200-1.0494680.3339121103.1951281167.0011297.380300.4619060.4957183.510844e+44...5.152151e+437.352910.5877410.6368992.614441e+445.932730e+425.793543e+421.038449e+454.258567e+433.821942e+43
1SDSSXCS-17923350.47041019.8936040.314943488.754689443.1954941.424700.1382400.1963106.783900e+42...2.795416e+421.401730.1297800.1840376.394102e+421.094118e+421.193119e+421.253464e+432.328442e+422.391769e+42
2SDSSXCS-597735.868932-8.8689810.167958778.068206707.0359472.811960.3957390.4869022.659669e+43...6.952358e+423.064330.5552690.6721731.837119e+431.946610e+422.053221e+424.797792e+437.325597e+427.099368e+42
3SDSSXCS-890126.4894604.2460140.234921960.169165959.1169624.786060.2005130.2200791.661848e+44...1.454752e+434.928480.2913530.3125091.087360e+442.600194e+422.206501e+423.528936e+441.177247e+431.038003e+43
4SDSSXCS-30950251.92973034.9360560.249875556.325349518.7537841.577510.1316210.1626886.839353e+42...1.170044e+421.600720.1326610.1985956.642926e+426.406245e+416.138104e+411.382094e+431.556601e+421.729733e+42
\n", + "

5 rows × 24 columns

\n", + "
" + ], + "text/plain": [ + " name ra dec redshift xcs_r500 r500 \\\n", + "0 SDSSXCS-487 323.799200 -1.049468 0.333912 1103.195128 1167.001129 \n", + "1 SDSSXCS-17923 350.470410 19.893604 0.314943 488.754689 443.195494 \n", + "2 SDSSXCS-5977 35.868932 -8.868981 0.167958 778.068206 707.035947 \n", + "3 SDSSXCS-890 126.489460 4.246014 0.234921 960.169165 959.116962 \n", + "4 SDSSXCS-30950 251.929730 34.936056 0.249875 556.325349 518.753784 \n", + "\n", + " Tx500 Tx500- Tx500+ Lx500_0.5-2.0 ... Lx500_0.01-100.0+ \\\n", + "0 7.38030 0.461906 0.495718 3.510844e+44 ... 5.152151e+43 \n", + "1 1.42470 0.138240 0.196310 6.783900e+42 ... 2.795416e+42 \n", + "2 2.81196 0.395739 0.486902 2.659669e+43 ... 6.952358e+42 \n", + "3 4.78606 0.200513 0.220079 1.661848e+44 ... 1.454752e+43 \n", + "4 1.57751 0.131621 0.162688 6.839353e+42 ... 1.170044e+42 \n", + "\n", + " Tx500ce Tx500ce- Tx500ce+ Lx500ce_0.5-2.0 Lx500ce_0.5-2.0- \\\n", + "0 7.35291 0.587741 0.636899 2.614441e+44 5.932730e+42 \n", + "1 1.40173 0.129780 0.184037 6.394102e+42 1.094118e+42 \n", + "2 3.06433 0.555269 0.672173 1.837119e+43 1.946610e+42 \n", + "3 4.92848 0.291353 0.312509 1.087360e+44 2.600194e+42 \n", + "4 1.60072 0.132661 0.198595 6.642926e+42 6.406245e+41 \n", + "\n", + " Lx500ce_0.5-2.0+ Lx500ce_0.01-100.0 Lx500ce_0.01-100.0- \\\n", + "0 5.793543e+42 1.038449e+45 4.258567e+43 \n", + "1 1.193119e+42 1.253464e+43 2.328442e+42 \n", + "2 2.053221e+42 4.797792e+43 7.325597e+42 \n", + "3 2.206501e+42 3.528936e+44 1.177247e+43 \n", + "4 6.138104e+41 1.382094e+43 1.556601e+42 \n", + "\n", + " Lx500ce_0.01-100.0+ \n", + "0 3.821942e+43 \n", + "1 2.391769e+42 \n", + "2 7.099368e+42 \n", + "3 1.038003e+43 \n", + "4 1.729733e+42 \n", + "\n", + "[5 rows x 24 columns]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "res.head(5)" + ] + }, + { + "cell_type": "markdown", + "id": "481df4ff", + "metadata": {}, + "source": [ + "Then we can see some summary statistics for all of the columns in the dataframe:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "fa872abc", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
radecredshiftxcs_r500r500Tx500Tx500-Tx500+Lx500_0.5-2.0Lx500_0.5-2.0-...Lx500_0.01-100.0+Tx500ceTx500ce-Tx500ce+Lx500ce_0.5-2.0Lx500ce_0.5-2.0-Lx500ce_0.5-2.0+Lx500ce_0.01-100.0Lx500ce_0.01-100.0-Lx500ce_0.01-100.0+
count40.00000040.00000040.00000040.00000040.00000040.00000040.00000040.0000004.000000e+014.000000e+01...4.000000e+0140.00000040.00000040.0000004.000000e+014.000000e+014.000000e+014.000000e+014.000000e+014.000000e+01
mean170.84506714.1294110.226732853.116880839.4700564.0165040.1976880.2341051.754471e+442.499889e+42...1.210809e+434.0528530.2377820.2909761.192408e+442.221964e+422.284057e+424.343402e+441.092843e+431.099772e+43
std109.00208714.4279550.071969238.719870248.7487392.0947060.1330900.1727282.709387e+443.492532e+42...1.697194e+432.0701560.1619170.2576921.572231e+442.695833e+422.743462e+426.538151e+441.558976e+431.662044e+43
min4.406325-8.8689810.100436340.505584329.4240370.9769550.0202850.0264222.550508e+422.447527e+41...9.885962e+410.9276120.0500210.0500252.208393e+422.886003e+412.647880e+413.683191e+421.402236e+421.154024e+42
25%104.0226642.5163300.166746753.451777705.3473182.6363280.1196830.1229212.294106e+437.427522e+41...2.754524e+422.7553970.1312440.1434921.818083e+437.617596e+416.428667e+414.604337e+432.507211e+422.618406e+42
50%184.87808011.9866120.229464846.063105832.4956523.5238500.1584680.1887696.914379e+431.435743e+42...5.949595e+423.7773300.1827790.2184414.903966e+431.373325e+421.319268e+421.482090e+445.069745e+425.252798e+42
75%227.91280726.5415250.282091962.179869951.1897104.6232580.2142860.2857952.108054e+442.557402e+42...1.088811e+434.6168250.2923040.3095651.520532e+442.310328e+422.656070e+424.382001e+441.114642e+431.058365e+43
max350.47041047.0527850.3493641424.4881101380.2602979.0877400.5912170.7289801.355846e+451.705671e+43...6.473349e+438.8974100.7620561.4936716.101462e+441.323394e+431.201972e+432.545512e+456.869477e+436.903895e+43
\n", + "

8 rows × 23 columns

\n", + "
" + ], + "text/plain": [ + " ra dec redshift xcs_r500 r500 Tx500 \\\n", + "count 40.000000 40.000000 40.000000 40.000000 40.000000 40.000000 \n", + "mean 170.845067 14.129411 0.226732 853.116880 839.470056 4.016504 \n", + "std 109.002087 14.427955 0.071969 238.719870 248.748739 2.094706 \n", + "min 4.406325 -8.868981 0.100436 340.505584 329.424037 0.976955 \n", + "25% 104.022664 2.516330 0.166746 753.451777 705.347318 2.636328 \n", + "50% 184.878080 11.986612 0.229464 846.063105 832.495652 3.523850 \n", + "75% 227.912807 26.541525 0.282091 962.179869 951.189710 4.623258 \n", + "max 350.470410 47.052785 0.349364 1424.488110 1380.260297 9.087740 \n", + "\n", + " Tx500- Tx500+ Lx500_0.5-2.0 Lx500_0.5-2.0- ... \\\n", + "count 40.000000 40.000000 4.000000e+01 4.000000e+01 ... \n", + "mean 0.197688 0.234105 1.754471e+44 2.499889e+42 ... \n", + "std 0.133090 0.172728 2.709387e+44 3.492532e+42 ... \n", + "min 0.020285 0.026422 2.550508e+42 2.447527e+41 ... \n", + "25% 0.119683 0.122921 2.294106e+43 7.427522e+41 ... \n", + "50% 0.158468 0.188769 6.914379e+43 1.435743e+42 ... \n", + "75% 0.214286 0.285795 2.108054e+44 2.557402e+42 ... \n", + "max 0.591217 0.728980 1.355846e+45 1.705671e+43 ... \n", + "\n", + " Lx500_0.01-100.0+ Tx500ce Tx500ce- Tx500ce+ Lx500ce_0.5-2.0 \\\n", + "count 4.000000e+01 40.000000 40.000000 40.000000 4.000000e+01 \n", + "mean 1.210809e+43 4.052853 0.237782 0.290976 1.192408e+44 \n", + "std 1.697194e+43 2.070156 0.161917 0.257692 1.572231e+44 \n", + "min 9.885962e+41 0.927612 0.050021 0.050025 2.208393e+42 \n", + "25% 2.754524e+42 2.755397 0.131244 0.143492 1.818083e+43 \n", + "50% 5.949595e+42 3.777330 0.182779 0.218441 4.903966e+43 \n", + "75% 1.088811e+43 4.616825 0.292304 0.309565 1.520532e+44 \n", + "max 6.473349e+43 8.897410 0.762056 1.493671 6.101462e+44 \n", + "\n", + " Lx500ce_0.5-2.0- Lx500ce_0.5-2.0+ Lx500ce_0.01-100.0 \\\n", + "count 4.000000e+01 4.000000e+01 4.000000e+01 \n", + "mean 2.221964e+42 2.284057e+42 4.343402e+44 \n", + "std 2.695833e+42 2.743462e+42 6.538151e+44 \n", + "min 2.886003e+41 2.647880e+41 3.683191e+42 \n", + "25% 7.617596e+41 6.428667e+41 4.604337e+43 \n", + "50% 1.373325e+42 1.319268e+42 1.482090e+44 \n", + "75% 2.310328e+42 2.656070e+42 4.382001e+44 \n", + "max 1.323394e+43 1.201972e+43 2.545512e+45 \n", + "\n", + " Lx500ce_0.01-100.0- Lx500ce_0.01-100.0+ \n", + "count 4.000000e+01 4.000000e+01 \n", + "mean 1.092843e+43 1.099772e+43 \n", + "std 1.558976e+43 1.662044e+43 \n", + "min 1.402236e+42 1.154024e+42 \n", + "25% 2.507211e+42 2.618406e+42 \n", + "50% 5.069745e+42 5.252798e+42 \n", + "75% 1.114642e+43 1.058365e+43 \n", + "max 6.869477e+43 6.903895e+43 \n", + "\n", + "[8 rows x 23 columns]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "res.describe()" + ] + }, + { + "cell_type": "markdown", + "id": "ecaaf438", + "metadata": {}, + "source": [ + "The last return from the pipeline function is the radius history dataframe, which tells you what the estimated radius was at each iterative step for each galaxy cluster which had X-ray data. It will also tell you if the radius was considered to be converged or not, which can provide context for the information given in the results dataframe:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2981edf2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
012345converged
SDSSXCS-487500.01124.1654211149.9213951162.7764471167.0011291167.001129True
SDSSXCS-17923500.0476.645964464.262874456.537207443.195494443.195494True
SDSSXCS-5977500.0784.721678688.894894742.935950707.035947707.035947True
SDSSXCS-890500.0928.603453974.196493950.556860959.116962959.116962True
SDSSXCS-30950500.0522.166575494.996261509.986735518.753784518.753784True
\n", + "
" + ], + "text/plain": [ + " 0 1 2 3 4 \\\n", + "SDSSXCS-487 500.0 1124.165421 1149.921395 1162.776447 1167.001129 \n", + "SDSSXCS-17923 500.0 476.645964 464.262874 456.537207 443.195494 \n", + "SDSSXCS-5977 500.0 784.721678 688.894894 742.935950 707.035947 \n", + "SDSSXCS-890 500.0 928.603453 974.196493 950.556860 959.116962 \n", + "SDSSXCS-30950 500.0 522.166575 494.996261 509.986735 518.753784 \n", + "\n", + " 5 converged \n", + "SDSSXCS-487 1167.001129 True \n", + "SDSSXCS-17923 443.195494 True \n", + "SDSSXCS-5977 707.035947 True \n", + "SDSSXCS-890 959.116962 True \n", + "SDSSXCS-30950 518.753784 True " + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rad_hist.head(5)" + ] + }, + { + "cell_type": "markdown", + "id": "afaf559f", + "metadata": {}, + "source": [ + "## How did the radius evolve with iterative steps?" + ] + }, + { + "cell_type": "markdown", + "id": "bed59e05", + "metadata": {}, + "source": [ + "We can use the radius history to produce a little diagnostic plot that shows the value of radius measured for the clusters (no legend is included here because of the large number of clusters that we are working with) at each iterative step:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "cf038a37", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "plt.figure(figsize=(8, 6))\n", + "clust_rad_arr = rad_hist.values[:, :-1]\n", + "for cl_ind in range(0, len(clust_rad_arr)):\n", + " plt.plot(np.arange(0, clust_rad_arr.shape[1]), clust_rad_arr[cl_ind, :])\n", + "\n", + "plt.xlim(0, clust_rad_arr.shape[1]-1)\n", + "plt.ylabel('Radius [kpc]', fontsize=15)\n", + "plt.xlabel('Iteration Step', fontsize=15)\n", + "plt.title(\"Evolution of radius estimates through iteration\", fontsize=(16))\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "666b8c7c", + "metadata": {}, + "source": [ + "## Comparing XGA-LT radii to XCS3P radii" + ] + }, + { + "cell_type": "markdown", + "id": "b46cd9b3", + "metadata": {}, + "source": [ + "As we have the $R_{500}$ values measured for these clusters for the [Giles et al. (2022)](https://arxiv.org/abs/2202.11107) analysis, we will compare our measurements of radius to those. We do not compare the measurements of temperature and luminosity, as that has already been done as part of a paper on cluster hydrostatic masses by Turner et al. (in prep); they were found to be very consistent.\n", + "\n", + "We do not expect to find that the radii measured by the XGA pipeline to be identical to the previous measurements, as there are slight differences in the approach (such as we use a fixed starting aperture, but the past analysis used a starting aperture based on the size of the detection region), and XGA is an entirely separate code-base to the previous XCS pipelines, but we do expect the results to be very similar. XGA has been developed by members of XCS, and there are numerous similarities in the approaches it takes to previous XCS work:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "144cead7", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAfAAAAHwCAYAAABZrD3mAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjQuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/MnkTPAAAACXBIWXMAAAsTAAALEwEAmpwYAABMg0lEQVR4nO3dd3xV9f3H8dcHEFQUF7jAXdRqrVYSi9YVBcQJaI20FbfW1L2o/DRYQWuJe9QodU8aEQRElHVddSW40aJUFEEU3LiYn98f3xO8hITcJDc5d7yfj8d93HO/Z9zPQeRzv9/zHebuiIiISHZpFXcAIiIi0nBK4CIiIllICVxERCQLKYGLiIhkISVwERGRLKQELiIikoViTeBmdpeZzTezt5PKdjOzl8zsdTOrMrM9kvYNMrOZZjbDzA5KKu9mZm9F+24yM2vpexEREWlJcdfA7wF61ygrAy53992AwdFnzGwnoD+wc3TOrWbWOjqnHDgN6Bq9al5TREQkp8SawN39WeDLmsVAh2h7PeCTaLsPMMLdF7n7LGAmsIeZbQZ0cPcXPcxKcx/Qt9mDFxERiVGbuAOoxbnAU2Z2DeEHxl5ReWfgpaTj5kRlS6LtmuW1MrPTCLV12rdv323HHXdMW+AiIiK1Wr4cWrVi2rRpn7t7p3RcMhMTeAlwnrs/ambFwJ1AD6C259q+mvJauftwYDhAQUGBV1VVNT1iERGRukydCsceC+PHY7vv/lG6Lhv3M/DaHA+MirYfAao7sc0Btkg6rguheX1OtF2zXEREJF4vvQRHHAEbbghbbpnWS2diAv8E2C/aPgB4P9oeC/Q3s3Zmtg2hs9or7j4PWGhm3aPe58cBY1o6aBERkZW88QYcfDBsuilMmgQbbZTWy8fahG5mDwP7Ax3NbA5wGXAqcKOZtQF+Inpe7e7TzawCeAdYCpzh7suiS5UQerSvBUyIXiIiIvGYNQt69YJ11oHJk2GzzdL+FZbPy4nqGbiIiDSLRYvgjDPgootghx1WFJvZNHcvSMdXZGInNhERkez06afQtm145n3HHc36VUrgIiIi6fDll9CzJ6y7LvznP9DMk4IqgYuIiDTVwoXQuze8/z6MH9/syRuUwEVERJrmxx/h8MPh1Vdh1Cg48MAW+VolcBERkaY47zx49ll44IEw5ruFKIGLiIg0xeDBsP/+0L9/i35tJk7kIiIiktmWL4d77oFly2DzzVs8eYMSuIiISMO4w/nnw4knwsiRsYWhBC4iItIQf/sb3HgjnHMOFBfHFoYSuIiISKquvRaGDIGTToLrrmuR4WJ1UQIXERFJxbx5cNllcPTRMHw4tIo3haoXuoiISCo22wxeeAF23BFat447GtXARUREVmvcOCgvD9u//nWY6zwDKIGLiIjUZcqU0GR+zz2wZEnc0axECVxERKQ2L74IffpA164wYQKssUbcEa1ECVxERKSm11+HQw4Jz70nTQrLg2YYJXAREZGaXngBOnSAyZNh003jjqZWSuAiIiLV3MP7X/4Cb78NW20VbzyroQQuIiICYZx3QQE891z4vO668cZTD40DFxER+eIL6NULZs3KuM5qdVECFxGR/LZwIRx8MLz/PjzxBHTvHndEKVECFxGR/PXjj3D44fDaazBqFBxwQNwRpUzPwEVEJH+1aQNdusD994dEnkVUAxcRkfyzbBl8/TVstBE88EDc0TSKauAiIpJfli+HU0+FvfaC776LO5pGUwIXEZH84Q7nnQd33w1/+AOss07cETWaEriIiOSPyy6Dm24KSfyyy+KOpkmUwEVEJD/ccQcMHQqnnALXXgtmcUfUJOrEJiIi+eGII+DDD+Hyy7M+eYNq4CIikuueey6s5b3xxnDFFdC6ddwRpYUSuIiI5K4xY6CoKCTuHKMELiIiuWnyZCguhm7d4MIL444m7ZTARUQk97zwAvTpAzvsABMmZPzKYo2hBC4iIrll8eIwxrtzZ5g4ETbcMO6ImoV6oYuISG5p2zYsTNKpE2y6adzRNBvVwEVEJDd8+CHcdlvY7tYNttwy1nCam2rgIiKS/T75BA48EL78Evr1g002iTuiZqcELiIi2e2LL6BXL/jsM5gyJS+SNyiBi4hINvv2W+jdG2bODL3Nf/vbuCNqMUrgIiKSvaZMgTffhJEjw4QteUQJXEREsle/fvD++znfYa026oUuIiLZZelSOOGEMNMa5GXyBiVwERHJJsuXw6mnwr33wvTpcUcTKyVwERHJDu5w3nlwzz3wt7/BOefEHVGslMBFRCQ7DB4MN90E558ftvOcEriIiGS+5cthzhw45RS45howizui2KkXuoiIZLaffoI114Q77wzN6EregGrgIiKSyR54AHbZJdS+W7WC1q3jjihjKIGLiEhmeuyx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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "plt.figure(figsize=(7, 7))\n", + "\n", + "plt.plot(res['xcs_r500'].values, res['r500'], 'x', color='black', label='Data')\n", + "plt.plot([300, 1800], [300, 1800], color='red', linestyle='dashed', label='1:1')\n", + "\n", + "plt.xlim(300, 1800)\n", + "plt.ylim(300, 1800)\n", + "\n", + "plt.xlabel(\"XCS3P $R_{500}$ [kpc]\", fontsize=15)\n", + "plt.ylabel(\"XGA-LT $R_{500}$ [kpc]\", fontsize=15)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/notebooks/pipeline_tutorials/lt_examp_samp.csv b/docs/source/notebooks/pipeline_tutorials/lt_examp_samp.csv new file mode 100644 index 00000000..c9a284e1 --- /dev/null +++ b/docs/source/notebooks/pipeline_tutorials/lt_examp_samp.csv @@ -0,0 +1,41 @@ +name,ra,dec,redshift,xcs_r500 +SDSSXCS-487,323.7992,-1.0494682,0.33391213,1103.1951275572167 +SDSSXCS-17923,350.47041,19.893604,0.31494313,488.75468852522727 +SDSSXCS-5977,35.868932,-8.868980699999998,0.16795832,778.0682064602911 +SDSSXCS-890,126.48946,4.2460138,0.23492122,960.1691652285194 +SDSSXCS-30950,251.92973,34.936056,0.24987537,556.3253487919281 +SDSSXCS-11154,349.59,18.734,0.16006234,894.8513823865371 +SDSSXCS-225,188.5954,9.7885313,0.22960329,929.6790065336635 +SDSSXCS-34,347.95361,3.6796591,0.30071348,814.707213032374 +SDSSXCS-35404,30.191204,-6.708112599999999,0.3417674,563.0008291520414 +SDSSXCS-120,37.927216,-4.8818112000000005,0.18926916,968.211980170912 +SDSSXCS-110,336.52107,17.372292,0.11359597,1172.5938180147475 +SDSSXCS-155,323.82011,1.4333368,0.23449183,1337.0305860577657 +SDSSXCS-2347,189.67042,9.4769605,0.22824667,836.2564749043244 +SDSSXCS-21,140.08869,30.504042,0.30510542,1075.1998349442015 +SDSSXCS-176,204.2036,10.440014,0.15694562,758.5282030782445 +SDSSXCS-2836,220.58255,22.302753,0.1050965,842.0449819788956 +SDSSXCS-15,11.62825,20.467691,0.103966124,850.0812283514175 +SDSSXCS-7783,30.429572,-2.1962592,0.19421071,827.6911996130351 +SDSSXCS-1018,4.4063253,-0.87619162,0.21440332,902.2592310706574 +SDSSXCS-1131,33.111835,-5.6262911,0.29934615,942.8931701895706 +SDSSXCS-43,210.25825,2.877328,0.25708973,1140.5955833429487 +SDSSXCS-4003,217.95843,13.533210999999998,0.1631074,832.7778552436893 +SDSSXCS-62,340.47773,17.53801,0.32349214,1048.6416484799986 +SDSSXCS-26424,133.23206000000002,17.96369,0.20101751,533.609718958986 +SDSSXCS-134,4.9083898,3.6098177,0.27730444,1123.320736258806 +SDSSXCS-31,130.74006,36.365002,0.29645097,1424.4881101072942 +SDSSXCS-7405,161.44015,4.340415599999999,0.15054649,792.2444936992645 +SDSSXCS-31144,18.604062,0.49461554,0.34936443,543.093308472926 +SDSSXCS-14,260.61258,32.132799,0.22932445,1233.2242077052338 +SDSSXCS-165,217.61219,24.599226,0.13736627,804.8279916025748 +SDSSXCS-28849,17.953107,-0.017865822,0.2743446,605.019750398047 +SDSSXCS-28269,194.08956,25.768056,0.24395767,519.0943339406642 +SDSSXCS-575,249.90358,47.052785,0.22638986,855.3472821980862 +SDSSXCS-7432,196.95654,29.430381,0.2615181,738.2224980763173 +SDSSXCS-9313,189.07545,28.983881,0.2214931,828.8988668001388 +SDSSXCS-1884,137.21898000000002,28.861933,0.25381398,850.9436080305214 +SDSSXCS-19922,126.05448,30.076932,0.31855735,554.8287100545067 +SDSSXCS-3746,174.09881,7.2270701,0.11559857,887.3301491492427 +SDSSXCS-7247,181.16076,35.375998,0.1896899,340.5055838585081 +SDSSXCS-5159,134.16993,5.8953329000000005,0.100435905,866.1191069650715 diff --git a/docs/source/notebooks/tutorials/products.ipynb b/docs/source/notebooks/tutorials/products.ipynb index d6e79614..bf688c6f 100644 --- a/docs/source/notebooks/tutorials/products.ipynb +++ b/docs/source/notebooks/tutorials/products.ipynb @@ -709,11 +709,11 @@ "evalue": "Cannot find any combined ratemaps matching your input.", "output_type": "error", "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mNoProductAvailableError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0msrc\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mget_combined_ratemaps\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mQuantity\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;36m0.5\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'keV'\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mQuantity\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;36m4.2\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'keV'\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", - "\u001b[0;32m~/code/PycharmProjects/XGA/xga/sources/base.py\u001b[0m in \u001b[0;36mget_combined_ratemaps\u001b[0;34m(self, lo_en, hi_en, psf_corr, psf_model, psf_bins, psf_algo, psf_iter)\u001b[0m\n\u001b[1;32m 2902\u001b[0m \u001b[0mmatched_prods\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mmatched_prods\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 2903\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mmatched_prods\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;36m0\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 2904\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mNoProductAvailableError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"Cannot find any combined ratemaps matching your input.\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 2905\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 2906\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mmatched_prods\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mNoProductAvailableError\u001b[0m: Cannot find any combined ratemaps matching your input." + "\u001B[0;31m---------------------------------------------------------------------------\u001B[0m", + "\u001B[0;31mNoProductAvailableError\u001B[0m Traceback (most recent call last)", + "\u001B[0;32m\u001B[0m in \u001B[0;36m\u001B[0;34m\u001B[0m\n\u001B[0;32m----> 1\u001B[0;31m \u001B[0msrc\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mget_combined_ratemaps\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mQuantity\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;36m0.5\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0;34m'keV'\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0mQuantity\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;36m4.2\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0;34m'keV'\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m", + "\u001B[0;32m~/code/PycharmProjects/XGA/xga/sources/base.py\u001B[0m in \u001B[0;36mget_combined_ratemaps\u001B[0;34m(self, lo_en, hi_en, psf_corr, psf_model, psf_bins, psf_algo, psf_iter)\u001B[0m\n\u001B[1;32m 2902\u001B[0m \u001B[0mmatched_prods\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0mmatched_prods\u001B[0m\u001B[0;34m[\u001B[0m\u001B[0;36m0\u001B[0m\u001B[0;34m]\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 2903\u001B[0m \u001B[0;32melif\u001B[0m \u001B[0mlen\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mmatched_prods\u001B[0m\u001B[0;34m)\u001B[0m \u001B[0;34m==\u001B[0m \u001B[0;36m0\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m-> 2904\u001B[0;31m \u001B[0;32mraise\u001B[0m \u001B[0mNoProductAvailableError\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m\"Cannot find any combined ratemaps matching your input.\"\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 2905\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 2906\u001B[0m \u001B[0;32mreturn\u001B[0m \u001B[0mmatched_prods\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n", + "\u001B[0;31mNoProductAvailableError\u001B[0m: Cannot find any combined ratemaps matching your input." ] } ], @@ -743,4 +743,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/docs/source/notebooks/tutorials/sources_samples.ipynb b/docs/source/notebooks/tutorials/sources_samples.ipynb index 36cb1499..2d1d5d55 100644 --- a/docs/source/notebooks/tutorials/sources_samples.ipynb +++ b/docs/source/notebooks/tutorials/sources_samples.ipynb @@ -131,7 +131,7 @@ "source": [ "Here I demonstrate just how simple it is to define a PointSource object, all I've done is to supply the Right Ascension and Declination of Castor, a famous sextuple star system that emits in X-ray. **All coordinates used with XGA must be passed as decimal degrees, sexagesimal coordinates are not supported by this module.**\n", "\n", - "PointSource also accepts various other keyword arguments that you may wish to change from defaults, please see [this documentation](../../xga.sources.rst#xga.sources.general.PointSource) for a full list. A particularly useful keyword argument is cosmology, which allows you to pass an Astropy cosmology object, which will then be used in all aspects of analysis; the default cosmology is currently Planck15 - this ability to set the cosmology is present in all source and sample objects, and is used throughout any analysis done with that source/sample." + "PointSource also accepts various other keyword arguments that you may wish to change from defaults, please see [this documentation](../../xga.sources.rst#xga.sources.general.PointSource) for a full list. A particularly useful keyword argument is cosmology, which allows you to pass an Astropy cosmology object, which will then be used in all aspects of analysis; the default cosmology is currently a flat LambdaCDM concordance module - this ability to set the cosmology is present in all source and sample objects, and is used throughout any analysis done with that source/sample." ] }, { @@ -637,4 +637,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/docs/source/pipeline_tutorials.rst b/docs/source/pipeline_tutorials.rst new file mode 100644 index 00000000..6e10cfc3 --- /dev/null +++ b/docs/source/pipeline_tutorials.rst @@ -0,0 +1,7 @@ +Pipeline Tutorials +================== + +.. toctree:: + :maxdepth: 1 + + Galaxy cluster luminosity, temperature, and radius measurement pipeline \ No newline at end of file diff --git a/docs/source/publications.rst b/docs/source/publications.rst index 8cdcf3c1..db8bf287 100644 --- a/docs/source/publications.rst +++ b/docs/source/publications.rst @@ -6,26 +6,31 @@ using XGA please cite the XGA software paper, which is the first entry in this t your work to this page. .. list-table:: - :widths: 20 15 20 15 30 + :widths: 20 15 10 15 40 :header-rows: 1 * - Lead Author - Year - - DOI - - NASA/ADS + - ADS + - Code - Notes * - `D. J. Turner `_ - - In Prep - - - - + - 2022 + - `ADS `_ + - `Repo `_ - XGA Software Paper * - `D. J. Turner `_ - 2021 - - - `ADS `_ - - eFEDS-XCS Cluster Comparison + - `Repo `_ + - eFEDS-XCS Cluster Analysis * - `D. S. Pillay `_ - 2021 - - `MDPI Galaxies `_ - `ADS `_ - - ACTCL J0019.6+0336 Follow-up \ No newline at end of file + - + - ACTCLJ0019.6+0336 Analysis + * - `C. J. Burke `_ + - 2021 + - `ADS `_ + - `Repo `_ + - DES AGN Confirmation diff --git a/docs/source/support.rst b/docs/source/support.rst index 1ba94cbc..f505a9fa 100644 --- a/docs/source/support.rst +++ b/docs/source/support.rst @@ -5,7 +5,7 @@ If you encounter a bug, or would like to make a feature request, please use the `issues `_ page, it really helps to keep track of everything. However, if you have further questions, or just want to make doubly sure I notice the issue, please feel free to send -me an email at david.turner@sussex.ac.uk, I will be happy to help you. +me an email at turne540@msu.edu, I will be happy to help you. If you have a specific feature request, or wish to contribute to XGA, and opening an issue isn't sufficient to communicate your idea properly then please send me an email and we can arrange a virtual meeting. diff --git a/docs/source/xga.rst b/docs/source/xga.rst index c2ee1628..40302017 100644 --- a/docs/source/xga.rst +++ b/docs/source/xga.rst @@ -13,4 +13,5 @@ xga package xga.sourcetools xga.relations xga.models + xga.tools diff --git a/docs/source/xga.sourcetools.rst b/docs/source/xga.sourcetools.rst index 75776119..e502f075 100644 --- a/docs/source/xga.sourcetools.rst +++ b/docs/source/xga.sourcetools.rst @@ -17,6 +17,14 @@ sourcetools.temperature module :undoc-members: :show-inheritance: +sourcetools.entropy module +-------------------------- + +.. automodule:: xga.sourcetools.entropy + :members: + :undoc-members: + :show-inheritance: + sourcetools.mass module -------------------------- diff --git a/docs/source/xga.tools.rst b/docs/source/xga.tools.rst new file mode 100644 index 00000000..88c20680 --- /dev/null +++ b/docs/source/xga.tools.rst @@ -0,0 +1,10 @@ +tools +======= + +tools.clusters.LT module +------------------- + +.. automodule:: xga.tools.clusters.LT + :members: + :undoc-members: + :show-inheritance: diff --git a/paper/figures/A907_ann_spec.png b/paper/figures/A907_ann_spec.png deleted file mode 100644 index 25491fa1..00000000 Binary files a/paper/figures/A907_ann_spec.png and /dev/null differ diff --git a/paper/figures/A907_spec.png b/paper/figures/A907_spec.png deleted file mode 100644 index 726c0a26..00000000 Binary files a/paper/figures/A907_spec.png and /dev/null differ diff --git a/paper/figures/ann_spec.png b/paper/figures/ann_spec.png deleted file mode 100644 index 119900ee..00000000 Binary files a/paper/figures/ann_spec.png and /dev/null differ diff --git a/paper/figures/combo_rt_spec_a907.png b/paper/figures/combo_rt_spec_a907.png new file mode 100644 index 00000000..58a7965d Binary files /dev/null and b/paper/figures/combo_rt_spec_a907.png differ diff --git a/paper/figures/quick_xga_logo.png b/paper/figures/quick_xga_logo.png deleted file mode 100644 index bf30598f..00000000 Binary files a/paper/figures/quick_xga_logo.png and /dev/null differ diff --git a/paper/figures/ratemap_crosshair.png b/paper/figures/ratemap_crosshair.png deleted file mode 100644 index a1f3933e..00000000 Binary files a/paper/figures/ratemap_crosshair.png and /dev/null differ diff --git a/paper/figures/ratemap_crosshair_intmask.png b/paper/figures/ratemap_crosshair_intmask.png deleted file mode 100644 index af0348ae..00000000 Binary files a/paper/figures/ratemap_crosshair_intmask.png and /dev/null differ diff --git a/paper/figures/xga_flowchart.png b/paper/figures/xga_flowchart.png new file mode 100644 index 00000000..785e2f48 Binary files /dev/null and b/paper/figures/xga_flowchart.png differ diff --git a/paper/paper.bib b/paper/paper.bib index fd2f770c..62661e4b 100644 --- a/paper/paper.bib +++ b/paper/paper.bib @@ -256,3 +256,53 @@ @ARTICLE{actdr5 adsnote = {Provided by the SAO/NASA Astrophysics Data System} } +@ARTICLE{efedsxcs, + author = {{Turner}, D.~J. and {Giles}, P.~A. and {Romer}, A.~K. and {Wilkinson}, R. and {Upsdell}, E.~W. and {Bhargava}, S. and {Collins}, C.~A. and {Hilton}, M. and {Mann}, R.~G. and {Sahl}, M. and {Stott}, J.~P. and {Viana}, P.~T.~P.}, + title = "{The XMM Cluster Survey: An independent demonstration of the fidelity of the eFEDS galaxy cluster data products and implications for future studies}", + journal = {arXiv e-prints}, + keywords = {Astrophysics - Cosmology and Nongalactic Astrophysics, Astrophysics - High Energy Astrophysical Phenomena, Astrophysics - Instrumentation and Methods for Astrophysics}, + year = 2021, + month = sep, + eid = {arXiv:2109.11807}, + pages = {arXiv:2109.11807}, +archivePrefix = {arXiv}, + eprint = {2109.11807}, + primaryClass = {astro-ph.CO}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2021arXiv210911807T}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} + +@ARTICLE{denisha, + author = {{Pillay}, Denisha S. and {Turner}, David J. and {Hilton}, Matt and {Knowles}, Kenda and {Kesebonye}, Kabelo C. and {Moodley}, Kavilan and {Mroczkowski}, Tony and {Oozeer}, Nadeem and {Pfrommer}, Christoph and {Sikhosana}, Sinenhlanhla P. and {Wollack}, Edward J.}, + title = "{A Multiwavelength Dynamical State Analysis of ACT-CL J0019.6+0336}", + journal = {Galaxies}, + keywords = {Astrophysics - High Energy Astrophysical Phenomena, Astrophysics - Cosmology and Nongalactic Astrophysics}, + year = 2021, + month = nov, + volume = {9}, + number = {4}, + pages = {97}, + doi = {10.3390/galaxies9040097}, +archivePrefix = {arXiv}, + eprint = {2111.04340}, + primaryClass = {astro-ph.HE}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2021Galax...9...97P}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} + +@ARTICLE{desagn, + author = {{Burke}, Colin J. and {Liu}, Xin and {Shen}, Yue and {Phadke}, Kedar A. and {Yang}, Qian and {Hartley}, Will G. and {Harrison}, Ian and {Palmese}, Antonella and {Guo}, Hengxiao and {Zhang}, Kaiwen and {Kron}, Richard and {Turner}, David J. and {Giles}, Paul A. and {Lidman}, Christopher and {Chen}, Yu-Ching and {Gruendl}, Robert A. and {Choi}, Ami and {Amon}, Alexandra and {Sheldon}, Erin and {Aguena}, M. and {Allam}, S. and {Andrade-Oliveira}, F. and {Bacon}, D. and {Bertin}, E. and {Brooks}, D. and {Carnero Rosell}, A. and {Carrasco Kind}, M. and {Carretero}, J. and {Conselice}, C. and {Costanzi}, M. and {da Costa}, L.~N. and {Pereira}, M.~E.~S. and {Davis}, T.~M. and {De Vicente}, J. and {Desai}, S. and {Diehl}, H.~T. and {Everett}, S. and {Ferrero}, I. and {Flaugher}, B. and {Garc{\'\i}a-Bellido}, J. and {Gaztanaga}, E. and {Gruen}, D. and {Gschwend}, J. and {Gutierrez}, G. and {Hinton}, S.~R. and {Hollowood}, D.~L. and {Honscheid}, K. and {Hoyle}, B. and {James}, D.~J. and {Kuehn}, K. and {Maia}, M.~A.~G. and {Marshall}, J.~L. and {Menanteau}, F. and {Miquel}, R. and {Morgan}, R. and {Paz-Chinch{\'o}n}, F. and {Pieres}, A. and {Plazas Malag{\'o}n}, A.~A. and {Reil}, K. and {Romer}, A.~K. and {Sanchez}, E. and {Schubnell}, M. and {Serrano}, S. and {Sevilla-Noarbe}, I. and {Smith}, M. and {Suchyta}, E. and {Tarle}, G. and {Thomas}, D. and {To}, C. and {Varga}, T.~N. and {Wilkinson}, R.~D. and {DES Collaboration}}, + title = "{Variability-Selected Dwarf AGNs in the Dark Energy Survey Deep Fields}", + journal = {arXiv e-prints}, + keywords = {Astrophysics - Astrophysics of Galaxies, Astrophysics - High Energy Astrophysical Phenomena}, + year = 2021, + month = nov, + eid = {arXiv:2111.03079}, + pages = {arXiv:2111.03079}, +archivePrefix = {arXiv}, + eprint = {2111.03079}, + primaryClass = {astro-ph.GA}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2021arXiv211103079B}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} + diff --git a/paper/paper.md b/paper/paper.md index d7b8749c..54059b09 100644 --- a/paper/paper.md +++ b/paper/paper.md @@ -1,5 +1,5 @@ --- -title: '\texttt{XGA}: A module for the large-scale scientific exploitation of X-ray data' +title: 'XGA: A module for the large-scale scientific exploitation of archival X-ray astronomy data' tags: - Python - Astronomy @@ -8,7 +8,7 @@ tags: - Galaxy clusters - XMM authors: - - name: David J. Turner + - name: David J. Turner[^*] orcid: 0000-0001-9658-1396 affiliation: 1 - name: Paul A. Giles @@ -17,122 +17,180 @@ authors: - name: Kathy Romer orcid: 0000-0002-9328-879X affiliation: 1 + - name: Violetta Korbina + orcid: + affiliation: 1 affiliations: - name: Department of Physics and Astronomy, University of Sussex, Brighton, BN1 9QH, UK index: 1 -date: 02 November 2021 +date: 01 February 2022 bibliography: paper.bib --- + # Summary -X-ray telescopes allow for the investigation of some of the most extreme objects and processes in the -Universe; this includes galaxy clusters, active galactic nuclei (where a supermassive black hole at the centre of the -galaxy is actively accreting matter), and supernovae remnants. This makes the analysis of X-ray observations very -useful for a wide variety of fields in astrophysics and cosmology. Galaxy clusters, for instance, can act as -laboratories for the exploration of many astrophysical processes, as well as providing insight into how the Universe -has evolved during its lifetime, as they are excellent tracers of the formation of large scale structure. - -We have developed a new Python module (X-ray: Generate and Analyse, hereafter referred to as \texttt{XGA}) to provide -interactive and automated analyses of X-ray emitting sources, in order to . `XGA` revolves -around `source` objects, which are representative of X-ray sources in real life. These `source` classes all have -different properties and methods, which either relate to relevant properties of or perform measurements which are only -relevant to that type of astronomical source, with some properties/methods being common to all sources. - -[comment]: <> (![The XGA logo. \label{fig:xga_logo}](figures/quick_xga_logo.png){width=35%}) - -XGA also contains `product` classes, which provide interfaces to X-ray data products, with built in methods for -analysis, manipulation, and visualisation. The `RateMap` (a count rate map of a particular observation) class for -instance includes view methods (demonstrated in \autoref{fig:ratemap_mask}), -methods for coordinate conversion, and for measuring the peak of the X-ray emission. -We also provide classes for interacting with spectra (both global and annular, where \autoref{fig:a907_spec} and -\autoref{fig:ann_spec} demonstrate the view methods), PSFs, and a base class for XGA profile -objects, which allow for the storage, fitting, and viewing of radial profiles generated through XGA processes. - -![The output of the view method of a RateMap instance where a mask to remove interloper sources has been applied, with -an added crosshair to indicate coordinates of -interest. \label{fig:ratemap_mask}](figures/ratemap_crosshair_intmask.png){width=80%} - -This approach means that the user can either remain removed from contact with the X-ray data if they choose, or -interact with it directly for lower level analyses that they are building around the `XGA` platform. -With the advent of new X-ray observatories such as eROSITA (@erosita), XRISM (@xrism), ATHENA (@athena), and -Lynx (@lynx), it would seem to be a good time for a new, open-source, software package that is open for anyone to -use and scrutinise. +The _XMM_ Cluster Survey [XCS, @xcsfoundation] have developed a new Python module (X-ray: Generate and Analyse, hereafter +referred to as \texttt{XGA}) to provide interactive and automated analyses of X-ray emitting sources observed by the +_XMM_-Newton space telescope. \texttt{XGA} only requires that a set of cleaned, processed, event lists has been created, and (optionally) that a +source detector has generated region lists for the observations. \texttt{XGA} is centered around the concept of making +all available data easily accessible and analysable. The user provides information (e.g. RA, Dec, redshift) on the +source they wish to investigate, and \texttt{XGA} will locate all relevant observations and generate all required data products. This +approach means that the user can quickly and easily complete common analyses without manually searching through large amounts of archival +data for relevant observations, thus being left free to focus on extracting the maximum scientific gain. In the future, we +will add support for X-ray telescopes other than _XMM_ (e.g. _Chandra_, _eROSITA_), as well as the ability to perform +multi-mission joint analyses. With the advent of new X-ray observatories such as _eROSITA_ [@erosita], _XRISM_ [@xrism], +_ATHENA_ [@athena], and _Lynx_ [@lynx], it is the perfect time for a new, open-source, software package that is open for +anyone to use and scrutinise. # Statement of need -The initial goal for this new module was the measurement of hydrostatic masses of galaxy clusters for the XMM -Cluster Survey (XCS, @xcsfoundation), but has become an attempt to provide the X-ray astronomy community with an -open source, general purpose tool to build research projects upon. One of the chief advantages of this module is that -it simplifies the process of generating the data products which are required for most work involving X-ray -analysis; once the user has supplied cleaned event lists (and optionally region files), and a source object has decided -which observations should be associated with it, an analysis region can be specified and spectra (along with any -auxiliary files that are required) can be created. We can use XGA to investigate both average properties and, in the -case of extended sources, how these properties vary spatially. Similar procedures for image based analysis are also -available, where images (and merged images from all available data for a given source) can be easily generated en -masse, then combined with masks automatically generated from supplied region files to perform photometric analyses. - -Software to generate X-ray data products is supplied by the telescope teams, but in the case of XMM-Newton it can -only be used on the command line, and most commands require significant setup and configuration. XGA wraps the most -useful commands and provides the user with an easy way to generate these products for large samples of -objects (which will scale across multiple cores), while taking into account complex factors (such as removing interloper sources) -that vary from source to source. To extract useful information from the generated spectra, we implemented a method -for fitting models, creating an interface with XSPEC (@xspec), the popular X-ray spectral fitting language. This interface again -provides simplified interaction with the underlying software that can be run simultaneously when multiple sources are -being analysed at the same time. - -![The output of the view method of a `Spectrum` instance associated with a GalaxyCluster source, which has been fitted -with a plasma emission model. \label{fig:a907_spec}](figures/A907_spec.png){width=85%} - -![The output of the view method of an `AnnularSpectrum` instance associated with a GalaxyCluster source. Here the -plasma emission models which have been fitted to each annulus are -displayed.\label{fig:ann_spec}](figures/ann_spec.png){width=90%} - -Many more features are built into XGA, enabled by the source based structure, as well as the product generation -and XSPEC interface. These features are largely motivated by a desire to measure hydrostatic galaxy cluster masses; this -includes the measurement of 3D gas density profiles, 3D temperature profiles, gas mass, and total mass profiles. New -methods for the measurement of central cluster coordinates and PSF correction of XMM images were also created to enable -this, as well as Python classes for various data products (with many useful built in methods). This includes a radial -profile class, with built in viewing methods, and a fitting method based around the `emcee` ensemble MCMC -sampler (@emcee). The profile fitting capability also motivated the creation of model class, with methods for -storing and interacting with fitted models; including integration and differentiation methods, inverse abel -transforms, and predictions from the model. +X-ray telescopes allow for the investigation of some of the most extreme +objects and processes in the Universe; this includes galaxy clusters, +active galactic nuclei (AGN), and X-ray emitting stars. This makes the +analysis of X-ray observations useful for a variety of fields in +astrophysics and cosmology. Galaxy clusters, for instance, are useful as +astrophysical laboratories, and provide insight into how the Universe +has evolved during its lifetime. + +Current generation X-ray telescopes have large archives of publicly +available observations; _XMM_-Newton has been observing for over +two decades, for instance. This allows for analysis of +large amounts of archival data, but also introduces issues with respect +to accessing and analysing all the relevant data for a particular +source. \texttt{XGA} solves this problem by automatically identifying +the relevant _XMM_ observations then generating whatever data +products the user requires; from images to sets of annular spectra. Once +the user has supplied cleaned event lists (and optionally region files) +an analysis region can be specified and spectra (along with any +auxiliary files that are required) can be created. + +Software to generate X-ray data products is supplied by the telescope +teams, and most commands require significant setup and configuration. +The complexity only increases when analysing multiple observations of a +single source, as is often the case due to the large archive of data +available. \texttt{XGA} provides the user with an easy way to generate +_XMM_ data products for large samples of objects (which will scale +across multiple cores), while taking into account complex factors (such +as removing interloper sources) that vary from source to source. + +[^*]: david.turner@sussex.ac.uk + +![Demonstration of the view methods of the `RateMap` and `Spectrum` classes, when applied to the Abell 907 galaxy +cluster. Data from the _XMM_ EPIC-PN instrument of 0404910601 is used. _Left_: A count-rate map with a mask that +removes contaminant sources (using XCS region information) and applies an $R_{500}$ aperture. _Right_: A spectrum +generated for the $R_{500}$ region with contaminants removed, and fit with an absorbed plasma emission model using +XSPEC. \label{fig:rtspec}](figures/combo_rt_spec_a907.png) + +# Features +\texttt{XGA} is centered around \texttt{source} and \texttt{sample} +classes. Different \texttt{source} classes, which represent different +types of X-ray emitting astrophysical objects, all have different +properties and methods. Some properties and methods are common to all sources, but some store quantities or perform measurements that are only relevant to a particular type of astronomical source. + +\texttt{XGA} also contains \texttt{product} classes, which provide +interfaces to X-ray data products, with built-in methods for analysis, +manipulation, and visualisation. The \texttt{RateMap} (a count rate map +of a particular observation) class for instance includes view methods +(left hand side of \autoref{fig:rtspec}), methods for coordinate +conversion, and for measuring the peak of the X-ray emission. We also +provide classes for interacting with, analysing, and viewing spectra +(see right hand side of \autoref{fig:rtspec}), both global and annular; as such we can use +\texttt{XGA} to investigate both average properties and, in the case of +extended sources, how these properties vary radially. Similar +procedures for image based analysis are also available, where images +(and merged images from all available data for a given source) can be +easily generated en masse, then combined with masks automatically +generated from supplied region files to perform photometric analyses. + +We also include a set of profile classes, with built-in viewing methods, +and a fitting method based around the \texttt{emcee} ensemble MCMC +sampler [@emcee]. Profiles also support storing and +interacting with fitted models; including integration and +differentiation methods, inverse abel transforms, and predictions from +the model. An example of the utility of these profiles is the galaxy +cluster hydrostatic mass measurement feature; this requires the +measurement of 3D gas density profiles, 3D temperature profiles, gas +mass, and total mass profiles. + +To extract useful information from the generated spectra, we implemented +a method for fitting models, creating an interface with XSPEC [@xspec], a popular X-ray spectral fitting language. This interface +includes the ability to fit XSPEC models (e.g.~plasma emission and +blackbody) and simplifies interaction with the underlying software and +data by automatically performing simultaneous fits with all available +data. + +![A flowchart giving a brief overview of the \texttt{XGA} workflow. \label{fig:flowchart}](figures/xga_flowchart.png) # Existing software packages -To the knowledge of the authors, no software package exists that provides features completely equivalent to -XGA, particularly in the open source domain. That is not to say that there are no software tools similar to -the module that we have constructed; several research groups including XCS (@xcsmethod), XXL (@xxllt), -LoCuSS (@locusshydro), and the cluster group at UC Santa Cruz (@matcha) have developed pipelines to measure -the luminosity and temperature of X-ray emitting galaxy clusters, though these have not been made public. It is -also important to note that these pipelines are normally designed to measure a particular aspect of a -particular type of X-ray source (galaxy clusters in these cases), and as such they lack the generality and flexibility -of `XGA`. Our new software is also designed to be used interactively, as well as a basis for building pipelines such -as these. - -Some specific analyses built into `XGA` have comparable open source software packages available; for instance -`pyprofit` (@erositagasmass) is a recently released Python module that was designed -for the measurement of gas density from X-ray surface brightness profiles. We do not believe that any existing X-ray -analysis module has an equivalent to the source and sample based structure which XGA is built around, or to the -product classes that have been written to interact with X-ray data products. - -The `XSPEC` (@xspec) interface we have developed for XGA is far less comprehensive than the full Python wrapping -implemented in the `PyXspec` module, but scales with multiple cores for the analysis of multiple sources -simultaneously much more easily. - -# Ongoing research projects -As \texttt{XGA} is a new piece of work, written over the last year, there are currently no published works that make use of -it. There are, however, several projects that use XGA extensively nearing publication. The first of these is a hydrostatic -and gas mass analysis of the SDSS redMaPPer (@redmappersdss)-XCS optically selected galaxy cluster sample (@sdssxcs) and -well as the ACTDR5 (@actdr5)-XCS sample of Sunyaev-Zel'dovich (SZ) selected galaxy clusters. This work also compares commonly measured X-ray properties of clusters -(the X-ray luminosity L$_{\rm{x}}$, and the temperature T$_{\rm{x}}$) both to results from the existing XCS pipeline and from literature, confirming -that `XGA` measurements are consistent with previous work. Similar work is being done on a Dark Energy Survey (DES)Y3-XCS optically -selected sample of clusters, though this will also include analysis from other XCS tools, and will not be focussed only -on mass measurements. `XGA`'s ability to stack and combine X-ray surface brightness profiles is currently being -used, in combination with weak lensing information from DES, to look for signs of modified gravity in galaxy -clusters. Finally an exploration of the X-ray properties of a new sample of Pea galaxies is being performed using -the point source class, the `XSPEC` interface, and the upper limit luminosity functionality. +To the knowledge of the authors, no software package exists that +provides features completely equivalent to \texttt{XGA}, particularly in +the open source domain. That is not to say that there are no software +tools similar to the module that we have constructed; several research +groups including XCS [@xcsmethod], XXL [@xxllt], LoCuSS [@locusshydro], and the cluster group at UC Santa +Cruz [@matcha] have developed pipelines to measure the +luminosity and temperature of X-ray emitting galaxy clusters, though +these have not been made public. It is also important to note that these +pipelines are normally designed to measure a particular aspect of a +particular type of X-ray source (galaxy clusters in these cases), and as +such they lack the generality and flexibility of \texttt{XGA}. Our new +software is also designed to be used interactively, as well as a basis +for building pipelines such as these. + +Some specific analyses built into \texttt{XGA} have comparable open +source software packages available; for instance \texttt{pyproffit} [@erositagasmass] is a recently released Python module that was +designed for the measurement of gas density from X-ray surface +brightness profiles of galaxy clusters. We do not believe that any existing X-ray analysis +module has an equivalent to the source and sample based structure which +\texttt{XGA} is built around, or to the product classes that have been +written to interact with X-ray data products. + +The \texttt{XSPEC} [@xspec] interface we have developed for +\texttt{XGA} is far less comprehensive than the full Python wrapping +implemented in the \texttt{PyXspec} module, but scales with multiple +cores for the analysis of multiple sources simultaneously much more +easily. + +# Research projects using \texttt{XGA} +\texttt{XGA} is stable and appropriate for scientific use, and as such +it has been used in several recent pieces of work; this has included an +_XMM_ analysis of the eFEDS cluster candidate catalogue [@efedsxcs], where we produced the first temperature calibration between _XMM_ +and _eROSITA_, a multi-wavelength analysis of an ACT selected galaxy +cluster [@denisha], and _XMM_ follow-up of Dark Energy Survey +(DES) variability selected low-mass AGN candidates [@desagn]. + +There are also several projects that use \texttt{XGA} nearing +publication. The first of these is a hydrostatic and gas mass analysis +of the redMaPPeR [@redmappersdss] SDSS selected XCS galaxy cluster +sample [@sdssxcs] and well as the ACTDR5 [@actdr5] Sunyaev-Zel'dovich (SZ) selected XCS sample of galaxy clusters. +This work also compares commonly measured X-ray properties of clusters +(the X-ray luminosity $L_{\rm{x}}$, and the temperature +$T_{\rm{x}}$) both to results from the existing XCS pipeline and from +literature, confirming that \texttt{XGA} measurements are consistent +with previous work. This process is repeated with \texttt{XGA}'s galaxy cluster gas and hydrostatic mass measurements, again showing they are consistent with previous work. \texttt{XGA}'s ability to stack and +combine X-ray surface brightness profiles is currently being used, in +combination with weak lensing information from DES, to look for signs of +modified gravity in galaxy clusters. + +# Future Work +In the future we intend to introduce support for the analysis of X-ray +telescopes other than _XMM_-Newton, first focusing on +_Chandra_ and _eROSITA_, and then possibly considering the +addition of other X-ray instruments. This will include the same ability +to find relevant data and generate data products as is already +implemented for _XMM_, and will also involve the introduction of +powerful multi-mission joint analyses to fully exploit the X-ray +archives. We are also happy to work +with others to introduce specific analysis features that aren't already +included in the module. # Acknowledgements -DT, KR, and PG acknowledge support from the UK Science and Technology Facilities Council via grants ST/P006760/1 (DT), ST/P000525/1 and ST/T000473/1 (PG, KR). +David J. Turner (DT), Kathy Romer (KR), and Paul A. Giles (PG) +acknowledge support from the UK Science and Technology Facilities +Council via grants ST/P006760/1 (DT), ST/P000525/1 and ST/T000473/1 (PG, +KR). + +David J. Turner would like to thank Aswin P. Vijayan, Lucas Porth, and +Tim Lingard for useful discussions during the course of writing this +module. -David J. Turner would like to thank Aswin P. Vijayan, Lucas Porth, Tim Lingard, and Reese Wilkinson for useful -discussions during the course of writing this module. +We acknowledge contributions to the _XMM_ Cluster Survey from A. Bermeo, M. Hilton, P. J. Rooney, S. Bhargava, L. Ebrahimpour, R. G. Mann, M. Manolopoulou, J. Mayers, E. W. Upsdell, C. Vergara, P. T. P. Viana, R. Wilkinson, C. A. Collins, R. C. Nichol, J. P. Stott, and others. # References diff --git a/paper/xga_software_paper.pdf b/paper/xga_software_paper.pdf new file mode 100644 index 00000000..eefc4e45 Binary files /dev/null and b/paper/xga_software_paper.pdf differ diff --git a/requirements.txt b/requirements.txt index cdebfdc7..5cd44a83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ tqdm>=4.45.0 pandas>=1.0.3 regions==0.4 fitsio>=1.1.2 -matplotlib>=3.1.3 +matplotlib>=3.4.3 scipy>=1.4.1 pyabel>=0.8.3 corner>=2.1.0 @@ -18,5 +18,7 @@ ipython tabulate>=0.8.9 getdist>=1.1.3 pytest~=6.2.1 -cycler~=0.10.0 -docutils==0.17 \ No newline at end of file +cycler~=0.11.0 +docutils==0.17 +ipympl>=0.9.1 +exceptiongroup~=1.1.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 92cbcc52..72f1932a 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 10/03/2021, 16:22. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from os import path @@ -25,7 +25,7 @@ url='http://github.com/DavidT3/XGA', setup_requires=[], install_requires=["astropy>=4.0", "numpy>=1.18", "tqdm>=4.45", "regions==0.4", "pandas>=1.0.3", - "fitsio>=1.1.2", "matplotlib>=3.1.3", "scipy>=1.4.1", "pyabel>=0.8.3", "corner>=2.1.0", + "fitsio>=1.1.2", "matplotlib>=3.4.3", "scipy>=1.4.1", "pyabel>=0.8.3", "corner>=2.1.0", "emcee>=3.0.2", "tabulate>=0.8.9", "getdist>=1.1.3", "docutils==0.17"], include_package_data=True, python_requires='>=3') diff --git a/tests/__init__.py b/tests/__init__.py index d10efaff..1373586e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,8 +1,8 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 12/10/2021, 10:53. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors -import sys import os +import sys from astropy.units import Quantity diff --git a/tests/all_test_and_badge.sh b/tests/all_test_and_badge.sh index 71932983..23f59794 100644 --- a/tests/all_test_and_badge.sh +++ b/tests/all_test_and_badge.sh @@ -1,7 +1,5 @@ -# -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 07/01/2022, 10:35. Copyright (c) David J Turner -# +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors pytest --cov coverage-badge -o coverage_badge.svg -f \ No newline at end of file diff --git a/tests/coverage_badge.svg b/tests/coverage_badge.svg index 5063e928..3a277c10 100644 --- a/tests/coverage_badge.svg +++ b/tests/coverage_badge.svg @@ -1,4 +1,9 @@ + + diff --git a/tests/imagetools/__init__.py b/tests/imagetools/__init__.py index d47d87cd..bb8519b5 100644 --- a/tests/imagetools/__init__.py +++ b/tests/imagetools/__init__.py @@ -1,2 +1,2 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 12/10/2021, 09:53. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors diff --git a/tests/imagetools/test_it_misc.py b/tests/imagetools/test_it_misc.py index 72f09b66..41625893 100644 --- a/tests/imagetools/test_it_misc.py +++ b/tests/imagetools/test_it_misc.py @@ -1,16 +1,15 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 12/10/2021, 09:51. Copyright (c) David J Turner -import numpy as np +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 23:33. Copyright (c) The Contributors import pytest from astropy.cosmology import Planck15 from astropy.units import Quantity from numpy import zeros from numpy.testing import assert_array_equal -from xga.products.phot import Image from xga.imagetools.misc import pix_deg_scale, sky_deg_scale, pix_rad_to_physical, physical_rad_to_pix, \ data_limits, edge_finder -from .. import A907_LOC, A907_IM_PN_INFO, A907_EX_PN_INFO +from xga.products.phot import Image +from .. import A907_LOC, A907_IM_PN_INFO OFF_PIX = Quantity([20, 20], 'pix') diff --git a/tests/sourcetools/__init__.py b/tests/sourcetools/__init__.py index ed192cc9..bb8519b5 100644 --- a/tests/sourcetools/__init__.py +++ b/tests/sourcetools/__init__.py @@ -1,2 +1,2 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 11/10/2021, 17:42. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors diff --git a/tests/sourcetools/test_st_misc.py b/tests/sourcetools/test_st_misc.py index 5ab14daf..a30bba8d 100644 --- a/tests/sourcetools/test_st_misc.py +++ b/tests/sourcetools/test_st_misc.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 12/10/2021, 09:51. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors import pytest from astropy.cosmology import Planck15 @@ -9,7 +9,6 @@ from .. import A907_LOC - @pytest.mark.heasoft def test_nh_value(): """ diff --git a/versioneer.py b/versioneer.py index 64fea1c8..2a4b8183 100644 --- a/versioneer.py +++ b/versioneer.py @@ -1,4 +1,7 @@ +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors + # Version: 0.18 """The Versioneer - like a rocketeer, but for versions. diff --git a/xga/__init__.py b/xga/__init__.py index c64fece0..3f38a453 100644 --- a/xga/__init__.py +++ b/xga/__init__.py @@ -1,11 +1,11 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 13/05/2021, 15:14. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 15:15. Copyright (c) The Contributors from ._version import get_versions __version__ = get_versions()['version'] from .utils import xga_conf, CENSUS, OUTPUT, NUM_CORES, XGA_EXTRACT, BASE_XSPEC_SCRIPT, MODEL_PARS, \ MODEL_UNITS, ABUND_TABLES, XSPEC_FIT_METHOD, COUNTRATE_CONV_SCRIPT, NHC, BLACKLIST, HY_MASS, MEAN_MOL_WEIGHT, \ - SAS_VERSION, XSPEC_VERSION, SAS_AVAIL + SAS_VERSION, XSPEC_VERSION, SAS_AVAIL, DEFAULT_COSMO del get_versions diff --git a/xga/_version.py b/xga/_version.py index 1e2fde5d..cbe40fc8 100644 --- a/xga/_version.py +++ b/xga/_version.py @@ -1,3 +1,6 @@ +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors + # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build diff --git a/xga/exceptions.py b/xga/exceptions.py index 0e20fd4a..13ff4ee1 100644 --- a/xga/exceptions.py +++ b/xga/exceptions.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 13/05/2021, 20:46. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 09/03/2023, 23:34. Copyright (c) The Contributors class HeasoftError(Exception): @@ -593,3 +593,23 @@ def __str__(self): else: return 'InvalidProductError has been raised' + +class NotSampleMemberError(Exception): + def __init__(self, *args): + """ + Raised when a feature reserved for sources that belong to samples is used, and the source is independent + of a sample. + :param expression: + :param message: + """ + if args: + self.message = args[0] + else: + self.message = None + + def __str__(self): + if self.message: + return '{}'.format(self.message) + else: + return 'NotSampleMemberError has been raised' + diff --git a/xga/imagetools/__init__.py b/xga/imagetools/__init__.py index 1f3224d6..c5ced39b 100644 --- a/xga/imagetools/__init__.py +++ b/xga/imagetools/__init__.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 15/07/2020, 10:42. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from .misc import pix_deg_scale, data_limits from .profile import ann_radii, radial_brightness, pizza_brightness, annular_mask diff --git a/xga/imagetools/bin.py b/xga/imagetools/bin.py index 7f90efdc..6710b961 100644 --- a/xga/imagetools/bin.py +++ b/xga/imagetools/bin.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 30/08/2021, 09:32. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from warnings import warn diff --git a/xga/imagetools/misc.py b/xga/imagetools/misc.py index e66ec50a..b72e48e2 100644 --- a/xga/imagetools/misc.py +++ b/xga/imagetools/misc.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 28/07/2021, 22:08. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from typing import Tuple, List, Union diff --git a/xga/imagetools/profile.py b/xga/imagetools/profile.py index b57d67a1..2ecc3fd5 100644 --- a/xga/imagetools/profile.py +++ b/xga/imagetools/profile.py @@ -1,14 +1,15 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 28/04/2021, 11:54. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 23:33. Copyright (c) The Contributors from typing import Tuple import numpy as np -from astropy.cosmology import Planck15 +from astropy.cosmology import Cosmology from astropy.units import Quantity, UnitBase, pix, deg, arcsec, UnitConversionError from .misc import pix_deg_scale, pix_rad_to_physical, physical_rad_to_pix, rad_to_ang +from .. import DEFAULT_COSMO from ..products import Image, RateMap from ..products.profile import SurfaceBrightness1D @@ -109,7 +110,7 @@ def annular_mask(centre: Quantity, inn_rad: np.ndarray, out_rad: np.ndarray, sha def ann_radii(im_prod: Image, centre: Quantity, rad: Quantity, z: float = None, pix_step: int = 1, - rad_units: UnitBase = arcsec, cosmo=Planck15, min_central_pix_rad: int = 3, + rad_units: UnitBase = arcsec, cosmo: Cosmology = DEFAULT_COSMO, min_central_pix_rad: int = 3, start_pix_rad: int = 0) -> Tuple[np.ndarray, np.ndarray, Quantity]: """ Will probably only ever be called by an internal brightness calculation, but two different methods @@ -123,7 +124,7 @@ def ann_radii(im_prod: Image, centre: Quantity, rad: Quantity, z: float = None, :param int pix_step: The width (in pixels) of each annular bin, default is 1. :param UnitBase rad_units: The output units for the centres of the annulli returned by this function. The inner and outer radii will always be in pixels. - :param cosmo: An instance of an astropy cosmology, the default is Planck15. + :param Cosmology cosmo: An instance of an astropy cosmology, the default is a concordance flat LambdaCDM model. :param int start_pix_rad: The pixel radius at which the innermost annulus starts, default is zero. :param int min_central_pix_rad: The minimum radius of the innermost circular annulus (will only be used if start_pix_rad is 0, otherwise the innermost annulus is not a circle), default is three. @@ -173,7 +174,7 @@ def ann_radii(im_prod: Image, centre: Quantity, rad: Quantity, z: float = None, def radial_brightness(rt: RateMap, centre: Quantity, outer_rad: Quantity, back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, interloper_mask: np.ndarray = None, z: float = None, pix_step: int = 1, rad_units: UnitBase = arcsec, - cosmo=Planck15, min_snr: float = 0.0, min_central_pix_rad: int = 3, + cosmo: Cosmology = DEFAULT_COSMO, min_snr: float = 0.0, min_central_pix_rad: int = 3, start_pix_rad: int = 0) -> Tuple[SurfaceBrightness1D, bool]: """ A simple method to calculate the average brightness in circular annuli upto the radius of @@ -191,7 +192,7 @@ def radial_brightness(rt: RateMap, centre: Quantity, outer_rad: Quantity, back_i :param float z: The redshift of the source of interest. :param int pix_step: The width (in pixels) of each annular bin, default is 1. :param BaseUnit rad_units: The desired output units for the central radii of the annuli. - :param cosmo: An astropy cosmology object for source coordinate conversions. + :param Cosmology cosmo: An astropy cosmology object for source coordinate conversions. :param float min_snr: The minimum signal to noise allowed for each bin in the profile. If any point is below this threshold the profile will be rebinned. Default is 0.0 :param int start_pix_rad: The pixel radius at which the innermost annulus starts, default is zero. @@ -379,7 +380,7 @@ def _iterative_profile(annulus_masks: np.ndarray, inner_rads: np.ndarray, outer_ def pizza_brightness(im_prod: Image, src_mask: np.ndarray, back_mask: np.ndarray, centre: Quantity, rad: Quantity, num_slices: int = 4, z: float = None, pix_step: int = 1, cen_rad_units: UnitBase = arcsec, - cosmo=Planck15) -> Tuple[np.ndarray, Quantity, Quantity, np.float64, np.ndarray, np.ndarray]: + cosmo=DEFAULT_COSMO) -> Tuple[np.ndarray, Quantity, Quantity, np.float64, np.ndarray, np.ndarray]: raise NotImplementedError("The supporting infrastructure to allow pizza profile product objects hasn't been" " written yet sorry!") diff --git a/xga/imagetools/psf.py b/xga/imagetools/psf.py index 59fc5336..ea27d479 100644 --- a/xga/imagetools/psf.py +++ b/xga/imagetools/psf.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 24/05/2021, 13:34. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors import os import warnings diff --git a/xga/imagetools/smooth.py b/xga/imagetools/smooth.py index 23efc78e..eac3d27e 100644 --- a/xga/imagetools/smooth.py +++ b/xga/imagetools/smooth.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 30/08/2021, 09:32. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 09/03/2023, 16:06. Copyright (c) The Contributors import os from random import randint @@ -160,7 +160,7 @@ def general_smooth(prod: Union[Image, RateMap], kernel: Kernel, mask: np.ndarray # Sets up the XGA product, regardless of whether its just been generated or it already # existed, the process is the same sm_prod = Image(new_path, prod.obs_id, prod.instrument, "", "", "", prod.energy_bounds[0], - prod.energy_bounds[1], "", True, kernel) + prod.energy_bounds[1], "", smoothed=True, smoothed_info=kernel) elif type(prod) == RateMap and not sm_im: raise NotImplementedError("I haven't yet made sure that the rest of XGA will like this.") @@ -173,7 +173,7 @@ def general_smooth(prod: Union[Image, RateMap], kernel: Kernel, mask: np.ndarray new_line = sm_prod.inventory_entry # Add the new product to the inventory, even if it already existed - inven = inven.append(new_line, ignore_index=True) + inven = pd.concat([inven, new_line.to_frame().T], ignore_index=True) # Drop any duplicates in the inventory, which corrects the extra added in the last step if the file # already existed inven = inven.drop_duplicates(subset='file_name', keep='first', ignore_index=True) diff --git a/xga/models/__init__.py b/xga/models/__init__.py index a6ae443d..a86a1603 100644 --- a/xga/models/__init__.py +++ b/xga/models/__init__.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 08/03/2021, 17:29. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors import inspect from types import FunctionType diff --git a/xga/models/base.py b/xga/models/base.py index e050de5a..e4afed98 100644 --- a/xga/models/base.py +++ b/xga/models/base.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 11/06/2021, 14:29. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors import inspect from abc import ABCMeta, abstractmethod @@ -155,6 +155,12 @@ def __init__(self, x_unit: Union[Unit, str], y_unit: Union[Unit, str], start_par # I'm going to store any volume integral results within the model itself self._vol_ints = {'pars': {}, 'par_dists': {}} + # This attribute stores a reference to a profile that a model instance has been used to fit. If the model + # exists in isolation and hasn't been fit through a profile fit() method then it will remain None, but + # otherwise I'm storing the profile to avoid one model being fit to two different profiles accidentally. + # The BaseProfile1D internal method _model_allegiance sets this + self._profile = None + def __call__(self, x: Quantity, use_par_dist: bool = False) -> Quantity: """ This method gets run when an instance of a particular model class gets called (i.e. an x-value is @@ -173,7 +179,7 @@ def __call__(self, x: Quantity, use_par_dist: bool = False) -> Quantity: raise UnitConversionError("You have passed an x value in units of {p}, but this model expects units of " "{e}".format(p=x.unit.to_string(), e=self._x_unit.to_string())) else: - # Just to be sure its in exactly the right units + # Just to be sure it's in exactly the right units x = x.to(self._x_unit) if self._x_lims is not None and (np.any(x < self._x_lims[0]) or np.any(x > self._x_lims[1])): @@ -309,7 +315,7 @@ def inverse_abel(self, x: Quantity, use_par_dist: bool = False, method: str = 'd method = 'direct' force_change = True else: - dr = (x[1]-x[0]).value + dr = (x[1] - x[0]).value # If the user just wants to use the current values of the model parameters then this is what happens if not use_par_dist: @@ -356,27 +362,37 @@ def inverse_abel(self, x: Quantity, use_par_dist: bool = False, method: str = 'd else: raise ValueError("{} is not a recognised inverse abel transform type".format(method)) - transform_res = Quantity(transform_res, self._y_unit/self._x_unit) + transform_res = Quantity(transform_res, self._y_unit / self._x_unit) return transform_res - def volume_integral(self, outer_radius: Quantity, use_par_dist: bool = False) -> Quantity: + def volume_integral(self, outer_radius: Quantity, inner_radius: Quantity = None, + use_par_dist: bool = False) -> Quantity: """ Calculates a numerical value for the volume integral of the function over a sphere of radius outer_radius. The scipy quad function is used. This method can either return a single value calculated using the current model parameters, or a distribution of values using the parameter distributions (assuming that this model has had a fit run on it). - This method will be overridden if there is an analytical solution to a particular model's volume + This method may be overridden if there is an analytical solution to a particular model's volume integration over a sphere. - :param Quantity outer_radius: The radius to integrate out to. + The results of calculations with single values of outer and inner radius are stored in the model object + to reduce processing time if they are needed again, but if a distribution of radii are passed then + the results will not be stored and will be re-calculated each time. + + :param Quantity outer_radius: The radius to integrate out to. Either a single value or, if you want to + marginalise over a radius distribution when 'use_par_dist=True', a non-scalar quantity of the same + length as the number of samples in the parameter posteriors. + :param Quantity inner_radius: The inner bound of the radius integration. Default is None, which results + in an inner radius of 0 in the units of outer_radius being used. :param bool use_par_dist: Should the parameter distributions be used to calculate a volume integral distribution; this can only be used if a fit has been performed using the model instance. Default is False, in which case the current parameters will be used to calculate a single value :return: The result of the integration, either a single value or a distribution. :rtype: Quantity """ + def integrand(x: float, pars: List[float]): """ Internal function to wrap the model function. @@ -387,47 +403,120 @@ def integrand(x: float, pars: List[float]): :rtype: float """ - return x**2 * self.model(x, *pars) + return x ** 2 * self.model(x, *pars) - # Perform checks on the input radius units + # This variable just tells the rest of the function whether either the inner or outer radii are actually + # a distribution rather than a single value. + if not inner_radius.isscalar or not outer_radius.isscalar: + rad_dist = True + else: + rad_dist = False + + # This checks to see if inner radius is None (probably how it will be used most of the time), and if + # it is then creates a Quantity with the same units as outer_radius + if inner_radius is None: + inner_radius = Quantity(0, outer_radius.unit) + elif inner_radius is not None and not inner_radius.unit.is_equivalent(outer_radius.unit): + raise UnitConversionError("If an inner_radius Quantity is supplied, then it must be in the same units" + " as the outer_radius Quantity.") + + if (not outer_radius.isscalar or not inner_radius.isscalar) and not use_par_dist: + raise ValueError("Radius distributions can only be used with use_par_dist set to True.") + elif not outer_radius.isscalar and len(outer_radius) != len(self.par_dists[0]): + raise ValueError("The outer_radius distribution must have the same number of entries (currently {rd}) " + "as the model posterior distributions ({md}).".format(rd=len(outer_radius), + md=len(self.par_dists[0]))) + elif not inner_radius.isscalar and len(inner_radius) != len(self.par_dists[0]): + raise ValueError("The inner_radius distribution must have the same number of entries (currently {rd}) " + "as the model posterior distributions ({md}).".format(rd=len(inner_radius), + md=len(self.par_dists[0]))) + + # Do a basic sanity checks on the radii, they can't be below zero because that doesn't make any sense + # physically. Also make sure that outer_radius isn't less than inner_radius + if (inner_radius.value < 0).any() or (not rad_dist and outer_radius < inner_radius): + raise ValueError("Both inner_radius and outer_radius must be greater than zero (though inner_radius " + "may be None, which is equivalent to zero). Also, outer_radius must be greater than " + "inner_radius.") + + # Perform checks on the input outer radius units - don't need to explicitly check the inner radius units + # because I've already ensured that they're the same as outer_radius if not outer_radius.unit.is_equivalent(self._x_unit): raise UnitConversionError("Outer radius cannot be converted to units of " "{}".format(self._x_unit.to_string())) else: outer_radius = outer_radius.to(self._x_unit) - - if use_par_dist and outer_radius in self._vol_ints['pars']: + # We already know that this conversion is possible, because I checked that inner_radius units are + # equivalent to outer_radius + inner_radius = inner_radius.to(self._x_unit) + + # Here I just check to see whether this particular integral has been performed already, no sense repeating a + # costly-ish calculation if it has. Where the results are stored depends on whether the integral was performed + # using the median parameter values or the distributions + if not use_par_dist and (outer_radius in self._vol_ints['pars'] and + inner_radius in self._vol_ints['pars'][outer_radius]): + # This makes sure the rest of the code in this function knows that this calculation has already been run already_run = True - integral_res = self._vol_ints['pars'][outer_radius] - elif not use_par_dist and outer_radius in self._vol_ints['par_dists']: + integral_res = self._vol_ints['pars'][outer_radius][inner_radius] + + # Equivalent to the above clause but for par distribution results rather than the median single values used + # to concisely represent the models + elif use_par_dist and not rad_dist and (outer_radius in self._vol_ints['par_dists'] and + inner_radius in self._vol_ints['par_dists'][outer_radius]): already_run = True - integral_res = self._vol_ints['par_dists'][outer_radius] + integral_res = self._vol_ints['par_dists'][outer_radius][inner_radius] + + # Otherwise, this particular integral just hasn't been run + elif not rad_dist: + already_run = False + # In this case I pre-emptively add the outer radius to the dictionary keys, for use later to store + # the result. I don't add the inner radius because it will be automatically added + if use_par_dist: + self._vol_ints['par_dists'][outer_radius] = {} + else: + self._vol_ints['pars'][outer_radius] = {} else: + # In the case where we are using a radius distribution, we still need to set this parameter so + # that the calculation is actually run already_run = False # The user can either request a single value using the current model parameters, or a distribution # using the current parameter distributions (if set) if not use_par_dist and not already_run: - integral_res = 4 * np.pi * quad(integrand, 0, outer_radius.value, args=[p.value for p - in self._model_pars])[0] + # Runs the volume integral for a sphere for the representative parameter values of this model + integral_res = 4 * np.pi * quad(integrand, inner_radius.value, outer_radius.value, + args=[p.value for p in self._model_pars])[0] elif use_par_dist and len(self._par_dists[0]) != 0 and not already_run: + # Runs the volume integral for the parameter distributions (assuming there are any) of this model unitless_dists = [par_d.value for par_d in self.par_dists] integral_res = np.zeros(len(unitless_dists[0])) + # An unfortunately unsophisticated way of doing this, but stepping through the parameter distributions + # one by one. for par_ind in range(len(unitless_dists[0])): - integral_res[par_ind] = 4 * np.pi * quad(integrand, 0, outer_radius.value, + if not outer_radius.isscalar: + out_rad = outer_radius[par_ind].value + else: + out_rad = outer_radius.value + + if not inner_radius.isscalar: + inn_rad = inner_radius[par_ind].value + else: + inn_rad = inner_radius.value + integral_res[par_ind] = 4 * np.pi * quad(integrand, inn_rad, out_rad, args=[par_d[par_ind] for par_d in unitless_dists])[0] elif use_par_dist and len(self._par_dists[0]) == 0 and not already_run: raise XGAFitError("No fit has been performed with this model, so there are no parameter distributions" " available.") + # If there wasn't already a result stored, the integration result is saved in a dictionary if not already_run: - integral_res = Quantity(integral_res, self.y_unit * self.x_unit**3) - if use_par_dist: - self._vol_ints['par_dists'][outer_radius] = integral_res - else: - self._vol_ints['pars'][outer_radius] = integral_res + integral_res = Quantity(integral_res, self.y_unit * self.x_unit ** 3) - return integral_res + if not rad_dist and use_par_dist: + self._vol_ints['par_dists'][outer_radius][inner_radius] = integral_res + elif not rad_dist: + self._vol_ints['pars'][outer_radius][inner_radius] = integral_res + + return integral_res.copy() def allowed_prior_types(self, table_format: str = 'fancy_grid'): """ @@ -558,7 +647,7 @@ def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightslat # Check if there are parameter distributions associated with this model if len(self._par_dists[0] != 0): # Set up the figure - figsize = (6, 5*self.num_pars) + figsize = (6, 5 * self.num_pars) fig, ax_arr = plt.subplots(ncols=1, nrows=self.num_pars, figsize=figsize) # Iterate through the axes and plot the histograms @@ -571,8 +660,8 @@ def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightslat err = self.model_par_errs[ax_ind] # Depending how many entries there are per parameter in the error quantity depends how we plot them if err.isscalar: - ax.axvline(self.model_pars[ax_ind].value-err.value, color='red', linestyle='dashed') - ax.axvline(self.model_pars[ax_ind].value+err.value, color='red', linestyle='dashed') + ax.axvline(self.model_pars[ax_ind].value - err.value, color='red', linestyle='dashed') + ax.axvline(self.model_pars[ax_ind].value + err.value, color='red', linestyle='dashed') elif not err.isscalar and len(err) == 2: ax.axvline(self.model_pars[ax_ind].value - err[0].value, color='red', linestyle='dashed') ax.axvline(self.model_pars[ax_ind].value + err[1].value, color='red', linestyle='dashed') @@ -1047,12 +1136,28 @@ def success(self, new_val: bool): """ self._success = new_val + @property + def profile(self): + """ + The profile that this model has been fit to. + :return: The profile object that this has been fit to, if no fit has been performed then this property + will return None. + :rtype: BaseProfile1D + """ + return self._profile + @profile.setter + def profile(self, new_val): + """ + The property setter for the profile that this model has been fit to. + :param BaseProfile1D new_val: A profile object that this model has been fit to. + """ + # Have to do this here to avoid circular import errors + from ..products import BaseProfile1D - - - - - + if new_val is not None and not isinstance(new_val, BaseProfile1D): + raise TypeError("You may only set the profile property with an XGA profile object, or None.") + else: + self._profile = new_val diff --git a/xga/models/density.py b/xga/models/density.py index ee450bcd..e880349a 100644 --- a/xga/models/density.py +++ b/xga/models/density.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 26/03/2021, 16:58. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from typing import Union, List @@ -72,7 +72,7 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = priors = [{'prior': Quantity([0, 3]), 'type': 'uniform'}, r_core_priors[xu_ind], norm_priors[yu_ind]] - nice_pars = [r"$\beta$", r"R$_{\rm{core}}$", "S$_{0}$"] + nice_pars = [r"$\beta$", r"R$_{\rm{core}}$", "N$_{0}$"] info_dict = {'author': 'placeholder', 'year': 'placeholder', 'reference': 'placeholder', 'general': 'The un-projected version of the beta profile, suitable for a simple fit\n' ' to 3D density distributions. Describes a simple isothermal sphere.'} @@ -116,6 +116,121 @@ def derivative(self, x: Quantity, dx: Quantity = Quantity(0, ''), use_par_dist: return (-6*beta*norm*x/np.power(r_core, 2))*np.power((1+np.power(x/r_core, 2)), (-3*beta) - 1) +class DoubleKingProfile1D(BaseModel1D): + """ + An XGA model implementation of the double King profile, simply the sum of two King profiles. This describes a + radial density profile and assumes spherical symmetry. + + :param Unit/str x_unit: The unit of the x-axis of this model, kpc for instance. May be passed as a string + representation or an astropy unit object. + :param Unit/str y_unit: The unit of the output of this model, keV for instance. May be passed as a string + representation or an astropy unit object. + :param List[Quantity] cust_start_pars: The start values of the model parameters for any fitting function that + used start values. The units are checked against default start values. + """ + def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = Unit('Msun/Mpc^3'), + cust_start_pars: List[Quantity] = None): + """ + The init of a subclass of the XGA BaseModel1D class, describing a basic model for galaxy cluster gas + density, the king profile. + """ + # If a string representation of a unit was passed then we make it an astropy unit + if isinstance(x_unit, str): + x_unit = Unit(x_unit) + if isinstance(y_unit, str): + y_unit = Unit(y_unit) + + poss_y_units = [Unit('Msun/Mpc^3'), Unit('1/cm^3')] + y_convertible = [u.is_equivalent(y_unit) for u in poss_y_units] + if not any(y_convertible): + allowed = ", ".join([u.to_string() for u in poss_y_units]) + raise UnitConversionError("{p} is not convertible to any of the allowed units; " + "{a}".format(p=y_unit.to_string(), a=allowed)) + else: + yu_ind = y_convertible.index(True) + + poss_x_units = [kpc, deg, r200, r500, r2500] + x_convertible = [u.is_equivalent(x_unit) for u in poss_x_units] + if not any(x_convertible): + allowed = ", ".join([u.to_string() for u in poss_x_units]) + raise UnitConversionError("{p} is not convertible to any of the allowed units; " + "{a}".format(p=x_unit.to_string(), a=allowed)) + else: + xu_ind = x_convertible.index(True) + + r_core_starts = [Quantity(100, 'kpc'), Quantity(0.2, 'deg'), Quantity(0.05, r200), Quantity(0.1, r500), + Quantity(0.5, r2500)] + # TODO MAKE THE NEW START PARAMETERS MORE SENSIBLE + norm_starts = [Quantity(1e+13, 'Msun/Mpc^3'), Quantity(1e-3, '1/cm^3')] + start_pars = [Quantity(1, ''), r_core_starts[xu_ind], norm_starts[yu_ind], + Quantity(1, ''), r_core_starts[xu_ind], norm_starts[yu_ind]] + if cust_start_pars is not None: + # If the custom start parameters can run this gauntlet without tripping an error then we're all good + # This method also returns the custom start pars converted to exactly the same units as the default + start_pars = self.compare_units(cust_start_pars, start_pars) + + # TODO MAYBE ADJUST ALL OF THE PRIORS ETC AS THEY'RE JUST COPIED FROM KING + r_core_priors = [{'prior': Quantity([0, 2000], 'kpc'), 'type': 'uniform'}, + {'prior': Quantity([0, 1], 'deg'), 'type': 'uniform'}, + {'prior': Quantity([0, 1], r200), 'type': 'uniform'}, + {'prior': Quantity([0, 1], r500), 'type': 'uniform'}, + {'prior': Quantity([0, 1], r2500), 'type': 'uniform'}] + norm_priors = [{'prior': Quantity([1e+12, 1e+16], 'Msun/Mpc^3'), 'type': 'uniform'}, + {'prior': Quantity([0, 10], '1/cm^3'), 'type': 'uniform'}] + + priors = [{'prior': Quantity([0, 3]), 'type': 'uniform'}, r_core_priors[xu_ind], norm_priors[yu_ind], + {'prior': Quantity([0, 3]), 'type': 'uniform'}, r_core_priors[xu_ind], norm_priors[yu_ind]] + + nice_pars = [r"$\beta_{1}$", r"R$_{\rm{core}, 1}$", "N$_{0, 1}$", r"$\beta_{2}$", r"R$_{\rm{core}, 2}$", + "N$_{0, 2}$"] + info_dict = {'author': 'placeholder', 'year': 'placeholder', 'reference': 'placeholder', + 'general': 'placeholder'} + super().__init__(x_unit, y_unit, start_pars, priors, 'double_king', 'Double King Profile', nice_pars, + 'Gas Density', info_dict) + + @staticmethod + def model(x: Quantity, beta_one: Quantity, r_core_one: Quantity, norm_one: Quantity, beta_two: Quantity, + r_core_two: Quantity, norm_two: Quantity) -> Quantity: + """ + The model function for the double King profile. + + :param Quantity x: The radii to calculate y values for. + :param Quantity beta_one: The beta slope parameter of the first King model. + :param Quantity r_core_one: The core radius of the first King model. + :param Quantity norm_one: The normalisation of the first King model. + :param Quantity beta_two: The beta slope parameter of the second King model. + :param Quantity r_core_two: The core radius of the second King model. + :param Quantity norm_two: The normalisation of the second King model. + :return: The y values corresponding to the input x values. + :rtype: Quantity + """ + return (norm_one * ((1 + (x / r_core_one)**2)**(-3 * beta_one))) + \ + (norm_two * ((1 + (x / r_core_two)**2)**(-3 * beta_two))) + + def derivative(self, x: Quantity, dx: Quantity = Quantity(0, ''), use_par_dist: bool = False) -> Quantity: + """ + Calculates the gradient of the double King profile at a given point, overriding the numerical method implemented + in the BaseModel1D class, as this simple model has an easily derivable first derivative. + + :param Quantity x: The point(s) at which the slope of the model should be measured. + :param Quantity dx: This makes no difference here, as this is an analytical derivative. It has + been left in so that the inputs for this method don't vary between models. + :param bool use_par_dist: Should the parameter distributions be used to calculate a derivative + distribution; this can only be used if a fit has been performed using the model instance. + Default is False, in which case the current parameters will be used to calculate a single value. + :return: The calculated slope of the model at the supplied x position(s). + :rtype: Quantity + """ + x = x[..., None] + if not use_par_dist: + beta_one, r_core_one, norm_one, beta_two, r_core_two, norm_two = self._model_pars + else: + beta_one, r_core_one, norm_one, beta_two, r_core_two, norm_two = self.par_dists + p1 = (-6*beta_one*norm_one*x/np.power(r_core_one, 2))*np.power((1+np.power(x/r_core_one, 2)), (-3*beta_one) - 1) + p2 = (-6*beta_two*norm_two*x/np.power(r_core_two, 2))*np.power((1+np.power(x/r_core_two, 2)), (-3*beta_two) - 1) + return p1 + p2 + + class SimpleVikhlininDensity1D(BaseModel1D): """ An XGA model implementation of a simplified version of Vikhlinin's full density model. Used relatively recently @@ -402,7 +517,7 @@ def derivative(self, x: Quantity, dx: Quantity = Quantity(0, ''), use_par_dist: DENS_MODELS = {"simple_vikhlinin_dens": SimpleVikhlininDensity1D, 'king': KingProfile1D, - 'vikhlinin_dens': VikhlininDensity1D} + 'double_king': DoubleKingProfile1D, 'vikhlinin_dens': VikhlininDensity1D} DENS_MODELS_PAR_NAMES = {n: m().par_publication_names for n, m in DENS_MODELS.items()} DENS_MODELS_PUB_NAMES = {n: m().publication_name for n, m in DENS_MODELS.items()} diff --git a/xga/models/fitting.py b/xga/models/fitting.py index 08dbbc77..d7ef5d83 100644 --- a/xga/models/fitting.py +++ b/xga/models/fitting.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 26/03/2021, 15:58. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from typing import List diff --git a/xga/models/misc.py b/xga/models/misc.py index 1f86e7e9..e5d65061 100644 --- a/xga/models/misc.py +++ b/xga/models/misc.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 08/02/2021, 16:48. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from typing import Union diff --git a/xga/models/sb.py b/xga/models/sb.py index a85d3a81..48b33ed7 100644 --- a/xga/models/sb.py +++ b/xga/models/sb.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 26/03/2021, 16:58. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from typing import Union, List @@ -328,7 +328,7 @@ def inverse_abel(self, x: Quantity, use_par_dist: bool = False, method='analytic def transform(x_val: Quantity, beta: Quantity, r_core: Quantity, norm: Quantity, beta_two: Quantity, r_core_two: Quantity, norm_two: Quantity): """ - The function that calculates the inverse abel transform of this beta profile. + The function that calculates the inverse abel transform of this double beta profile. :param Quantity x_val: The x location(s) at which to calculate the value of the inverse abel transform. :param Quantity beta: The beta parameter of the first beta profile. diff --git a/xga/models/temperature.py b/xga/models/temperature.py index 0664182c..eaf2773c 100644 --- a/xga/models/temperature.py +++ b/xga/models/temperature.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 26/03/2021, 16:58. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from typing import Union, List @@ -53,12 +53,12 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = else: xu_ind = x_convertible.index(True) - r_cool_starts = [Quantity(100, 'kpc'), Quantity(0.2, 'deg'), Quantity(0.05, r200), Quantity(0.1, r500), + r_cool_starts = [Quantity(50, 'kpc'), Quantity(0.01, 'deg'), Quantity(0.05, r200), Quantity(0.1, r500), Quantity(0.5, r2500)] - r_tran_starts = [Quantity(400, 'kpc'), Quantity(0.6, 'deg'), Quantity(0.2, r200), Quantity(0.4, r500), - Quantity(1, r2500)] - t_min_starts = [Quantity(1, 'keV'), (Quantity(1, 'keV')/k_B).to('K')] - t_zero_starts = [Quantity(5, 'keV'), (Quantity(5, 'keV')/k_B).to('K')] + r_tran_starts = [Quantity(200, 'kpc'), Quantity(0.015, 'deg'), Quantity(0.2, r200), Quantity(0.4, r500), + Quantity(0.7, r2500)] + t_min_starts = [Quantity(3, 'keV'), (Quantity(3, 'keV')/k_B).to('K')] + t_zero_starts = [Quantity(6, 'keV'), (Quantity(6, 'keV')/k_B).to('K')] start_pars = [r_cool_starts[xu_ind], Quantity(1, ''), t_min_starts[yu_ind], t_zero_starts[yu_ind], r_tran_starts[xu_ind], Quantity(1, '')] @@ -68,16 +68,23 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = # This method also returns the custom start pars converted to exactly the same units as the default start_pars = self.compare_units(cust_start_pars, start_pars) - r_priors = [{'prior': Quantity([0, 2000], 'kpc'), 'type': 'uniform'}, - {'prior': Quantity([0, 2], 'deg'), 'type': 'uniform'}, - {'prior': Quantity([0, 1], r200), 'type': 'uniform'}, - {'prior': Quantity([0, 1.5], r500), 'type': 'uniform'}, - {'prior': Quantity([0, 3], r2500), 'type': 'uniform'}] - t_priors = [{'prior': Quantity([0, 15], 'keV'), 'type': 'uniform'}, - {'prior': (Quantity([0, 15], 'keV')/k_B).to('K'), 'type': 'uniform'}] - - priors = [r_priors[xu_ind], {'prior': Quantity([-10, 10]), 'type': 'uniform'}, t_priors[yu_ind], - t_priors[yu_ind], r_priors[xu_ind], {'prior': Quantity([-10, 10]), 'type': 'uniform'}] + rc_priors = [{'prior': Quantity([10, 500], 'kpc'), 'type': 'uniform'}, + {'prior': Quantity([0.0, 0.032951243], 'deg'), 'type': 'uniform'}, + {'prior': Quantity([0, 0.5], r200), 'type': 'uniform'}, + {'prior': Quantity([0, 0.3], r500), 'type': 'uniform'}, + {'prior': Quantity([0, 1], r2500), 'type': 'uniform'}] + rt_priors = [{'prior': Quantity([100, 500], 'kpc'), 'type': 'uniform'}, + {'prior': Quantity([0.001, 0.032951243], 'deg'), 'type': 'uniform'}, + {'prior': Quantity([0.1, 0.5], r200), 'type': 'uniform'}, + {'prior': Quantity([0.07, 0.3], r500), 'type': 'uniform'}, + {'prior': Quantity([0.2, 1], r2500), 'type': 'uniform'}] + t0_priors = [{'prior': Quantity([0.5, 15], 'keV'), 'type': 'uniform'}, + {'prior': (Quantity([0.5, 15], 'keV')/k_B).to('K'), 'type': 'uniform'}] + tm_priors = [{'prior': Quantity([0.1, 6], 'keV'), 'type': 'uniform'}, + {'prior': (Quantity([0.1, 6], 'keV') / k_B).to('K'), 'type': 'uniform'}] + + priors = [rc_priors[xu_ind], {'prior': Quantity([0, 5]), 'type': 'uniform'}, tm_priors[yu_ind], + t0_priors[yu_ind], rt_priors[xu_ind], {'prior': Quantity([0, 5]), 'type': 'uniform'}] nice_pars = [r"R$_{\rm{cool}}$", r"a$_{\rm{cool}}$", r"T$_{\rm{min}}$", "T$_{0}$", r"R$_{\rm{T}}$", "c"] info_dict = {'author': 'Ghirardini et al.', 'year': 2019, @@ -183,12 +190,12 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = else: xu_ind = x_convertible.index(True) - r_cool_starts = [Quantity(100, 'kpc'), Quantity(0.2, 'deg'), Quantity(0.05, r200), Quantity(0.1, r500), + r_cool_starts = [Quantity(50, 'kpc'), Quantity(0.01, 'deg'), Quantity(0.05, r200), Quantity(0.1, r500), Quantity(0.5, r2500)] - r_tran_starts = [Quantity(400, 'kpc'), Quantity(0.6, 'deg'), Quantity(0.2, r200), Quantity(0.4, r500), - Quantity(1, r2500)] - t_min_starts = [Quantity(1, 'keV'), (Quantity(1, 'keV')/k_B).to('K')] - t_zero_starts = [Quantity(5, 'keV'), (Quantity(5, 'keV')/k_B).to('K')] + r_tran_starts = [Quantity(200, 'kpc'), Quantity(0.015, 'deg'), Quantity(0.2, r200), Quantity(0.4, r500), + Quantity(0.7, r2500)] + t_min_starts = [Quantity(3, 'keV'), (Quantity(3, 'keV') / k_B).to('K')] + t_zero_starts = [Quantity(6, 'keV'), (Quantity(6, 'keV') / k_B).to('K')] start_pars = [r_cool_starts[xu_ind], Quantity(1, ''), t_min_starts[yu_ind], t_zero_starts[yu_ind], r_tran_starts[xu_ind], Quantity(1, ''), Quantity(1, ''), Quantity(1, '')] @@ -198,17 +205,24 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = # This method also returns the custom start pars converted to exactly the same units as the default start_pars = self.compare_units(cust_start_pars, start_pars) - r_priors = [{'prior': Quantity([0, 2000], 'kpc'), 'type': 'uniform'}, - {'prior': Quantity([0, 2], 'deg'), 'type': 'uniform'}, - {'prior': Quantity([0, 1], r200), 'type': 'uniform'}, - {'prior': Quantity([0, 1.5], r500), 'type': 'uniform'}, - {'prior': Quantity([0, 3], r2500), 'type': 'uniform'}] - t_priors = [{'prior': Quantity([0, 15], 'keV'), 'type': 'uniform'}, - {'prior': (Quantity([0, 15], 'keV')/k_B).to('K'), 'type': 'uniform'}] - - priors = [r_priors[xu_ind], {'prior': Quantity([-10, 10]), 'type': 'uniform'}, t_priors[yu_ind], - t_priors[yu_ind], r_priors[xu_ind], {'prior': Quantity([-10, 10]), 'type': 'uniform'}, - {'prior': Quantity([-10, 10]), 'type': 'uniform'}, {'prior': Quantity([-10, 10]), 'type': 'uniform'}] + rc_priors = [{'prior': Quantity([10, 500], 'kpc'), 'type': 'uniform'}, + {'prior': Quantity([0.0, 0.032951243], 'deg'), 'type': 'uniform'}, + {'prior': Quantity([0, 0.5], r200), 'type': 'uniform'}, + {'prior': Quantity([0, 0.3], r500), 'type': 'uniform'}, + {'prior': Quantity([0, 1], r2500), 'type': 'uniform'}] + rt_priors = [{'prior': Quantity([100, 500], 'kpc'), 'type': 'uniform'}, + {'prior': Quantity([0.001, 0.032951243], 'deg'), 'type': 'uniform'}, + {'prior': Quantity([0.1, 0.5], r200), 'type': 'uniform'}, + {'prior': Quantity([0.07, 0.3], r500), 'type': 'uniform'}, + {'prior': Quantity([0.2, 1], r2500), 'type': 'uniform'}] + t0_priors = [{'prior': Quantity([0.5, 15], 'keV'), 'type': 'uniform'}, + {'prior': (Quantity([0.5, 15], 'keV') / k_B).to('K'), 'type': 'uniform'}] + tm_priors = [{'prior': Quantity([0.1, 6], 'keV'), 'type': 'uniform'}, + {'prior': (Quantity([0.1, 6], 'keV') / k_B).to('K'), 'type': 'uniform'}] + + priors = [rc_priors[xu_ind], {'prior': Quantity([0, 5]), 'type': 'uniform'}, tm_priors[yu_ind], + t0_priors[yu_ind], rt_priors[xu_ind], {'prior': Quantity([0, 5]), 'type': 'uniform'}, + {'prior': Quantity([0, 5]), 'type': 'uniform'}, {'prior': Quantity([0, 5]), 'type': 'uniform'}] nice_pars = [r"R$_{\rm{cool}}$", r"a$_{\rm{cool}}$", r"T$_{\rm{min}}$", "T$_{0}$", r"R$_{\rm{T}}$", "a", "b", "c"] diff --git a/xga/products/__init__.py b/xga/products/__init__.py index c83d026e..7f91a10a 100644 --- a/xga/products/__init__.py +++ b/xga/products/__init__.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 11/02/2021, 12:30. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from .base import BaseProduct, BaseAggregateProduct, BaseProfile1D, BaseAggregateProfile1D from .misc import EventList diff --git a/xga/products/base.py b/xga/products/base.py index 09485917..f87f2eb0 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 30/08/2021, 09:32. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors import inspect import os @@ -15,6 +15,8 @@ from astropy.units import Quantity, UnitConversionError, Unit, deg from getdist import plots, MCSamples from matplotlib import pyplot as plt +from matplotlib.axes import Axes +from matplotlib.figure import Figure from matplotlib.ticker import FuncFormatter from scipy.optimize import curve_fit, minimize from tabulate import tabulate @@ -705,6 +707,22 @@ def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_n self._save_path = None + def _model_allegiance(self, model: BaseModel1D): + """ + This internal method with a silly name just checks whether a model instance has already been associated + with a profile other than this one, or if it has any association with a profile. If there is an association + with another profile then it throws an error, as that that can cause serious problems that have caught me + out before (see issue #742). If there is no association it sets the models profile attribute. + + :param BaseModel1D model: An instance of a BaseModel1D class (or subclass) to check. + """ + if model.profile is not None and model.profile != self: + raise ModelNotAssociatedError("The passed model instance is already associated with another profile, and" + " as such cannot be fit to this one. Ensure that individual model instances" + " are declared for each profile you are fitting.") + elif model.profile is None: + model.profile = self + def emcee_fit(self, model: BaseModel1D, num_steps: int, num_walkers: int, progress_bar: bool, show_warn: bool, num_samples: int) -> Tuple[BaseModel1D, bool]: """ @@ -714,7 +732,7 @@ def emcee_fit(self, model: BaseModel1D, num_steps: int, num_walkers: int, progre likelihood estimate is run, and if that fails the method will revert to using the start parameters set in the model instance. - :param BaseModel1D model: The model to be fit to the data. + :param BaseModel1D model: The model to be fit to the data, you cannot pass a model name for this argument. :param int num_steps: The number of steps each chain should take. :param int num_walkers: The number of walkers to be run for the ensemble sampler. :param bool progress_bar: Whether a progress bar should be displayed. @@ -744,6 +762,17 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: return to_replace_arr + # The very first thing I do is to check whether the passed model is ACTUALLY a model or a model name - I + # expect this confusion could arise because the fit() method (which is what users should REALLY be using) + # allows either an instance or a model name. + if not isinstance(model, BaseModel1D): + raise TypeError("This fitting method requires that a model instance be passed for the model argument, " + "rather than a model name.") + # Then I check that the model instance hasn't already been fit to another profile - I would do this in the + # fit() method (because then I wouldn't have to it in every separate fitting method), but I can't + else: + self._model_allegiance(model) + # I'm just defining these here so that the lines don't get too long for PEP standards y_data = (self.values.copy() - self._background).value y_errs = self.values_err.copy().value @@ -761,7 +790,11 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: # We can run a curve_fit fit to try and get start values for the model parameters, and if that fails # we try maximum likelihood, and if that fails then we fall back on the default start parameters in the # model. - curve_fit_model, success = self.nlls_fit(deepcopy(model), 10, show_warn=False) + # Making a copy of the model, and setting the profile to None otherwise the allegiance check gets very upset + curve_fit_model = deepcopy(model) + curve_fit_model.profile = None + + curve_fit_model, success = self.nlls_fit(curve_fit_model, 10, show_warn=False) if success or curve_fit_model.fit_warning == "Very large parameter uncertainties": base_start_pars = np.array([p.value for p in curve_fit_model.model_pars]) else: @@ -911,6 +944,9 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: # And finally storing the fit method used in the model itself model.fit_method = "mcmc" + # Explicitly deleting the curve fit model, just to be safe + del curve_fit_model + return model, success def nlls_fit(self, model: BaseModel1D, num_samples: int, show_warn: bool) -> Tuple[BaseModel1D, bool]: @@ -927,6 +963,17 @@ def nlls_fit(self, model: BaseModel1D, num_samples: int, show_warn: bool) -> Tup fit was successful or not. :rtype: Tuple[BaseModel1D, bool] """ + # The very first thing I do is to check whether the passed model is ACTUALLY a model or a model name - I + # expect this confusion could arise because the fit() method (which is what users should REALLY be using) + # allows either an instance or a model name. + if not isinstance(model, BaseModel1D): + raise TypeError("This fitting method requires that a model instance be passed for the model argument, " + "rather than a model name.") + # Then I check that the model instance hasn't already been fit to another profile - I would do this in the + # fit() method (because then I wouldn't have to it in every separate fitting method), but I can't + else: + self._model_allegiance(model) + y_data = (self.values.copy() - self._background).value y_errs = self.values_err.copy().value rads = self.fit_radii.copy().value @@ -1011,6 +1058,17 @@ def _odr_fit(self, model: BaseModel1D, show_warn: bool): # Tell the model whether we think the fit was successful or not # model.success = success + # The very first thing I do is to check whether the passed model is ACTUALLY a model or a model name - I + # expect this confusion could arise because the fit() method (which is what users should REALLY be using) + # allows either an instance or a model name. + if not isinstance(model, BaseModel1D): + raise TypeError("This fitting method requires that a model instance be passed for the model argument, " + "rather than a model name.") + # Then I check that the model instance hasn't already been fit to another profile - I would do this in the + # fit() method (because then I wouldn't have to it in every separate fitting method), but I can't + else: + self._model_allegiance(model) + # And finally storing the fit method used in the model itself model.fit_method = "odr" raise NotImplementedError("This fitting method is still under construction!") @@ -1380,16 +1438,18 @@ def generate_data_realisations(self, num_real: int): return realisations - def view(self, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None, models=True, - back_sub: bool = True, just_models: bool = False, custom_title: str = None, draw_rads: dict = {}, - x_norm: Union[bool, Quantity] = False, y_norm: Union[bool, Quantity] = False, x_label: str = None, - y_label: str = None, save_path: str = None): + def get_view(self, fig: Figure, main_ax: Axes, xscale="log", yscale="log", xlim=None, ylim=None, models=True, + back_sub: bool = True, just_models: bool = False, custom_title: str = None, draw_rads: dict = {}, + x_norm: Union[bool, Quantity] = False, y_norm: Union[bool, Quantity] = False, x_label: str = None, + y_label: str = None, data_colour: str = 'black', model_colour: str = 'seagreen', + show_legend: bool = True, show_residual_ax: bool = True): """ - A method that allows us to view the current profile, as well as any models that have been fitted to it, - and their residuals. The models are plotted by generating random model realisations from the parameter - distributions, then plotting the median values, with 1sigma confidence limits. + A get method for an axes (or multiple axes) showing this profile and model fits. The idea of this get method + is that, whilst it is used by the view() method, it can also be called by external methods that wish to use + the profile plot in concert with other views. - :param Tuple figsize: The desired size of the figure, the default is (10, 7) + :param Figure fig: The figure which has been set up for this profile plot. + :param Axes main_ax: The matplotlib axes on which to show the image. :param str xscale: The scaling to be applied to the x axis, default is log. :param str yscale: The scaling to be applied to the y axis, default is log. :param Tuple xlim: The limits to be applied to the x axis, upper and lower, default is @@ -1413,9 +1473,13 @@ def view(self, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None will attempt to normalise using that. :param str x_label: Custom label for the x-axis (excluding units, which will be added automatically). :param str y_label: Custom label for the y-axis (excluding units, which will be added automatically). - :param str save_path: The path where the figure produced by this method should be saved. Default is None, in - which case the figure will not be saved. + :param str data_colour: Used to set the colour of the data points. + :param str model_colour: Used to set the colour of a model fit. + :param bool show_legend: Whether the legend should be displayed or not. Default is True. + :param bool show_residual_ax: Controls whether a lower axis showing the residuals between data and + model (if a model is fitted and being shown) is displayed. Default is True. """ + # Checks that any extra radii that have been passed are the correct units (i.e. the same as the radius units # used in this profile) if not all([r.unit == self.radii_unit for r in draw_rads.values()]): @@ -1449,12 +1513,8 @@ def view(self, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None elif isinstance(y_norm, bool) and not y_norm: y_norm = Quantity(1, '') - # Setting up figure for the plot - fig = plt.figure(figsize=figsize) - # Grabbing the axis object and making sure the ticks are set up how we want - main_ax = plt.gca() main_ax.minorticks_on() - if models: + if models and show_residual_ax: # This sets up an axis for the residuals to be plotted on, if model plotting is enabled res_ax = fig.add_axes((0.125, -0.075, 0.775, 0.2)) res_ax.minorticks_on() @@ -1482,18 +1542,18 @@ def view(self, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None if self.radii_err is not None and self.values_err is None: x_errs = (self.radii_err.copy() / x_norm).value line = main_ax.errorbar(rad_vals.value, plot_y_vals.value, xerr=x_errs, fmt="x", capsize=2, - label=leg_label) + label=leg_label, color=data_colour) elif self.radii_err is None and self.values_err is not None: y_errs = (self.values_err.copy() / y_norm).value line = main_ax.errorbar(rad_vals.value, plot_y_vals.value, yerr=y_errs, fmt="x", capsize=2, - label=leg_label) + label=leg_label, color=data_colour) elif self.radii_err is not None and self.values_err is not None: x_errs = (self.radii_err.copy() / x_norm).value y_errs = (self.values_err.copy() / y_norm).value line = main_ax.errorbar(rad_vals.value, plot_y_vals.value, xerr=x_errs, yerr=y_errs, fmt="x", capsize=2, - label=leg_label) + label=leg_label, color=data_colour) else: - line = main_ax.plot(rad_vals.value, plot_y_vals.value, 'x', label=leg_label) + line = main_ax.plot(rad_vals.value, plot_y_vals.value, 'x', label=leg_label, color=data_colour) if just_models and models: line[0].set_visible(False) @@ -1521,22 +1581,26 @@ def view(self, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None lower_model = np.percentile(mod_reals, 15.9, axis=1) mod_lab = model_obj.publication_name + " - {}".format(self._nice_fit_methods[method]) - mod_line = main_ax.plot(mod_rads.value/x_norm.value, median_model.value/y_norm, - label=mod_lab) - model_colour = mod_line[0].get_color() + main_ax.plot(mod_rads.value / x_norm.value, median_model.value / y_norm, label=mod_lab, + color=model_colour) - main_ax.fill_between(mod_rads.value/x_norm.value, lower_model.value/y_norm.value, - upper_model.value/y_norm.value, alpha=0.7, interpolate=True, + main_ax.fill_between(mod_rads.value / x_norm.value, lower_model.value / y_norm.value, + upper_model.value / y_norm.value, alpha=0.7, interpolate=True, where=upper_model.value >= lower_model.value, facecolor=model_colour) - main_ax.plot(mod_rads.value/x_norm.value, lower_model.value/y_norm.value, color=model_colour, + main_ax.plot(mod_rads.value / x_norm.value, lower_model.value / y_norm.value, color=model_colour, linestyle="dashed") - main_ax.plot(mod_rads.value/x_norm.value, upper_model.value/y_norm.value, color=model_colour, + main_ax.plot(mod_rads.value / x_norm.value, upper_model.value / y_norm.value, color=model_colour, linestyle="dashed") - # This calculates and plots the residuals between the model and the data on the extra - # axis we added near the beginning of this method - res = np.percentile(model_obj.get_realisations(self.fit_radii), 50, axis=1) - (plot_y_vals*y_norm) - res_ax.plot(rad_vals.value, res.value, 'D', color=model_colour) + # I only want this to trigger if the user has decided they want a residual axis. I expect most + # of the time that they will, but for things like the Hydrostatic mass diagnostic plots I want + # to be able to turn the residual axis off. + if show_residual_ax: + # This calculates and plots the residuals between the model and the data on the extra + # axis we added near the beginning of this method + res = np.percentile(model_obj.get_realisations(self.fit_radii), 50, axis=1) \ + - (plot_y_vals * y_norm) + res_ax.plot(rad_vals.value, res.value, 'D', color=model_colour) # Parsing the astropy units so that if they are double height then the square brackets will adjust size x_unit = r"$\left[" + rad_vals.unit.to_string("latex").strip("$") + r"\right]$" @@ -1562,10 +1626,12 @@ def view(self, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None elif y_label is not None: main_ax.set_ylabel(y_label + ' {}'.format(y_unit), fontsize=13) - main_leg = main_ax.legend(loc="upper left", bbox_to_anchor=(1.01, 1), ncol=1, borderaxespad=0) - # This makes sure legend keys are shown, even if the data is hidden - for leg_key in main_leg.legendHandles: - leg_key.set_visible(True) + # If the user wants a legend to be shown, then we create one + if show_legend: + main_leg = main_ax.legend(loc="upper left", bbox_to_anchor=(1.01, 1), ncol=1, borderaxespad=0) + # This makes sure legend keys are shown, even if the data is hidden + for leg_key in main_leg.legendHandles: + leg_key.set_visible(True) # If the user has manually set limits then we can use them, only on the main axis because # we grab those limits from the axes object for the residual axis later @@ -1577,7 +1643,7 @@ def view(self, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None # Setup the scale that the user wants to see, again on the main axis main_ax.set_xscale(xscale) main_ax.set_yscale(yscale) - if models: + if models and show_residual_ax: # We want the residual x axis limits to be identical to the main axis, as the # points should line up res_ax.set_xlim(main_ax.get_xlim()) @@ -1622,7 +1688,7 @@ def view(self, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None if max(x_axis_lims) < 100 and not models and min(x_axis_lims) > 0.1: main_ax.xaxis.set_minor_formatter(FuncFormatter(lambda inp, _: '{:g}'.format(inp))) main_ax.xaxis.set_major_formatter(FuncFormatter(lambda inp, _: '{:g}'.format(inp))) - elif max(x_axis_lims) < 100 and models: + elif max(x_axis_lims) < 100 and models and show_residual_ax: res_ax.xaxis.set_minor_formatter(FuncFormatter(lambda inp, _: '{:g}'.format(inp))) res_ax.xaxis.set_major_formatter(FuncFormatter(lambda inp, _: '{:g}'.format(inp))) @@ -1632,13 +1698,126 @@ def view(self, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None elif max(y_axis_lims) < 100 and min(y_axis_lims) <= 0.1: main_ax.yaxis.set_major_formatter(FuncFormatter(lambda inp, _: '{:g}'.format(inp))) - # If the user passed a save_path value, then we assume they want to save the figure - if save_path is not None: - plt.savefig(save_path) + if models and show_residual_ax: + return main_ax, res_ax + else: + return main_ax, None - # And of course actually showing it + def view(self, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None, models=True, + back_sub: bool = True, just_models: bool = False, custom_title: str = None, draw_rads: dict = {}, + x_norm: Union[bool, Quantity] = False, y_norm: Union[bool, Quantity] = False, x_label: str = None, + y_label: str = None, data_colour: str = 'black', model_colour: str = 'seagreen', show_legend: bool = True, + show_residual_ax: bool = True): + """ + A method that allows us to view the current profile, as well as any models that have been fitted to it, + and their residuals. The models are plotted by generating random model realisations from the parameter + distributions, then plotting the median values, with 1sigma confidence limits. + + :param Tuple figsize: The desired size of the figure, the default is (10, 7) + :param str xscale: The scaling to be applied to the x axis, default is log. + :param str yscale: The scaling to be applied to the y axis, default is log. + :param Tuple xlim: The limits to be applied to the x axis, upper and lower, default is + to let matplotlib decide by itself. + :param Tuple ylim: The limits to be applied to the y axis, upper and lower, default is + to let matplotlib decide by itself. + :param str models: Should the fitted models to this profile be plotted, default is True + :param bool back_sub: Should the plotted data be background subtracted, default is True. + :param bool just_models: Should ONLY the fitted models be plotted? Default is False + :param str custom_title: A plot title to replace the automatically generated title, default is None. + :param dict draw_rads: A dictionary of extra radii (as astropy Quantities) to draw onto the plot, where + the dictionary key they are stored under is what they will be labelled. + e.g. ({'r500': Quantity(), 'r200': Quantity()} + :param bool x_norm: Controls whether the x-axis of the profile is normalised by another value, the default is + False, in which case no normalisation is applied. If it is set to True then it will attempt to use the + internal normalisation value (which can be set with the x_norm property), and if a quantity is passed it + will attempt to normalise using that. + :param bool y_norm: Controls whether the y-axis of the profile is normalised by another value, the default is + False, in which case no normalisation is applied. If it is set to True then it will attempt to use the + internal normalisation value (which can be set with the y_norm property), and if a quantity is passed it + will attempt to normalise using that. + :param str x_label: Custom label for the x-axis (excluding units, which will be added automatically). + :param str y_label: Custom label for the y-axis (excluding units, which will be added automatically). + :param str data_colour: Used to set the colour of the data points. + :param str model_colour: Used to set the colour of a model fit. + :param bool show_legend: Whether the legend should be displayed or not. Default is True. + :param bool show_residual_ax: Controls whether a lower axis showing the residuals between data and + model (if a model is fitted and being shown) is displayed. Default is True. + """ + # Setting up figure for the plot + fig = plt.figure(figsize=figsize) + # Grabbing the axis object and making sure the ticks are set up how we want + main_ax = plt.gca() + + main_ax, res_ax = self.get_view(fig, main_ax, xscale, yscale, xlim, ylim, models, back_sub, just_models, + custom_title, draw_rads, x_norm, y_norm, x_label, y_label, data_colour, + model_colour, show_legend, show_residual_ax) + + # plt.tight_layout() + plt.show() + + # Wipe the figure + plt.close("all") + + def save_view(self, save_path: str, figsize=(10, 7), xscale="log", yscale="log", xlim=None, ylim=None, models=True, + back_sub: bool = True, just_models: bool = False, custom_title: str = None, draw_rads: dict = {}, + x_norm: Union[bool, Quantity] = False, y_norm: Union[bool, Quantity] = False, x_label: str = None, + y_label: str = None, data_colour: str = 'black', model_colour: str = 'seagreen', + show_legend: bool = True, show_residual_ax: bool = True): + """ + A method that allows us to save a view of the current profile, as well as any models that have been + fitted to it, and their residuals. The models are plotted by generating random model realisations from + the parameter distributions, then plotting the median values, with 1sigma confidence limits. + + This method will not display a figure, just save it at the supplied save_path. + + :param str save_path: The path (including file name) where you wish to save the profile view. + :param Tuple figsize: The desired size of the figure, the default is (10, 7) + :param str xscale: The scaling to be applied to the x axis, default is log. + :param str yscale: The scaling to be applied to the y axis, default is log. + :param Tuple xlim: The limits to be applied to the x axis, upper and lower, default is + to let matplotlib decide by itself. + :param Tuple ylim: The limits to be applied to the y axis, upper and lower, default is + to let matplotlib decide by itself. + :param str models: Should the fitted models to this profile be plotted, default is True + :param bool back_sub: Should the plotted data be background subtracted, default is True. + :param bool just_models: Should ONLY the fitted models be plotted? Default is False + :param str custom_title: A plot title to replace the automatically generated title, default is None. + :param dict draw_rads: A dictionary of extra radii (as astropy Quantities) to draw onto the plot, where + the dictionary key they are stored under is what they will be labelled. + e.g. ({'r500': Quantity(), 'r200': Quantity()} + :param bool x_norm: Controls whether the x-axis of the profile is normalised by another value, the default is + False, in which case no normalisation is applied. If it is set to True then it will attempt to use the + internal normalisation value (which can be set with the x_norm property), and if a quantity is passed it + will attempt to normalise using that. + :param bool y_norm: Controls whether the y-axis of the profile is normalised by another value, the default is + False, in which case no normalisation is applied. If it is set to True then it will attempt to use the + internal normalisation value (which can be set with the y_norm property), and if a quantity is passed it + will attempt to normalise using that. + :param str x_label: Custom label for the x-axis (excluding units, which will be added automatically). + :param str y_label: Custom label for the y-axis (excluding units, which will be added automatically). + :param str data_colour: Used to set the colour of the data points. + :param str model_colour: Used to set the colour of a model fit. + :param bool show_legend: Whether the legend should be displayed or not. Default is True. + :param bool show_residual_ax: Controls whether a lower axis showing the residuals between data and + model (if a model is fitted and being shown) is displayed. Default is True. + """ + # Setting up figure for the plot + fig = plt.figure(figsize=figsize) + # Grabbing the axis object and making sure the ticks are set up how we want + main_ax = plt.gca() + + main_ax, res_ax = self.get_view(fig, main_ax, xscale, yscale, xlim, ylim, models, back_sub, just_models, + custom_title, draw_rads, x_norm, y_norm, x_label, y_label, data_colour, + model_colour, show_legend, show_residual_ax) + + plt.savefig(save_path) + + # plt.tight_layout() plt.show() + # Wipe the figure + plt.close("all") + def save(self, save_path: str = None): """ This method pickles and saves the profile object. This will be called automatically when the profile diff --git a/xga/products/misc.py b/xga/products/misc.py index 69b32d04..68126d5d 100644 --- a/xga/products/misc.py +++ b/xga/products/misc.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 15/01/2021, 16:30. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from . import BaseProduct diff --git a/xga/products/phot.py b/xga/products/phot.py index c74e69ac..2c59f138 100644 --- a/xga/products/phot.py +++ b/xga/products/phot.py @@ -1,22 +1,25 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 05/01/2022, 11:21. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors import os import warnings from copy import deepcopy -from typing import Tuple, List, Union +from typing import Tuple, List, Union, Dict import numpy as np import pandas as pd from astropy import wcs -from astropy.convolution import Kernel +from astropy.convolution import Kernel, Gaussian2DKernel, convolve_fft from astropy.units import Quantity, UnitBase, UnitsError, deg, pix, UnitConversionError, Unit -from astropy.visualization import LogStretch, MinMaxInterval, ImageNormalize, BaseStretch +from astropy.visualization import MinMaxInterval, ImageNormalize, BaseStretch, ManualInterval +from astropy.visualization.stretch import LogStretch, SinhStretch, AsinhStretch, SqrtStretch, SquaredStretch, \ + LinearStretch from fitsio import read, read_header, FITSHDR from matplotlib import pyplot as plt from matplotlib.axes import Axes -from matplotlib.patches import Circle -from regions import read_ds9, PixelRegion, SkyRegion +from matplotlib.patches import Circle, Ellipse +from matplotlib.widgets import Button, RangeSlider, Slider +from regions import read_ds9, PixelRegion, SkyRegion, EllipsePixelRegion, CirclePixelRegion, PixCoord, write_ds9 from scipy.cluster.hierarchy import fclusterdata from scipy.signal import fftconvolve @@ -26,6 +29,10 @@ from ..utils import xmm_sky, xmm_det, find_all_wcs EMOSAIC_INST = {"EPN": "pn", "EMOS1": "mos1", "EMOS2": "mos2"} +plt.rcParams['keymap.save'] = '' +plt.rcParams['keymap.quit'] = '' +stretch_dict = {'LOG': LogStretch(), 'SINH': SinhStretch(), 'ASINH': AsinhStretch(), 'SQRT': SqrtStretch(), + 'SQRD': SquaredStretch(), 'LIN': LinearStretch()} class Image(BaseProduct): @@ -42,7 +49,13 @@ class Image(BaseProduct): :param str gen_cmd: The command used to generate the product. :param Quantity lo_en: The lower energy bound used to generate this product. :param Quantity hi_en: The upper energy bound used to generate this product. - :param str reg_file_path: Path to a region file for this image. + :param str/List[SkyRegion/PixelRegion]/dict regs: A region list file path, a list of region objects, or a + dictionary of region lists with ObsIDs as dictionary keys. + :param dict/SkyRegion/PixelRegion matched_regs: Similar to the regs argument, but in this case for a region + that has been designated as 'matched', i.e. is the subject of a current analysis. This should either be + supplied as a single region object, or as a dictionary of region objects with ObsIDs as keys, or None values + if there is no match. Such a dictionary can be retrieved from a source using the 'matched_regions' + property. Default is None. :param bool smoothed: Has this image been smoothed, default is False. This information can also be set after the instantiation of an image. :param dict/Kernel smoothed_info: Information on how the image was smoothed, given either by the Astropy @@ -53,7 +66,8 @@ class Image(BaseProduct): ['0404910601', 'mos2'], ['0201901401', 'pn'], ['0201901401', 'mos1'], ['0201901401', 'mos2']]. """ def __init__(self, path: str, obs_id: str, instrument: str, stdout_str: str, stderr_str: str, gen_cmd: str, - lo_en: Quantity, hi_en: Quantity, reg_file_path: str = '', smoothed: bool = False, + lo_en: Quantity, hi_en: Quantity, regs: Union[str, List[Union[SkyRegion, PixelRegion]], dict] = '', + matched_regs: Union[SkyRegion, PixelRegion, dict] = None, smoothed: bool = False, smoothed_info: Union[dict, Kernel] = None, obs_inst_combs: List[List] = None): """ The initialisation method for the Image class. @@ -67,6 +81,8 @@ def __init__(self, path: str, obs_id: str, instrument: str, stdout_str: str, std self._prod_type = "image" self._data = None self._header = None + # Adding an attribute to tell the product what its data units are, as there are subclasses of Image + self._data_unit = Unit("ct") # This is a flag to let XGA know that the Image object has been PSF corrected self._psf_corrected = False @@ -77,18 +93,26 @@ def __init__(self, path: str, obs_id: str, instrument: str, stdout_str: str, std self._psf_num_iterations = None self._psf_model = None - # This checks whether a region file has been passed, and if it has then processes it - if reg_file_path != '' and os.path.exists(reg_file_path): - self._regions = self._process_regions(reg_file_path) - self._reg_file_path = reg_file_path - elif reg_file_path != '' and not os.path.exists(reg_file_path): + # This checks whether a region file has been passed, and if it has then processes it. If a list or dictionary + # of regions has been passed instead (as is allowed) then the behaviour is modified slightly. + if isinstance(regs, str) and regs != '' and os.path.exists(regs): + self._regions = self._process_regions(regs) + self._reg_file_path = regs + elif isinstance(regs, str) and regs != '' and not os.path.exists(regs): warnings.warn("That region file path does not exist") - self._regions = [] - self._reg_file_path = reg_file_path + self._regions = {} + self._reg_file_path = regs + elif isinstance(regs, (list, dict)): + self._regions = self._process_regions(reg_objs=regs) + self._reg_file_path = '' else: - self._regions = [] + self._regions = {} self._reg_file_path = '' + # This uses an internal function to process and return matched regions in a standard form, which is what + # I really should have done for the chunk above but oh well! + self._matched_regions = self._process_matched_regions(matched_regs) + self._smoothed = smoothed # If the user says at this point that the image has been smoothed, then we try and parse the smoothing info if smoothed: @@ -204,42 +228,107 @@ def _read_wcs_on_demand(self): raise FailedProductError("SAS has generated this image without a WCS capable of " "going from pixels to RA-DEC.") - def _process_regions(self, path: str = None, reg_list: List[Union[PixelRegion, SkyRegion]] = None) \ - -> List[PixelRegion]: + def _process_regions(self, path: str = None, reg_objs: Union[List[Union[PixelRegion, SkyRegion]], dict] = None) \ + -> dict: """ This internal function just takes the path to a region file and processes it into a form that this object requires for viewing. :param str path: The path of a region file to be processed, can be None but only if the other argument is given. - :param List[PixelRegion/SkyRegion] reg_list: A list of region objects to be processed, default is None. - :return: A list of pixel regions. - :rtype: List[PixelRegion] + :param Union[List[PixelRegion/SkyRegion]/dict] reg_objs: A list or dictionary of region objects to be + processed, default is None. + :return: A dictionary of lists of pixel regions, with dictionary keys being ObsIDs. + :rtype: Dict """ # This method can deal with either an input of a region file path or of a list of region objects, but # firstly we need to check that at least one of the inputs isn't None - if all([path is None, reg_list is None]): + if all([path is None, reg_objs is None]): raise ValueError("Either a path or a list of region objects must be passed, you have passed neither") - elif all([path is not None, reg_list is not None]): + elif all([path is not None, reg_objs is not None]): raise ValueError("You have passed both a path and a list of regions, pass one or the other.") # The behaviour here depends on whether regions or a path have been passed if path is not None: ds9_regs = read_ds9(path) else: - ds9_regs = deepcopy(reg_list) + ds9_regs = deepcopy(reg_objs) + + # As we now support passing either a dictionary or a list of region objects, some preprocessing is needed + if type(ds9_regs) == list: + ds9_regs = {self._obs_id: ds9_regs} + elif type(ds9_regs) == dict: + obs_keys = ds9_regs.keys() + # Checks whether all the ObsIDs present in the XGA product are represented in the region dictionary + check = [o in obs_keys for o in self.obs_ids] + if not all(check): + missing = np.array(self.obs_ids)[~np.array(check)] + raise KeyError("The passed region dictionary does not have an ObsID entry for every ObsID " + "associated with this object, the following are " + "missing; {a}.".format(a=','.join(missing))) # Checking what kind of regions there are, as that changes whether they need to be converted or not - final_regs = [] - for reg in ds9_regs: - if isinstance(reg, PixelRegion): - final_regs.append(reg) - else: - # Regions in sky coordinates need to be in pixels for overlaying on the image - final_regs.append(reg.to_pixel(self._wcs_radec)) + final_regs = {} + # Top level of iteration is through the ObsID keys + for o in ds9_regs: + # Setting up an entry in the output dictionary for that ObsID + final_regs[o] = [] + for reg in ds9_regs[o]: + if isinstance(reg, PixelRegion): + final_regs[o].append(reg) + else: + # Regions in sky coordinates need to be in pixels for overlaying on the image + final_regs[o].append(reg.to_pixel(self._wcs_radec)) return final_regs + def _process_matched_regions(self, matched_reg_input: Union[SkyRegion, PixelRegion, dict]): + """ + This processes input matched region information, making sure that it is in an acceptable format, and then + returning a dictionary in the form expected by this class. Also makes sure that all matched regions are + converted to pixel coordinates. + + :param SkyRegion/PixelRegion/dict matched_reg_input: A region that has been designated as 'matched', i.e. + is the subject of a current analysis. This should either be supplied as a single region object, or as + a dictionary of region objects with ObsIDs as keys, or None values if there is no match. Such a + dictionary can be retrieved from a source using the 'matched_regions' property. + :return: A dictionary with ObsIDs as keys, and matching regions as values. If a single region is passed then + the ObsID key it is paired with is set to the current ObsID of this object. + :rtype: dict + """ + # It is possible to set this to None, in which case no information is recorded. + if matched_reg_input is None: + matched_reg_input = {} + # This is triggered when a dictionary is passed, and all of its values are regions or None (indicating no match) + elif isinstance(matched_reg_input, dict) and all([r is None or isinstance(r, (SkyRegion, PixelRegion)) + for o, r in matched_reg_input.items()]): + obs_keys = matched_reg_input.keys() + # Checks whether all the ObsIDs present in the XGA product are represented in the region dictionary + check = [o in obs_keys for o in self.obs_ids] + if not all(check): + missing = np.array(self.obs_ids)[~np.array(check)] + raise KeyError("The passed matched region dictionary does not have an ObsID entry for every ObsID " + "associated with this object, the following are " + "missing; {a}.".format(a=','.join(missing))) + # This is triggered when a dictionary is passed but not all of its values are regions + elif isinstance(matched_reg_input, dict) and not all([r is None or isinstance(r, (SkyRegion, PixelRegion)) + for o, r in matched_reg_input.items()]): + raise TypeError('The input matched region dictionary has entries that are not a SkyRegion or PixelRegion.') + # If one single region is passed, it's put in a dictionary with the current ObsID of the object as the key + elif isinstance(matched_reg_input, (PixelRegion, SkyRegion)): + matched_reg_input = {self._obs_id: matched_reg_input} + else: + raise TypeError("The input matched region is not a dictionary of regions, nor is it a single " + "PixelRegion or SkyRegion instance.") + + # Finally we run through any matched regions that made it this far, and make sure that they + # are all in pixel coordinates (it makes it easier for plotting etc. later) + for obs_id, matched_reg in matched_reg_input.items(): + if matched_reg is not None and not isinstance(matched_reg, PixelRegion): + matched_reg_input[obs_id] = matched_reg.to_pixel(self._wcs_radec) + + return matched_reg_input + @staticmethod def parse_smoothing(info: Union[dict, Kernel]) -> Tuple[str, dict]: """ @@ -423,40 +512,80 @@ def inventory_entry(self) -> pd.Series: return new_line @property - def regions(self) -> List[PixelRegion]: + def regions(self) -> Dict: """ Property getter for regions associated with this image. - :return: Returns a list of regions, if they have been associated with this object. - :rtype: List[PixelRegion] + :return: Returns a dictionary of regions, if they have been associated with this object. + :rtype: Dict[PixelRegion] """ return self._regions @regions.setter - def regions(self, new_reg: Union[str, List[Union[SkyRegion, PixelRegion]]]): + def regions(self, new_reg: Union[str, List[Union[SkyRegion, PixelRegion]], dict]): """ - A setter for regions associated with this object, a region file path is passed, then that file - is processed into the required format. + A setter for regions associated with this object, a region file path or a list/dict of regions is passed, then + that file/set of regions is processed into the required format. If a list of regions is passed, it will + be assumed that they are for the ObsID of the image. In the case of passing a dictionary of regions to a + combined image we require that each ObsID that goes into the image has an entry in the dictionary. - :param str/List[SkyRegion/PixelRegion] new_reg: A new region file path, or a list of region objects. + :param str/List[SkyRegion/PixelRegion]/dict new_reg: A new region file path, a list of region objects, or a + dictionary of region lists with ObsIDs as dictionary keys. """ - if not isinstance(new_reg, (str, list)): - raise TypeError("Please pass either a path to a region file or a list of " - "SkyRegion/PixelRegion objects.") + if not isinstance(new_reg, (str, list, dict)): + raise TypeError("Please pass either a path to a region file, a list of " + "SkyRegion/PixelRegion objects, or a dictionary of lists of SkyRegion/PixelRegion objects " + "with ObsIDs as keys.") + # Checks to make sure that a region file path exists, if passed, then processes the file if isinstance(new_reg, str) and new_reg != '' and os.path.exists(new_reg): self._reg_file_path = new_reg self._regions = self._process_regions(new_reg) + # Possible for an empty string to be passed in which case nothing happens elif isinstance(new_reg, str) and new_reg == '': pass elif isinstance(new_reg, str): warnings.warn("That region file path does not exist") + # If an existing list of regions are passed then we just process them and assign them to regions attribute elif isinstance(new_reg, List) and all([isinstance(r, (SkyRegion, PixelRegion)) for r in new_reg]): self._reg_file_path = "" - self._regions = self._process_regions(reg_list=new_reg) + self._regions = self._process_regions(reg_objs=new_reg) + elif isinstance(new_reg, dict) and all([all([isinstance(r, (SkyRegion, PixelRegion)) for r in rl]) + for o, rl in new_reg.items()]): + self._reg_file_path = "" + self._regions = self._process_regions(reg_objs=new_reg) else: raise ValueError("That value of new_reg is not valid, please pass either a path to a region file or " - "a list of SkyRegion/PixelRegion objects") + "a list/dictionary of SkyRegion/PixelRegion objects") + + @property + def matched_regions(self) -> Dict: + """ + Property getter for any regions which have been designated a 'match' in the current analysis, if + they have been set. + + :return: Returns a dictionary of matched regions, if they have been associated with this object. + :rtype: Dict[PixelRegion] + """ + return self._matched_regions + + @matched_regions.setter + def matched_regions(self, new_reg: Union[str, List[Union[SkyRegion, PixelRegion]], dict]): + """ + A setter for matched regions associated with this object, with a new single matched region or dictionary of + matched regions (with keys being ObsIDs and one entry for each ObsID associated with this object) being passed. + If a single region is passed then it will be assumed that it is associated with the current ObsID of this + object. + + :param dict/SkyRegion/PixelRegion new_reg: A region that has been designated as 'matched', i.e. is the + subject of a current analysis. This should either be supplied as a single region object, or as a + dictionary of region objects with ObsIDs as keys. + """ + if new_reg is not None and not isinstance(new_reg, (PixelRegion, SkyRegion, dict)): + raise TypeError("Please pass either a dictionary of SkyRegion/PixelRegion objects with ObsIDs as " + "keys, or a single SkyRegion/PixelRegion object. Alternatively pass None for no match.") + + self._matched_regions = self._process_matched_regions(new_reg) @property def shape(self) -> Tuple[int, int]: @@ -520,6 +649,16 @@ def data(self): del self._data self._data = None + @property + def data_unit(self) -> Unit: + """ + The unit of the data associated with this photometric product. + + :return: An astropy unit object describing the units of this objects' data. + :rtype: Unit + """ + return self._data_unit + # This one doesn't get a setter, as I require this WCS to not be none in the _read_on_demand method @property def radec_wcs(self) -> wcs.WCS: @@ -718,14 +857,14 @@ def coord_conv(self, coords: Quantity, output_unit: Union[Unit, str]) -> Quantit # outside the range covered by an image, but we can at least catch the error if out_name == "pix" and np.any(out_coord < 0) and self._prod_type != "psf": raise ValueError("You've converted to pixel coordinates, and some elements are less than zero.") - # Have to compare to the [1] element of shape because numpy arrays are flipped and we want + # Have to compare to the [1] element of shape because numpy arrays are flipped, and we want # to compare x to x - elif out_name == "pix" and np.any(out_coord[:, 0].value> self.shape[1]) and self._prod_type != "psf": + elif out_name == "pix" and np.any(out_coord[:, 0].value >= self.shape[1]) and self._prod_type != "psf": raise ValueError("You've converted to pixel coordinates, and some x coordinates are larger than the " "image x-shape.") - # Have to compare to the [0] element of shape because numpy arrays are flipped and we want + # Have to compare to the [0] element of shape because numpy arrays are flipped, and we want # to compare y to y - elif out_name == "pix" and np.any(out_coord[:, 1].value > self.shape[0]) and self._prod_type != "psf": + elif out_name == "pix" and np.any(out_coord[:, 1].value >= self.shape[0]) and self._prod_type != "psf": raise ValueError("You've converted to pixel coordinates, and some y coordinates are larger than the " "image y-shape.") @@ -937,10 +1076,12 @@ def get_view(self, ax: Axes, cross_hair: Quantity = None, mask: np.ndarray = Non lower limit, second the upper limit. Variable zoom_in must still be true for these limits to be applied. :param np.ndarray radial_bins_pix: Radii (in units of pixels) of annuli to plot on top of the image, will - only be triggered if a cross_hair coordinate is also specified and contains only one coordinate. + only be triggered if a cross_hair coordinate is also specified, as this acts as the central coordinate + of the annuli. If two cross-hair coordinates are specified, the first will be used as the centre. :param np.ndarray back_bin_pix: The inner and outer radii (in pixel units) of the annulus used to measure - the background value for a given profile, will only be triggered if a cross_hair coordinate is - also specified and contains only one coordinate. + the background value for a given profile, will only be triggered if a cross_hair coordinate is also + specified, as this acts as the central coordinate of the annuli. If two cross-hair coordinates are + specified, the first will be used as the centre. :param BaseStretch stretch: The astropy scaling to use for the image data, default is log. :param bool mask_edges: If viewing a RateMap, this variable will control whether the chip edges are masked to remove artificially bright pixels, default is True. @@ -1009,7 +1150,7 @@ def get_view(self, ax: Axes, cross_hair: Quantity = None, mask: np.ndarray = Non for cl in other_points: ax.plot(cl[:, 0], cl[:, 1], 'D') - # If we want a cross hair, then we put one on here + # If we want a cross-hair, then we put one on here if cross_hair is not None: # For the case of a single coordinate if cross_hair.shape == (2,): @@ -1019,21 +1160,6 @@ def get_view(self, ax: Axes, cross_hair: Quantity = None, mask: np.ndarray = Non ax.axvline(pix_coord[0], color="white", linewidth=ch_thickness) ax.axhline(pix_coord[1], color="white", linewidth=ch_thickness) - # Drawing annular radii on the image, if they are enabled and passed. Only works with a - # single coordinate, otherwise we wouldn't know which to centre on - for ann_rad in radial_bins_pix: - artist = Circle(pix_coord, ann_rad, fill=False, ec='white', linewidth=1.5) - ax.add_artist(artist) - - # This draws the background region on as well, if present - if back_bin_pix is not None: - inn_artist = Circle(pix_coord, back_bin_pix[0], fill=False, ec='white', linewidth=1.6, - linestyle='dashed') - out_artist = Circle(pix_coord, back_bin_pix[1], fill=False, ec='white', linewidth=1.6, - linestyle='dashed') - ax.add_artist(inn_artist) - ax.add_artist(out_artist) - # For the case of two coordinate pairs elif cross_hair.shape == (2, 2): # Converts from whatever input coordinate to pixels @@ -1047,11 +1173,33 @@ def get_view(self, ax: Axes, cross_hair: Quantity = None, mask: np.ndarray = Non ax.axvline(pix_coord[1, 0], color="white", linewidth=ch_thickness, linestyle='dashed') ax.axhline(pix_coord[1, 1], color="white", linewidth=ch_thickness, linestyle='dashed') + # Here I reset the pix_coord variable, so it ONLY contains the first entry. This is for the benefit + # of the annulus-drawing part of the code that comes after + pix_coord = pix_coord[0, :] + else: # I don't want to bring someone's code grinding to a halt just because they passed crosshair wrong, - # it isn't essential so I'll just display a warning + # it isn't essential, so I'll just display a warning warnings.warn("You have passed a cross_hair quantity that has more than two coordinate " "pairs in it, or is otherwise the wrong shape.") + # Just in case annuli were also passed, I set the coordinate to None so that it knows something is wrong + pix_coord = None + + if pix_coord is not None: + # Drawing annular radii on the image, if they are enabled and passed. If multiple coordinates have been + # passed then I assume that they want to centre on the first entry + for ann_rad in radial_bins_pix: + artist = Circle(pix_coord, ann_rad, fill=False, ec='white', linewidth=1.5) + ax.add_artist(artist) + + # This draws the background region on as well, if present + if back_bin_pix is not None: + inn_artist = Circle(pix_coord, back_bin_pix[0], fill=False, ec='white', linewidth=1.6, + linestyle='dashed') + out_artist = Circle(pix_coord, back_bin_pix[1], fill=False, ec='white', linewidth=1.6, + linestyle='dashed') + ax.add_artist(inn_artist) + ax.add_artist(out_artist) # Adds the actual image to the axis. ax.imshow(plot_data, norm=norm, origin="lower", cmap="gnuplot2") @@ -1060,7 +1208,8 @@ def get_view(self, ax: Axes, cross_hair: Quantity = None, mask: np.ndarray = Non if view_regions: # We can just loop through the _regions attribute because its default is an empty # list, so no need to check - for reg in self._regions: + flattened_reg = [r for o, rl in self._regions.items() for r in rl] + for reg in flattened_reg: # Use the regions module conversion method to go to a matplotlib artist reg_art = reg.as_artist() # Set line thickness and add to the axes @@ -1136,7 +1285,8 @@ def view(self, cross_hair: Quantity = None, mask: np.ndarray = None, chosen_poin ax = self.get_view(ax, cross_hair, mask, chosen_points, other_points, zoom_in, manual_zoom_xlims, manual_zoom_ylims, radial_bins_pix, back_bin_pix, stretch, mask_edges, view_regions, ch_thickness) - plt.colorbar(ax.images[0]) + cbar = plt.colorbar(ax.images[0]) + cbar.ax.set_ylabel(self.data_unit.to_string('latex'), fontsize=15) plt.tight_layout() # Display the image plt.show() @@ -1144,6 +1294,1145 @@ def view(self, cross_hair: Quantity = None, mask: np.ndarray = None, chosen_poin # Wipe the figure plt.close("all") + def save_view(self, save_path: str, cross_hair: Quantity = None, mask: np.ndarray = None, + chosen_points: np.ndarray = None, other_points: List[np.ndarray] = None, figsize: Tuple = (10, 8), + zoom_in: bool = False, manual_zoom_xlims: tuple = None, manual_zoom_ylims: tuple = None, + radial_bins_pix: np.ndarray = np.array([]), back_bin_pix: np.ndarray = None, + stretch: BaseStretch = LogStretch(), mask_edges: bool = True, view_regions: bool = False, + ch_thickness: float = 0.8): + """ + This is entirely equivalent to the view() method, but instead of displaying the view it will save it to + a path of your choosing. + + :param str save_path: The path (including file name) where you wish to save the view. + :param Quantity cross_hair: An optional parameter that can be used to plot a cross hair at + the coordinates. Up to two cross-hairs can be plotted, as any more can be visually confusing. If + passing two, each row of a quantity is considered to be a separate coordinate pair. + :param np.ndarray mask: Allows the user to pass a numpy mask and view the masked + data if they so choose. + :param np.ndarray chosen_points: A numpy array of a chosen point cluster from a hierarchical peak finder. + :param list other_points: A list of numpy arrays of point clusters that weren't chosen by the + hierarchical peak finder. + :param Tuple figsize: Allows the user to pass a custom size for the figure produced by this method. + :param bool zoom_in: Sets whether the figure limits should be set automatically so that borders with no + data are reduced. + :param tuple manual_zoom_xlims: If set, this will override the automatic zoom in and manually set a part + of the x-axis to limit the image to, default is None. Pass a tuple with two elements, first being the + lower limit, second the upper limit. Variable zoom_in must still be true for these limits + to be applied. + :param tuple manual_zoom_ylims: If set, this will override the automatic zoom in and manually set a part + of the y-axis to limit the image to, default is None. Pass a tuple with two elements, first being the + lower limit, second the upper limit. Variable zoom_in must still be true for these limits + to be applied. + :param np.ndarray radial_bins_pix: Radii (in units of pixels) of annuli to plot on top of the image, will + only be triggered if a cross_hair coordinate is also specified and contains only one coordinate. + :param np.ndarray back_bin_pix: The inner and outer radii (in pixel units) of the annulus used to measure + the background value for a given profile, will only be triggered if a cross_hair coordinate is + also specified and contains only one coordinate. + :param BaseStretch stretch: The astropy scaling to use for the image data, default is log. + :param bool mask_edges: If viewing a RateMap, this variable will control whether the chip edges are masked + to remove artificially bright pixels, default is True. + :param bool view_regions: If regions have been associated with this object (either on init or using + the 'regions' property setter, should they be displayed. Default is False. + :param float ch_thickness: The desired linewidth of the crosshair(s), can be useful to increase this in + certain circumstances. Default is 0.8. + """ + + # Create figure object + fig = plt.figure(figsize=figsize) + + # Turns off any ticks and tick labels, we don't want them in an image + ax = plt.gca() + + ax = self.get_view(ax, cross_hair, mask, chosen_points, other_points, zoom_in, manual_zoom_xlims, + manual_zoom_ylims, radial_bins_pix, back_bin_pix, stretch, mask_edges, view_regions, + ch_thickness) + cbar = plt.colorbar(ax.images[0], label=self.data_unit.to_string('latex')) + cbar.ax.set_ylabel(self.data_unit.to_string('latex'), fontsize=15) + plt.tight_layout() + + # Save figure to disk + plt.savefig(save_path) + + # Wipe the figure + plt.close("all") + + def edit_regions(self, figsize: Tuple = (7, 7), cmap: str = 'gnuplot2', reg_save_path: str = None, + cross_hair: Quantity = None, radial_bins_pix: Quantity = Quantity(np.array([]), 'pix'), + back_bin_pix: Quantity = None): + """ + This allows for displaying, interacting with, editing, and adding new regions to an image. These can + then be saved as a new region file. It also allows for the dynamic adjustment of which regions + are displayed, the scaling of the image, and smoothing, in order to make placing new regions as + simple as possible. If a save path for region files is passed, then it will be possible to save + new region files in RA-Dec coordinates. + + :param Tuple figsize: Allows the user to pass a custom size for the figure produced by this class. + :param str cmap: The colour map to use for displaying the image. Default is gnuplot2. + :param str reg_save_path: A string that will have ObsID values added before '.reg' to construct + save paths for the output region lists (if that feature is activated by the user). Default is + None, in which case saving will be disabled. + :param Quantity cross_hair: An optional parameter that can be used to plot a cross hair at + the coordinates. Up to two cross-hairs can be plotted, as any more can be visually confusing. If + passing two, each row of a quantity is considered to be a separate coordinate pair. + :param Quantity radial_bins_pix: Radii (in units of pixels) of annuli to plot on top of the image, will + only be triggered if a cross_hair coordinate is also specified and contains only one coordinate. + :param Quantity back_bin_pix: The inner and outer radii (in pixel units) of the annulus used to measure + the background value for a given profile, will only be triggered if a cross_hair coordinate is + also specified and contains only one coordinate. + """ + # TODO UPDATE THE DOCSTRING WHEN I HAVE INTEGRATED THIS WITH THE REST OF XGA + view_inst = self._InteractiveView(self, figsize, cmap, reg_save_path, cross_hair, radial_bins_pix, + back_bin_pix) + view_inst.edit_view() + + def dynamic_view(self, figsize: Tuple = (7, 7), cmap: str = 'gnuplot2', cross_hair: Quantity = None, + radial_bins_pix: Quantity = Quantity(np.array([]), 'pix'), + back_bin_pix: Quantity = None): + """ + This allows for displaying regions on an image. It also allows for the dynamic adjustment of which regions + are displayed, the scaling of the image, and smoothing. + + :param Tuple figsize: Allows the user to pass a custom size for the figure produced by this class. + :param str cmap: The colour map to use for displaying the image. Default is gnuplot2. + :param Quantity cross_hair: An optional parameter that can be used to plot a cross hair at + the coordinates. Up to two cross-hairs can be plotted, as any more can be visually confusing. If + passing two, each row of a quantity is considered to be a separate coordinate pair. + :param Quantity radial_bins_pix: Radii (in units of pixels) of annuli to plot on top of the image, will + only be triggered if a cross_hair coordinate is also specified and contains only one coordinate. + :param Quantity back_bin_pix: The inner and outer radii (in pixel units) of the annulus used to measure + the background value for a given profile, will only be triggered if a cross_hair coordinate is + also specified and contains only one coordinate. + """ + view_inst = self._InteractiveView(self, figsize, cmap, None, cross_hair, radial_bins_pix, back_bin_pix) + view_inst.dynamic_view() + + class _InteractiveView: + """ + An internal class of the Image class, designed to enable the interactive and dynamic editing of regions + for an observation (with the capability of adding completely new regions as well). This is 'private' as + I can't really see a use-case where the user would define an instance of this themselves. + """ + def __init__(self, phot_prod, figsize: Tuple = (7, 7), cmap: str = "gnuplot2", reg_save_path: str = None, + cross_hair: Quantity = None, radial_bins_pix: Quantity = Quantity(np.array([]), 'pix'), + back_bin_pix: Quantity = None): + """ + The init of the _InteractiveView class, which enables dynamic viewing of XGA photometric products. + + :param Image/RateMap/ExpMap phot_prod: The XGA photometric product which we want to interact with. + :param Tuple figsize: Allows the user to pass a custom size for the figure produced by this class. + :param str cmap: The colour map to use for displaying the image. Default is gnuplot2. + :param str reg_save_path: A string that will have ObsID values added before '.reg' to construct + save paths for the output region lists (if that feature is activated by the user). Default is + None, in which case saving will be disabled. + :param Quantity cross_hair: An optional parameter that can be used to plot a cross hair at + the coordinates. Up to two cross-hairs can be plotted, as any more can be visually confusing. If + passing two, each row of a quantity is considered to be a separate coordinate pair. + :param Quantity radial_bins_pix: Radii (in units of pixels) of annuli to plot on top of the image, will + only be triggered if a cross_hair coordinate is also specified and contains only one coordinate. + :param Quantity back_bin_pix: The inner and outer radii (in pixel units) of the annulus used to measure + the background value for a given profile, will only be triggered if a cross_hair coordinate is + also specified and contains only one coordinate. + """ + # Just saving a reference to the photometric object that declared this instance of this class, and + # then making a copy of whatever regions are associated with it + self._parent_phot_obj = phot_prod + self._regions = deepcopy(phot_prod.regions) + + # Store the passed-in save path for regions in an attribute for later + if reg_save_path is not None and reg_save_path[-4:] == '.reg': + self._reg_save_path = reg_save_path + elif reg_save_path is not None and reg_save_path[-4:] != '.reg': + raise ValueError("The last four characters of the save path must be '.reg', as extra strings " + "will be inserted into the save path to account for different region files for " + "different ObsIDs.") + else: + self._reg_save_path = None + + # This is for storing references to artists with an ObsID key, so we know which artist belongs + # to which ObsID. Populated in the first part of _draw_regions. We also construct the reverse so that + # an artist instance can be easily used to lookup the ObsID it belongs to + self._obsid_artists = {o: [] for o in self._parent_phot_obj.obs_ids} + self._artist_obsids = {} + # In the same vein I setup a lookup dictionary for artist to region + self._artist_region = {} + + # Setting up the figure within which all the axes (data, buttons, etc.) are placed + in_fig = plt.figure(figsize=figsize) + # Storing the figure in an attribute, as well as the image axis (i.e. the axis on which the data + # are displayed) in another attribute, for convenience. + self._fig = in_fig + self._im_ax = plt.gca() + self._ax_loc = self._im_ax.get_position() + + # Setting up the look of the data axis, removing ticks and tick labels because it's an image + self._im_ax.tick_params(axis='both', direction='in', which='both', top=False, right=False) + self._im_ax.xaxis.set_ticklabels([]) + self._im_ax.yaxis.set_ticklabels([]) + + # Setting up some visual stuff that is used in multiple places throughout the class + # First the colours of buttons in an active and inactive state (the region toggles) + self._but_act_col = "0.85" + self._but_inact_col = "0.99" + # Now the standard line widths used both for all regions, and for the region that is currently selected + self._reg_line_width = 1.2 + self._sel_reg_line_width = 2.3 + # These are the increments when adjusting the regions by pressing wasd and qe. So for the size and + # angle of the selected region. + self._size_step = 2 + self._rot_step = 10 + + # Setting up and storing the connections to events on the matplotlib canvas. These are what + # allow specific methods to be triggered when things like button presses or clicking on the + # figure occur. They are stored in attributes, though I'm not honestly sure that's necessary + # Not all uses of this class will make use of all of these connections, but I'm still defining them + # all here anyway + self._pick_cid = self._fig.canvas.mpl_connect("pick_event", self._on_region_pick) + self._move_cid = self._fig.canvas.mpl_connect("motion_notify_event", self._on_motion) + self._rel_cid = self._fig.canvas.mpl_connect("button_release_event", self._on_release) + self._undo_cid = self._fig.canvas.mpl_connect("key_press_event", self._key_press) + self._click_cid = self._fig.canvas.mpl_connect("button_press_event", self._click_event) + + # All uses of this class (both editing regions and just having a vaguely interactive view of the + # observation) will have these buttons that allow regions to be turned off and on, so they are + # defined here. All buttons are defined in separate axes. + # These buttons act as toggles, they are all active by default and clicking one will turn off the source + # type its associated with. Clicking it again will turn it back on. + # This button toggles extended (green) sources. + top_pos = self._ax_loc.y1-0.0771 + ext_src_loc = plt.axes([0.045, top_pos, 0.075, 0.075]) + self._ext_src_button = Button(ext_src_loc, "EXT", color=self._but_act_col) + self._ext_src_button.on_clicked(self._toggle_ext) + + # This button toggles point (red) sources. + pnt_src_loc = plt.axes([0.045, top_pos-(0.075 + 0.005), 0.075, 0.075]) + self._pnt_src_button = Button(pnt_src_loc, "PNT", color=self._but_act_col) + self._pnt_src_button.on_clicked(self._toggle_pnt) + + # This button toggles types of region other than green or red (mostly valid for XCS XAPA sources). + oth_src_loc = plt.axes([0.045, top_pos-2*(0.075 + 0.005), 0.075, 0.075]) + self._oth_src_button = Button(oth_src_loc, "OTHER", color=self._but_act_col) + self._oth_src_button.on_clicked(self._toggle_oth) + + # This button toggles custom source regions + cust_src_loc = plt.axes([0.045, top_pos-3*(0.075 + 0.005), 0.075, 0.075]) + self._cust_src_button = Button(cust_src_loc, "CUST", color=self._but_act_col) + self._cust_src_button.on_clicked(self._toggle_cust) + + # These are buttons that can be present depending on the usage of the class + self._new_ell_button = None + self._new_circ_button = None + + # A dictionary describing the current type of regions that are on display + self._cur_act_reg_type = {"EXT": True, "PNT": True, "OTH": True, "CUST": True} + + # These set up the default colours, red for point, green for extended, and white for custom. I already + # know these colour codes because this is what the regions module colours translate into in matplotlib + # Maybe I should automate this rather than hard coding + self._colour_convert = {(1.0, 0.0, 0.0, 1.0): 'red', (0.0, 0.5019607843137255, 0.0, 1.0): 'green', + (1.0, 1.0, 1.0, 1.0): 'white'} + # There can be other coloured regions though, XAPA for instance has lots of subclasses of region. This + # loop goes through the regions and finds their colour name / matplotlib colour code and adds it to the + # dictionary for reference + for region in [r for o, rl in self._regions.items() for r in rl]: + art_reg = region.as_artist() + self._colour_convert[art_reg.get_edgecolor()] = region.visual["color"] + + # This just provides a conversion between name and colour tuple, the inverse of colour_convert + self._inv_colour_convert = {v: k for k, v in self._colour_convert.items()} + + # Unfortunately I cannot rely on regions being of an appropriate type (Ellipse/Circle) for what they + # are. For instance XAPA point source regions are still ellipses, just with the height and width + # set equal. So this dictionary is an independent reference point for the shape, with entries for the + # original regions made in the first part of _draw_regions, and otherwise set when a new region is added. + self._shape_dict = {} + + # I also wish to keep track of whether a particular region has been edited or not, for reference when + # outputting the final edited region list (if it is requested). I plan to do this with a similar approach + # to the shape_dict, have a dictionary with artists as keys, but then have a boolean as a value. Will + # also be initially populated in the first part of _draw_regions. + self._edited_dict = {} + + # This controls whether interacting with regions is allowed - turned off for the dynamic view method + # as that is not meant for editing regions + self._interacting_on = False + # The currently selected region is referenced in this attribute + self._cur_pick = None + # The last coordinate ON THE IMAGE that was clicked is stored here. Initial value is set to the centre + self._last_click = (phot_prod.shape[0] / 2, phot_prod.shape[1] / 2) + # This describes whether the artist stored in _cur_pick (if there is one) is right now being clicked + # and held - this is used for enabling clicking and dragging so the method knows when to stop. + self._select = False + self._history = [] + + # These store the current settings for colour map, stretch, and scaling + self._cmap = cmap + self._interval = MinMaxInterval() + self._stretch = stretch_dict['LOG'] + # This is just a convenient place to store the name that XGA uses for the current stretch - it lets us + # access the current stretch instance from stretch_dict more easily (and accompanying buttons etc.) + self._active_stretch_name = 'LOG' + # This is used to store all the button instances created for controlling stretch + self._stretch_buttons = {} + + # Here we define attribute to store the data and normalisation in. I copy the data to make sure that + # the original information doesn't get changed when smoothing is applied. + self._plot_data = self._parent_phot_obj.data.copy() + # It's also possible to mask and display the data, and the current mask is stored in this attribute + self._plot_mask = np.ones(self._plot_data.shape) + self._norm = self._renorm() + + # The output of the imshow command lives in here + self._im_plot = None + # Adds the actual image to the axis. + self._replot_data() + + # This bit is where all the stretch buttons are set up, as well as the slider. All methods should + # be able to use re-stretching so that's why this is all in the init + ax_slid = plt.axes([self._ax_loc.x0, 0.885, 0.7771, 0.03], facecolor="white") + # Hides the ticks to make it look nicer + ax_slid.set_xticks([]) + ax_slid.set_yticks([]) + # Use the initial defined MinMaxInterval to get the initial range for the RangeSlider - used both + # as upper and lower boundaries and starting points for the sliders. + init_range = self._interval.get_limits(self._plot_data) + # Define the RangeSlider instance, set the value text to invisible, and connect to the method it activates + self._vrange_slider = RangeSlider(ax_slid, 'DATA INTERVAL', *init_range, init_range) + # We move the RangeSlider label so that is sits within the bar + self._vrange_slider.label.set_x(0.6) + self._vrange_slider.label.set_y(0.45) + self._vrange_slider.valtext.set_visible(False) + self._vrange_slider.on_changed(self._change_interval) + + # Sets up an initial location for the stretch buttons to iterate over, so I can make this + # as automated as possible. An advantage is that I can just add new stretches to the stretch_dict, + # and they should be automatically added here. + loc = [self._ax_loc.x0 - (0.075 + 0.005), 0.92, 0.075, 0.075] + # Iterate through the stretches that I chose to store in the stretch_dict + for stretch_name, stretch in stretch_dict.items(): + # Increments the position of the button + loc[0] += (0.075 + 0.005) + # Sets up an axis for the button we're about to create + stretch_loc = plt.axes(loc) + + # Sets the colour for this button. Sort of unnecessary to do it like this because LOG should always + # be the initially active stretch, but better to generalise + if stretch_name == self._active_stretch_name: + col = self._but_act_col + else: + col = self._but_inact_col + # Creates the button for the current stretch + self._stretch_buttons[stretch_name] = Button(stretch_loc, stretch_name, color=col) + + # Generates and adds the function for the current stretch button + self._stretch_buttons[stretch_name].on_clicked(self._change_stretch(stretch_name)) + + # This is the bit where we set up the buttons and slider for the smoothing function + smooth_loc = plt.axes([self._ax_loc.x1 + 0.005, top_pos, 0.095, 0.075]) + self._smooth_button = Button(smooth_loc, "SMOOTH", color=self._but_inact_col) + self._smooth_button.on_clicked(self._toggle_smooth) + + ax_smooth_slid = plt.axes([self._ax_loc.x1 + 0.03, self._ax_loc.y0+0.002, 0.05, 0.685], facecolor="white") + # Hides the ticks to make it look nicer + ax_smooth_slid.set_xticks([]) + # Define the Slider instance, add and position a label, and connect to the method it activates + self._smooth_slider = Slider(ax_smooth_slid, 'KERNEL RADIUS', 0.5, 5, 1, valstep=0.5, + orientation='vertical') + # Remove the annoying line representing initial value that is automatically added + self._smooth_slider.hline.remove() + # We move the Slider label so that is sits within the bar + self._smooth_slider.label.set_rotation(270) + self._smooth_slider.label.set_x(0.5) + self._smooth_slider.label.set_y(0.45) + self._smooth_slider.on_changed(self._change_smooth) + + # We also create an attribute to store the current value of the slider in. Not really necessary as we + # could always fetch the value out of the smooth slider attribute but its neater this way I think + self._kernel_rad = self._smooth_slider.val + + # This is a definition for a save button that is used in edit_view + self._save_button = None + + # Adding a button to apply a mask generated from the regions, largely to help see if any emission + # from an object isn't being properly removed. + mask_loc = plt.axes([self._ax_loc.x0 + (0.075 + 0.005), self._ax_loc.y0 - 0.08, 0.075, 0.075]) + self._mask_button = Button(mask_loc, "MASK", color=self._but_inact_col) + self._mask_button.on_clicked(self._toggle_mask) + + # This next part allows for the over-plotting of annuli to indicate analysis regions, this can be + # very useful to give context when manually editing regions. The only way I know of to do this is + # with artists, but unfortunately artists (and iterating through the artist property of the image axis) + # is the way a lot of stuff in this class works. So here I'm going to make a new class attribute + # that stores which artists are added to visualise analysis areas and therefore shouldn't be touched. + self._ignore_arts = [] + # As this was largely copied from the get_view method of Image, I am just going to define this + # variable here for ease of testing + ch_thickness = 0.8 + # If we want a cross-hair, then we put one on here + if cross_hair is not None: + # For the case of a single coordinate + if cross_hair.shape == (2,): + # Converts from whatever input coordinate to pixels + pix_coord = self._parent_phot_obj.coord_conv(cross_hair, pix).value + # Drawing the horizontal and vertical lines + self._im_ax.axvline(pix_coord[0], color="white", linewidth=ch_thickness) + self._im_ax.axhline(pix_coord[1], color="white", linewidth=ch_thickness) + + # For the case of two coordinate pairs + elif cross_hair.shape == (2, 2): + # Converts from whatever input coordinate to pixels + pix_coord = self._parent_phot_obj.coord_conv(cross_hair, pix).value + + # This draws the first crosshair + self._im_ax.axvline(pix_coord[0, 0], color="white", linewidth=ch_thickness) + self._im_ax.axhline(pix_coord[0, 1], color="white", linewidth=ch_thickness) + + # And this the second + self._im_ax.axvline(pix_coord[1, 0], color="white", linewidth=ch_thickness, linestyle='dashed') + self._im_ax.axhline(pix_coord[1, 1], color="white", linewidth=ch_thickness, linestyle='dashed') + + # Here I reset the pix_coord variable, so it ONLY contains the first entry. This is for the benefit + # of the annulus-drawing part of the code that comes after + pix_coord = pix_coord[0, :] + + else: + # I don't want to bring someone's code grinding to a halt just because they passed crosshair wrong, + # it isn't essential, so I'll just display a warning + warnings.warn("You have passed a cross_hair quantity that has more than two coordinate " + "pairs in it, or is otherwise the wrong shape.") + # Just in case annuli were also passed, I set the coordinate to None so that it knows something is + # wrong + pix_coord = None + + if pix_coord is not None: + # Drawing annular radii on the image, if they are enabled and passed. If multiple coordinates have + # been passed then I assume that they want to centre on the first entry + for ann_rad in radial_bins_pix: + # Creates the artist for the current annular region + artist = Circle(pix_coord, ann_rad.value, fill=False, ec='white', + linewidth=ch_thickness) + # Means it can't be interacted with + artist.set_picker(False) + # Adds it to the list that lets the class know it needs to not treat it as a region + # found by a source detector + self._ignore_arts.append(artist) + # And adds the artist to axis + self._im_ax.add_artist(artist) + + # This draws the background region on as well, if present + if back_bin_pix is not None: + # The background annulus is guaranteed to only have two entries, inner and outer + inn_artist = Circle(pix_coord, back_bin_pix[0].value, fill=False, ec='white', + linewidth=ch_thickness, linestyle='dashed') + out_artist = Circle(pix_coord, back_bin_pix[1].value, fill=False, ec='white', + linewidth=ch_thickness, linestyle='dashed') + # Make sure neither region can be interacted with + inn_artist.set_picker(False) + out_artist.set_picker(False) + # Add to the ignore list and to the axis + self._im_ax.add_artist(inn_artist) + self._ignore_arts.append(inn_artist) + self._im_ax.add_artist(out_artist) + self._ignore_arts.append(out_artist) + + # This chunk checks to see if there were any matched regions associated with the parent + # photometric object, and if so it adds them to the image and makes sure that they + # cannot be interacted with + for obs_id, match_reg in self._parent_phot_obj.matched_regions.items(): + if match_reg is not None: + art_reg = match_reg.as_artist() + # Setting the style for these regions, to make it obvious that they are different from + # any other regions that might be displayed + art_reg.set_linestyle('dotted') + + # Makes sure that the region cannot be 'picked' + art_reg.set_picker(False) + # Sets the standard linewidth + art_reg.set_linewidth(self._sel_reg_line_width) + # And actually adds the artist to the data axis + self._im_ax.add_artist(art_reg) + # Also makes sure this artist is on the ignore list, as it's a constant and shouldn't be redrawn + # or be able to be modified + self._ignore_arts.append(art_reg) + + def dynamic_view(self): + """ + The simplest view method of this class, enables the turning on and off of regions. + """ + # Draws on any regions associated with this instance + self._draw_regions() + + # I THINK that activating this is what turns on automatic refreshing + plt.ion() + plt.show() + + def edit_view(self): + """ + An extremely useful view method of this class - allows for direct interaction with and editing of + regions, as well as the ability to add new regions. If a save path for region files was passed on + declaration of this object, then it will be possible to save new region files in RA-Dec coordinates. + """ + # This mode we DO want to be able to interact with regions + self._interacting_on = True + + # Add two buttons to the figure to enable the adding of new elliptical and circular regions + new_ell_loc = plt.axes([0.045, 0.191, 0.075, 0.075]) + self._new_ell_button = Button(new_ell_loc, "ELL") + self._new_ell_button.on_clicked(self._new_ell_src) + + new_circ_loc = plt.axes([0.045, 0.111, 0.075, 0.075]) + self._new_circ_button = Button(new_circ_loc, "CIRC") + self._new_circ_button.on_clicked(self._new_circ_src) + + # This sets up a button that saves an updated region list to a file path that was passed in on the + # declaration of this instance of the class. If no path was passed, then the button doesn't + # even appear. + if self._reg_save_path is not None: + save_loc = plt.axes([self._ax_loc.x0, self._ax_loc.y0 - 0.08, 0.075, 0.075]) + self._save_button = Button(save_loc, "SAVE", color=self._but_inact_col) + self._save_button.on_clicked(self._save_region_files) + + # Draws on any regions associated with this instance + self._draw_regions() + + plt.ion() + plt.show(block=True) + + def _replot_data(self): + """ + This method updates the currently plotted data using the relevant class attributes. Such attributes + are updated and edited by other parts of the class. The plot mask is always applied to data, but when + not turned on by the relevant button it will be all ones so will make no difference. + """ + # This removes the existing image data without removing the region artists + if self._im_plot is not None: + self._im_plot.remove() + + # This does the actual plotting bit, saving the output in an attribute, so it can be + # removed when re-plotting + self._im_plot = self._im_ax.imshow(self._plot_data*self._plot_mask, norm=self._norm, origin="lower", + cmap=self._cmap) + + def _renorm(self) -> ImageNormalize: + """ + Re-calculates the normalisation of the plot data with current interval and stretch settings. Takes into + account the mask if applied. The plot mask is always applied to data, but when not turned on by the + relevant button it will be all ones so will make no difference. + + :return: The normalisation object. + :rtype: ImageNormalize + """ + # We calculate the normalisation using masked data, but mask will be all ones if that + # feature is not currently turned on + norm = ImageNormalize(data=self._plot_data*self._plot_mask, interval=self._interval, + stretch=self._stretch) + + return norm + + def _draw_regions(self): + """ + This method is called by an _InteractiveView instance when regions need to be drawn on top of the + data view axis (i.e. the image/ratemap). Either for the first time or as an update due to a button + click, region changing, or new region being added. + """ + # These artists are the ones that represent regions, the ones in self._ignore_arts are there + # just for visualisation (for instance showing an analysis/background region) and can't be + # turned on or off, can't be edited, and shouldn't be saved. + rel_artists = [arty for arty in self._im_ax.artists if arty not in self._ignore_arts] + + # This will trigger in initial cases where there ARE regions associated with the photometric product + # that has spawned this InteractiveView, but they haven't been added as artists yet. ALSO, this will + # always run prior to any artists being added that are just there to indicate analysis regions, see + # toward the end of the __init__ for what I mean. + if len(rel_artists) == 0 and len([r for o, rl in self._regions.items() for r in rl]) != 0: + for o in self._regions: + for region in self._regions[o]: + # Uses the region module's convenience function to turn the region into a matplotlib artist + art_reg = region.as_artist() + # Makes sure that the region can be 'picked', which enables selecting regions to modify + art_reg.set_picker(True) + # Sets the standard linewidth + art_reg.set_linewidth(self._reg_line_width) + # And actually adds the artist to the data axis + self._im_ax.add_artist(art_reg) + # Adds an entry to the shape dictionary. If a region from the parent Image is elliptical but + # has the same height and width then I define it as a circle. + if type(art_reg) == Circle or (type(art_reg) == Ellipse and art_reg.height == art_reg.width): + self._shape_dict[art_reg] = 'circle' + elif type(art_reg) == Ellipse: + self._shape_dict[art_reg] = 'ellipse' + else: + raise NotImplementedError("This method does not currently support regions other than " + "circles or ellipses, but please get in touch to discuss " + "this further.") + # Add entries in the dictionary that keeps track of whether a region has been edited or + # not. All entries start out being False of course. + self._edited_dict[art_reg] = False + # Here we save the knowledge of which artists belong to which ObsID, and vice versa + self._obsid_artists[o].append(art_reg) + self._artist_obsids[art_reg] = o + # This allows us to lookup the original regions from their artist + self._artist_region[art_reg] = region + + # Need to update this in this case + rel_artists = [arty for arty in self._im_ax.artists if arty not in self._ignore_arts] + + # This chunk controls which regions will be drawn when this method is called. The _cur_act_reg_type + # dictionary has keys representing the four toggle buttons, and their values are True or False. This + # first option is triggered if all entries are True and thus draws all regions + if all(self._cur_act_reg_type.values()): + allowed_colours = list(self._colour_convert.keys()) + + # This checks individual entries in the dictionary, and adds allowed colours to the colour checking + # list which the method uses to identify the regions its allowed to draw for a particular call of this + # method. + else: + allowed_colours = [] + if self._cur_act_reg_type['EXT']: + allowed_colours.append(self._inv_colour_convert['green']) + if self._cur_act_reg_type['PNT']: + allowed_colours.append(self._inv_colour_convert['red']) + if self._cur_act_reg_type['CUST']: + allowed_colours.append(self._inv_colour_convert['white']) + if self._cur_act_reg_type['OTH']: + allowed_colours += [self._inv_colour_convert[c] for c in self._inv_colour_convert + if c not in ['green', 'red', 'white']] + + # This iterates through all the artists currently added to the data axis, setting their linewidth + # to zero if their colour isn't in the approved list + for artist in rel_artists: + if artist.get_edgecolor() in allowed_colours: + # If we're here then the region type of this artist is enabled by a button, and thus it should + # be visible. We also use set_picker to make sure that this artist is allowed to be clicked on. + artist.set_linewidth(self._reg_line_width) + artist.set_picker(True) + + # Slightly ugly nested if statement, but this just checks to see whether the current artist + # is one that the user has selected. If yes then the line width should be different. + if self._cur_pick is not None and self._cur_pick == artist: + artist.set_linewidth(self._sel_reg_line_width) + + else: + # This part is triggered if the artist colour isn't 'allowed' - the button for that region type + # hasn't been toggled on. And thus the width is set to 0 and the region becomes invisible + artist.set_linewidth(0) + # We turn off 'picker' to make sure that invisible regions can't be selected accidentally + artist.set_picker(False) + # We also make sure that if this artist (which is not currently being displayed) was the one + # selected by the user, it is de-selected, so they don't accidentally make changes to an invisible + # region. + if self._cur_pick is not None and self._cur_pick == artist: + self._cur_pick = None + + def _change_stretch(self, stretch_name: str): + """ + Triggered when any of the stretch change buttons are pressed - acts as a generator for the response + functions that are actually triggered when the separate buttons are pressed. Written this way to + allow me to just write one of these functions rather than one function for each stretch. + + :param str stretch_name: The name of the stretch associated with a specific button. + :return: A function matching the input stretch_name that will change the stretch applied to the data. + """ + def gen_func(event): + """ + A generated function to change the data stretch. + + :param event: The event passed by clicking the button associated with this function + """ + # This changes the colours of the buttons so the active button has a different colour + self._stretch_buttons[stretch_name].color = self._but_act_col + # And this sets the previously active stretch button colour back to inactive + self._stretch_buttons[self._active_stretch_name].color = self._but_inact_col + # Now I change the currently active stretch stored in this class + self._active_stretch_name = stretch_name + + # This alters the currently selected stretch stored by this class. Fetches the appropriate stretch + # object by using the stretch name passed when this function was generated. + self._stretch = stretch_dict[stretch_name] + # Performs the renormalisation that takes into account the newly selected stretch + self._norm = self._renorm() + # Performs the actual re-plotting that takes into account the newly calculated normalisation + self._replot_data() + + return gen_func + + def _change_interval(self, boundaries: Tuple): + """ + This method is called when a change is made to the RangeSlider that controls the inverval range + of the data that is displayed. + + :param Tuple boundaries: The lower and upper boundary currently selected by the RangeSlider + controlling the interval. + """ + # Creates a new interval, manually defined this time, with boundaries taken from the RangeSlider + self._interval = ManualInterval(*boundaries) + # Recalculate the normalisation with this new interval + self._norm = self._renorm() + # And finally replot the data. + self._replot_data() + + def _apply_smooth(self): + """ + This very simple function simply sets the internal data to a smooth version, making using of the + currently stored information on the kernel radius. The smoothing is with a 2D Gaussian kernel, but + the kernel is symmetric. + """ + # Sets up the kernel instance - making use of Astropy because I've used it before + the_kernel = Gaussian2DKernel(self._kernel_rad, self._kernel_rad) + # Using an FFT convolution for now, I think this should be okay as this is purely for visual + # use and so I don't care much about edge effects + self._plot_data = convolve_fft(self._plot_data, the_kernel) + + def _toggle_smooth(self, event): + """ + This method is triggered by toggling the smooth button, and will turn smoothing on or off. + + :param event: The button event that triggered this toggle. + """ + # If the current colour is the active button colour then smoothing is turned on already. Don't + # know why I didn't think of doing it this way before + if self._smooth_button.color == self._but_act_col: + # Put the button colour back to inactive + self._smooth_button.color = self._but_inact_col + # Sets the plot data back to the original unchanged version + self._plot_data = self._parent_phot_obj.data.copy() + else: + # Set the button colour to active + self._smooth_button.color = self._but_act_col + # This runs the symmetric 2D Gaussian smoothing, then stores the result in the data + # attribute of the class + self._apply_smooth() + + # Runs re-normalisation on the data and then re-plots it, necessary for either option of the toggle. + self._renorm() + self._replot_data() + + def _change_smooth(self, new_rad: float): + """ + This method is triggered by a change of the slider, and sets a new smoothing kernel radius + from the slider value. This will trigger a change if smoothing is currently turned on. + + :param float new_rad: The new radius for the smoothing kernel. + """ + # Sets the kernel radius attribute to the new value + self._kernel_rad = new_rad + # But if the smoothing button is the active colour (i.e. smoothing is on), then we update the smooth + if self._smooth_button.color == self._but_act_col: + # Need to reset the data even though we're still smoothing, otherwise the smoothing will be + # applied on top of other smoothing + self._plot_data = self._parent_phot_obj.data.copy() + # Same deal as the else part of _toggle_smooth + self._apply_smooth() + self._renorm() + self._replot_data() + + def _toggle_mask(self, event): + """ + A method triggered by a button press that toggles whether the currently displayed image is + masked or not. + + :param event: The event passed by the button that triggers this toggle method. + """ + # In this case we know that masking is already applied because the button is the active colour and + # we set about to return everything to non-masked + if self._mask_button.color == self._but_act_col: + # Set the button colour to inactive + self._mask_button.color = self._but_inact_col + # Reset the plot mask to just ones, meaning nothing is masked + self._plot_mask = np.ones(self._parent_phot_obj.shape) + else: + # Set the button colour to active + self._mask_button.color = self._but_act_col + # Generate a mask from the current regions + self._plot_mask = self._gen_cur_mask() + + # Run renorm and replot, which will both now apply the current mask, whether it's been set to all ones + # or one generated from the current regions + self._renorm() + self._replot_data() + + def _gen_cur_mask(self): + """ + Uses the current region list to generate a mask for the parent image that can be applied to the data. + + :return: The current mask. + :rtype: np.ndarray + """ + masks = [] + # Because the user might have added regions, we have to generate an updated region dictionary. However, + # we don't want to save the updated region list in the existing _regions attribute as that + # might break things + cur_regs = self._update_reg_list() + # Iterating through the flattened region dictionary + for r in [r for o, rl in cur_regs.items() for r in rl]: + # If the rotation angle is zero then the conversion to mask by the regions module will be upset, + # so I perturb the angle by 0.1 degrees + if isinstance(r, EllipsePixelRegion) and r.angle.value == 0: + r.angle += Quantity(0.1, 'deg') + masks.append(r.to_mask().to_image(self._parent_phot_obj.shape)) + + interlopers = sum([m for m in masks if m is not None]) + mask = np.ones(self._parent_phot_obj.shape) + mask[interlopers != 0] = 0 + + return mask + + def _toggle_ext(self, event): + """ + Method triggered by the extended source toggle button, either causes extended sources to be displayed + or not, depending on the existing state. + + :param event: The matplotlib event passed through from the button press that triggers this method. + """ + # Need to save the new state of this type of region being displayed in the dictionary thats used + # to keep track of such things. The invert function just switches whatever entry was already there + # (True or False) to the opposite (False or True). + self._cur_act_reg_type['EXT'] = np.invert(self._cur_act_reg_type['EXT']) + + # Then the colour of the button is switched to indicate whether its toggled on or not + if self._cur_act_reg_type['EXT']: + self._ext_src_button.color = self._but_act_col + else: + self._ext_src_button.color = self._but_inact_col + + # Then the currently displayed regions are updated with this method + self._draw_regions() + + def _toggle_pnt(self, event): + """ + Method triggered by the point source toggle button, either causes point sources to be displayed + or not, depending on the existing state. + + :param event: The matplotlib event passed through from the button press that triggers this method. + """ + # See the _toggle_ext method for comments explaining + self._cur_act_reg_type['PNT'] = np.invert(self._cur_act_reg_type['PNT']) + if self._cur_act_reg_type['PNT']: + self._pnt_src_button.color = self._but_act_col + else: + self._pnt_src_button.color = self._but_inact_col + + self._draw_regions() + + def _toggle_oth(self, event): + """ + Method triggered by the other source toggle button, either causes other (i.e. not extended, + point, or custom) sources to be displayed or not, depending on the existing state. + + :param event: The matplotlib event passed through from the button press that triggers this method. + """ + # See the _toggle_ext method for comments explaining + self._cur_act_reg_type['OTH'] = np.invert(self._cur_act_reg_type['OTH']) + if self._cur_act_reg_type['OTH']: + self._oth_src_button.color = self._but_act_col + else: + self._oth_src_button.color = self._but_inact_col + + self._draw_regions() + + def _toggle_cust(self, event): + """ + Method triggered by the custom source toggle button, either causes custom sources to be displayed + or not, depending on the existing state. + + :param event: The matplotlib event passed through from the button press that triggers this method. + """ + # See the _toggle_ext method for comments explaining + self._cur_act_reg_type['CUST'] = np.invert(self._cur_act_reg_type['CUST']) + if self._cur_act_reg_type['CUST']: + self._cust_src_button.color = self._but_act_col + else: + self._cust_src_button.color = self._but_inact_col + + self._draw_regions() + + def _new_ell_src(self, event): + """ + Makes a new elliptical region on the data axis. + + :param event: The matplotlib event passed through from the button press that triggers this method. + """ + # This matplotlib patch is what we add as an 'artist' to the data (i.e. image) axis and is the + # visual representation of our new region. This creates the matplotlib instance for an extended + # source, which is an Ellipse. + new_patch = Ellipse(self._last_click, 36, 28) + # Now the face and edge colours are set up. Face colour is completely see through as I want regions + # to just be denoted by their edges. The edge colour is set to white, fetching the colour definition + # set up in the class init. + new_patch.set_facecolor((0.0, 0.0, 0.0, 0.0)) + new_patch.set_edgecolor(self._inv_colour_convert['white']) + # This enables 'picking' of the artist. When enabled picking will trigger an event when the + # artist is clicked on + new_patch.set_picker(True) + # Setting up the linewidth of the new region + new_patch.set_linewidth(self._reg_line_width) + # And adds the artist into the axis. As this is a new artist we don't call _draw_regions for this one. + self._im_ax.add_artist(new_patch) + # Updates the shape dictionary + self._shape_dict[new_patch] = 'ellipse' + # Adds an entry to the dictionary that keeps track of whether regions have been modified or not. In + # this case the region in question is brand new so the entry will always be True. + self._edited_dict[new_patch] = True + + def _new_circ_src(self, event): + """ + Makes a new circular region on the data axis. + + :param event: The matplotlib event passed through from the button press that triggers this method. + """ + # This matplotlib patch is what we add as an 'artist' to the data (i.e. image) axis and is the + # visual representation of our new region. This creates the instance, a circle in this case. + new_patch = Circle(self._last_click, 8) + # Now the face and edge colours are set up. Face colour is completely see through as I want regions + # to just be denoted by their edges. The edge colour is set to white, fetching the colour definition + # set up in the class init. + new_patch.set_facecolor((0.0, 0.0, 0.0, 0.0)) + new_patch.set_edgecolor(self._inv_colour_convert['white']) + # This enables 'picking' of the artist. When enabled picking will trigger an event when the + # artist is clicked on + new_patch.set_picker(True) + # Setting up the linewidth of the new region + new_patch.set_linewidth(self._reg_line_width) + # And adds the artist into the axis. As this is a new artist we don't call _draw_regions for this one. + self._im_ax.add_artist(new_patch) + # Updates the shape dictionary + self._shape_dict[new_patch] = 'circle' + # Adds an entry to the dictionary that keeps track of whether regions have been modified or not. In + # this case the region in question is brand new so the entry will always be True. + self._edited_dict[new_patch] = True + + def _click_event(self, event): + """ + This method is triggered by clicking somewhere on the data axis. + + :param event: The click event that triggered this method. + """ + # Checks whether the click was 'in axis' - so whether it was actually on the image being displayed + # If it wasn't then we don't care about it + if event.inaxes == self._im_ax: + # This saves the position that the user clicked as the 'last click', as the user may now which + # to insert a new region there + self._last_click = (event.xdata, event.ydata) + + def _on_region_pick(self, event): + """ + This is triggered by selecting a region + + :param event: The event triggered on 'picking' an artist. Contains information about which artist + triggered the event, location, etc. + """ + # If interacting is turned off then we don't want this to do anything, likewise if a region that + # is just there for visualisation is clicked ons + if not self._interacting_on or event.artist in self._ignore_arts: + return + + # The _cur_pick attribute references which artist is currently selected, which we can grab from the + # artist picker event that triggered this method + self._cur_pick = event.artist + # Makes sure the instance knows a region is selected right now, set to False again when the click ends + self._select = True + # Stores the current position of the current pick + # self._history.append([self._cur_pick, self._cur_pick.center]) + + # Redraws the regions so that thicker lines are applied to the newly selected region + self._draw_regions() + + def _on_release(self, event): + """ + Method triggered when button released. + + :param event: Event triggered by releasing a button click. + """ + # This method's one purpose is to set this to False, meaning that the currently picked artist + # (as referenced in self._cur_pick) isn't currently being clicked and held on + self._select = False + + def _on_motion(self, event): + """ + This is triggered when someone clicks and holds an artist, and then drags it around. + + :param event: Event triggered by motion of the mouse. + """ + # Makes sure that an artist is actually clicked and held on, to make sure something should be + # being moved around right now + if self._select is False: + return + + # Set the new position of the currently picked artist to the new position of the event + self._cur_pick.center = (event.xdata, event.ydata) + + # Changes the entry in the edited dictionary to True, as the region in question has been moved + self._edited_dict[self._cur_pick] = True + + def _key_press(self, event): + """ + A method triggered by the press of a key (or combination of keys) on the keyboard. For most keys + this method does absolutely nothing, but it does enable the resizing and rotation of regions. + + :param event: The keyboard press event that triggers this method. + """ + # if event.key == "ctrl+z": + # if len(self._history) != 0: + # self._history[-1][0].center = self._history[-1][1] + # self._history[-1][0].figure.canvas.draw() + # self._history.pop(-1) + + if event.key == "w" and self._cur_pick is not None: + if type(self._cur_pick) == Circle: + self._cur_pick.radius += self._size_step + # It is possible for actual artist type to be an Ellipse but for the region to be circular when + # it was taken from the parent Image of this instance, and in that case we still want it to behave + # like a circle for resizing. + elif self._shape_dict[self._cur_pick] == 'circle': + self._cur_pick.height += self._size_step + self._cur_pick.width += self._size_step + else: + self._cur_pick.height += self._size_step + self._cur_pick.figure.canvas.draw() + # The region has had its size changed, thus we make sure the class knows the region has been edited + self._edited_dict[self._cur_pick] = True + + # For comments for the rest of these, see the event key 'w' one, they're the same but either shrinking + # or growing different axes + if event.key == "s" and self._cur_pick is not None: + if type(self._cur_pick) == Circle: + self._cur_pick.radius -= self._size_step + elif self._shape_dict[self._cur_pick] == 'circle': + self._cur_pick.height -= self._size_step + self._cur_pick.width -= self._size_step + else: + self._cur_pick.height -= self._size_step + self._cur_pick.figure.canvas.draw() + # The region has had its size changed, thus we make sure the class knows the region has been edited + self._edited_dict[self._cur_pick] = True + + if event.key == "d" and self._cur_pick is not None: + if type(self._cur_pick) == Circle: + self._cur_pick.radius += self._size_step + elif self._shape_dict[self._cur_pick] == 'circle': + self._cur_pick.height += self._size_step + self._cur_pick.width += self._size_step + else: + self._cur_pick.width += self._size_step + self._cur_pick.figure.canvas.draw() + # The region has had its size changed, thus we make sure the class knows the region has been edited + self._edited_dict[self._cur_pick] = True + + if event.key == "a" and self._cur_pick is not None: + if type(self._cur_pick) == Circle: + self._cur_pick.radius -= self._size_step + elif self._shape_dict[self._cur_pick] == 'circle': + self._cur_pick.height -= self._size_step + self._cur_pick.width -= self._size_step + else: + self._cur_pick.width -= self._size_step + self._cur_pick.figure.canvas.draw() + # The region has had its size changed, thus we make sure the class knows the region has been edited + self._edited_dict[self._cur_pick] = True + + if event.key == "q" and self._cur_pick is not None: + self._cur_pick.angle += self._rot_step + self._cur_pick.figure.canvas.draw() + # The region has had its size changed, thus we make sure the class knows the region has been edited + self._edited_dict[self._cur_pick] = True + + if event.key == "e" and self._cur_pick is not None: + self._cur_pick.angle -= self._rot_step + self._cur_pick.figure.canvas.draw() + # The region has had its size changed, thus we make sure the class knows the region has been edited + self._edited_dict[self._cur_pick] = True + + def _update_reg_list(self) -> Dict: + """ + This method goes through the current artists, checks whether any represent new or updated regions, and + generates a new list of region objects from them. + + :return: The updated region dictionary. + :rtype: Dict + """ + # Here we use the edited dictionary to note that there have been changes to regions + if any(self._edited_dict.values()): + # Setting up the dictionary to store the altered regions in. We include keys for each of the ObsIDs + # associated with the parent product, and then another list with the key 'new'; for regions + # that have been added during the editing. + new_reg_dict = {o: [] for o in self._parent_phot_obj.obs_ids} + new_reg_dict['new'] = [] + + # These artists are the ones that represent regions, the ones in self._ignore_arts are there + # just for visualisation (for instance showing an analysis/background region) and can't be + # turned on or off, can't be edited, and shouldn't be saved. + rel_artists = [arty for arty in self._im_ax.artists if arty not in self._ignore_arts] + + for artist in rel_artists: + # Fetches the boolean variable that describes if the region was edited + altered = self._edited_dict[artist] + # The altered variable is True if an existing region has changed or if a new artist exists + if altered and type(artist) == Ellipse: + # As instances of this class are always declared internally by an Image class, and I know + # the image class always turns SkyRegions into PixelRegions, we know that its valid to + # output PixelRegions here + cen = PixCoord(x=artist.center[0], y=artist.center[1]) + # Creating the equivalent region object from the artist + new_reg = EllipsePixelRegion(cen, artist.width, artist.height, Quantity(artist.angle, 'deg')) + # Fetches and sets the colour of the region, converting from matplotlib colour + new_reg.visual['color'] = self._colour_convert[artist.get_edgecolor()] + elif altered and type(artist) == Circle: + cen = PixCoord(x=artist.center[0], y=artist.center[1]) + # Creating the equivalent region object from the artist + new_reg = CirclePixelRegion(cen, artist.radius) + # Fetches and sets the colour of the region, converting from matplotlib colour + new_reg.visual['color'] = self._colour_convert[artist.get_edgecolor()] + else: + # Looking up the region because if we get to this point we know its an original region that + # hasn't been altered + # Note that in this case its not actually a new reg, its just called that + new_reg = self._artist_region[artist] + + # Checks to see whether it's an artist that has been modified or a new one + if artist in self._artist_obsids: + new_reg_dict[self._artist_obsids[artist]].append(new_reg) + else: + new_reg_dict['new'].append(new_reg) + + # In this case none of the entries in the dictionary that stores whether regions have been + # edited (or added) is True, so the new region list is exactly the same as the old one + else: + new_reg_dict = self._regions + + return new_reg_dict + + def _save_region_files(self, event=None): + """ + This just creates the updated region dictionary from any modifications, converts the separate ObsID + entries to individual region files, and then saves them to disk. All region files are output in RA-Dec + coordinates, making use of the parent photometric objects WCS information. + + :param event: If triggered by a button, this is the event passed. + """ + if self._reg_save_path is not None: + # If the event is not the default None then this function has been triggered by the save button + if event is not None: + # In the case of this button being successfully clicked I want it to turn green. Really I wanted + # it to just flash green, but that doesn't seem to be working so turning green will be fine + self._save_button.color = 'green' + + # Runs the method that updates the list of regions with any alterations that the user has made + final_regions = self._update_reg_list() + for o in final_regions: + # Read out the regions for the current ObsID + rel_regs = final_regions[o] + # Convert them to degrees + rel_regs = [r.to_sky(self._parent_phot_obj.radec_wcs) for r in rel_regs] + # Construct a save_path + rel_save_path = self._reg_save_path.replace('.reg', '_{o}.reg'.format(o=o)) + # write_ds9(rel_regs, rel_save_path, 'image', radunit='') + # This function is a part of the regions module, and will write out a region file. + # Specifically RA-Dec coordinate system in units of degrees. + write_ds9(rel_regs, rel_save_path) + + else: + raise ValueError('No save path was passed, so region files cannot be output.') + class ExpMap(Image): """ @@ -1169,6 +2458,8 @@ def __init__(self, path: str, obs_id: str, instrument: str, stdout_str: str, std super().__init__(path, obs_id, instrument, stdout_str, stderr_str, gen_cmd, lo_en, hi_en, obs_inst_combs=obs_inst_combs) self._prod_type = "expmap" + # Need to overwrite the data unit attribute set by the Image init + self._data_unit = Unit("s") def get_exp(self, at_coord: Quantity) -> float: """ @@ -1218,9 +2509,17 @@ class RateMap(Image): :param Image xga_image: The image component of the RateMap. :param ExpMap xga_expmap: The exposure map component of the RateMap. - :param str reg_file_path: A path to a region file that you might wish to overlay on views of this product. + :param str/List[SkyRegion/PixelRegion]/dict regs: A region list file path, a list of region objects, or a + dictionary of region lists with ObsIDs as dictionary keys. + :param dict/SkyRegion/PixelRegion matched_regs: Similar to the regs argument, but in this case for a region + that has been designated as 'matched', i.e. is the subject of a current analysis. This should either be + supplied as a single region object, or as a dictionary of region objects with ObsIDs as keys, or None values + if there is no match. Such a dictionary can be retrieved from a source using the 'matched_regions' + property. Default is None. """ - def __init__(self, xga_image: Image, xga_expmap: ExpMap, reg_file_path: str = ''): + def __init__(self, xga_image: Image, xga_expmap: ExpMap, + regs: Union[str, List[Union[SkyRegion, PixelRegion]], dict] = '', + matched_regs: Union[SkyRegion, PixelRegion, dict] = None): """ This initialises a RateMap instance, where a count-rate image is divided by an exposure map, to create a map of X-ray counts. @@ -1242,6 +2541,7 @@ def __init__(self, xga_image: Image, xga_expmap: ExpMap, reg_file_path: str = '' super().__init__(xga_image.path, xga_image.obs_id, xga_image.instrument, xga_image.unprocessed_stdout, xga_image.unprocessed_stderr, "", xga_image.energy_bounds[0], xga_image.energy_bounds[1]) self._prod_type = "ratemap" + self._data_unit = Unit("ct/s") # Reading in the PSF status from the Image passed in self._psf_corrected = xga_image.psf_corrected @@ -1263,7 +2563,8 @@ def __init__(self, xga_image: Image, xga_expmap: ExpMap, reg_file_path: str = '' self._on_sensor_mask = None # Don't have to do any checks, they'll be done for me in the image object. - self._im_obj.regions = reg_file_path + self._im_obj.regions = regs + self._im_obj.matched_regions = matched_regs def _construct_on_demand(self): """ @@ -1685,6 +2986,51 @@ def signal_to_noise(self, source_mask: np.ndarray, back_mask: np.ndarray, exp_co return sn + def background_subtracted_counts(self, source_mask: np.ndarray, back_mask: np.ndarray) -> Quantity: + """ + This method uses a user-supplied source and background mask (alongside knowledge of the sensor layout + drawn from the exposure map) to calculate the number of background-subtracted counts within the source + region of the image used to construct this RateMap. + + The exposure map is used to construct a sensor mask, so that we know where the chip gaps are and take + them into account when calculating the ratio of areas of the source region to the background region. This + is why this method is built into the RateMap rather than Image class. + + :param np.ndarray source_mask: The mask which defines the source region, ideally with interlopers removed. + :param np.ndarray back_mask: The mask which defines the background region, ideally with interlopers removed. + :return: The background subtracted counts in the source region. + :rtype: Quantity + """ + # Perform some quick checks on the masks to check they are broadly compatible with this ratemap + if source_mask.shape != self.shape: + raise ValueError("The source mask shape {sm} is not the same as the ratemap shape " + "{rt}!".format(sm=source_mask.shape, rt=self.shape)) + elif not (source_mask >= 0).all() or not (source_mask <= 1).all(): + raise ValueError("The source mask has illegal values in it, there should only be ones and zeros.") + elif back_mask.shape != self.shape: + raise ValueError("The background mask shape {bm} is not the same as the ratemap shape " + "{rt}!".format(bm=back_mask.shape, rt=self.shape)) + elif not (back_mask >= 0).all() or not (back_mask <= 1).all(): + raise ValueError("The background mask has illegal values in it, there should only be ones and zeros.") + + # Find the total mask areas. As the mask is just an array of ones and zeros we can just sum the + # whole thing to find the total pixel area covered. + src_area = (source_mask * self.sensor_mask).sum() + back_area = (back_mask * self.sensor_mask).sum() + + # Calculate an area normalisation so the background counts can be scaled to the source counts properly + area_norm = src_area / back_area + # Find the total counts within the source area + tot_cnt = (self.image.data * source_mask).sum() + # Find the total counts within the background area + bck_cnt = (self.image.data * back_mask).sum() + + # Simple calculation, re-normalising the background counts with the area ratio and subtracting background + # from the source. Then storing it in an astropy quantity + cnts = Quantity(tot_cnt - (bck_cnt*area_norm), 'ct') + + return cnts + @property def edge_mask(self) -> np.ndarray: """ @@ -1740,6 +3086,94 @@ def expmap(self) -> ExpMap: """ return self._ex_obj + @property + def regions(self) -> Dict: + """ + Property getter for regions associated with this ratemap. + + :return: Returns a dictionary of regions, if they have been associated with this object. + :rtype: Dict[PixelRegion] + """ + return self._regions + + @regions.setter + def regions(self, new_reg: Union[str, List[Union[SkyRegion, PixelRegion]], dict]): + """ + A setter for regions associated with this object, a region file path or a list/dict of regions is passed, then + that file/set of regions is processed into the required format. If a list of regions is passed, it will + be assumed that they are for the ObsID of the image. In the case of passing a dictionary of regions to a + combined image we require that each ObsID that goes into the image has an entry in the dictionary. + + :param str/List[SkyRegion/PixelRegion]/dict new_reg: A new region file path, a list of region objects, or a + dictionary of region lists with ObsIDs as dictionary keys. + """ + if not isinstance(new_reg, (str, list, dict)): + raise TypeError("Please pass either a path to a region file, a list of " + "SkyRegion/PixelRegion objects, or a dictionary of lists of SkyRegion/PixelRegion objects " + "with ObsIDs as keys.") + + # Checks to make sure that a region file path exists, if passed, then processes the file + if isinstance(new_reg, str) and new_reg != '' and os.path.exists(new_reg): + self._reg_file_path = new_reg + self._regions = self._process_regions(new_reg) + # Possible for an empty string to be passed in which case nothing happens + elif isinstance(new_reg, str) and new_reg == '': + pass + elif isinstance(new_reg, str): + warnings.warn("That region file path does not exist") + # If an existing list of regions are passed then we just process them and assign them to regions attribute + elif isinstance(new_reg, List) and all([isinstance(r, (SkyRegion, PixelRegion)) for r in new_reg]): + self._reg_file_path = "" + self._regions = self._process_regions(reg_objs=new_reg) + elif isinstance(new_reg, dict) and all([all([isinstance(r, (SkyRegion, PixelRegion)) for r in rl]) + for o, rl in new_reg.items()]): + self._reg_file_path = "" + self._regions = self._process_regions(reg_objs=new_reg) + else: + raise ValueError("That value of new_reg is not valid, please pass either a path to a region file or " + "a list/dictionary of SkyRegion/PixelRegion objects") + + # This is the only part that's different from the implementation in the superclass. Here we make sure that + # the same attribute is set for the Image, so if the user were to access the image from the RateMap + # they would still see any regions that have been added. No doubt there is a more elegant solution but this + # is what you're getting right now because I am exhausted + self._im_obj.regions = new_reg + + @property + def matched_regions(self) -> Dict: + """ + Property getter for any regions which have been designated a 'match' in the current analysis, if + they have been set. + + :return: Returns a dictionary of matched regions, if they have been associated with this object. + :rtype: Dict[PixelRegion] + """ + return self._matched_regions + + @matched_regions.setter + def matched_regions(self, new_reg: Union[str, List[Union[SkyRegion, PixelRegion]], dict]): + """ + A setter for matched regions associated with this object, with a new single matched region or dictionary of + matched regions (with keys being ObsIDs and one entry for each ObsID associated with this object) being passed. + If a single region is passed then it will be assumed that it is associated with the current ObsID of this + object. + + :param dict/SkyRegion/PixelRegion new_reg: A region that has been designated as 'matched', i.e. is the + subject of a current analysis. This should either be supplied as a single region object, or as a + dictionary of region objects with ObsIDs as keys. + """ + if new_reg is not None and not isinstance(new_reg, (PixelRegion, SkyRegion, dict)): + raise TypeError("Please pass either a dictionary of SkyRegion/PixelRegion objects with ObsIDs as " + "keys, or a single SkyRegion/PixelRegion object. Alternatively pass None for no match.") + + self._matched_regions = self._process_matched_regions(new_reg) + + # This is the only part that's different from the implementation in the superclass. Here we make sure that + # the same attribute is set for the Image, so if the user were to access the image from the RateMap + # they would still see any regions that have been added. No doubt there is a more elegant solution but this + # is what you're getting right now because I am exhausted + self._im_obj.matched_regions = new_reg + class PSF(Image): """ @@ -1763,6 +3197,7 @@ def __init__(self, path: str, psf_model: str, obs_id: str, instrument: str, stdo hi_en = Quantity(100, 'keV') super().__init__(path, obs_id, instrument, stdout_str, stderr_str, gen_cmd, lo_en, hi_en) self._prod_type = "psf" + self._data_unit = Unit('') self._psf_centre = None self._psf_model = psf_model diff --git a/xga/products/profile.py b/xga/products/profile.py index 2cc2586a..22334ba2 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 15/06/2021, 16:59. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/04/2023, 13:57. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List from warnings import warn @@ -8,6 +8,7 @@ from astropy.constants import k_B, G, m_p from astropy.units import Quantity, UnitConversionError, Unit from matplotlib import pyplot as plt +from matplotlib.figure import Figure from .. import NHC, ABUND_TABLES, MEAN_MOL_WEIGHT from ..exceptions import ModelNotAssociatedError, XGAInvalidModelError, XGAFitError @@ -425,21 +426,33 @@ def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_n # The density class has an extra bit of information in the storage key, the method used to generate it self._storage_key = "me" + dens_method + extra_info + self._storage_key - def gas_mass(self, model: str, outer_rad: Quantity, conf_level: float = 68.2, - fit_method: str = 'mcmc') -> Tuple[Quantity, Quantity]: + def gas_mass(self, model: str, outer_rad: Quantity, inner_rad: Quantity = None, conf_level: float = 68.2, + fit_method: str = 'mcmc', radius_err: Quantity = None) -> Tuple[Quantity, Quantity]: """ A method to calculate and return the gas mass (with uncertainties). This method uses the model to generate a gas mass distribution (using the fit parameter distributions from the fit performed using the model), then measures the median mass, along with lower and upper uncertainties. + Passing uncertainties on the outer (and inner) radii for the gas mass calculation is supported, with such + uncertainties assumed to be representing a Gaussian distribution. Radii distributions will be drawn from a + Gaussian, though any radii that are negative will be set to zero, so it could be a truncated Gaussian. + :param str model: The name of the model from which to derive the gas mass. - :param Quantity outer_rad: The radius to measure the gas mass out to. + :param Quantity outer_rad: The radius to measure the gas mass out to. Only one radius may be passed at a time. + :param Quantity inner_rad: The inner radius within which to measure the gas mass, this enables measuring + core-excised gas masses. Default is None, which equates to zero. If passing separate uncertainties for + inner and outer radii using `radius_err', the inner radius error must be the second entry. :param float conf_level: The confidence level to use to calculate the mass errors :param str fit_method: The method that was used to fit the model, default is 'mcmc'. + :param Quantity radius_err: A standard deviation on radius, which will be taken into account during the + calculation of gas mass. If both an inner and outer radius have been passed, then you may pass either + a single standard deviation value for both, or a Quantity with two entries. THE FIRST being the outer + radius error, THE SECOND being inner radius error. :return: A Quantity containing three values (mass, -err, +err), and another Quantity containing the entire mass distribution from the whole realisation. :rtype: Tuple[Quantity, Quantity] """ + # First of all we have to find the model that has been fit to this gas density profile. if model not in PROF_TYPE_MODELS[self._prof_type]: raise XGAInvalidModelError("{m} is not a valid model for a gas density profile".format(m=model)) elif model not in self.good_model_fits: @@ -450,39 +463,135 @@ def gas_mass(self, model: str, outer_rad: Quantity, conf_level: float = 68.2, if not model_obj.success: raise ValueError("The fit to that model was not considered a success by the fit method, cannot proceed.") + if not outer_rad.isscalar: + raise ValueError("Gas masses can only be calculated within one radii at a time, please pass a scalar " + "value for outer_rad.") + elif inner_rad is not None and not inner_rad.isscalar: + raise ValueError("Gas masses can only be calculated within one radii at a time, please pass a scalar " + "value for inner_rad.") + + # This checks to see if inner radius is None (probably how it will be used most of the time), and if + # it is then creates a Quantity with the same units as outer_radius + if inner_rad is None: + inner_rad = Quantity(0, outer_rad.unit) + elif inner_rad is not None and not inner_rad.unit.is_equivalent(outer_rad): + raise UnitConversionError("If an inner_radius Quantity is supplied, then it must be in the same units" + " as the outer_radius Quantity.") + # Checking the input radius units if not outer_rad.unit.is_equivalent(self.radii_unit): raise UnitConversionError("The supplied outer radius cannot be converted to the radius unit" " of this profile ({u})".format(u=self.radii_unit.to_string())) else: + # This is for consistency, to make sure the same units as the profile radii are used for calculation + # and for storage keys outer_rad = outer_rad.to(self.radii_unit) + inner_rad = inner_rad.to(self.radii_unit) + + # When only an outer radius has been passed (i.e. inner radius is zero), then we can only allow one + # radius error to be passed + if radius_err is not None and inner_rad == 0 and not radius_err.isscalar: + raise ValueError('You may only pass a two-element radius error quantity if you have also set inner_radius ' + 'to a non-zero value.') + # We know that there is no circumstance where more than two radius errors should be passed + elif radius_err is not None and not radius_err.isscalar and len(radius_err) > 2: + raise ValueError("The 'radius_error' argument may have a maximum of two entries, a single value for both" + "outer and inner radii, or separate entries for outer and inner radii.") + # Now we check to see whether the radius error unit is compatible with the radius units we're already + # working with + elif radius_err is not None and not radius_err.unit.is_equivalent(outer_rad.unit): + raise UnitConversionError("The radius_err quantity must be in units that are equivalent to units " + "of {}.".format(outer_rad.unit.to_string())) + # Now we make absolutely sure that the radius error(s) are in the correct units + elif radius_err is not None: + radius_err = radius_err.to(self.radii_unit) # Doing an extra check to warn the user if the radius they supplied is outside the radii # covered by the data if outer_rad >= self.radii[-1]: warn("The outer radius you supplied is greater than or equal to the outer radius covered by the data, so" - " you are effectively extrapolating using the model.") + " you are effectively extrapolating using the model.", stacklevel=2) + + # The next step is setting up radius distributions, if the radius error is not None. The outer_rad + # and inner_rad (if applicable) variables will be overwritten with a distribution, which will be picked up + # on by the volume integral part of the model function. + rng = np.random.default_rng() + if radius_err is None: + # This is the simplest case, where there is no error at all - here the storage keys are just string + # versions of the inner and outer radii + out_stor_key = str(outer_rad) + inn_stor_key = str(inner_rad) + elif radius_err is not None and inner_rad == 0: + # The keys are defined first because 'outer_rad' is about to be turned into a radius distribution rather + # than a single value and we need the original values for string representations. Here the outer radius + # is uncertain and the size of the standard deviation becomes part of the storage key + out_stor_key = str(outer_rad.value) + '_' + str(radius_err.value) + " " + str(outer_rad.unit) + inn_stor_key = str(inner_rad) + # The length of one of the parameter distributions in the model is used to tell us how many samples to + # draw from our radius distribution, as we need it to be the same length for the volume integral. + outer_rad = Quantity(rng.normal(outer_rad.value, radius_err.value, len(model_obj.par_dists[0])), + radius_err.unit) + elif radius_err is not None and radius_err.isscalar: + # The keys are defined first because the radii variables are about to be turned into radius + # distributions rather than single values and we need the original values for string representations. + # Here the radii are uncertain (with the same st dev) and the size of the standard deviation becomes + # part of the storage key + out_stor_key = str(outer_rad.value) + '_' + str(radius_err.value) + " " + str(outer_rad.unit) + inn_stor_key = str(inner_rad.value) + '_' + str(radius_err.value) + " " + str(outer_rad.unit) + outer_rad = Quantity(rng.normal(outer_rad.value, radius_err.value, len(model_obj.par_dists[0])), + radius_err.unit) + inner_rad = Quantity(rng.normal(inner_rad.value, radius_err.value, len(model_obj.par_dists[0])), + radius_err.unit) + elif radius_err is not None and len(radius_err) == 2: + # The keys are defined first because the radii variables are about to be turned into radius + # distributions rather than single values and we need the original values for string representations. + # Here the radii are uncertain (with different st devs) and the size of the standard deviations become + # part of the storage keys + out_stor_key = str(outer_rad.value) + '_' + str(radius_err[0].value) + " " + str(outer_rad.unit) + inn_stor_key = str(inner_rad.value) + '_' + str(radius_err[1].value) + " " + str(outer_rad.unit) + outer_rad = Quantity(rng.normal(outer_rad.value, radius_err.value[0], len(model_obj.par_dists[0])), + radius_err.unit) + inner_rad = Quantity(rng.normal(inner_rad.value, radius_err.value[1], len(model_obj.par_dists[0])), + radius_err.unit) + else: + raise ValueError("Somehow you have passed a radius error with more than two entries and " + "it hasn't been caught - contact the developer.") - # Just preparing the way, setting up the storage dictionary + # If we're using a radius distribution(s), then this part checks to ensure that none of the values are + # negative because that doesn't make any sense! In such cases the offending radii are set to zero, so really + # the radii could be a truncated Gaussian distribution. + if not outer_rad.isscalar: + outer_rad[outer_rad < 0] = 0 + if not inner_rad.isscalar: + inner_rad[inner_rad < 0] = 0 + + # Just preparing the way, setting up the storage dictionary - top level identifies the model if str(model_obj) not in self._gas_masses: self._gas_masses[str(model_obj)] = {} - - if outer_rad not in self._gas_masses[str(model_obj)] and outer_rad != 0: - mass_dist = model_obj.volume_integral(outer_rad, use_par_dist=True) + # The next layer is the outer radius key, then finally the result will be stored using the inner radius key + if out_stor_key not in self._gas_masses[str(model_obj)]: + self._gas_masses[str(model_obj)][out_stor_key] = {} + + # This runs the volume integral on the density profile, using the built-in integral method in the model. + if inn_stor_key not in self._gas_masses[str(model_obj)][out_stor_key] and \ + out_stor_key != str(Quantity(0, outer_rad.unit)): + mass_dist = model_obj.volume_integral(outer_rad, inner_rad, use_par_dist=True) + # Converts to an actual mass rather than a total number of particles if self._sub_type == 'num_dens': mass_dist *= (MEAN_MOL_WEIGHT*m_p) - + # Converts to solar masses and stores inside the current profile for future reference mass_dist = mass_dist.to('Msun') - self._gas_masses[str(model_obj)][outer_rad] = mass_dist + self._gas_masses[str(model_obj)][out_stor_key][inn_stor_key] = mass_dist # Obviously the mass contained within a zero radius bin is zero, but the integral can fall over sometimes when # this is requested so I put in this special case - elif outer_rad not in self._gas_masses[str(model_obj)] and outer_rad == 0: + elif inn_stor_key not in self._gas_masses[str(model_obj)][out_stor_key] and \ + (outer_rad.isscalar and outer_rad == 0): mass_dist = Quantity(np.zeros(len(model_obj.par_dists[0])), 'Msun') - self._gas_masses[str(model_obj)][outer_rad] = mass_dist + self._gas_masses[str(model_obj)][out_stor_key][inn_stor_key] = mass_dist else: - mass_dist = self._gas_masses[str(model_obj)][outer_rad] + mass_dist = self._gas_masses[str(model_obj)][out_stor_key][inn_stor_key] med_mass = np.percentile(mass_dist, 50).value upp_mass = np.percentile(mass_dist, 50 + (conf_level/2)).value @@ -538,7 +647,7 @@ def view_gas_mass_dist(self, model: str, outer_rad: Quantity, conf_level: float raise ValueError("Unfortunately this method can only display a distribution for one radius, so " "arrays of radii are not supported.") - gas_mass, gas_mass_dist = self.gas_mass(model, outer_rad, conf_level, fit_method) + gas_mass, gas_mass_dist = self.gas_mass(model, outer_rad, conf_level=conf_level, fit_method=fit_method) plt.figure(figsize=figsize) ax = plt.gca() @@ -1083,9 +1192,9 @@ def __init__(self, temperature_profile: GasTemperature3D, temperature_model: Uni raise ValueError("The temperature and density profiles do not have the same central coordinate.") # Same reasoning with the ObsID and instrument elif temperature_profile.obs_id != density_profile.obs_id: - warn("The temperature and density profiles do not have the same associated ObsID.") + warn("The temperature and density profiles do not have the same associated ObsID.", stacklevel=2) elif temperature_profile.instrument != density_profile.instrument: - warn("The temperature and density profiles do not have the same associated instrument.") + warn("The temperature and density profiles do not have the same associated instrument.", stacklevel=2) # We see if either of the profiles have an associated spectrum if temperature_profile.set_ident is None and density_profile.set_ident is None: @@ -1099,8 +1208,8 @@ def __init__(self, temperature_profile: GasTemperature3D, temperature_model: Uni set_store = temperature_profile.associated_set_storage_key elif temperature_profile.set_ident is not None and density_profile.set_ident is not None: if temperature_profile.set_ident != density_profile.set_ident: - warn("The temperature and density profile you passed where generated from different sets of annular" - " spectra, the mass profiles associated set ident will be set to None.") + warn("The temperature and density profile you passed were generated from different sets of annular" + " spectra, the mass profiles associated set ident will be set to None.", stacklevel=2) set_id = None set_store = None else: @@ -1229,7 +1338,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2) -> Union[Quantity, Qu # Here there are no logs in the derivatives, because its easier to take advantage of astropy's quantities # that way. mass_dist = ((-1 * k_B * np.power(radius[..., None], 2)) / (dens * (MEAN_MOL_WEIGHT*m_p) * G)) * \ - ((dens * temp_der) + (temp * dens_der)) + ((dens * temp_der) + (temp * dens_der)) # Just converts the mass/masses to the unit we normally use for them mass_dist = mass_dist.to('Msun').T @@ -1252,6 +1361,46 @@ def mass(self, radius: Quantity, conf_level: float = 68.2) -> Union[Quantity, Qu return mass_res, mass_dist + def annular_mass(self, outer_radius: Quantity, inner_radius: Quantity, conf_level: float = 68.2): + """ + Calculate the hydrostatic mass contained within a specific 3D annulus, bounded by the outer and inner radius + supplied to this method. Annular mass is calculated by measuring the mass within the inner and outer + radii, and then subtracting the inner from the outer. Also supports calculating multiple annular masses + when inner_radius and outer_radius are non-scalar. + + WARNING - THIS METHOD INVOLVES SUBTRACTING TWO MASS DISTRIBUTIONS, WHICH CAN'T NECESSARILY BE APPROXIMATED + AS GAUSSIAN DISTRIBUTIONS, AS SUCH RESULTS FROM THIS METHOD SHOULD BE TREATED WITH SOME SUSPICION. + + :param Quantity outer_radius: Astropy containing outer radius (or radii) for the annulus (annuli) within + which you wish to measure the mass. If calculating multiple annular masses, the length of outer_radius + must be the same as inner_radius. + :param Quantity inner_radius: Astropy containing inner radius (or radii) for the annulus (annuli) within + which you wish to measure the mass. If calculating multiple annular masses, the length of inner_radius + must be the same as outer_radius. + :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). + :return: An astropy quantity containing a mass distribution(s). Quantity will become two-dimensional + when multiple sets of inner and outer radii are passed by the user. + :rtype: Quantity + """ + # Perform some checks to make sure that the user has passed inner and outer radii quantities that are valid + # and won't break any of the calculations that will be happening in this method + if outer_radius.isscalar != inner_radius.isscalar: + raise ValueError("The outer_radius and inner_radius Quantities must both be scalar, or both " + "be non-scalar.") + elif (not inner_radius.isscalar and inner_radius.ndim != 1) or \ + (not outer_radius.isscalar and outer_radius.ndim != 1): + raise ValueError('Non-scalar radius Quantities must have only one dimension') + elif not outer_radius.isscalar and not inner_radius.isscalar and outer_radius.shape != inner_radius.shape: + raise ValueError('The outer_radius and inner_radius Quantities must be the same shape.') + + # This just measures the masses within two radii, the outer and the inner supplied by the user. The mass() + # method will automatically deal with the input of multiple entries for each radius + outer_mass, outer_mass_dist = self.mass(outer_radius, conf_level) + inner_mass, inner_mass_dist = self.mass(inner_radius, conf_level) + + # This PROBABLY NOT AT ALL valid because they're just posterior distributions of mass + return outer_mass_dist - inner_mass_dist + def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize=(8, 8), bins: Union[str, int] = 'auto', colour: str = "lightslategrey"): """ @@ -1320,8 +1469,8 @@ def baryon_fraction(self, radius: Quantity, conf_level: float = 68.2) -> Tuple[Q # Grab out the hydrostatic mass distribution, and the gas mass distribution hy_mass, hy_mass_dist = self.mass(radius, conf_level) - gas_mass, gas_mass_dist = self._dens_prof.gas_mass(self._dens_model.name, radius, conf_level, - self._dens_model.fit_method) + gas_mass, gas_mass_dist = self._dens_prof.gas_mass(self._dens_model.name, radius, conf_level=conf_level, + fit_method=self._dens_model.fit_method) # If the distributions don't have the same number of entries (though as far I can recall they always should), # then we just make sure we have two equal length distributions to divide @@ -1409,6 +1558,294 @@ def baryon_fraction_profile(self) -> BaryonFraction: self.radii_err, frac_err, self.set_ident, self.associated_set_storage_key, self.deg_radii) + def overdensity_radius(self, delta: int, redshift: float, cosmo, init_lo_rad: Quantity = Quantity(100, 'kpc'), + init_hi_rad: Quantity = Quantity(3500, 'kpc'), init_step: Quantity = Quantity(100, 'kpc'), + out_unit: Union[Unit, str] = Unit('kpc')) -> Quantity: + """ + This method uses the mass profile to find the radius that corresponds to the user-supplied + overdensity - common choices for cluster analysis are Δ=2500, 500, and 200. Overdensity radii are + defined as the radius at which the density is Δ times the critical density of the Universe at the + cluster redshift. + + This method takes a numerical approach to the location of the requested radius. Though we have calculated + analytical hydrostatic mass models for common choices of temperature and density profile models, there are + no analytical solutions for R. + + When an overdensity radius is being calculated, we initially measure masses for a range of radii between + init_lo_rad - init_hi_rad in steps of init_step. From this we find the two radii that bracket the radius where + average density - Delta*critical density = 0. Between those two radii we perform the same test with another + range of radii (in steps of 1 kpc this time), finding the radius that corresponds to the minimum + density difference value. + + :param int delta: The overdensity factor for which a radius is to be calculated. + :param float redshift: The redshift of the cluster. + :param cosmo: The cosmology in which to calculate the overdensity. Should be an astropy cosmology instance. + :param Quantity init_lo_rad: The lower radius bound for the first radii array generated to find the wide + brackets around the requested overdensity radius. Default value is 100 kpc. + :param Quantity init_hi_rad: The upper radius bound for the first radii array generated to find the wide + brackets around the requested overdensity radius. Default value is 3500 kpc. + :param Quantity init_step: The step size for the first radii array generated to find the wide brackets + around the requested overdensity radius. Default value is 100 kpc, recommend that you don't set it + smaller than 10 kpc. + :param Unit/str out_unit: The unit that this method should output the radius with. + :return: The calculated overdensity radius. + :rtype: Quantity + """ + def turning_point(brackets: Quantity, step_size: Quantity) -> Quantity: + """ + This is the meat of the overdensity_radius method. It goes looking for radii that bracket the + requested overdensity radius. This works by calculating an array of masses, calculating densities + from them and the radius array, then calculating the difference between Delta*critical density at + source redshift. Where the difference array flips from being positive to negative is where the + bracketing radii are. + + :param Quantity brackets: The brackets within which to generate our array of radii. + :param Quantity step_size: The step size for the array of radii + :return: The bracketing radii for the requested overdensity for this search. + :rtype: Quantity + """ + # Just makes sure that the step size is definitely in the same unit as the bracket + # variable, as I take the value of step_size later + step_size = step_size.to(brackets.unit) + + # This sets up a range of radii within which to calculate masses, which in turn are used to find the + # closest value to the Delta*critical density we're looking for + rads = Quantity(np.arange(*brackets.value, step_size.value), 'kpc') + # The masses contained within the test radii, the transpose is just there because the array output + # by that function is weirdly ordered - there is an issue open that will remind to eventually change that + rad_masses = self.mass(rads)[0].T + # Calculating the density from those masses - uses the radii that the masses were measured within + rad_dens = rad_masses[:, 0] / (4 * np.pi * (rads ** 3) / 3) + # Finds the difference between the density array calculated above and the requested + # overdensity (i.e. Delta * the critical density of the Universe at the source redshift). + rad_dens_diffs = rad_dens - (delta * z_crit_dens) + + if np.all(rad_dens_diffs.value > 0) or np.all(rad_dens_diffs.value < 0): + raise ValueError("The passed lower ({l}) and upper ({u}) radii don't appear to bracket the " + "requested overdensity (Delta={d}) radius.".format(l=brackets[0], u=brackets[1], + d=delta)) + + # This finds the index of the radius where the turnover between the density difference being + # positive and negative happens. The radius of that index, and the index before it, bracket + # the requested overdensity. + turnover = np.where(rad_dens_diffs.value < 0, rad_dens_diffs.value, -np.inf).argmax() + brackets = rads[[turnover - 1, turnover]] + + return brackets + + # First perform some sanity checks to make sure that the user hasn't passed anything silly + # Check that the overdensity is a positive, non-zero (because that wouldn't make sense) integer. + if not type(delta) == int or delta <= 0: + raise ValueError("The overdensity must be a positive, non-zero, integer.") + + # The user is allowed to pass either a unit instance or a string, we make sure the out_unit is consistently + # a unit instance for the benefit of the rest of this method. + if isinstance(out_unit, str): + out_unit = Unit(out_unit) + elif not isinstance(out_unit, Unit): + raise ValueError("The out_unit argument must be either an astropy Unit instance, or a string " + "representing an astropy unit.") + + # We know that if we have arrived here then the out_unit variable is a Unit instance, so we just check + # that it's a distance unit that makes sense. I haven't allowed degrees, arcmins etc. because it would + # entail a little extra work, and I don't care enough right now. + if not out_unit.is_equivalent('kpc'): + raise UnitConversionError("The out_unit argument must be supplied with a unit that is convertible " + "to kpc. Angular units such as deg are not currently supported.") + + # Obviously redshift can't be negative, and I won't allow zero redshift because it doesn't + # make sense for clusters and completely changes how distance calculations are done. + if redshift <= 0: + raise ValueError("Redshift cannot be less than or equal to zero.") + + # This is the critical density of the Universe at the cluster redshift - this is what we compare the + # cluster density too to figure out the requested overdensity radius. + z_crit_dens = cosmo.critical_density(redshift) + + wide_bracket = turning_point(Quantity([init_lo_rad, init_hi_rad]), init_step) + if init_step != Quantity(1, 'kpc'): + # In this case I buffer the wide bracket (subtract 5 kpc from the lower bracket and add 5 kpc to the upper + # bracket) - this is a fix to help avoid errors when the turning point is equal to the upper or lower + # bracket + buffered_wide_bracket = wide_bracket + Quantity([-5, 5], 'kpc') + tight_bracket = turning_point(buffered_wide_bracket, Quantity(1, 'kpc')) + else: + tight_bracket = wide_bracket + + return ((tight_bracket[0]+tight_bracket[1])/2).to(out_unit) + + def _diag_view_prep(self, src) -> Tuple[int, RateMap, SurfaceBrightness1D]: + """ + This internal function just serves to grab the relevant photometric products (if available) and check to + see how many plots will be in the diagnostic view. The maximum is five; mass profile, temperature profile, + density profile, surface brightness profile, and ratemap. + + :param GalaxyCluster src: The source object for which this hydrostatic mass profile was created + :return: The number of plots, a RateMap (if src was pass, otherwise None), and a SB profile (if the + density profile was created with the SB method, otherwise None). + :rtype: Tuple[int, RateMap, SurfaceBrightness1D] + """ + + # This checks to make sure that the source is a galaxy cluster, I do it this way (with strings) to avoid + # annoying circular import errors. The source MUST be a galaxy cluster because you can only calculate + # hydrostatic mass profiles for galaxy clusters. + if src is not None and type(src).__name__ != 'GalaxyCluster': + raise TypeError("The src argument must be a GalaxyCluster object.") + + # This just checks to make sure that the name of the passed source is the same as the stored source name + # of this profile. Maybe in the future this won't be necessary because a reference to the source + # will be stored IN the profile. + if src is not None and src.name != self.src_name: + raise ValueError("The passed source has a different name to the source that was used to generate" + " this HydrostaticMass profile.") + + # If the hydrostatic mass profile was created using combined data then I grab a combined image + if self.obs_id == 'combined' and src is not None: + rt = src.get_combined_ratemaps(src.peak_lo_en, src.peak_hi_en) + # Otherwise we grab the specific relevant image + elif self.obs_id != 'combined' and src is not None: + rt = src.get_ratemaps(self.obs_id, self.instrument, src.peak_lo_en, src.peak_hi_en) + # If there is no source passed, then we don't get a ratemap + else: + rt = None + + # Checks to see whether the generation profile of the density profile is a surface brightness + # profile. The other option is that it's an apec normalisation profile if generated from the spectra method + if type(self.density_profile.generation_profile) == SurfaceBrightness1D: + sb = self.density_profile.generation_profile + # Otherwise there is no SB profile + else: + sb = None + + # Maximum number of plots is five, this just figures out how many there are going to be based on what the + # ratemap and surface brightness profile values are + num_plots = 5 - sum([rt is None, sb is None]) + + return num_plots, rt, sb + + def _gen_diag_view(self, fig: Figure, src, num_plots: int, rt: RateMap, sb: SurfaceBrightness1D): + """ + This populates the diagnostic plot figure, grabbing axes from various classes of profile product. + + :param Figure fig: The figure instance being populated. + :param GalaxyCluster src: The galaxy cluster source that this hydrostatic mass profile was created for. + :param int num_plots: The number of plots in this diagnostic view. + :param RateMap rt: A RateMap to add to this diagnostic view. + :param SurfaceBrightness1D sb: A surface brightness profile to add to this diagnostic view. + :return: The axes array of this diagnostic view. + :rtype: np.ndarray([Axes]) + """ + from ..imagetools.misc import physical_rad_to_pix + + # The preparation method has already figured out how many plots there will be, so we create those subplots + ax_arr = fig.subplots(nrows=1, ncols=num_plots) + + # If a RateMap has been passed then we need to get the view, calculate some things, and then add it to our + # diagnostic plot + if rt is not None: + # As the RateMap is the first plot, and is not guaranteed to be present, I use the offset parameter + # later in this function to shift the other plots across by 1 if it is present. + offset = 1 + # If the source was setup to use a peak coordinate, then we want to include that in the ratemap display + if src.use_peak: + ch = Quantity([src.peak, src.ra_dec]) + # I also grab the annulus boundaries from the temperature profile used to create this + # HydrostaticMass profile, then convert to pixels. That does depend on there being a source, but + # we know that we wouldn't have a RateMap at this point if the user hadn't passed a source + pix_rads = physical_rad_to_pix(rt, self.temperature_profile.annulus_bounds, src.peak, src.redshift, + src.cosmo) + + else: + # No peak means we just use the original user-passed RA-Dec + ch = src.ra_dec + pix_rads = physical_rad_to_pix(rt, self.temperature_profile.annulus_bounds, src.ra_dec, src.redshift, + src.cosmo) + + # This gets the nicely setup view from the RateMap object and adds it to our array of matplotlib axes + ax_arr[0] = rt.get_view(ax_arr[0], ch, radial_bins_pix=pix_rads.value) + else: + # In this case there is no RateMap to add, so I don't need to shift the other plots across + offset = 0 + + # These simply plot the mass, temperature, and density profiles with legends turned off, residuals turned + # off, and no title + ax_arr[0+offset] = self.get_view(fig, ax_arr[0+offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + ax_arr[1+offset] = self.temperature_profile.get_view(fig, ax_arr[1+offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + ax_arr[2+offset] = self.density_profile.get_view(fig, ax_arr[2+offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + # Then if there is a surface brightness profile thats added too + if sb is not None: + ax_arr[3+offset] = sb.get_view(fig, ax_arr[3+offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + + return ax_arr + + def diagnostic_view(self, src=None, figsize: tuple = None): + """ + This method produces a figure with the most important products that went into the creation of this + HydrostaticMass profile, for the purposes of quickly checking that everything looks sensible. The + maximum number of plots included is five; mass profile, temperature profile, density profile, + surface brightness profile, and ratemap. The RateMap will only be included if the source that this profile + was generated from is passed. + + :param GalaxyCluster src: The GalaxyCluster source that this HydrostaticMass profile was generated from. + :param tuple figsize: A tuple that sets the size of the diagnostic plot, default is None in which case + it is set automatically. + """ + + # Run the preparatory method to get the number of plots, RateMap, and SB profile - also performs + # some common sense checks if a source has been passed. + num_plots, rt, sb = self._diag_view_prep(src) + + # Calculate a sensible figsize if the user didn't pass one + if figsize is None: + figsize = (7.2*num_plots, 7) + + # Set up the figure + fig = plt.figure(figsize=figsize) + # Set up and populate the axes with plots + ax_arr = self._gen_diag_view(fig, src, num_plots, rt, sb) + + # And show the figure + plt.tight_layout() + plt.show() + + plt.close('all') + + def save_diagnostic_view(self, save_path: str, src=None, figsize: tuple = None): + """ + This method saves a figure (without displaying) with the most important products that went into the creation + of this HydrostaticMass profile, for the purposes of quickly checking that everything looks sensible. The + maximum number of plots included is five; mass profile, temperature profile, density profile, surface + brightness profile, and ratemap. The RateMap will only be included if the source that this profile + was generated from is passed. + + :param str save_path: The path and filename where the diagnostic figure should be saved. + :param GalaxyCluster src: The GalaxyCluster source that this HydrostaticMass profile was generated from. + :param tuple figsize: A tuple that sets the size of the diagnostic plot, default is None in which case + it is set automatically. + """ + # Run the preparatory method to get the number of plots, RateMap, and SB profile - also performs + # some common sense checks if a source has been passed. + num_plots, rt, sb = self._diag_view_prep(src) + + # Calculate a sensible figsize if the user didn't pass one + if figsize is None: + figsize = (7.2*num_plots, 7) + + # Set up the figure + fig = plt.figure(figsize=figsize) + # Set up and populate the axes with plots + ax_arr = self._gen_diag_view(fig, src, num_plots, rt, sb) + + # And show the figure + plt.tight_layout() + plt.savefig(save_path) + + plt.close('all') + @property def temperature_profile(self) -> GasTemperature3D: """ @@ -1463,7 +1900,350 @@ def rad_check(self, rad: Quantity): if (self._temp_prof.annulus_bounds is not None and (rad > self._temp_prof.annulus_bounds[-1]).any()) \ or (self._dens_prof.annulus_bounds is not None and (rad > self._dens_prof.annulus_bounds[-1]).any()): warn("Some radii are outside the data range covered by the temperature or density profiles, as such " - "you will be extrapolating based on the model fits.") + "you will be extrapolating based on the model fits.", stacklevel=2) + + +class SpecificEntropy(BaseProfile1D): + """ + A profile product which uses input GasTemperature3D and GasDensity3D profiles to generate a specific entropy + profile. Functionally this is extremely similar to the HydrostaticMass profile class, as it calculates the y + values itself, rather than them being part of the declaration. + + :param GasTemperature3D temperature_profile: The XGA 3D temperature profile to take temperature + information from. + :param str/BaseModel1D temperature_model: The model to fit to the temperature profile, either a name or an + instance of an XGA temperature model class. + :param GasDensity3D density_profile: The XGA 3D density profile to take density information from. + :param str/BaseModel1D density_model: The model to fit to the density profile, either a name or an + instance of an XGA density model class. + :param Quantity radii: The radii at which to measure the entropy for the declaration of the profile. + :param Quantity radii_err: The uncertainties on the radii. + :param Quantity deg_radii: The radii values, but in units of degrees. This is required to set up a storage key + for the profile to be filed in an XGA source. + :param str fit_method: The name of the fit method to use for the fitting of the profiles, default is 'mcmc'. + :param int num_walkers: If the fit method is 'mcmc' then this will set the number of walkers for the emcee + sampler to set up. + :param list/int num_steps: If the fit method is 'mcmc' this will set the number of steps for each sampler to + take. If a single number is passed then that number of steps is used for both profiles, otherwise if a list + is passed the first entry is used for the temperature fit, and the second for the density fit. + :param int num_samples: The number of random samples to be drawn from the posteriors of the fit results. + :param bool show_warn: Should warnings thrown during the fitting processes be shown. + :param bool progress: Should fit progress bars be shown. + """ + def __init__(self, temperature_profile: GasTemperature3D, temperature_model: Union[str, BaseModel1D], + density_profile: GasDensity3D, density_model: Union[str, BaseModel1D], radii: Quantity, + radii_err: Quantity, deg_radii: Quantity, fit_method: str = "mcmc", num_walkers: int = 20, + num_steps: [int, List[int]] = 20000, num_samples: int = 10000, show_warn: bool = True, + progress: bool = True): + """ + The init method for the SpecificEntropy profile class, uses temperature and density profiles, along with + models, to set up the entropy profile. + + A profile product which uses input GasTemperature3D and GasDensity3D profiles to generate a specific + entropy profile. Functionally this is extremely similar to the HydrostaticMass profile class, as it + calculates the y values itself, rather than them being part of the declaration. + + :param GasTemperature3D temperature_profile: The XGA 3D temperature profile to take temperature + information from. + :param str/BaseModel1D temperature_model: The model to fit to the temperature profile, either a name or an + instance of an XGA temperature model class. + :param GasDensity3D density_profile: The XGA 3D density profile to take density information from. + :param str/BaseModel1D density_model: The model to fit to the density profile, either a name or an + instance of an XGA density model class. + :param Quantity radii: The radii at which to measure the entropy for the declaration of the profile. + :param Quantity radii_err: The uncertainties on the radii. + :param Quantity deg_radii: The radii values, but in units of degrees. This is required to set up a + storage key for the profile to be filed in an XGA source. + :param str fit_method: The name of the fit method to use for the fitting of the profiles, default is 'mcmc'. + :param int num_walkers: If the fit method is 'mcmc' then this will set the number of walkers for the emcee + sampler to set up. + :param list/int num_steps: If the fit method is 'mcmc' this will set the number of steps for each sampler + to take. If a single number is passed then that number of steps is used for both profiles, otherwise + if a list is passed the first entry is used for the temperature fit, and the second for the + density fit. + :param int num_samples: The number of random samples to be drawn from the posteriors of the fit results. + :param bool show_warn: Should warnings thrown during the fitting processes be shown. + :param bool progress: Should fit progress bars be shown. + """ + # This init is unfortunately almost identical to HydrostaticMass, there is a lot of duplicated code. + + # We check whether the temperature profile passed is actually the type of profile we need + if type(temperature_profile) != GasTemperature3D: + raise TypeError("Only a GasTemperature3D instance may be passed for temperature_profile, check " + "you haven't accidentally passed a ProjectedGasTemperature1D.") + + # We repeat this process with the density profile and model + if type(density_profile) != GasDensity3D: + raise TypeError("Only a GasDensity3D instance may be passed for density_profile, check you haven't " + "accidentally passed a GasDensity1D.") + + # We also need to check that someone hasn't done something dumb like pass profiles from two different + # clusters, so we'll compare source names. + if temperature_profile.src_name != density_profile.src_name: + raise ValueError("You have passed temperature and density profiles from two different " + "sources, any resulting entropy measurements would not be valid, so this is not " + "allowed.") + # And check they were generated with the same central coordinate, otherwise they may not be valid. I + # considered only raising a warning, but I need a consistent central coordinate to pass to the super init + elif np.any(temperature_profile.centre != density_profile.centre): + raise ValueError("The temperature and density profiles do not have the same central coordinate.") + # Same reasoning with the ObsID and instrument + elif temperature_profile.obs_id != density_profile.obs_id: + warn("The temperature and density profiles do not have the same associated ObsID.", stacklevel=2) + elif temperature_profile.instrument != density_profile.instrument: + warn("The temperature and density profiles do not have the same associated instrument.", stacklevel=2) + + # We see if either of the profiles have an associated spectrum + if temperature_profile.set_ident is None and density_profile.set_ident is None: + set_id = None + set_store = None + elif temperature_profile.set_ident is None and density_profile.set_ident is not None: + set_id = density_profile.set_ident + set_store = density_profile.associated_set_storage_key + elif temperature_profile.set_ident is not None and density_profile.set_ident is None: + set_id = temperature_profile.set_ident + set_store = temperature_profile.associated_set_storage_key + elif temperature_profile.set_ident is not None and density_profile.set_ident is not None: + if temperature_profile.set_ident != density_profile.set_ident: + warn("The temperature and density profile you passed were generated from different sets of annular" + " spectra, the entropy profile's associated set ident will be set to None.", stacklevel=2) + set_id = None + set_store = None + else: + set_id = temperature_profile.set_ident + set_store = temperature_profile.associated_set_storage_key + + self._temp_prof = temperature_profile + self._dens_prof = density_profile + + if not radii.unit.is_equivalent("kpc"): + raise UnitConversionError("Radii unit cannot be converted to kpc") + else: + radii = radii.to('kpc') + radii_err = radii_err.to('kpc') + # This will be overwritten by the super() init call, but it allows rad_check to work + self._radii = radii + + # We won't REQUIRE that the profiles have data point generated at the same radii, as we're gonna + # measure entropy from the models, but I do need to check that the passed radii are within the radii of the + # and warn the user if they aren't + self.rad_check(radii) + + if isinstance(num_steps, int): + temp_steps = num_steps + dens_steps = num_steps + elif isinstance(num_steps, list) and len(num_steps) == 2: + temp_steps = num_steps[0] + dens_steps = num_steps[1] + else: + raise ValueError("If a list is passed for num_steps then it must have two entries, the first for the " + "temperature profile fit and the second for the density profile fit") + + # Make sure the model fits have been run, and retrieve the model objects + temperature_model = temperature_profile.fit(temperature_model, fit_method, num_samples, temp_steps, num_walkers, + progress, show_warn) + density_model = density_profile.fit(density_model, fit_method, num_samples, dens_steps, num_walkers, progress, + show_warn) + + # Have to check whether the fits were actually successful, as the fit method will return a model instance + # either way + if not temperature_model.success: + raise XGAFitError("The fit to the temperature was unsuccessful, cannot define entropy profile.") + if not density_model.success: + raise XGAFitError("The fit to the density was unsuccessful, cannot define entropy profile.") + + self._temp_model = temperature_model + self._dens_model = density_model + + ent, ent_dist = self.entropy(radii, conf_level=68) + ent_vals = ent[0, :] + ent_errs = np.mean(ent[1:, :], axis=0) + + super().__init__(radii, ent_vals, self._temp_prof.centre, self._temp_prof.src_name, self._temp_prof.obs_id, + self._temp_prof.instrument, radii_err, ent_errs, set_id, set_store, deg_radii) + + # Need a custom storage key for this entropy profile, incorporating all the information we have about what + # went into it, density profile, temperature profile, radii, density and temperature models - identical to + # the form used by HydrostaticMass profiles. + dens_part = "dprof_{}".format(self._dens_prof.storage_key) + temp_part = "tprof_{}".format(self._temp_prof.storage_key) + cur_part = self.storage_key + new_part = "tm{t}_dm{d}".format(t=self._temp_model.name, d=self._dens_model.name) + whole_new = "{n}_{c}_{t}_{d}".format(n=new_part, c=cur_part, t=temp_part, d=dens_part) + self._storage_key = whole_new + + # Setting the type + self._prof_type = "specific_entropy" + + # This is what the y-axis is labelled as during plotting + self._y_axis_name = r"K$_{\rm{X}}$" + + # Setting up a dictionary to store entropy results in. + self._entropies = {} + + def entropy(self, radius: Quantity, conf_level: float = 68.2) -> Union[Quantity, Quantity]: + """ + A method which will measure a specific entropy and specific entropy uncertainty within the given + radius/radii. + + If the models for temperature and density have analytical solutions to their derivative wrt to radius then + those will be used to calculate the gradients at radius, but if not then a numerical method will be used for + which dx will be set to radius/1e+6. + + :param Quantity radius: An astropy quantity containing the radius/radii that you wish to calculate the + mass within. + :param float conf_level: The confidence level for the entropy uncertainties, the default is 68.2% (~1σ). + :return: An astropy quantity containing the entropy/entropies, lower and upper uncertainties, and another + containing the mass realisation distribution. + :rtype: Union[Quantity, Quantity] + """ + upper = 50 + (conf_level / 2) + lower = 50 - (conf_level / 2) + + # Prints a warning if the radius at which to calculate the entropy is outside the range of the data + self.rad_check(radius) + + if radius.isscalar and radius in self._entropies: + already_run = True + ent_dist = self._entropies[radius] + else: + already_run = False + + if not already_run and self._dens_model.success and self._temp_model.success: + # This grabs gas density values from the density model, need to check whether the model is in units + # of mass or number density + if self._dens_model.y_unit.is_equivalent('1/cm^3'): + dens = self._dens_model.get_realisations(radius) + else: + dens = self._dens_model.get_realisations(radius) / (MEAN_MOL_WEIGHT*m_p) + + # We do the same for the temperature vals, again need to check the units + if self._temp_model.y_unit.is_equivalent("keV"): + temp = self._temp_model.get_realisations(radius) + else: + temp = (self._temp_model.get_realisations(radius)*k_B).to('keV') + + ent_dist = (temp / dens**(2/3)).T + + if radius.isscalar: + self._entropies[radius] = ent_dist + + elif not self._temp_model.success or not self._dens_model.success: + raise XGAFitError("One or both of the fits to the temperature model and density profiles were " + "not successful") + + ent_med = np.percentile(ent_dist, 50, axis=0) + ent_lower = ent_med - np.percentile(ent_dist, lower, axis=0) + ent_upper = np.percentile(ent_dist, upper, axis=0) - ent_med + + ent_res = Quantity(np.array([ent_med.value, ent_lower.value, ent_upper.value]), ent_dist.unit) + + if np.any(ent_res[0] < 0): + raise ValueError("A specific entropy of less than zero has been measured, which is not physical.") + + return ent_res, ent_dist + + def view_entropy_dist(self, radius: Quantity, conf_level: float = 68.2, figsize=(8, 8), + bins: Union[str, int] = 'auto', colour: str = "lightslategrey"): + """ + A method which will generate a histogram of the entropy distribution that resulted from the entropy calculation + at the supplied radius. If the entropy for the passed radius has already been measured it, and the entropy + distribution, will be retrieved from the storage of this product rather than re-calculated. + + :param Quantity radius: An astropy quantity containing the radius/radii that you wish to calculate the + entropy at. + :param float conf_level: The confidence level for the entropy uncertainties, the default is 68.2% (~1σ). + :param int/str bins: The argument to be passed to plt.hist, either a number of bins or a binning + algorithm name. + :param str colour: The desired colour of the histogram. + :param tuple figsize: The desired size of the histogram figure. + """ + if not radius.isscalar: + raise ValueError("Unfortunately this method can only display a distribution for one radius, so " + "arrays of radii are not supported.") + + # Grabbing out the mass distribution, as well as the single result that describes the entropy distribution. + ent, ent_dist = self.entropy(radius, conf_level) + # Setting up the figure + plt.figure(figsize=figsize) + ax = plt.gca() + # Includes nicer ticks + ax.tick_params(axis='both', direction='in', which='both', top=True, right=True) + # And removing the yaxis tick labels as it's just a number of values per bin + ax.yaxis.set_ticklabels([]) + + # Plot the histogram and set up labels + plt.hist(ent_dist.value, bins=bins, color=colour, alpha=0.7, density=False) + plt.xlabel(self._y_axis_name + '[' + self.values_unit.to_string('latex') + ']') + plt.title("Entropy Distribution at {}".format(radius.to_string())) + + vals_label = '$' + str(ent[0].round(2).value) + "^{+" + str(ent[2].round(2).value) + "}" + \ + "_{-" + str(ent[1].round(2).value) + "}$" + res_label = r"$K_{\rm{X}}$ = " + vals_label + '[' + self.values_unit.to_string('latex') + ']' + + # And this just plots the 'result' on the distribution as a series of vertical lines + plt.axvline(ent[0].value, color='red', label=res_label) + plt.axvline(ent[0].value-ent[1].value, color='red', linestyle='dashed') + plt.axvline(ent[0].value+ent[2].value, color='red', linestyle='dashed') + plt.legend(loc='best', prop={'size': 12}) + plt.tight_layout() + plt.show() + + @property + def temperature_profile(self) -> GasTemperature3D: + """ + A method to provide access to the 3D temperature profile used to generate this entropy profile. + + :return: The input temperature profile. + :rtype: GasTemperature3D + """ + return self._temp_prof + + @property + def density_profile(self) -> GasDensity3D: + """ + A method to provide access to the 3D density profile used to generate this entropy profile. + + :return: The input density profile. + :rtype: GasDensity3D + """ + return self._dens_prof + + @property + def temperature_model(self) -> BaseModel1D: + """ + A method to provide access to the model that was fit to the temperature profile. + + :return: The fit temperature model. + :rtype: BaseModel1D + """ + return self._temp_model + + @property + def density_model(self) -> BaseModel1D: + """ + A method to provide access to the model that was fit to the density profile. + + :return: The fit density profile. + :rtype: BaseModel1D + """ + return self._dens_model + + def rad_check(self, rad: Quantity): + """ + Very simple method that prints a warning if the radius is outside the range of data covered by the + density or temperature profiles. + + :param Quantity rad: The radius to check. + """ + if not rad.unit.is_equivalent(self.radii_unit): + raise UnitConversionError("You can only check radii in units convertible to the radius units of " + "the profile ({})".format(self.radii_unit.to_string())) + + if (self._temp_prof.annulus_bounds is not None and (rad > self._temp_prof.annulus_bounds[-1]).any()) \ + or (self._dens_prof.annulus_bounds is not None and (rad > self._dens_prof.annulus_bounds[-1]).any()): + warn("Some radii are outside the data range covered by the temperature or density profiles, as such " + "you will be extrapolating based on the model fits.", stacklevel=2) class Generic1D(BaseProfile1D): diff --git a/xga/products/relation.py b/xga/products/relation.py index a49d68ef..a24b3da1 100644 --- a/xga/products/relation.py +++ b/xga/products/relation.py @@ -1,17 +1,22 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 16/08/2021, 16:20. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 25/04/2023, 15:39. Copyright (c) The Contributors import inspect +import pickle +from copy import deepcopy from datetime import date -from typing import List +from typing import List, Union from warnings import warn import numpy as np import scipy.odr as odr +from astropy.cosmology import Cosmology from astropy.units import Quantity, Unit, UnitConversionError from cycler import cycler from getdist import plots, MCSamples +from matplotlib import cm from matplotlib import pyplot as plt +from matplotlib.colors import TABLEAU_COLORS, BASE_COLORS, Colormap, CSS4_COLORS, Normalize from matplotlib.ticker import FuncFormatter from ..models import MODEL_PUBLICATION_NAMES @@ -42,6 +47,9 @@ class ScalingRelation: inferred from an astropy Quantity. :param str y_name: The name to be used for the y-axis of the plot (DON'T include the unit, that will be inferred from an astropy Quantity. + :param float/int dim_hubb_ind: This is used to tell the ScalingRelation which power of E(z) has been applied + to the y-axis data, this can then be used by the predict method to remove the E(z) contribution from + predictions. The default is None. :param str fit_method: The method used to fit this data, if known. :param Quantity x_data: The x-data used to fit this scaling relation, if available. This should be the raw, un-normalised data. @@ -66,13 +74,24 @@ class ScalingRelation: :param np.ndarray scatter_par: A parameter describing the intrinsic scatter of y|x. Optional as many fits don't include this. :param np.ndarray scatter_chain: A corresponding MCMC chain for the scatter parameter. Optional. + :param str model_colour: This variable can be used to set the colour that the fit should be displayed in + when plotting. Setting it at definition or setting the property means that the colour doesn't have + to be set for every view method, and it will be remembered when multiple relations are viewed together. + :param np.ndarray/list point_names: The source names associated with the data points passed in to this scaling + relation, can be used for diagnostic purposes (i.e. identifying which source an outlier belongs to). + :param np.ndarray/Quantity third_dim_info: A set of data points which represent a faux third dimension. They should + not have been involved in the fitting process, and the relation should not be in three dimensions, but these + can be used to colour the data points in a view method. + :param str third_dim_name: The name of the third dimension data. """ def __init__(self, fit_pars: np.ndarray, fit_par_errs: np.ndarray, model_func, x_norm: Quantity, y_norm: Quantity, - x_name: str, y_name: str, fit_method: str = 'unknown', x_data: Quantity = None, + x_name: str, y_name: str, dim_hubb_ind=None, fit_method: str = 'unknown', x_data: Quantity = None, y_data: Quantity = None, x_err: Quantity = None, y_err: Quantity = None, x_lims: Quantity = None, odr_output: odr.Output = None, chains: np.ndarray = None, relation_name: str = None, relation_author: str = 'XGA', relation_year: str = str(date.today().year), relation_doi: str = '', - scatter_par: np.ndarray = None, scatter_chain: np.ndarray = None): + scatter_par: np.ndarray = None, scatter_chain: np.ndarray = None, model_colour: str = None, + point_names: Union[np.ndarray, list] = None, third_dim_info: Union[np.ndarray, Quantity] = None, + third_dim_name: str = None): """ The init for the ScalingRelation class, all information necessary to enable the different functions of this class will be supplied by the user here. @@ -95,6 +114,10 @@ def __init__(self, fit_pars: np.ndarray, fit_par_errs: np.ndarray, model_func, x self._x_name = x_name self._y_name = y_name + # Wanted the relation to know if it had some power of E(z) applied to the y-axis data - this is quite common + # in galaxy cluster scaling relations to account for cosmological evolution of certain parameters + self._ez_power = dim_hubb_ind + # The default fit method is 'unknown', as we may not know the method of any relation from literature, but # if the fit was performed by XGA then something more useful can be passed self._fit_method = fit_method @@ -183,6 +206,45 @@ def __init__(self, fit_pars: np.ndarray, fit_par_errs: np.ndarray, model_func, x "to this relation.") self._scatter_chain = scatter_chain + # This sets an internal colour attribute so the default plotting colour is always the one that the + # user defined + self._model_colour = model_colour + + # This checks the input for 'point_names', which can be used to associate each data point in this scaling + # relation with a source name so that outliers can be properly investigated. + if (x_data is None or y_data is None) and point_names is not None: + raise ValueError("You cannot set the 'point_names' argument if you have not passed data to " + "x_data and y_data.") + elif point_names is not None and len(point_names) != len(x_data): + raise ValueError("You have passed a 'point_names' argument that has a different number of entries ({dn}) " + "than the data given to this scaling relation ({d}).".format(dn=len(point_names), + d=len(x_data))) + else: + self._point_names = point_names + + # The user is allowed to pass information that can be used to colour the data points of a scaling relation + # when it is viewed. Here we check that, if present, the extra data are the right shape + if (x_data is None or y_data is None) and third_dim_info is not None: + raise ValueError("You cannot set the 'third_dim_info' argument if you have not passed data to " + "x_data and y_data.") + elif third_dim_info is not None and len(third_dim_info) != len(x_data): + raise ValueError("You have passed a 'third_dim_info' argument that has a different number of " + "entries ({dn}) than the data given to this scaling relation " + "({d}).".format(dn=len(third_dim_info), d=len(x_data))) + elif third_dim_info is not None and third_dim_info.ndim != 1: + raise ValueError("Only single-dimension Quantities are accepted by 'third_dim_info'.") + elif third_dim_info is not None and third_dim_name is None: + raise ValueError("If 'third_dim_info' is set, then the 'third_dim_name' argument must be as well.") + elif third_dim_info is None and third_dim_name is not None: + # If the user accidentally passed a name but no data then I will just null the name and let them carry on + # with a warning + third_dim_name = None + warn("A value was passed to 'third_dim_name' without a corresponding 'third_dim_info' " + "value, 'third_dim_name' has been set to None.", stacklevel=2) + # Setting the attributes, if we've gotten this far then there are no problems + self._third_dim_info = third_dim_info + self._third_dim_name = third_dim_name + @property def pars(self) -> np.ndarray: """ @@ -225,6 +287,18 @@ def y_name(self) -> str: """ return self._y_name + @property + def dimensionless_hubble_parameter(self) -> Union[float, int]: + """ + This property should be set on the declaration of a scaling relation, and exists to tell the relation what + power of E(z) has been applied to the y-axis data before fitting. This also helps the predict method remove + the E(z) contribution (if any) from predictions. + + :return: The power of E(z) applied to the y-axis data before fitting. Default is None. + :rtype: float/int + """ + return self._ez_power + @property def x_norm(self) -> Quantity: """ @@ -341,6 +415,17 @@ def author(self) -> str: """ return self._author + @author.setter + def author(self, new_val: str): + """ + Property setter for the author of the relation. + + :param str new_val: The new author string. + """ + if not isinstance(new_val, str): + raise TypeError('You must set the author property with a string.') + self._author = new_val + @property def year(self) -> str: """ @@ -352,6 +437,20 @@ def year(self) -> str: """ return self._year + @year.setter + def year(self, new_val: Union[int, str]): + """ + The property setter for the year related with a particular scaling relation. + + :param int/str new_val: The new value for the year of the relation, either an integer year that can be + converted to a string, or a string representing a year. + """ + if type(new_val) != int and type(new_val) != str: + raise TypeError('You must set the year property with an integer or string.') + elif type(new_val) == int: + new_val = str(new_val) + self._year = new_val + @property def doi(self) -> str: """ @@ -363,6 +462,18 @@ def doi(self) -> str: """ return self._doi + @doi.setter + def doi(self, new_val: str): + """ + The property setter for the DOI of the work related with the relation. + + :param str new_val: The new value of the doi. + """ + if not isinstance(new_val, str): + raise TypeError("You must set the doi property with a string.") + + self._doi = new_val + @property def scatter_par(self) -> np.ndarray: """ @@ -404,12 +515,88 @@ def par_names(self) -> List: """ return self._par_names - def view_chains(self, figsize: tuple = None): + @property + def model_colour(self) -> str: + """ + Property getter for the model colour assigned to this relation. If it wasn't set at definition or set + via the property setter then it defaults to 'tab:gray'. + + :return: The currently set model colour. If one wasn't set on definition then we default to tab:gray. + :rtype: str + """ + if self._model_colour is not None: + return self._model_colour + else: + return 'tab:gray' + + @model_colour.setter + def model_colour(self, new_colour: str): + """ + Property setter for the model colour attribute, which controls the colour used in plots for the fit + of this relation. New colours are checked against matplotlibs list of named colours. + + :param str new_colour: The new matplotlib colour. + """ + all_col = list(TABLEAU_COLORS.keys()) + list(CSS4_COLORS.keys()) + list(BASE_COLORS.keys()) + if new_colour not in all_col: + all_names = ', '.join(all_col) + raise ValueError("{c} is not a named matplotlib colour, please use one of the " + "following; {cn}".format(c=new_colour, cn=all_names)) + else: + self._model_colour = new_colour + + @property + def point_names(self) -> Union[np.ndarray, None]: + """ + Returns an array of point names, with one entry per data point, and in the same order (unless the user passes + a differently ordered name array than data array, there is no way we can detect that). + + :return: The names associated with the data points, if supplied on initialization. The default is None. + :rtype: np.ndarray/None + """ + if isinstance(self._point_names, list): + return np.ndarray(self._point_names) + else: + return self._point_names + + @property + def third_dimension_data(self) -> Union[Quantity, None]: + """ + Returns a Quantity containing a third dimension of data associated with the data points (this can be used to + colour the points in the view method), with one entry per data point, and in the same order (unless the + user passes a differently ordered name array than data array, there is no way we can detect that). + + :return: The third dimension data associated with the data points, if supplied on initialization. The + default is None. + :rtype: Quantity/None + """ + if isinstance(self._third_dim_info, (list, np.ndarray)): + return Quantity(self._third_dim_info) + else: + return self._third_dim_info + + @property + def third_dimension_name(self) -> Union[str, None]: + """ + Returns the name of the third data dimension passed to this relation on initialization. + + :return: The name of the third dimension, if supplied on initialization. The default is None. + :rtype: Quantity/None + """ + return self._third_dim_name + + def view_chains(self, figsize: tuple = None, colour: str = None): """ Simple view method to quickly look at the MCMC chains for a scaling relation fit. :param tuple figsize: Desired size of the figure, if None will be set automatically. + :param str colour: The colour that the chains should be in the plot. Default is None in which case + the value of the model_colour property of the relation is used. """ + # If the colour is None then we fetch the model colour property + if colour is None: + colour = self.model_colour + if self._chains is None: raise ValueError('No chains are available for this scaling relation') @@ -425,14 +612,14 @@ def view_chains(self, figsize: tuple = None): # Now we iterate through the parameters and plot their chains for i in range(len(self._fit_pars)): ax = axes[i] - ax.plot(self._chains[:, i], "k", alpha=0.5) + ax.plot(self._chains[:, i], colour, alpha=0.7) ax.set_xlim(0, self._chains.shape[0]) ax.set_ylabel(self._par_names[i]) ax.yaxis.set_label_coords(-0.1, 0.5) if num_ch > len(self._fit_pars): ax = axes[-1] - ax.plot(self._scatter_chain, "k", alpha=0.5) + ax.plot(self._scatter_chain, colour, alpha=0.7) ax.set_xlim(0, len(self._scatter_chain)) ax.set_ylabel(r'$\sigma$') ax.yaxis.set_label_coords(-0.1, 0.5) @@ -441,7 +628,7 @@ def view_chains(self, figsize: tuple = None): plt.show() def view_corner(self, figsize: tuple = (10, 10), cust_par_names: List[str] = None, - colour: str = 'tab:gray', save_path: str = None): + colour: str = None, save_path: str = None): """ A convenient view method to examine the corner plot of the parameter posterior distributions. @@ -449,10 +636,14 @@ def view_corner(self, figsize: tuple = (10, 10), cust_par_names: List[str] = Non :param List[str] cust_par_names: A list of custom parameter names. If the names include LaTeX code do not include $$ math environment symbols - you may also need to pass a string literal (e.g. r"\sigma"). Do not include an entry for a scatter parameter. - :param List[str] colour: Colour for the contours, the default is tab:gray. + :param List[str] colour: Colour for the contours. Default is None in which case the value of the + model_colour property of the relation is used. :param str save_path: The path where the figure produced by this method should be saved. Default is None, in which case the figure will not be saved. """ + # If the colour is None then we fetch the model colour property + if colour is None: + colour = self.model_colour # Checks whether custom parameter names were passed, and if they were it checks whether there are the right # number @@ -461,7 +652,7 @@ def view_corner(self, figsize: tuple = (10, 10), cust_par_names: List[str] = Non elif cust_par_names is not None and len(cust_par_names) != len(self._par_names): raise ValueError("cust_par_names must have one entry per parameter of the scaling relation model.") else: - par_names = self._par_names + par_names = deepcopy(self._par_names) if self._chains is None: raise ValueError('No chains are available for this scaling relation') @@ -485,13 +676,20 @@ def view_corner(self, figsize: tuple = (10, 10), cust_par_names: List[str] = Non plt.show() - def predict(self, x_values: Quantity) -> Quantity: + def predict(self, x_values: Quantity, redshift: Union[float, np.ndarray] = None, + cosmo: Cosmology = None) -> Quantity: """ This method allows for the prediction of y values from this scaling relation, you just need to pass in an - appropriate set of x values. + appropriate set of x values. If a power of E(z) was applied to the y-axis data before fitting, and that + information was passed on declaration (using 'dim_hubb_ind'), then a redshift and cosmology are required + to remove out the E(z) contribution. :param Quantity x_values: The x values to predict y values for. - :return: The predicted y values + :param float/np.ndarray redshift: The redshift(s) of the objects for which we wish to predict values. This is + only necessary if the 'dim_hubb_ind' argument was set on declaration. Default is None. + :param Cosmology cosmo: The cosmology in which we wish to predict values. This is only necessary if the + 'dim_hubb_ind' argument was set on declaration. Default is None. + :return: The predicted y values. :rtype: Quantity """ # Got to check that people aren't passing any nonsense x quantities in @@ -506,6 +704,17 @@ def predict(self, x_values: Quantity) -> Quantity: warn("Some of the x values you have passed are outside the validity range of this relation " "({l}-{h}{u}).".format(l=self.x_lims[0].value, h=self.x_lims[1].value, u=self.x_unit.to_string())) + # Need to check if any power of E(z) was applied to the y-axis data before fitting, if so (and no + # cosmo/redshift was passed) then it's time to throw an error. + if (redshift is None or cosmo is None) and self._ez_power is not None: + raise ValueError("A power of E(z) was applied to the y-axis data before fitting, as such you must pass" + " redshift and cosmology information to this predict method.") + elif self._ez_power is not None and isinstance(redshift, float) and not x_values.isscalar: + raise ValueError("You must supply one redshift for every entry in x_values.") + elif self._ez_power is not None and isinstance(redshift, np.ndarray) and len(x_values) != len(redshift): + raise ValueError("The x_values argument has {x} entries, and the redshift argument has {z} entries; " + "please supply one redshift per x_value.".format(x=len(x_values), z=len(redshift))) + # Units that are convertible to the x-units of this relation are allowed, so we make sure we convert # to the exact units the fit was done in. This includes dividing by the x_norm value x_values = x_values.to(self.x_unit) / self.x_norm @@ -513,13 +722,19 @@ def predict(self, x_values: Quantity) -> Quantity: # the y normalisation predicted_y = self._model_func(x_values.value, *self.pars[:, 0]) * self.y_norm + # If there was a power of E(z) applied to the data, we undo it for the prediction. + if self._ez_power is not None: + predicted_y /= (cosmo.efunc(redshift)**self._ez_power) + return predicted_y def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str = None, figsize: tuple = (10, 8), - data_colour: str = 'black', model_colour: str = 'grey', grid_on: bool = False, conf_level: int = 90, + data_colour: str = 'black', model_colour: str = None, grid_on: bool = False, conf_level: int = 90, custom_x_label: str = None, custom_y_label: str = None, fontsize: float = 15, legend_fontsize: float = 13, x_ticks: list = None, x_minor_ticks: list = None, y_ticks: list = None, y_minor_ticks: list = None, - save_path: str = None): + save_path: str = None, label_points: bool = False, point_label_colour: str = 'black', + point_label_size: int = 10, point_label_offset: tuple = (0.01, 0.01), show_third_dim: bool = True, + third_dim_cmap: Union[str, Colormap] = 'plasma'): """ A method that produces a high quality plot of this scaling relation (including the data it is based upon, if available). @@ -530,7 +745,8 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str :param str plot_title: A custom title to be used for the plot, otherwise one will be generated automatically. :param tuple figsize: A custom figure size for the plot, default is (8, 8). :param str data_colour: The colour to use for the data points in the plot, default is black. - :param str model_colour: The colour to use for the model in the plot, default is grey. + :param str model_colour: The colour to use for the model in the plot. Default is None in which case + the value of the model_colour property of the relation is used. :param bool grid_on: If True then a grid will be included on the plot. Default is True. :param int conf_level: The confidence level to use when plotting the model. :param str custom_x_label: Passing a string to this variable will override the x axis label @@ -549,6 +765,20 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str None in which case they are determined automatically. :param str save_path: The path where the figure produced by this method should be saved. Default is None, in which case the figure will not be saved. + :param bool label_points: If True, and source name information for each point was passed on the declaration of + this scaling relation, then points will be accompanied by an index that can be used with the 'point_names' + property to retrieve the source name for a point. Default is False. + :param str point_label_colour: The colour of the label text. + :param int point_label_size: The fontsize of the label text. + :param bool show_third_dim: Colour the data points by the third dimension data passed in on creation of this + scaling relation, with a colour bar to communicate values. Only possible if data were passed to + 'third_dim_info' on initialization. Default is False. + :param str/Colormap third_dim_cmap: The colour map which should be used for the third dimension data points. + A matplotlib colour map name or a colour map object may be passed. Default is 'plasma'. This essentially + overwrites the 'data_colour' argument if show_third_dim is True. + :param Tuple[float, float] point_label_offset: A fractional offset (in display coordinates) applied to the + data point coordinates to determine the location a label should be added. You can use this to fine-tune + the label positions relative to their data point. """ # First we check that the passed axis limits are in appropriate units, if they weren't supplied then we check # if any were supplied at initialisation, if that isn't the case then we make our own from the data, and @@ -569,6 +799,10 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str elif x_lims is None and len(self._x_data) == 0: raise ValueError('There is no data available to infer suitable axis limits from, please pass x limits.') + # Just grabs the model colour from the property if the user doesn't set a value for model_colour + if model_colour is None: + model_colour = self.model_colour + # Setting up the matplotlib figure fig = plt.figure(figsize=figsize) fig.tight_layout() @@ -586,10 +820,66 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str ax.minorticks_on() ax.tick_params(axis='both', direction='in', which='both', top=True, right=True) + # We check to see a) whether the user wants a third dimension of data communicated via the colour of the + # data, and b) if they actually passed the data necessary to make that happen. If there is no data but they + # have set show_third_dim=True, we set it back to False and give a warning + if show_third_dim and self.third_dimension_data is None: + warn("The 'show_third_dim' argument should only be set to True if 'third_dim_info' was set on the creation " + "of this scaling relation. Setting 'show_third_im' to False.") + show_third_dim = False + # Plot the data with uncertainties, if any data is present in this scaling relation. - if len(self.x_data) != 0: + if len(self.x_data) != 0 and not show_third_dim: ax.errorbar(self._x_data.value, self._y_data.value, xerr=self._x_err.value, yerr=self._y_err.value, fmt="x", color=data_colour, capsize=2) + elif len(self.x_data) != 0 and show_third_dim: + # The user can either set the cmap with a string name, or actually pass a colormap object + if isinstance(third_dim_cmap, str): + cmap = cm.get_cmap(third_dim_cmap) + else: + cmap = third_dim_cmap + # We want to normalise this colourmap to our specific data range + norm = Normalize(vmin=self.third_dimension_data.value.min(), vmax=self.third_dimension_data.value.max()) + # Now this mapper can be constructed so that we can take that information about the cmap and normalisation + # and use it with our data to calculate colours + cmap_mapper = cm.ScalarMappable(norm=norm, cmap=cmap) + # This calculates the colours + colours = cmap_mapper.to_rgba(self.third_dimension_data.value) + # I didn't really want to do this but errorbar calls plot (rather than scatter) so it will only do one + # colour at a time. + for c_ind, col in enumerate(colours): + ax.errorbar(self._x_data[c_ind].value, self._y_data[c_ind].value, xerr=self._x_err[c_ind].value, + yerr=self._y_err[c_ind].value, fmt="x", c=colours[c_ind, :], capsize=2) + + # This will check a) if the scaling relation knows the source names associated with the points, and b) if the + # user wants us to label them + if self.point_names is not None and label_points: + # If both those conditions are satisfied then we will start to iterate through the data points + for ind in range(len(self.point_names)): + # These are the current points being read out to help position the overlaid text + cur_x = self._x_data[ind].value + cur_y = self._y_data[ind].value + # Then we check to make sure neither coord is None, and add the index (which is used as the short + # ID for these points) to the axes - the user can then look at the number and use that to retrieve + # the name. + if not np.isnan(cur_x) and not np.isnan(cur_y): + # This measures the x_size of the plot in display coordinates (TRANSFORMED from data, thus avoiding + # any issues with the scaling of the axis) + x_size = ax.transData.transform((x_lims[1], 0))[0] - ax.transData.transform((x_lims[0], 0))[0] + # This does the same thing with the y-data + y_dat_lims = ax.get_ylim() + y_size = ax.transData.transform((0, y_dat_lims[1]))[1] - \ + ax.transData.transform((0, y_dat_lims[0]))[1] + # Then we convert the current data coordinate into display coordinate system + cur_fig_coord = ax.transData.transform((cur_x, cur_y)) + # And make a label coordinate by offsetting the x and y data coordinate by some fraction of the + # overall size of the axis, in display coordinates, with the final coordinate transformed back + # to data coordinates. + inv_tran = ax.transData.inverted() + lab_data_coord = inv_tran.transform((cur_fig_coord[0]+(point_label_offset[0]*x_size), + cur_fig_coord[1]+(point_label_offset[0]*y_size))) + plt.text(lab_data_coord[0], lab_data_coord[1], str(ind), fontsize=point_label_size, + color=point_label_colour) # Need to randomly sample from the fitted model num_rand = 10000 @@ -633,9 +923,9 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str # Dimensionless quantities can be fitted too, and this make the axis label look nicer by not having empty # square brackets - if x_unit == r"$\left[\\mathrm{}\right]$": + if x_unit == r"$\left[\\mathrm{}\right]$" or x_unit == r'$\left[\mathrm{}\right]$': x_unit = '' - if y_unit == r"$\left[\\mathrm{}\right]$": + if y_unit == r"$\left[\\mathrm{}\right]$" or y_unit == r'$\left[\mathrm{}\right]$': y_unit = '' # The scaling relation object knows what its x and y axes are called, though the user may pass @@ -705,6 +995,15 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str ax.set_xticks(y_minor_ticks, minor=True) ax.set_xticklabels(y_minor_ticks, minor=True) + # If we did colour the data by a third dimension then we should add a colour-bar to the relation + if show_third_dim: + cbar = plt.colorbar(cmap_mapper) + if self.third_dimension_data.unit.is_equivalent(''): + cbar_lab = self.third_dimension_name + else: + cbar_lab = self.third_dimension_name + ' [' + self.third_dimension_data.unit.to_string('latex') + ']' + cbar.ax.set_ylabel(cbar_lab, fontsize=fontsize) + plt.legend(loc="best", fontsize=legend_fontsize) plt.tight_layout() @@ -714,6 +1013,19 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str plt.show() + def save(self, save_path: str): + """ + This method pickles and saves the scaling relation object. The save file is a pickled version of this object. + + :param str save_path: The path where this relation should be saved. + """ + # if '/' in save_path and not os.path.exists('/'.join(save_path.split('/')[:-1])): + # raise FileNotFoundError('The path before your file name does not seem to exist.') + + # Pickles and saves this ScalingRelation instance. + with open(save_path, 'wb') as picklo: + pickle.dump(self, picklo) + def __add__(self, other): to_combine = [self] if type(other) == list: @@ -833,9 +1145,23 @@ def view_corner(self, figsize: tuple = (10, 10), cust_par_names: List[str] = Non elif len(par_names) != 1: raise ValueError('Not all scaling relations have the same model parameter names, cannot view aggregate' ' corner plot.') - elif len(contour_colours) != len(self._relations): + elif contour_colours is not None and len(contour_colours) != len(self._relations): raise ValueError("If you pass a list of contour colours, there must be one entry per scaling relation.") + # This draws the colours from the model_colour parameters of the various relations, but only if each + # has a unique colour + if contour_colours is None: + # Use a set to check for duplicate colours, they are not allowed. This is primarily to catch + # instances where the model_colour property has not been set for all the relations, as then all + # the colours would be grey + all_rel_cols = list(set([r.model_colour for r in self._relations])) + # If there are N unique colours for N relations, then we'll use those colours, otherwise matplotlib + # can choose whatever colours it likes + if len(all_rel_cols) == len(self._relations): + # Don't use the all_rel_cols variable here is it is inherently unordered as a set() was used + # in its construction. + contour_colours = [r.model_colour for r in self._relations] + # The number of non-scatter parameters in the scaling relation models num_pars = len(self._relations[0].par_names) @@ -879,7 +1205,8 @@ def view_corner(self, figsize: tuple = (10, 10), cust_par_names: List[str] = Non def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str = None, figsize: tuple = (10, 8), colour_list: list = None, grid_on: bool = False, conf_level: int = 90, show_data: bool = True, fontsize: float = 15, legend_fontsize: float = 13, x_ticks: list = None, x_minor_ticks: list = None, - y_ticks: list = None, y_minor_ticks: list = None, save_path: str = None): + y_ticks: list = None, y_minor_ticks: list = None, save_path: str = None, data_colour_list: list = None, + data_shape_list: list = None, custom_x_label: str = None, custom_y_label: str = None): """ A method that produces a high quality plot of the component scaling relations in this AggregateScalingRelation. @@ -906,15 +1233,44 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str None in which case they are determined automatically. :param str save_path: The path where the figure produced by this method should be saved. Default is None, in which case the figure will not be saved. + :param list data_colour_list: A list of matplotlib colours to use as a colour cycle specifically for + data points. This should be used when you want data points to be a different colour to their model. + :param list data_shape_list: A list of matplotlib format shapes, to manually set the shapes of plotted + data points. + :param str custom_x_label: Passing a string to this variable will override the x-axis label of this + plot, including the unit string. + :param str custom_y_label: Passing a string to this variable will override the y-axis label of this + plot, including the unit string. """ # Very large chunks of this are almost direct copies of the view method of ScalingRelation, but this - # was the easiest way of setting this up so I think the duplication is justified. - - # Set up the colour cycle - if colour_list is None: + # was the easiest way of setting this up, so I think the duplication is justified. + + # Grabs the colours that may have been set for each relation, uses a set to check that there are + # no duplicates + set_mod_cols = list(set([r.model_colour for r in self._relations])) + # Set up the colour cycle, if the user hasn't passed a colour list we'll try to use colours set for the + # individual relations, but if they haven't all been set then we'll use the predefined colour cycle + if colour_list is None and len(set_mod_cols) == len(self._relations): + # Don't use the set_mod_cols variable as its unordered due to the use of set + colour_list = [r.model_colour for r in self._relations] + elif colour_list is None and len(set_mod_cols) != len(self._relations): colour_list = PRETTY_COLOUR_CYCLE new_col_cycle = cycler(color=colour_list) + # If the user didn't pass their own list of DATA colours to use, then they will be the same as the + # model colours. + if data_colour_list is None: + data_colour_list = deepcopy(colour_list) + elif data_colour_list is not None and len(data_colour_list) != len(self._relations): + raise ValueError('If a data_colour_list is passed, then it must have the same number of entries as there' + ' are relations.') + + if data_shape_list is None: + data_shape_list = ['x' for i in range(0, len(self._relations))] + elif data_shape_list is not None and len(data_shape_list) != len(self._relations): + raise ValueError('If a data_shape_list is passed, then it must have the same number of entries as there' + ' are relations.') + # This part decides the x_lims of the plot, much the same as in the ScalingRelation view but it works # on a combined sets of x-data or combined built in validity ranges, though user limits passed to view # will still override everything else @@ -965,17 +1321,19 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str ax.minorticks_on() ax.tick_params(axis='both', direction='in', which='both', top=True, right=True) - for rel in self._relations: + for rel_ind, rel in enumerate(self._relations): # This is a horrifying bodge, but I do just want the colour out and I can't be bothered to figure out # how to use the colour cycle object properly if len(rel.x_data.value[:, 0]) == 0 or not show_data: # Sets up a null error bar instance for the colour basically - d_out = ax.errorbar(None, None, xerr=None, yerr=None, fmt="x", capsize=2, label='') + d_out = ax.errorbar(None, None, xerr=None, yerr=None, fmt=data_shape_list[rel_ind], capsize=2, label='', + color=data_colour_list[rel_ind]) else: d_out = ax.errorbar(rel.x_data.value[:, 0], rel.y_data.value[:, 0], xerr=rel.x_data.value[:, 1], - yerr=rel.y_data.value[:, 1], fmt="x", capsize=2) + yerr=rel.y_data.value[:, 1], fmt=data_shape_list[rel_ind], capsize=2, + color=data_colour_list[rel_ind], alpha=0.7) - d_colour = d_out[0].get_color() + m_colour = colour_list[rel_ind] # Need to randomly sample from the fitted model num_rand = 10000 @@ -1006,12 +1364,12 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str else: relation_label = rel.name + ' Scaling Relation' plt.plot(model_x * rel.x_norm.value, rel.model_func(model_x, *model_pars[0, :]) * rel.y_norm.value, - color=d_colour, label=relation_label) + color=m_colour, label=relation_label) - plt.plot(model_x * rel.x_norm.value, model_upper, color=d_colour, linestyle="--") - plt.plot(model_x * rel.x_norm.value, model_lower, color=d_colour, linestyle="--") + plt.plot(model_x * rel.x_norm.value, model_upper, color=m_colour, linestyle="--") + plt.plot(model_x * rel.x_norm.value, model_lower, color=m_colour, linestyle="--") ax.fill_between(model_x * rel.x_norm.value, model_lower, model_upper, where=model_upper >= model_lower, - facecolor=d_colour, alpha=0.6, interpolate=True) + facecolor=m_colour, alpha=0.6, interpolate=True) # I can dynamically grab the units in LaTeX formatting from the Quantity objects (thank you astropy) # However I've noticed specific instances where the units can be made prettier @@ -1021,14 +1379,24 @@ def view(self, x_lims: Quantity = None, log_scale: bool = True, plot_title: str # Dimensionless quantities can be fitted too, and this make the axis label look nicer by not having empty # square brackets - if x_unit == r"$\left[\\mathrm{}\right]$": + if x_unit == r"$\left[\\mathrm{}\right]$" or x_unit == r'$\left[\mathrm{}\right]$': x_unit = '' - if y_unit == r"$\left[\\mathrm{}\right]$": + if y_unit == r"$\left[\\mathrm{}\right]$" or y_unit == r'$\left[\mathrm{}\right]$': y_unit = '' - # The scaling relation object knows what its x and y axes are called - plt.xlabel("{xn} {un}".format(xn=self._x_name, un=x_unit), fontsize=fontsize) - plt.ylabel("{yn} {un}".format(yn=self._y_name, un=y_unit), fontsize=fontsize) + # The user is allowed to define their own x and y axis labels if they want, otherwise we construct it + # from the relations in this aggregate scaling relation. + if custom_x_label is None: + # The scaling relation object knows what its x-axis is called + plt.xlabel("{xn} {un}".format(xn=self._x_name, un=x_unit), fontsize=fontsize) + else: + plt.xlabel(custom_x_label, fontsize=fontsize) + + if custom_y_label is None: + # The scaling relation object knows what its y-axis is called + plt.ylabel("{yn} {un}".format(yn=self._y_name, un=y_unit), fontsize=fontsize) + else: + plt.ylabel(custom_y_label, fontsize=fontsize) # The user can also pass a plot title, but if they don't then I construct one automatically if plot_title is None: diff --git a/xga/products/spec.py b/xga/products/spec.py index a07e65b2..c6107b3c 100644 --- a/xga/products/spec.py +++ b/xga/products/spec.py @@ -241,6 +241,18 @@ def _update_spec_headers(self, which_spec: str): if which_spec == "main" and self.usable: # Currently having to use astropy's fits interface, I don't really want to because of risk of segfaults with fits.open(self._path, mode='update') as spec_fits: + # I delete the headers first, as I've found issues with XSPEC not being able to read the + # path if the SAS version I'm using adds entries for the headers before I do. See issue #745 + # Do have to check that the offending headers are actually present, as they won't be introduced by + # all versions of SAS + if "RESPFILE" in spec_fits["SPECTRUM"].header: + del spec_fits["SPECTRUM"].header["RESPFILE"] + if "ANCRFILE" in spec_fits["SPECTRUM"].header: + del spec_fits["SPECTRUM"].header["ANCRFILE"] + if "BACKFILE" in spec_fits["SPECTRUM"].header: + del spec_fits["SPECTRUM"].header["BACKFILE"] + + # This writes the new response file paths to the headers. spec_fits["SPECTRUM"].header["RESPFILE"] = self._rmf spec_fits["SPECTRUM"].header["ANCRFILE"] = self._arf spec_fits["SPECTRUM"].header["BACKFILE"] = self._back_spec @@ -248,8 +260,12 @@ def _update_spec_headers(self, which_spec: str): elif which_spec == "back" and self.usable: with fits.open(self._back_spec, mode='update') as spec_fits: if self._back_rmf is not None: + if 'RESPFILE' in spec_fits["SPECTRUM"].header: + del spec_fits["SPECTRUM"].header["RESPFILE"] spec_fits["SPECTRUM"].header["RESPFILE"] = self._back_rmf if self._back_arf is not None: + if 'ANCRFILE' in spec_fits["SPECTRUM"].header: + del spec_fits["SPECTRUM"].header["ANCRFILE"] spec_fits["SPECTRUM"].header["ANCRFILE"] = self._back_arf def _read_on_demand(self, src_spec: bool = True): @@ -1825,7 +1841,14 @@ def __init__(self, spectra: List[Spectrum]): # Here I run through all the spectra and access their annulus_ident property, that way we can determine how # many annuli there are and start storing spectra appropriately - self._num_ann = len(set([s.annulus_ident for s in spectra])) + uniq_ann_ids = list(set([s.annulus_ident for s in spectra])) + if min(uniq_ann_ids) != 0 or max(uniq_ann_ids) != (len(uniq_ann_ids) - 1): + raise ValueError("Some expected annulus IDs are missing from the spectra passed to this AnnularSpectra. " + "Spectra with IDs {p} have been " + "passed.".format(p=', '.join([str(i) for i in uniq_ann_ids]))) + # Now we've made certain that the input annuli IDs are making sense, we can just use the length of the + # list of unique IDs to set the number of annuli + self._num_ann = len(uniq_ann_ids) # While the official ObsID and Instrument of this product are 'combined', I do still # want to know which ObsIDs and instruments the spectra belong to @@ -2112,7 +2135,6 @@ def all_spectra(self) -> List[Spectrum]: ann_spec = self.get_spectra(ann_i) if isinstance(ann_spec, Spectrum): ann_spec = [ann_spec] - all_spec += ann_spec return all_spec diff --git a/xga/relations/__init__.py b/xga/relations/__init__.py index e2c6075a..3ba63a78 100644 --- a/xga/relations/__init__.py +++ b/xga/relations/__init__.py @@ -1,3 +1,3 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 03/12/2020, 14:21. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors diff --git a/xga/relations/clusters/LT.py b/xga/relations/clusters/LT.py index 7e6f7747..a2fe8150 100644 --- a/xga/relations/clusters/LT.py +++ b/xga/relations/clusters/LT.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 10/01/2021, 16:51. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 17/04/2023, 21:00. Copyright (c) The Contributors import numpy as np from astropy.units import Quantity @@ -13,17 +13,17 @@ xcs_sdss_r500_52 = ScalingRelation(np.array([2.51, 0.97]), np.array([0.11, 0.06]), power_law, Quantity(4, 'keV'), Quantity(0.8e+44, 'erg / s'), r"T$_{\rm{x},500}$", - r"E(z)$^{-1}$L$_{\rm{x},500,0.5-2.0}$", relation_author='Giles et al.', - relation_year='In Prep', relation_doi='', + r"E(z)$^{-1}$L$_{\rm{x},500,0.5-2.0}$", x_lims=Quantity([1, 12], 'keV'), relation_name=r'SDSSRM-XCS$_{T_{\rm{x}},vol}$ 0.5-2.0keV', - x_lims=Quantity([1, 12], 'keV')) + relation_author='Giles et al.', relation_year='In Prep', relation_doi='', + dim_hubb_ind=-1) xcs_sdss_r500_bol = ScalingRelation(np.array([2.94, 3.06]), np.array([0.12, 0.18]), power_law, Quantity(4, 'keV'), Quantity(0.8e+44, 'erg / s'), r"T$_{\rm{x},500}$", - r"E(z)$^{-1}$L$_{\rm{x},500,bol}$", - relation_author='Giles et al.', relation_year='In Prep', relation_doi='', + r"E(z)$^{-1}$L$_{\rm{x},500,bol}$", x_lims=Quantity([1, 12], 'keV'), relation_name=r'SDSSRM-XCS$_{T_{\rm{x}},vol}$ Bolometric', - x_lims=Quantity([1, 12], 'keV')) + relation_author='Giles et al.', relation_year='In Prep', relation_doi='', + dim_hubb_ind=-1) diff --git "a/xga/relations/clusters/L\316\273.py" "b/xga/relations/clusters/L\316\273.py" index 25b82522..4b2e3cfc 100644 --- "a/xga/relations/clusters/L\316\273.py" +++ "b/xga/relations/clusters/L\316\273.py" @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 10/01/2021, 16:51. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 17/04/2023, 21:02. Copyright (c) The Contributors import numpy as np from astropy.units import Quantity @@ -9,9 +9,9 @@ xcs_sdss_r500_52 = ScalingRelation(np.array([1.67, 0.96]), np.array([0.13, 0.08]), power_law, Quantity(60), Quantity(0.8e+44, 'erg / s'), r"$\lambda$", r"E(z)$^{-1}$L$_{\rm{x},500,0.5-2.0}$", + x_lims=Quantity([20, 220]), relation_name='SDSSRM-XCS$_{T_{x},vol}$ 0.5-2.0keV', relation_author='Giles et al.', relation_year='In Prep', relation_doi='', - relation_name='SDSSRM-XCS$_{T_{x},vol}$ 0.5-2.0keV', - x_lims=Quantity([20, 220])) + dim_hubb_ind=-1) diff --git a/xga/relations/clusters/MT.py b/xga/relations/clusters/MT.py index 2431537d..b72f88ed 100644 --- a/xga/relations/clusters/MT.py +++ b/xga/relations/clusters/MT.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 25/03/2021, 18:39. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 17/04/2023, 21:04. Copyright (c) The Contributors import numpy as np from astropy.units import Quantity @@ -10,43 +10,43 @@ # These are from the classic M-T relation published by Arnaud arnaud_m200 = ScalingRelation(np.array([1.72, 5.34]), np.array([0.10, 0.22]), power_law, Quantity(5, 'keV'), Quantity(1e+14, 'solMass'), r"T$_{\rm{x}}$", "E(z)M$_{200}$", + x_lims=Quantity([1, 12], 'keV'), relation_name='Hydrostatic Mass-Temperature', relation_author='Arnaud et al.', relation_year='2005', - relation_doi='10.1051/0004-6361:20052856', - relation_name='Hydrostatic Mass-Temperature', x_lims=Quantity([1, 12], 'keV')) + relation_doi='10.1051/0004-6361:20052856', dim_hubb_ind=1) arnaud_m500 = ScalingRelation(np.array([1.71, 3.84]), np.array([0.09, 0.14]), power_law, Quantity(5, 'keV'), Quantity(1e+14, 'solMass'), r"T$_{\rm{x}}$", "E(z)M$_{500}$", + x_lims=Quantity([1, 12], 'keV'), relation_name='Hydrostatic Mass-Temperature', relation_author='Arnaud et al.', relation_year='2005', - relation_doi='10.1051/0004-6361:20052856', - relation_name='Hydrostatic Mass-Temperature', x_lims=Quantity([1, 12], 'keV')) + relation_doi='10.1051/0004-6361:20052856', dim_hubb_ind=1) arnaud_m2500 = ScalingRelation(np.array([1.70, 1.69]), np.array([0.07, 0.05]), power_law, Quantity(5, 'keV'), Quantity(1e+14, 'solMass'), r"T$_{\rm{x}}$", "E(z)M$_{2500}$", + x_lims=Quantity([1, 12], 'keV'), relation_name='Hydrostatic Mass-Temperature', relation_author='Arnaud et al.', relation_year='2005', - relation_doi='10.1051/0004-6361:20052856', - relation_name='Hydrostatic Mass-Temperature', x_lims=Quantity([1, 12], 'keV')) + relation_doi='10.1051/0004-6361:20052856', dim_hubb_ind=1) # These are the XXL weak-lensing mass to temperature scaling relation(s, if I can be bothered to put more than # one of them). I've averaged the parameter errors # TODO This doesn't seem to be working, chat to Paul - THE ERROR ON THE SECOND PARAMETER IS DEFINITELY WRONG xxl_m500 = ScalingRelation(np.array([1.78, 3.63]), np.array([0.345, 0.165]), power_law, Quantity(1, 'keV'), Quantity(1e+13, 'solMass'), r"T$_{\rm{x},300kpc}$", "E(z)$^{-1}$M$_{500}$", + x_lims=Quantity([1, 12], 'keV'), relation_name='Weak Lensing Mass-Temperature', relation_author='Lieu et al.', relation_year='2016', - relation_doi='10.1051/0004-6361/201526883', - relation_name='Weak Lensing Mass-Temperature', x_lims=Quantity([1, 12], 'keV')) + relation_doi='10.1051/0004-6361/201526883', dim_hubb_ind=-1) # TODO SECOND PARAMETER ERROR ALSO WRONG HERE, I raised 10 to the power of the intercept, but not sure what # to do with the errors - THIS MAY BE BAD xxl_cosmos_cccp_m500 = ScalingRelation(np.array([1.67, 3.72]), np.array([0.12, 0.09]), power_law, Quantity(1, 'keV'), Quantity(1e+13, 'solMass'), r"T$_{\rm{x},300kpc}$", "E(z)$^{-1}$M$_{500}$", + x_lims=Quantity([1, 12], 'keV'), relation_name='Weak Lensing Mass-Temperature', relation_author='Lieu et al.', relation_year='2016', - relation_doi='10.1051/0004-6361/201526883', - relation_name='Weak Lensing Mass-Temperature', x_lims=Quantity([1, 12], 'keV')) + relation_doi='10.1051/0004-6361/201526883', dim_hubb_ind=-1) # TODO SAME PROBLEM HERE arnaud_gm500 = ScalingRelation(np.array([2.10, 4.48]), np.array([0.05, 0.01]), power_law, Quantity(5, 'keV'), Quantity(1e+13, 'solMass'), r"T$_{\rm{x}}$", "E(z)$^{-1}$M$_{g,500}$", + x_lims=Quantity([1, 12], 'keV'), relation_name='Gas Mass-Temperature', relation_author='Arnaud et al.', relation_year='2007', - relation_doi='10.1051/0004-6361:20078541', - relation_name='Gas Mass-Temperature', x_lims=Quantity([1, 12], 'keV')) + relation_doi='10.1051/0004-6361:20078541', dim_hubb_ind=-1) diff --git "a/xga/relations/clusters/M\316\273.py" "b/xga/relations/clusters/M\316\273.py" index f72fd038..e47ff9f1 100644 --- "a/xga/relations/clusters/M\316\273.py" +++ "b/xga/relations/clusters/M\316\273.py" @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 11/12/2020, 16:41. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors diff --git a/xga/relations/clusters/RT.py b/xga/relations/clusters/RT.py index 99294d6f..0d87f96c 100644 --- a/xga/relations/clusters/RT.py +++ b/xga/relations/clusters/RT.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 25/02/2021, 13:52. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 17/04/2023, 21:04. Copyright (c) The Contributors import numpy as np from astropy.units import Quantity @@ -9,41 +9,36 @@ # These are from the classic M-T relation paper published by Arnaud, as R-T relations are a byproduct of M-T relations arnaud_r200 = ScalingRelation(np.array([0.57, 1674]), np.array([0.02, 23]), power_law, Quantity(5, 'keV'), - Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{200}$", - relation_author='Arnaud et al.', relation_year='2005', - relation_doi='10.1051/0004-6361:20052856', - relation_name=r'R$_{200}$-Temperature', x_lims=Quantity([1, 12], 'keV')) + Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{200}$", x_lims=Quantity([1, 12], 'keV'), + relation_name=r'R$_{200}$-Temperature', relation_author='Arnaud et al.', + relation_year='2005', relation_doi='10.1051/0004-6361:20052856', dim_hubb_ind=1) arnaud_r500 = ScalingRelation(np.array([0.57, 1104]), np.array([0.02, 13]), power_law, Quantity(5, 'keV'), - Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{500}$", - relation_author='Arnaud et al.', relation_year='2005', - relation_doi='10.1051/0004-6361:20052856', - relation_name=r'R$_{500}$-Temperature', x_lims=Quantity([1, 12], 'keV')) + Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{500}$", x_lims=Quantity([1, 12], 'keV'), + relation_name=r'R$_{500}$-Temperature', relation_author='Arnaud et al.', + relation_year='2005', relation_doi='10.1051/0004-6361:20052856', dim_hubb_ind=1) arnaud_r2500 = ScalingRelation(np.array([0.56, 491]), np.array([0.02, 4]), power_law, Quantity(5, 'keV'), - Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{2500}$", - relation_author='Arnaud et al.', relation_year='2005', - relation_doi='10.1051/0004-6361:20052856', - relation_name=r'R$_{2500}$-Temperature', x_lims=Quantity([1, 12], 'keV')) + Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{2500}$", x_lims=Quantity([1, 12], 'keV'), + relation_name=r'R$_{2500}$-Temperature', relation_author='Arnaud et al.', + relation_year='2005', relation_doi='10.1051/0004-6361:20052856', dim_hubb_ind=1) # These are equivelant relations specifically measured from the hot clusters (T > 3.5keV) in their sample arnaud_r200_hot = ScalingRelation(np.array([0.5, 1714]), np.array([0.05, 30]), power_law, Quantity(5, 'keV'), - Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{200}$", - relation_author='Arnaud et al.', relation_year='2005', - relation_doi='10.1051/0004-6361:20052856', + Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{200}$", x_lims=Quantity([1, 12], 'keV'), relation_name=r'T$_{\rm{x}}>3.5$keV R$_{200}$-Temperature', - x_lims=Quantity([1, 12], 'keV')) + relation_author='Arnaud et al.', relation_year='2005', + relation_doi='10.1051/0004-6361:20052856', dim_hubb_ind=1) arnaud_r500_hot = ScalingRelation(np.array([0.5, 1129]), np.array([0.05, 17]), power_law, Quantity(5, 'keV'), - Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{500}$", - relation_author='Arnaud et al.', relation_year='2005', - relation_doi='10.1051/0004-6361:20052856', + Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{500}$", x_lims=Quantity([1, 12], 'keV'), relation_name=r'T$_{\rm{x}}>3.5$keV R$_{500}$-Temperature', - x_lims=Quantity([1, 12], 'keV')) + relation_author='Arnaud et al.', relation_year='2005', + relation_doi='10.1051/0004-6361:20052856', dim_hubb_ind=1) arnaud_r2500_hot = ScalingRelation(np.array([0.5, 500]), np.array([0.03, 5]), power_law, Quantity(5, 'keV'), Quantity(1, 'kpc'), r"T$_{\rm{x}}$", "E(z)R$_{2500}$", - relation_author='Arnaud et al.', relation_year='2005', - relation_doi='10.1051/0004-6361:20052856', + x_lims=Quantity([1, 12], 'keV'), relation_name=r'Overdensity T$_{\rm{x}}>3.5$keV R$_{2500}$-Temperature', - x_lims=Quantity([1, 12], 'keV')) + relation_author='Arnaud et al.', relation_year='2005', + relation_doi='10.1051/0004-6361:20052856', dim_hubb_ind=1) diff --git a/xga/relations/clusters/__init__.py b/xga/relations/clusters/__init__.py index 586f19a2..dd3a16ee 100644 --- a/xga/relations/clusters/__init__.py +++ b/xga/relations/clusters/__init__.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 11/12/2020, 16:41. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors # TODO Update the XCS-SDSS scaling relations once its been published (change the year and give it a DOI) diff --git a/xga/relations/fit.py b/xga/relations/fit.py index 218eed06..8e766fd9 100644 --- a/xga/relations/fit.py +++ b/xga/relations/fit.py @@ -1,8 +1,8 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 08/07/2021, 11:16. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 25/04/2023, 15:33. Copyright (c) The Contributors import inspect from types import FunctionType -from typing import Tuple +from typing import Tuple, Union from warnings import warn import numpy as np @@ -20,8 +20,9 @@ def _fit_initialise(y_values: Quantity, y_errs: Quantity, x_values: Quantity, x_errs: Quantity = None, - y_norm: Quantity = None, x_norm: Quantity = None, log_data: bool = False) \ - -> Tuple[Quantity, Quantity, Quantity, Quantity, Quantity, Quantity]: + y_norm: Quantity = None, x_norm: Quantity = None, log_data: bool = False, + point_names: Union[np.ndarray, list] = None, third_dim: Union[np.ndarray, Quantity, list] = None) \ + -> Tuple[Quantity, Quantity, Quantity, Quantity, Quantity, Quantity, np.ndarray, Quantity]: """ A handy little function that prepares the data for fitting with the chosen method. @@ -35,16 +36,27 @@ def _fit_initialise(y_values: Quantity, y_errs: Quantity, x_values: Quantity, x_ :param Quantity x_norm: Quantity to normalise the x data by. :param bool log_data: This parameter controls whether the data is logged before being returned. The default is False as it isn't likely to be used often - its included because LIRA wants logged data. - :return: The x data, x errors, y data, and y errors. Also the x_norm, y_norm. - :rtype: Tuple[Quantity, Quantity, Quantity, Quantity, Quantity, Quantity] + :param np.ndarray/list point_names: A possible set of source names associated with all the data points + :param np.ndarray/Quantity/list third_dim: A possible set of extra data used to colour data points. + :return: The x data, x errors, y data, and y errors. Also the x_norm, y_norm, and the names of non-NaN points. + :rtype: Tuple[Quantity, Quantity, Quantity, Quantity, Quantity, Quantity, np.ndarray, Quantity] """ - # Check the lengths of the value and uncertainty quantities + # Check the lengths of the value and uncertainty quantities, as well as the extra information that can also + # flow through this function if len(x_values) != len(y_values): raise ValueError("The x and y quantities must have the same number of entries!") elif len(y_errs) != len(y_values): raise ValueError("Uncertainty quantities must have the same number of entries as the value quantities.") elif x_errs is not None and len(x_errs) != len(x_values): raise ValueError("Uncertainty quantities must have the same number of entries as the value quantities.") + # Not involved in the fitting process, but comes through here so that the sources dropped due to NaN values + # also have the values dropped in these variables + elif point_names is not None and len(point_names) != len(x_values): + ValueError("The 'point_names' argument is a different length ({p}) to the input data " + "({d}).".format(p=len(point_names), d=len(x_values))) + elif third_dim is not None and len(third_dim) != len(x_values): + ValueError("The 'third_dim' argument is a different length ({p}) to the input data " + "({d}).".format(p=len(third_dim), d=len(x_values))) elif y_errs.unit != y_values.unit: raise UnitConversionError("Uncertainty quantities must have the same units as value quantities.") elif x_errs is not None and x_errs.unit != x_values.unit: @@ -73,7 +85,7 @@ def _fit_initialise(y_values: Quantity, y_errs: Quantity, x_values: Quantity, x_ # Only values that aren't NaN will be permitted x_values = x_values[all_not_nans] y_values = y_values[all_not_nans] - # We're not changing the error arrays here because I'll do that in the place were I ensure the error arrays + # We're not changing the error arrays here because I'll do that in the place where I ensure the error arrays # are 1D # We need to see if the normalisation parameters have been set, and if not then make them a number @@ -124,13 +136,33 @@ def _fit_initialise(y_values: Quantity, y_errs: Quantity, x_values: Quantity, x_ # I know I'm setting something that already exists, but I want errors of 0 to be passed out x_fit_err = Quantity(np.zeros(len(x_values)), x_values.unit) - return x_fit_data, x_fit_err, y_fit_data, y_fit_err, x_norm, y_norm + # Make sure point_names actually is an array (if supplied) and remove the NaN entry equivalents + if point_names is not None: + if isinstance(point_names, list): + point_names = np.array(point_names) + point_names = point_names[all_not_nans] + elif point_names is None: + point_names = None + + # Same deal with the third dimension data that can optionally be supplied to the scaling relations (though + # isn't used in the fit process, it's just for colouring data points in a view method). + if third_dim is not None: + if isinstance(third_dim, list): + third_dim = Quantity(third_dim) + third_dim = third_dim[all_not_nans] + elif third_dim is None: + third_dim = None + + return x_fit_data, x_fit_err, y_fit_data, y_fit_err, x_norm, y_norm, point_names, third_dim def scaling_relation_curve_fit(model_func: FunctionType, y_values: Quantity, y_errs: Quantity, x_values: Quantity, x_errs: Quantity = None, y_norm: Quantity = None, x_norm: Quantity = None, x_lims: Quantity = None, start_pars: list = None, y_name: str = 'Y', - x_name: str = 'X') -> ScalingRelation: + x_name: str = 'X', dim_hubb_ind: Union[float, int] = None, + point_names: Union[np.ndarray, list] = None, + third_dim_info: Union[np.ndarray, Quantity] = None, third_dim_name: str = None) \ + -> ScalingRelation: """ A function to fit a scaling relation with the scipy non-linear least squares implementation (curve fit), generate an XGA ScalingRelation product, and return it. @@ -155,20 +187,31 @@ def scaling_relation_curve_fit(model_func: FunctionType, y_values: Quantity, y_e will be inferred from the astropy Quantity. :param str x_name: The name to be used for the x-axis of the scaling relation (DON'T include the unit, that will be inferred from the astropy Quantity. + :param float/int dim_hubb_ind: This is used to tell the ScalingRelation which power of E(z) has been applied + to the y-axis data, this can then be used by the predict method to remove the E(z) contribution from + predictions. The default is None. + :param np.ndarray/list point_names: The source names associated with the data points passed in to this scaling + relation, can be used for diagnostic purposes (i.e. identifying which source an outlier belongs to). + :param np.ndarray/Quantity third_dim_info: A set of data points which represent a faux third dimension. They should + not have been involved in the fitting process, and the relation should not be in three dimensions, but these + can be used to colour the data points in a view method. + :param str third_dim_name: The name of the third dimension data. :return: An XGA ScalingRelation object with all the information about the data and fit, a view method, and a predict method. :rtype: ScalingRelation """ - x_fit_data, x_fit_errs, y_fit_data, y_fit_errs, x_norm, y_norm = _fit_initialise(y_values, y_errs, x_values, - x_errs, y_norm, x_norm) + x_fit_data, x_fit_errs, y_fit_data, y_fit_errs, x_norm, y_norm, point_names, \ + third_dim_info = _fit_initialise(y_values, y_errs, x_values, x_errs, y_norm, x_norm, point_names=point_names, + third_dim=third_dim_info) fit_par, fit_cov = curve_fit(model_func, x_fit_data.value, y_fit_data.value, sigma=y_fit_errs.value, absolute_sigma=True, p0=start_pars) fit_par_err = np.sqrt(np.diagonal(fit_cov)) - sr = ScalingRelation(fit_par, fit_par_err, model_func, x_norm, y_norm, x_name, y_name, 'Curve Fit', - x_fit_data * x_norm, y_fit_data * y_norm, x_fit_errs * x_norm, y_fit_errs * y_norm, - x_lims=x_lims) + sr = ScalingRelation(fit_par, fit_par_err, model_func, x_norm, y_norm, x_name, y_name, fit_method='Curve Fit', + x_data=x_fit_data * x_norm, y_data=y_fit_data * y_norm, x_err=x_fit_errs * x_norm, + y_err=y_fit_errs * y_norm, x_lims=x_lims, dim_hubb_ind=dim_hubb_ind, point_names=point_names, + third_dim_info=third_dim_info, third_dim_name=third_dim_name) return sr @@ -176,7 +219,10 @@ def scaling_relation_curve_fit(model_func: FunctionType, y_values: Quantity, y_e def scaling_relation_odr(model_func: FunctionType, y_values: Quantity, y_errs: Quantity, x_values: Quantity, x_errs: Quantity = None, y_norm: Quantity = None, x_norm: Quantity = None, x_lims: Quantity = None, start_pars: list = None, y_name: str = 'Y', - x_name: str = 'X') -> ScalingRelation: + x_name: str = 'X', dim_hubb_ind: Union[float, int] = None, + point_names: Union[np.ndarray, list] = None, + third_dim_info: Union[np.ndarray, Quantity] = None, third_dim_name: str = None) \ + -> ScalingRelation: """ A function to fit a scaling relation with the scipy orthogonal distance regression implementation, generate an XGA ScalingRelation product, and return it. @@ -203,6 +249,15 @@ def scaling_relation_odr(model_func: FunctionType, y_values: Quantity, y_errs: Q will be inferred from the astropy Quantity. :param str x_name: The name to be used for the x-axis of the scaling relation (DON'T include the unit, that will be inferred from the astropy Quantity. + :param float/int dim_hubb_ind: This is used to tell the ScalingRelation which power of E(z) has been applied + to the y-axis data, this can then be used by the predict method to remove the E(z) contribution from + predictions. The default is None. + :param np.ndarray/list point_names: The source names associated with the data points passed in to this scaling + relation, can be used for diagnostic purposes (i.e. identifying which source an outlier belongs to). + :param np.ndarray/Quantity third_dim_info: A set of data points which represent a faux third dimension. They should + not have been involved in the fitting process, and the relation should not be in three dimensions, but these + can be used to colour the data points in a view method. + :param str third_dim_name: The name of the third dimension data. :return: An XGA ScalingRelation object with all the information about the data and fit, a view method, and a predict method. :rtype: ScalingRelation @@ -213,8 +268,9 @@ def scaling_relation_odr(model_func: FunctionType, y_values: Quantity, y_errs: Q start_pars = np.ones(num_par) # Standard data preparation - x_fit_data, x_fit_errs, y_fit_data, y_fit_errs, x_norm, y_norm = _fit_initialise(y_values, y_errs, x_values, - x_errs, y_norm, x_norm) + x_fit_data, x_fit_errs, y_fit_data, y_fit_errs, x_norm, y_norm, point_names, \ + third_dim_info = _fit_initialise(y_values, y_errs, x_values, x_errs, y_norm, x_norm, point_names=point_names, + third_dim=third_dim_info) # Immediately faced with a problem, because scipy's ODR is naff and wants functions defined like # blah(par_vector, x_values), which is completely different to my standard definition of models in this module. @@ -246,15 +302,19 @@ def scaling_relation_odr(model_func: FunctionType, y_values: Quantity, y_errs: Q fit_par = fit_results.beta fit_par_err = fit_results.sd_beta - sr = ScalingRelation(fit_par, fit_par_err, model_func, x_norm, y_norm, x_name, y_name, 'ODR', x_fit_data*x_norm, - y_fit_data*y_norm, x_fit_errs*x_norm, y_fit_errs*y_norm, odr_output=fit_results, x_lims=x_lims) + sr = ScalingRelation(fit_par, fit_par_err, model_func, x_norm, y_norm, x_name, y_name, fit_method='ODR', + x_data=x_fit_data * x_norm, y_data=y_fit_data * y_norm, x_err=x_fit_errs * x_norm, + y_err=y_fit_errs * y_norm, x_lims=x_lims, odr_output=fit_results, dim_hubb_ind=dim_hubb_ind, + point_names=point_names, third_dim_info=third_dim_info, third_dim_name=third_dim_name) return sr def scaling_relation_lira(y_values: Quantity, y_errs: Quantity, x_values: Quantity, x_errs: Quantity = None, y_norm: Quantity = None, x_norm: Quantity = None, x_lims: Quantity = None, y_name: str = 'Y', - x_name: str = 'X', num_steps: int = 100000, num_chains: int = 4, num_burn_in: int = 10000) \ + x_name: str = 'X', num_steps: int = 100000, num_chains: int = 4, num_burn_in: int = 10000, + dim_hubb_ind: Union[float, int] = None, point_names: Union[np.ndarray, list] = None, + third_dim_info: Union[np.ndarray, Quantity] = None, third_dim_name: str = None) \ -> ScalingRelation: """ A function to fit a power law scaling relation with the excellent R fitting package LIRA @@ -280,6 +340,15 @@ def scaling_relation_lira(y_values: Quantity, y_errs: Quantity, x_values: Quanti :param int num_chains: The number of chains to run. :param int num_burn_in: The number of steps to discard as a burn in period. This is also used as the adapt parameter of the LIRA fit. + :param float/int dim_hubb_ind: This is used to tell the ScalingRelation which power of E(z) has been applied + to the y-axis data, this can then be used by the predict method to remove the E(z) contribution from + predictions. The default is None. + :param np.ndarray/list point_names: The source names associated with the data points passed in to this scaling + relation, can be used for diagnostic purposes (i.e. identifying which source an outlier belongs to). + :param np.ndarray/Quantity third_dim_info: A set of data points which represent a faux third dimension. They should + not have been involved in the fitting process, and the relation should not be in three dimensions, but these + can be used to colour the data points in a view method. + :param str third_dim_name: The name of the third dimension data. :return: An XGA ScalingRelation object with all the information about the data and fit, a view method, and a predict method. :rtype: ScalingRelation @@ -307,8 +376,9 @@ def scaling_relation_lira(y_values: Quantity, y_errs: Quantity, x_values: Quanti 'the LIRA fitting package to your R environment') # Slightly different data preparation to the other fitting methods, this one returns logged data and errors - x_fit_data, x_fit_errs, y_fit_data, y_fit_errs, x_norm, y_norm = _fit_initialise(y_values, y_errs, x_values, - x_errs, y_norm, x_norm, True) + x_fit_data, x_fit_errs, y_fit_data, y_fit_errs, x_norm, y_norm, point_names, \ + third_dim_info = _fit_initialise(y_values, y_errs, x_values, x_errs, y_norm, x_norm, True, point_names, + third_dim_info) # And now we have to make some R objects so that we can pass it through our R interface to the LIRA package x_fit_data = robjects.FloatVector(x_fit_data.value) @@ -342,17 +412,19 @@ def scaling_relation_lira(y_values: Quantity, y_errs: Quantity, x_values: Quanti # This call to the fit initialisation function DOESN'T produce logged data, do this so the plot works # properly - it expects non logged data - x_fit_data, x_fit_errs, y_fit_data, y_fit_errs, x_norm, y_norm = _fit_initialise(y_values, y_errs, x_values, - x_errs, y_norm, x_norm) + x_fit_data, x_fit_errs, y_fit_data, y_fit_errs, x_norm, y_norm, \ + throw_away, sec_throw_away = _fit_initialise(y_values, y_errs, x_values, x_errs, y_norm, x_norm) # I'm re-formatting the chains into a shape that the ScalingRelation class will understand. xga_chains = np.concatenate([beta_par_chain.reshape(len(beta_par_chain), 1), alpha_par_chain.reshape(len(alpha_par_chain), 1)], axis=1) - sr = ScalingRelation(fit_par, fit_par_err, power_law, x_norm, y_norm, x_name, y_name, 'LIRA', x_fit_data * x_norm, - y_fit_data * y_norm, x_fit_errs * x_norm, y_fit_errs * y_norm, chains=xga_chains, - x_lims=x_lims, scatter_par=np.array([sigma_par_val, sigma_par_err]), - scatter_chain=sigma_par_chain) + sr = ScalingRelation(fit_par, fit_par_err, power_law, x_norm, y_norm, x_name, y_name, fit_method='LIRA', + x_data=x_fit_data * x_norm, y_data=y_fit_data * y_norm, x_err=x_fit_errs * x_norm, + y_err=y_fit_errs * y_norm, x_lims=x_lims, chains=xga_chains, + scatter_par=np.array([sigma_par_val, sigma_par_err]), scatter_chain=sigma_par_chain, + dim_hubb_ind=dim_hubb_ind, point_names=point_names, third_dim_info=third_dim_info, + third_dim_name=third_dim_name) return sr diff --git a/xga/samples/__init__.py b/xga/samples/__init__.py index 3e5ad6f7..18910d47 100644 --- a/xga/samples/__init__.py +++ b/xga/samples/__init__.py @@ -1,9 +1,9 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 06/01/2021, 16:36. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from .base import BaseSample -from .general import PointSample, ExtendedSample from .extended import ClusterSample +from .general import PointSample, ExtendedSample from .point import StarSample diff --git a/xga/samples/base.py b/xga/samples/base.py index 5e99cd8b..d6a26a26 100644 --- a/xga/samples/base.py +++ b/xga/samples/base.py @@ -1,18 +1,18 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 07/07/2021, 17:50. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 23:13. Copyright (c) The Contributors from typing import Union, List, Dict from warnings import warn import numpy as np -from astropy.cosmology import Planck15 +from astropy.cosmology import Cosmology from astropy.units import Quantity, Unit, arcmin, UnitConversionError +from matplotlib import pyplot as plt from numpy import ndarray from tqdm import tqdm -from matplotlib import pyplot as plt -from ..exceptions import NoMatchFoundError, ModelNotAssociatedError, ParameterNotAssociatedError, \ - NoValidObservationsError +from .. import DEFAULT_COSMO +from ..exceptions import NoMatchFoundError, ModelNotAssociatedError, ParameterNotAssociatedError from ..exceptions import NoValidObservationsError from ..sources.base import BaseSource from ..sourcetools.misc import coord_to_name @@ -30,13 +30,14 @@ class BaseSample: :param ndarray redshift: The redshifts of the sources, optional. Default is None :param ndarray name: The names of the sources, optional. Default is None, in which case the names will be constructed from the coordinates. - :param cosmology: An astropy cosmology object to be used in distance calculations and analyses. + :param Cosmology cosmology: An astropy cosmology object to be used in distance calculations and analyses. :param bool load_products: Whether existing products should be loaded from disk. :param bool load_fits: Whether existing fits should be loaded from disk. :param bool no_prog_bar: Whether a progress bar should be shown as sources are declared. """ - def __init__(self, ra: ndarray, dec: ndarray, redshift: ndarray = None, name: ndarray = None, cosmology=Planck15, - load_products: bool = True, load_fits: bool = False, no_prog_bar: bool = False): + def __init__(self, ra: ndarray, dec: ndarray, redshift: ndarray = None, name: ndarray = None, + cosmology: Cosmology = DEFAULT_COSMO, load_products: bool = True, load_fits: bool = False, + no_prog_bar: bool = False): if len(ra) == 0: raise ValueError("You have passed an empty array for the RA values.") @@ -74,7 +75,9 @@ def __init__(self, ra: ndarray, dec: ndarray, redshift: ndarray = None, name: nd z = None try: - temp = BaseSource(r, d, z, n, cosmology, load_products, load_fits) + # We declare the source object, making sure to tell it that its part of a sample + # using in_sample=True + temp = BaseSource(r, d, z, n, cosmology, load_products, load_fits, True) n = temp.name self._sources[n] = temp self._names.append(n) @@ -88,8 +91,7 @@ def __init__(self, ra: ndarray, dec: ndarray, redshift: ndarray = None, name: nd ra_dec = Quantity(np.array([r, d]), 'deg') n = coord_to_name(ra_dec) - warn("Source {n} does not appear to have any XMM data, and will not be included in the " - "sample.".format(n=n)) + # We record that a particular source name was not successfully declared self._failed_sources[n] = "NoMatch" dec_base.update(1) @@ -99,6 +101,22 @@ def __init__(self, ra: ndarray, dec: ndarray, redshift: ndarray = None, name: nd raise NoValidObservationsError("No sources have been declared, likely meaning that none of the sample have" " valid XMM data.") + # Put all the warnings for there being no XMM data in one - I think it's neater. Wait until after the check + # to make sure that are some sources because in that case this warning is redundant. + # HOWEVER - I only want this warning to appear in certain circumstances. For instance I wouldn't want it + # to be triggered here for a ClusterSample declaration that has called the super init (this method), as that + # class declaration does its own (somewhat different) check on which sources have data + no_data = [name for name in self._failed_sources if self._failed_sources[name] == 'NoMatch'] + # If there are names in that list, then we do the warning + if len(no_data) != 0 and type(self) == BaseSample: + warn("The following do not appear to have any XMM data, and will not be included in the " + "sample (can also check .failed_names); {n}".format(n=', '.join(no_data))) + + # This calls the method that checks for suppressed source-level warnings that occurred during declaration, but + # only if this init has been called for a BaseSample declaration, rather than by a sub-class + if type(self) == BaseSample: + self._check_source_warnings() + # These next few properties are all quantities passed in by the user on init, then used to # declare source objects - as such they cannot ever be set by the user. @property @@ -217,6 +235,18 @@ def failed_reasons(self) -> Dict[str, str]: """ return self._failed_sources + @property + def suppressed_warnings(self) -> Dict[str, List[str]]: + """ + A property getter for a dictionary of the suppressed warnings that occurred during the declaration of + sources for this sample. + + :return: A dictionary with source name as keys, and lists of warning text as values. Sources are + only included if they have had suppressed warnings. + :rtype: Dict[str, List[str]] + """ + return {n: s.suppressed_warnings for n, s in self._sources.items() if len(s.suppressed_warnings) > 0} + def Lx(self, outer_radius: Union[str, Quantity], model: str, inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'), lo_en: Quantity = Quantity(0.5, 'keV'), hi_en: Quantity = Quantity(2.0, 'keV'), group_spec: bool = True, min_counts: int = 5, min_sn: float = None, @@ -492,6 +522,17 @@ def __delitem__(self, key: Union[int, str]): # that will need to be deleted. self._del_data(key) + def _check_source_warnings(self): + """ + This method checks the suppressed_warnings property of the member sources, and if any have had warnings + suppressed then it itself raises a warning that instructs the user to look at the suppressed_warnings + property of the sample. It doesn't print them all because that could lead to a confusing mess. This method + is to be called at the end of every sub-class init. + """ + if any([len(src.suppressed_warnings) > 0 for src in self._sources.values()]): + warn("Non-fatal warnings occurred during the declaration of some sources, to access them please use the " + "suppressed_warnings property of this sample.", stacklevel=2) + def _del_data(self, key: int): """ This function will be replaced in subclasses that store more information about sources @@ -500,6 +541,3 @@ def _del_data(self, key: int): :param int key: The index or name of the source to delete. """ pass - - - diff --git a/xga/samples/extended.py b/xga/samples/extended.py index 5827b5d6..0ef41ccc 100644 --- a/xga/samples/extended.py +++ b/xga/samples/extended.py @@ -1,14 +1,15 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 07/07/2021, 17:50. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 18/04/2023, 23:19. Copyright (c) The Contributors -from typing import Union, List +from typing import List import numpy as np -from astropy.cosmology import Planck15 +from astropy.cosmology import Cosmology from astropy.units import Quantity from tqdm import tqdm from .base import BaseSample +from .. import DEFAULT_COSMO from ..exceptions import PeakConvergenceFailedError, ModelNotAssociatedError, ParameterNotAssociatedError, \ NoProductAvailableError, NoValidObservationsError from ..products.profile import GasDensity3D @@ -43,7 +44,7 @@ class ClusterSample(BaseSample): radius for the background region. Default is 1.05. :param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer radius for the background region. Default is 1.5. - :param cosmology: An astropy cosmology object for use throughout analysis of the source. + :param Cosmology cosmology: An astropy cosmology object for use throughout analysis of the source. :param bool load_fits: Whether existing fits should be loaded from disk. :param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default is hierarchical, simple may also be passed. @@ -52,17 +53,18 @@ class ClusterSample(BaseSample): :param str clean_obs_reg: The region to use for the cleaning step, default is R200. :param float clean_obs_threshold: The minimum coverage fraction for an observation to be kept for analysis. :param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default - is hierarchical, simple may also be passed. + is 'hierarchical' (uses XGA's hierarchical clustering peak finder), 'simple' may also be passed in which + case the brightest unmasked pixel within the source region will be selected. """ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray, name: np.ndarray, r200: Quantity = None, r500: Quantity = None, r2500: Quantity = None, richness: np.ndarray = None, richness_err: np.ndarray = None, wl_mass: Quantity = None, wl_mass_err: Quantity = None, custom_region_radius: Quantity = None, use_peak: bool = True, peak_lo_en: Quantity = Quantity(0.5, "keV"), peak_hi_en: Quantity = Quantity(2.0, "keV"), - back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, cosmology=Planck15, - load_fits: bool = False, clean_obs: bool = True, clean_obs_reg: str = "r200", - clean_obs_threshold: float = 0.3, no_prog_bar: bool = False, psf_corr: bool = False, - peak_find_method: str = "hierarchical"): + back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, + cosmology: Cosmology = DEFAULT_COSMO, load_fits: bool = False, clean_obs: bool = True, + clean_obs_reg: str = "r200", clean_obs_threshold: float = 0.3, no_prog_bar: bool = False, + psf_corr: bool = False, peak_find_method: str = "hierarchical"): """ The init of the ClusterSample XGA class, for the analysis of a large sample of galaxy clusters. Takes information on the clusters to enable analyses. @@ -91,6 +93,8 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray, name: # We have this final names list in case so that we don't need to remove elements of self.names if one of the # clusters doesn't pass the observation cleaning stage. final_names = [] + # This records which clusters had a failed peak finding attempt, for a warning at the end of the declaration + failed_peak_find = [] with tqdm(desc="Setting up Galaxy Clusters", total=len(self.names), disable=no_prog_bar) as dec_lb: for ind, r in enumerate(ra): # Just splitting out relevant values for this particular cluster so the object declaration isn't @@ -103,20 +107,31 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray, name: # So we want to check that the current object name is in the list of objects that have data if n in self.names: # I know this code is a bit ugly, but oh well - if r200 is not None: + if r200 is not None and not r200.isscalar: r2 = r200[ind] + elif r200 is not None and r200.isscalar: + r2 = r200 else: r2 = None - if r500 is not None: + + if r500 is not None and not r500.isscalar: r5 = r500[ind] + elif r500 is not None and r500.isscalar: + r5 = r500 else: r5 = None - if r2500 is not None: + + if r2500 is not None and not r2500.isscalar: r25 = r2500[ind] + elif r2500 is not None and r2500.isscalar: + r25 = r2500 else: r25 = None - if custom_region_radius is not None: + + if custom_region_radius is not None and not custom_region_radius.isscalar: cr = custom_region_radius[ind] + elif custom_region_radius is not None and custom_region_radius.isscalar: + cr = custom_region_radius else: cr = None @@ -138,27 +153,38 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray, name: # Will definitely load products (the True in this call), because I just made sure I generated a # bunch to make GalaxyCluster declaration quicker try: + # Declare the galaxy cluster, telling it is a part of a sample with in_sample=True self._sources[n] = GalaxyCluster(r, d, z, n, r2, r5, r25, lam, lam_err, wlm, wlm_err, cr, use_peak, peak_lo_en, peak_hi_en, back_inn_rad_factor, back_out_rad_factor, cosmology, True, load_fits, clean_obs, - clean_obs_reg, clean_obs_threshold, False, peak_find_method) + clean_obs_reg, clean_obs_threshold, False, peak_find_method, + True) final_names.append(n) + except PeakConvergenceFailedError: - warn("The peak finding algorithm has not converged for {}, using user " - "supplied coordinates".format(n)) - self._sources[n] = GalaxyCluster(r, d, z, n, r2, r5, r25, lam, lam_err, wlm, wlm_err, cr, False, - peak_lo_en, peak_hi_en, back_inn_rad_factor, - back_out_rad_factor, cosmology, True, load_fits, clean_obs, - clean_obs_reg, clean_obs_threshold, False, peak_find_method) - final_names.append(n) + try: + failed_peak_find.append(n) + # If the peak finding failed, we need to re-declare the galaxy cluster, telling it is + # a part of a sample with in_sample=True + self._sources[n] = GalaxyCluster(r, d, z, n, r2, r5, r25, lam, lam_err, wlm, wlm_err, cr, + False, peak_lo_en, peak_hi_en, back_inn_rad_factor, + back_out_rad_factor, cosmology, True, load_fits, clean_obs, + clean_obs_reg, clean_obs_threshold, False, + peak_find_method, True) + final_names.append(n) + except NoValidObservationsError: + # warn("After a failed attempt to find an X-ray peak, and after applying the criteria for " + # "the minimum amount of cluster required on an observation, {} cannot be declared as " + # "all potential observations were removed".format(n)) + self._failed_sources[n] = "Failed ObsClean" except NoValidObservationsError: - warn("After applying the criteria for the minimum amount of cluster required on an " - "observation, {} cannot be declared as all potential observations were removed".format(n)) + # warn("After applying the criteria for the minimum amount of cluster required on an " + # "observation, {} cannot be declared as all potential observations were removed".format(n)) # Note we don't append n to the final_names list here, as it is effectively being # removed from the sample self._failed_sources[n] = "Failed ObsClean" - dec_lb.update(1) + dec_lb.update(1) self._names = final_names @@ -174,12 +200,10 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray, name: # If the source in question has had data removed if self._sources[n].disassociated: try: - en_key = "bound_{0}-{1}".format(peak_lo_en.to("keV").value, - peak_hi_en.to("keV").value) - rt = self._sources[n].get_products("combined_ratemap", extra_key=en_key)[0] - peak = self._sources[n].find_peak(rt) - self._sources[n].peak = peak[0] - self._sources[n].default_coord = peak[0] + # This re-runs peak finding + self._sources[n]._all_peaks(peak_find_method) + self._sources[n]._default_coord = self._sources[n].peak + except PeakConvergenceFailedError: pass @@ -188,6 +212,30 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray, name: from ..imagetools.psf import rl_psf rl_psf(self, lo_en=peak_lo_en, hi_en=peak_hi_en) + # It is possible (especially if someone is using the Sample classes as a way to check whether things have + # XMM data) that no sources will have been declared by this point, in which case it should fail now + if len(self._sources) == 0: + raise NoValidObservationsError( + "No Galaxy Clusters have been declared, none of the sample passed the cleaning steps.") + + # Put all the warnings for there being no XMM data in one - I think it's neater. Wait until after the check + # to make sure that are some sources because in that case this warning is redundant. + no_data = [name for name in self._failed_sources if self._failed_sources[name] == 'NoMatch' or + self._failed_sources[name] == 'Failed ObsClean'] + # If there are names in that list, then we do the warning + if len(no_data) != 0: + warn("The following do not appear to have any XMM data, and will not be included in the " + "sample (can also check .failed_names); {n}".format(n=', '.join(no_data)), stacklevel=2) + + # We also do a combined warning for those clusters that had a failed peak finding attempt, if there are any + if len(failed_peak_find) != 0: + warn("Peak finding did not converge for the following; {n}, using user " + "supplied coordinates".format(n=', '.join(failed_peak_find)), stacklevel=2) + + # This shows a warning that tells the user how to see any suppressed warnings that occurred during source + # declarations, but only if there actually were any. + self._check_source_warnings() + @property def r200_snr(self) -> np.ndarray: """ @@ -280,28 +328,82 @@ def wl_mass(self) -> Quantity: return Quantity(wlm, wlm_unit) - @property - def r200(self) -> Quantity: + def _get_overdens_rad_checks(self, rad_name: str) -> Quantity: """ - Returns all the R200 values passed in on declaration, but in units of kpc. + An internal method to retrieve particular named overdensity radii from the constituent GalaxyCluster instances + of this class - basically because the process is exactly the same for the three implemented overdensity + radii, and there was no point repeating things. This method also performs checks to ensure that every entry + isn't just empty. - :return: A quantity of R200 values. + :param str rad_name: The overdensity radius name to retrieve; i.e. 'r2500', 'r500', 'r200'. + :return: The requested radii. :rtype: Quantity """ + # For the radii to be stored in as they are pulled out of the individual GalaxyCluster instances rads = [] + # Iterating through the galaxy cluster objects for gcs in self._sources.values(): - rad = gcs.get_radius('r200', 'kpc') + # Using the get radius method to ensure that all retrieved radii are in kpc units + rad = gcs.get_radius(rad_name, 'kpc') + # Result could be None, if the radius wasn't set for that clusters, have to account for that if rad is None: rads.append(np.NaN) else: - rads.append(rad.value) + rads.append(rad) - rads = np.array(rads) + # Turn list back into something nicer to work with + rads = Quantity(rads) + # Select only those radii which are not NaN - only to check, the whole set is returned (even NaN values) + # if even one of the values is not NaN check_rads = rads[~np.isnan(rads)] if len(check_rads) == 0: - raise ValueError("All R200 values appear to be NaN.") + raise ValueError("All {} values appear to be NaN.".format(rad_name.upper())) + + # Return the radii + return rads + + def _set_overdens_rad_checks(self, rad_name: str, new_val: Quantity): + """ + An internal method that does some checks on the new radii being used to set overdensity radii for clusters + in this sample - other checks are done on an individual level by the property setter of GalaxyCluster. + + :param str rad_name: The overdensity radius name to retrieve; i.e. 'r2500', 'r500', 'r200'. + :param Quantity new_val: The new overdensity radius values + """ + # Throw an error if the new value is scalar, because a ClusterSample should always contain multiple clusters + # and so passing a single value of radius is daft + if new_val.isscalar: + raise ValueError("Setting a sample {} with a single radius value is not allowed.".format(rad_name.upper())) + # Need to check that the passed quantity has the expected number of entries + elif len(new_val) != len(self): + raise ValueError("The new {r} quantity does not have the same number of entries ({nl}) as there are " + "clusters in this sample ({cl}).".format(nl=len(new_val), cl=len(self), + r=rad_name.upper())) + + @property + def r200(self) -> Quantity: + """ + Returns all the R200 values passed in on declaration, but in units of kpc. - return Quantity(rads, 'kpc') + :return: A quantity of R200 values. + :rtype: Quantity + """ + return self._get_overdens_rad_checks('r200') + + @r200.setter + def r200(self, new_val: Quantity): + """ + The property setter for R200 for the galaxy clusters in this sample. + + :param Quantity new_val: An quantity array (i.e. non-scalar) of new radius values. + """ + # This will throw an error if there is an obvious problem with new_val + self._set_overdens_rad_checks('r200', new_val) + + # If we get here then we can start setting the radii in the constituent GalaxyCluster objects + # by iterating through them! + for gcs_ind, gcs in enumerate(self._sources.values()): + gcs.r200 = new_val[gcs_ind] @property def r500(self) -> Quantity: @@ -311,20 +413,22 @@ def r500(self) -> Quantity: :return: A quantity of R500 values. :rtype: Quantity """ - rads = [] - for gcs in self._sources.values(): - rad = gcs.get_radius('r500', 'kpc') - if rad is None: - rads.append(np.NaN) - else: - rads.append(rad.value) + return self._get_overdens_rad_checks('r500') - rads = np.array(rads) - check_rads = rads[~np.isnan(rads)] - if len(check_rads) == 0: - raise ValueError("All R500 values appear to be NaN.") + @r500.setter + def r500(self, new_val: Quantity): + """ + The property setter for R500 for the galaxy clusters in this sample. - return Quantity(rads, 'kpc') + :param Quantity new_val: An quantity array (i.e. non-scalar) of new radius values. + """ + # This will throw an error if there is an obvious problem with new_val + self._set_overdens_rad_checks('r500', new_val) + + # If we get here then we can start setting the radii in the constituent GalaxyCluster objects + # by iterating through them! + for gcs_ind, gcs in enumerate(self._sources.values()): + gcs.r500 = new_val[gcs_ind] @property def r2500(self) -> Quantity: @@ -334,20 +438,42 @@ def r2500(self) -> Quantity: :return: A quantity of R2500 values. :rtype: Quantity """ - rads = [] - for gcs in self._sources.values(): - rad = gcs.get_radius('r2500', 'kpc') - if rad is None: - rads.append(np.NaN) - else: - rads.append(rad.value) + return self._get_overdens_rad_checks('r2500') - rads = np.array(rads) - check_rads = rads[~np.isnan(rads)] - if len(check_rads) == 0: - raise ValueError("All R2500 values appear to be NaN.") + @r2500.setter + def r2500(self, new_val: Quantity): + """ + The property setter for R2500 for the galaxy clusters in this sample. + + :param Quantity new_val: An quantity array (i.e. non-scalar) of new radius values. + """ + # This will throw an error if there is an obvious problem with new_val + self._set_overdens_rad_checks('r2500', new_val) + + # If we get here then we can start setting the radii in the constituent GalaxyCluster objects + # by iterating through them! + for gcs_ind, gcs in enumerate(self._sources.values()): + gcs.r2500 = new_val[gcs_ind] - return Quantity(rads, 'kpc') + def get_radius(self, rad_name: str) -> Quantity: + """ + Similar to the BaseSource get_radius method, but more limited in that it cannot convert radii to the desired + unit, this method will retrieve named overdensity radii in kpc. + + :param str rad_name: The name of the overdensity radii to retrieve; i.e. 'r2500', 'r500', or 'r200'. + :return: A quantity containing the overdensity radii in kpc. + :rtype: Quantity + """ + # Simple enough, use the properties depending on the radius name passed + if rad_name == 'r2500': + return self.r2500 + elif rad_name == 'r500': + return self.r500 + elif rad_name == 'r200': + return self.r200 + # And if we don't recognise the radius name then we throw an error. + else: + raise ValueError("Please pass either 'r2500', 'r500', or 'r200'.") def Lx(self, outer_radius: Union[str, Quantity], model: str = 'constant*tbabs*apec', inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'), lo_en: Quantity = Quantity(0.5, 'keV'), @@ -670,6 +796,67 @@ def hydrostatic_mass(self, rad_name: str, temp_model_name: str = None, dens_mode return Quantity(ms, 'Msun') + def calc_overdensity_radii(self, delta: int, temp_model_name: str = None, dens_model_name: str = None) -> Quantity: + """ + A convenience method that allows for the calculation of overdensity radii from hydrostatic mass profiles + measured for sources in this sample. This method uses the 'overdensity_radius' method of each mass profile + to find the radius that corresponds to the user-supplied overdensity - common choices for cluster analysis + are Δ=2500, 500, and 200. Overdensity radii are defined as the radius at which the density is Δ times the + critical density of the Universe at the cluster redshift. + + This function is limited, and if you have generated multiple hydrostatic mass profiles you may have to use + the get_hydrostatic_mass_profiles function of each source directly, or use the returned profiles from the + function that generated them, then use 'overdensity_radius' yourself. + + If only one hydrostatic mass profile has been generated for each source, then you do not need to specify model + names, but if the same temperature and density profiles have been used to make a hydrostatic mass profile but + with different models then you may use them. + + :param int delta: The overdensity factor for which a radius is to be calculated. + :param str temp_model_name: The name of the model used to fit the temperature profile used to generate the + hydrostatic mass profile required for measuring overdensity radii, default is None. + :param str dens_model_name: The name of the model used to fit the density profile used to generate the + hydrostatic mass profile required for measuring overdensity radii, default is None. + :return: An astropy quantity array of the calculated radii, in kpc. + :rtype: Quantity + """ + # Just a list to store the radii in as they're being calculated - turned into an array quantity at the end + rs = [] + # Iterating over the galaxy clusters in this sample + for gcs_ind, gcs in enumerate(self._sources.values()): + # First off, we try to fetch hydrostatic mass profile(s), and catch the exception if there + # aren't any matching profiles + try: + mass_profs = gcs.get_hydrostatic_mass_profiles(temp_model_name=temp_model_name, + dens_model_name=dens_model_name) + # As I just ask for temperature and density model names, it's entirely possible that there are + # multiple hydrostatic mass profiles that use those two models. If there are then the user + # has to do this the long way around. + if isinstance(mass_profs, list): + raise ValueError("There are multiple matching hydrostatic mass profiles associated with {}, " + "you will have to retrieve profiles and calculate radii " + "manually.".format(gcs.name)) + + try: + # Simply calculate the overdensity radius for the delta requested by the user + rad = mass_profs.overdensity_radius(delta, gcs.redshift, gcs.cosmo) + rs.append(rad) + except ValueError: + warn("Overdensity radius calculation for {s} failed because the default starting radii " + "didn't bracket the requested overdensity radius. See the docs of overdensity_radius " + "method of HydrostaticMass for more info.".format(s=gcs.name)) + + rs.append(np.NaN) + + except NoProductAvailableError: + # If no dens_prof has been run or something goes wrong then NaNs are added + rs.append(np.NaN) + warn("{s} doesn't have a matching hydrostatic mass profile associated".format(s=gcs.name)) + + # Turn the radii list into a quantity and return it + rs = Quantity(rs) + return rs + def gm_richness(self, rad_name: str, dens_model: str, prof_outer_rad: Union[Quantity, str], dens_method: str, x_norm: Quantity = Quantity(60), y_norm: Quantity = Quantity(1e+12, 'solMass'), fit_method: str = 'odr', start_pars: list = None, pix_step: int = 1, @@ -1253,3 +1440,22 @@ def mass_Lx(self, outer_radius: str = 'r500', x_norm: Quantity = Quantity(1e+44, '{a}'.format(e=fit_method, a=' '.join(ALLOWED_FIT_METHODS))) return scale_rel + + def __getitem__(self, key: Union[int, str]) -> GalaxyCluster: + """ + This returns the relevant source when a sample is addressed using the name of a source as the key, + or using an integer index. This overrides the BaseSample return but is functionally identical, only the + type hint changes. + + :param int/str key: The index or name of the source to fetch. + :return: The relevant Source object. + :rtype: GalaxyCluster + """ + if isinstance(key, (int, np.integer)): + src = self._sources[self._names[key]] + elif isinstance(key, str): + src = self._sources[key] + else: + src = None + raise ValueError("Only a source name or integer index may be used to address a sample object") + return src diff --git a/xga/samples/general.py b/xga/samples/general.py index cd624277..8ad53021 100644 --- a/xga/samples/general.py +++ b/xga/samples/general.py @@ -1,13 +1,16 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 24/05/2021, 13:34. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 15:21. Copyright (c) The Contributors + +from warnings import warn import numpy as np -from astropy.cosmology import Planck15 +from astropy.cosmology import Cosmology from astropy.units import Quantity, Unit from tqdm import tqdm from .base import BaseSample -from ..exceptions import NoValidObservationsError +from .. import DEFAULT_COSMO +from ..exceptions import NoValidObservationsError, PeakConvergenceFailedError from ..sources.general import PointSource, ExtendedSource @@ -32,7 +35,7 @@ class ExtendedSample(BaseSample): radius for the background region. Default is 1.05. :param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer radius for the background region. Default is 1.5. - :param cosmology: An astropy cosmology object for use throughout analysis of the source. + :param Cosmology cosmology: An astropy cosmology object for use throughout analysis of the source. :param bool load_fits: Whether existing fits should be loaded from disk. :param bool no_prog_bar: Should a source declaration progress bar be shown during setup. :param bool psf_corr: Should images be PSF corrected with default settings during sample setup. @@ -42,9 +45,9 @@ class ExtendedSample(BaseSample): def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray = None, name: np.ndarray = None, custom_region_radius: Quantity = None, use_peak: bool = True, peak_lo_en: Quantity = Quantity(0.5, "keV"), peak_hi_en: Quantity = Quantity(2.0, "keV"), - back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, cosmology=Planck15, - load_fits: bool = False, no_prog_bar: bool = False, psf_corr: bool = False, - peak_find_method: str = "hierarchical"): + back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, + cosmology: Cosmology = DEFAULT_COSMO, load_fits: bool = False, no_prog_bar: bool = False, + psf_corr: bool = False, peak_find_method: str = "hierarchical"): """ The init method of the ExtendedSample class. """ @@ -81,6 +84,8 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray = None, # attributes can be cleaned up later final_names = [] self._custom_radii = [] + # This records which sources had a failed peak finding attempt, for a warning at the end of the declaration + failed_peak_find = [] with tqdm(desc="Setting up Extended Sources", total=len(self._accepted_inds), disable=no_prog_bar) as dec_lb: for ind in range(len(self._accepted_inds)): r, d = ra[self._accepted_inds[ind]], dec[self._accepted_inds[ind]] @@ -92,9 +97,10 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray = None, cr = custom_region_radius[self._accepted_inds[ind]] try: + # Declare a generic extended source, telling it is that it is part of a sample with in_sample=True self._sources[n] = ExtendedSource(r, d, z, n, cr, use_peak, peak_lo_en, peak_hi_en, back_inn_rad_factor, back_out_rad_factor, cosmology, True, - load_fits, peak_find_method) + load_fits, peak_find_method, True) if isinstance(cr, Quantity): self._custom_radii.append(cr.value) # I know this will write to this over and over, but it seems a bit silly to check @@ -104,6 +110,14 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray = None, self._custom_radii.append(np.NaN) self._cr_unit = Unit('') final_names.append(n) + except PeakConvergenceFailedError: + warn("The peak finding algorithm has not converged for {}, using user " + "supplied coordinates".format(n)) + # Have to re-declare the source if peak finding failed + self._sources[n] = ExtendedSource(r, d, z, n, cr, False, peak_lo_en, peak_hi_en, + back_inn_rad_factor, back_out_rad_factor, cosmology, True, + load_fits, peak_find_method, True) + final_names.append(n) except NoValidObservationsError: self._failed_sources[n] = "CleanedNoMatch" @@ -121,6 +135,30 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray = None, from ..imagetools.psf import rl_psf rl_psf(self, lo_en=peak_lo_en, hi_en=peak_hi_en) + # It is possible (especially if someone is using the Sample classes as a way to check whether things have + # XMM data) that no sources will have been declared by this point, in which case it should fail now + if len(self._sources) == 0: + raise NoValidObservationsError( + "No Extended Sources have been declared, none of the sample passed the cleaning steps.") + + # Put all the warnings for there being no XMM data in one - I think it's neater. Wait until after the check + # to make sure that are some sources because in that case this warning is redundant. + no_data = [name for name in self._failed_sources if self._failed_sources[name] == 'NoMatch' or + self._failed_sources[name] == 'Failed ObsClean'] + # If there are names in that list, then we do the warning + if len(no_data) != 0: + warn("The following do not appear to have any XMM data, and will not be included in the " + "sample (can also check .failed_names); {n}".format(n=', '.join(no_data)), stacklevel=2) + + # We also do a combined warning for those clusters that had a failed peak finding attempt, if there are any + if len(failed_peak_find) != 0: + warn("Peak finding did not converge for the following; {n}, using user " + "supplied coordinates".format(n=', '.join(failed_peak_find)), stacklevel=2) + + # This shows a warning that tells the user how to see any suppressed warnings that occurred during source + # declarations, but only if there actually were any. + self._check_source_warnings() + @property def custom_radii(self) -> Quantity: """ @@ -175,7 +213,7 @@ class PointSample(BaseSample): radius for the background region. Default is 1.05. :param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer radius for the background region. Default is 1.5. - :param cosmology: An astropy cosmology object for use throughout analysis of the source. + :param Cosmology cosmology: An astropy cosmology object for use throughout analysis of the source. :param bool load_fits: Whether existing fits should be loaded from disk. :param bool no_prog_bar: Should a source declaration progress bar be shown during setup. :param bool psf_corr: Should images be PSF corrected with default settings during sample setup. @@ -184,7 +222,8 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray = None, point_radius: Quantity = Quantity(30, 'arcsec'), use_peak: bool = False, peak_lo_en: Quantity = Quantity(0.5, "keV"), peak_hi_en: Quantity = Quantity(2.0, "keV"), back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, - cosmology=Planck15, load_fits: bool = False, no_prog_bar: bool = False, psf_corr: bool = False): + cosmology: Cosmology = DEFAULT_COSMO, load_fits: bool = False, no_prog_bar: bool = False, + psf_corr: bool = False): """ The init method of the PointSample class. """ @@ -219,6 +258,8 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray = None, # attributes can be cleaned up later final_names = [] self._point_radii = [] + # This records which sources had a failed peak finding attempt, for a warning at the end of the declaration + failed_peak_find = [] with tqdm(desc="Setting up Point Sources", total=len(self._accepted_inds), disable=no_prog_bar) as dec_lb: for ind in range(len(self._accepted_inds)): r, d = ra[self._accepted_inds[ind]], dec[self._accepted_inds[ind]] @@ -234,12 +275,19 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray = None, try: self._sources[n] = PointSource(r, d, z, n, pr, use_peak, peak_lo_en, peak_hi_en, back_inn_rad_factor, back_out_rad_factor, cosmology, True, - load_fits, False) + load_fits, False, True) self._point_radii.append(pr.value) # I know this will write to this over and over, but it seems a bit silly to check whether this has # been set yet when all radii should be forced to be the same unit self._pr_unit = pr.unit final_names.append(n) + except PeakConvergenceFailedError: + warn("The peak finding algorithm has not converged for {}, using user " + "supplied coordinates".format(n)) + self._sources[n] = PointSource(r, d, z, n, pr, False, peak_lo_en, peak_hi_en, + back_inn_rad_factor, back_out_rad_factor, cosmology, True, + load_fits, False, True) + final_names.append(n) except NoValidObservationsError: self._failed_sources[n] = "CleanedNoMatch" @@ -257,6 +305,30 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray = None, from ..imagetools.psf import rl_psf rl_psf(self, lo_en=peak_lo_en, hi_en=peak_hi_en) + # It is possible (especially if someone is using the Sample classes as a way to check whether things have + # XMM data) that no sources will have been declared by this point, in which case it should fail now + if len(self._sources) == 0: + raise NoValidObservationsError( + "No Point Sources have been declared, none of the sample passed the cleaning steps.") + + # Put all the warnings for there being no XMM data in one - I think it's neater. Wait until after the check + # to make sure that are some sources because in that case this warning is redundant. + no_data = [name for name in self._failed_sources if self._failed_sources[name] == 'NoMatch' or + self._failed_sources[name] == 'Failed ObsClean'] + # If there are names in that list, then we do the warning + if len(no_data) != 0: + warn("The following do not appear to have any XMM data, and will not be included in the " + "sample (can also check .failed_names); {n}".format(n=', '.join(no_data)), stacklevel=2) + + # We also do a combined warning for those clusters that had a failed peak finding attempt, if there are any + if len(failed_peak_find) != 0: + warn("Peak finding did not converge for the following; {n}, using user " + "supplied coordinates".format(n=', '.join(failed_peak_find)), stacklevel=2) + + # This shows a warning that tells the user how to see any suppressed warnings that occurred during source + # declarations, but only if there actually were any. + self._check_source_warnings() + @property def point_radii(self) -> Quantity: """ diff --git a/xga/samples/point.py b/xga/samples/point.py index d1cb47e8..d78edc2d 100644 --- a/xga/samples/point.py +++ b/xga/samples/point.py @@ -1,12 +1,14 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 26/11/2021, 16:56. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 23:13. Copyright (c) The Contributors +from warnings import warn import numpy as np -from astropy.cosmology import Planck15 +from astropy.cosmology import Cosmology from astropy.units import Quantity, Unit, UnitConversionError from tqdm import tqdm from .base import BaseSample +from .. import DEFAULT_COSMO from ..exceptions import NoValidObservationsError from ..sources.point import Star @@ -40,7 +42,7 @@ class StarSample(BaseSample): radius for the background region. Default is 1.05. :param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer radius for the background region. Default is 1.5. - :param cosmology: An astropy cosmology object for use throughout analysis of the source. + :param Cosmology cosmology: An astropy cosmology object for use throughout analysis of the source. :param bool load_fits: Whether existing fits should be loaded from disk. :param bool no_prog_bar: Should a source declaration progress bar be shown during setup. :param bool psf_corr: Should images be PSF corrected with default settings during sample setup. @@ -49,8 +51,9 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, distance: np.ndarray = None, proper_motion: Quantity = None, point_radius: Quantity = Quantity(30, 'arcsec'), match_radius: Quantity = Quantity(10, 'arcsec'), use_peak: bool = False, peak_lo_en: Quantity = Quantity(0.5, "keV"), peak_hi_en: Quantity = Quantity(2.0, "keV"), - back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, cosmology=Planck15, - load_fits: bool = False, no_prog_bar: bool = False, psf_corr: bool = False): + back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, + cosmology: Cosmology = DEFAULT_COSMO, load_fits: bool = False, no_prog_bar: bool = False, + psf_corr: bool = False): """ The init of the StarSample XGA class. """ @@ -103,6 +106,8 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, distance: np.ndarray = None, self._point_radii = [] self._distances = [] self._proper_motions = [] + # This records which sources had a failed peak finding attempt, for a warning at the end of the declaration + failed_peak_find = [] with tqdm(desc="Setting up Stars", total=len(self._accepted_inds), disable=no_prog_bar) as dec_lb: for ind in range(len(self._accepted_inds)): r, d = ra[self._accepted_inds[ind]], dec[self._accepted_inds[ind]] @@ -123,7 +128,8 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, distance: np.ndarray = None, # thrown I have to catch it and not add that source to this sample. try: self._sources[n] = Star(r, d, di, n, pm, pr, match_radius, use_peak, peak_lo_en, peak_hi_en, - back_inn_rad_factor, back_out_rad_factor, cosmology, True, load_fits, False) + back_inn_rad_factor, back_out_rad_factor, cosmology, True, load_fits, + False, True) self._point_radii.append(pr.value) self._distances.append(di) self._proper_motions.append(pm) @@ -150,6 +156,30 @@ def __init__(self, ra: np.ndarray, dec: np.ndarray, distance: np.ndarray = None, from ..imagetools.psf import rl_psf rl_psf(self, lo_en=peak_lo_en, hi_en=peak_hi_en) + # It is possible (especially if someone is using the Sample classes as a way to check whether things have + # XMM data) that no sources will have been declared by this point, in which case it should fail now + if len(self._sources) == 0: + raise NoValidObservationsError( + "No Stars have been declared, none of the sample passed the cleaning steps.") + + # Put all the warnings for there being no XMM data in one - I think it's neater. Wait until after the check + # to make sure that are some sources because in that case this warning is redundant. + no_data = [name for name in self._failed_sources if self._failed_sources[name] == 'NoMatch' or + self._failed_sources[name] == 'Failed ObsClean'] + # If there are names in that list, then we do the warning + if len(no_data) != 0: + warn("The following do not appear to have any XMM data, and will not be included in the " + "sample (can also check .failed_names); {n}".format(n=', '.join(no_data)), stacklevel=2) + + # We also do a combined warning for those clusters that had a failed peak finding attempt, if there are any + if len(failed_peak_find) != 0: + warn("Peak finding did not converge for the following; {n}, using user " + "supplied coordinates".format(n=', '.join(failed_peak_find)), stacklevel=2) + + # This shows a warning that tells the user how to see any suppressed warnings that occurred during source + # declarations, but only if there actually were any. + self._check_source_warnings() + @property def point_radii(self) -> Quantity: """ diff --git a/xga/sas/__init__.py b/xga/sas/__init__.py index 23f808b2..ca05ed98 100644 --- a/xga/sas/__init__.py +++ b/xga/sas/__init__.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 01/09/2020, 17:08. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from .phot import * from .spec import * diff --git a/xga/sas/misc.py b/xga/sas/misc.py index c63dc9cd..739bdf67 100644 --- a/xga/sas/misc.py +++ b/xga/sas/misc.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 09/06/2021, 10:36. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors import os from random import randint diff --git a/xga/sas/phot.py b/xga/sas/phot.py index 88c41a2a..d2c70e1c 100644 --- a/xga/sas/phot.py +++ b/xga/sas/phot.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 09/06/2021, 10:36. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 14/04/2023, 15:27. Copyright (c) The Contributors import os from random import randint @@ -36,8 +36,7 @@ def evselect_image(sources: Union[BaseSource, NullSource, BaseSample], lo_en: Qu :param Quantity lo_en: The lower energy limit for the image, in astropy energy units. :param Quantity hi_en: The upper energy limit for the image, in astropy energy units. :param str add_expr: A string to be added to the SAS expression keyword - :param int num_cores: The number of cores to use (if running locally), default is set to - 90% of available. + :param int num_cores: The number of cores to use, default is set to 90% of available. :param bool disable_progress: Setting this to true will turn off the SAS generation progress bar. """ stack = False # This tells the sas_call routine that this command won't be part of a stack @@ -146,7 +145,7 @@ def eexpmap(sources: Union[BaseSource, NullSource, BaseSample], lo_en: Quantity # These are crucial, to generate an exposure map one must have a ccf.cif calibration file, and a reference # image. If they do not already exist, these commands should generate them. - cifbuild(sources, disable_progress=disable_progress) + cifbuild(sources, disable_progress=disable_progress, num_cores=num_cores) sources = evselect_image(sources, lo_en, hi_en) # This is necessary because the decorator will reduce a one element list of source objects to a single # source object. Useful for the user, not so much here where the code expects an iterable. @@ -254,10 +253,10 @@ def emosaic(sources: Union[BaseSource, BaseSample], to_mosaic: str, lo_en: Quant # To make a mosaic we need to have the individual products in the first place if to_mosaic == "image": - sources = evselect_image(sources, lo_en, hi_en, disable_progress=disable_progress) + sources = evselect_image(sources, lo_en, hi_en, disable_progress=disable_progress, num_cores=num_cores) for_name = "img" elif to_mosaic == "expmap": - sources = eexpmap(sources, lo_en, hi_en, disable_progress=disable_progress) + sources = eexpmap(sources, lo_en, hi_en, disable_progress=disable_progress, num_cores=num_cores) for_name = "expmap" # This is necessary because the decorator will reduce a one element list of source objects to a single @@ -373,7 +372,7 @@ def psfgen(sources: Union[BaseSource, BaseSample], bins: int = 4, psf_model: str " probably take too long...".format(bins)) # Need a valid CIF for this task, so run cifbuild first - cifbuild(sources, disable_progress=disable_progress) + cifbuild(sources, disable_progress=disable_progress, num_cores=num_cores) # This is necessary because the decorator will reduce a one element list of source objects to a single # source object. Useful for the user, not so much here where the code expects an iterable. diff --git a/xga/sas/run.py b/xga/sas/run.py index 6fed38cd..251f822e 100644 --- a/xga/sas/run.py +++ b/xga/sas/run.py @@ -1,10 +1,11 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 09/06/2021, 16:34. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 02/05/2023, 16:40. Copyright (c) The Contributors from functools import wraps from multiprocessing.dummy import Pool from subprocess import Popen, PIPE from typing import Tuple +from warnings import warn from tqdm import tqdm @@ -39,8 +40,8 @@ def execute_cmd(cmd: str, p_type: str, p_path: list, extra_info: dict, src: str) # pre-existing image if p_type == "image": # Maybe let the user decide not to raise errors detected in stderr - prod = Image(p_path[0], extra_info["obs_id"], extra_info["instrument"], out, err, cmd, - extra_info["lo_en"], extra_info["hi_en"]) + prod = Image(p_path[0], extra_info["obs_id"], extra_info["instrument"], out, err, cmd, extra_info["lo_en"], + extra_info["hi_en"]) if "psf_corr" in extra_info and extra_info["psf_corr"]: prod.psf_corrected = True prod.psf_bins = extra_info["psf_bins"] @@ -203,7 +204,7 @@ def err_callback(err): to_raise = [] for product in results[entry]: product: BaseProduct - ext_info = " {s} is the associated source, the specific data used is " \ + ext_info = "- {s} is the associated source, the specific data used is " \ "{o}-{i}.".format(s=sources[ind].name, o=product.obs_id, i=product.instrument) if len(product.sas_errors) == 1: to_raise.append(SASGenerationError(product.sas_errors[0] + ext_info)) @@ -226,6 +227,10 @@ def err_callback(err): # Really we're just re-creating the results dictionary here, but I want these products # to go through the error checking stuff like everything else does ann_spec_comps[entry].append(product) + elif prod_type_str == "annular spectrum set components": + raise warn("An annular spectrum component ({a}) for {n} has not been generated properly, contact " + "the development team if a SAS error is not " + "shown.".format(a=product.storage_key, n=product.src_name), stacklevel=2) if len(to_raise) != 0: all_to_raise.append(to_raise) @@ -235,10 +240,12 @@ def err_callback(err): # So now we pass the list of spectra to a AnnularSpectra definition - and it will sort them out # itself so the order doesn't matter ann_spec = AnnularSpectra(ann_spec_comps[entry]) + # Refresh the value of ind so that the correct source is used for radii conversion and so that + # the AnnularSpectra is added to the correct source. + ind = src_lookup[entry] if sources[ind].redshift is not None: # If we know the redshift we will add the radii to the annular spectra in proper distance units ann_spec.proper_radii = sources[ind].convert_radius(ann_spec.radii, 'kpc') - ind = src_lookup[entry] # And adding our exciting new set of annular spectra into the storage structure sources[ind].update_products(ann_spec) diff --git a/xga/sas/spec.py b/xga/sas/spec.py index b8be65d0..11770735 100644 --- a/xga/sas/spec.py +++ b/xga/sas/spec.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 09/06/2021, 10:36. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 02/05/2023, 16:40. Copyright (c) The Contributors import os import warnings @@ -469,14 +469,14 @@ def _spec_cmds(sources: Union[BaseSource, BaseSample], outer_radius: Union[str, # gr=group_spec, ex=extra_file_name) final_rmf_path = OUTPUT + obs_id + '/' + rmf - if one_rmf and not os.path.exists(final_rmf_path): + if one_rmf and (not os.path.exists(final_rmf_path) or force_gen): cmd_str = ";".join([s_cmd_str, dim_cmd_str, b_dim_cmd_str, d_cmd_str, rmf_cmd.format(r=rmf, s=spec, es=ex_src, ds=det_map, dt=dt), arf_cmd.format(s=spec, a=arf, r=rmf, e=evt_list.path, es=ex_src, ds=det_map, dt=dt), sb_cmd_str, bscal_cmd.format(s=spec, e=evt_list.path), bscal_cmd.format(s=b_spec, e=evt_list.path)]) #arf_cmd.format(s=b_spec, a=b_arf, r=b_rmf, e=evt_list.path, es=ex_src, ds=det_map) - elif not one_rmf and not os.path.exists(final_rmf_path): + elif not one_rmf and (not os.path.exists(final_rmf_path) or force_gen): cmd_str = ";".join([s_cmd_str, dim_cmd_str, b_dim_cmd_str, d_cmd_str, rmf_cmd.format(r=rmf, s=spec, es=ex_src, ds=det_map, dt=dt), arf_cmd.format(s=spec, a=arf, r=rmf, e=evt_list.path, es=ex_src, @@ -721,7 +721,7 @@ def spectrum_set(sources: Union[BaseSource, BaseSample], radii: Union[List[Quant for r_ind in range(len(radii[s_ind])-1): # Generate the SAS commands for the current annulus of the current source, for all observations spec_cmd_out = _spec_cmds(source, radii[s_ind][r_ind+1], radii[s_ind][r_ind], group_spec, min_counts, - min_sn, over_sample, one_rmf, num_cores, disable_progress, force_regen) + min_sn, over_sample, one_rmf, num_cores, disable_progress, True) # Read out some of the output into variables to be modified interim_paths = spec_cmd_out[5][0] diff --git a/xga/sources/__init__.py b/xga/sources/__init__.py index fb4cc880..a57e98c8 100644 --- a/xga/sources/__init__.py +++ b/xga/sources/__init__.py @@ -1,9 +1,9 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 06/01/2021, 12:53. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from .base import BaseSource, NullSource from .extended import GalaxyCluster -from .point import Star from .general import ExtendedSource, PointSource +from .point import Star diff --git a/xga/sources/base.py b/xga/sources/base.py index 18312f59..e6c2d882 100644 --- a/xga/sources/base.py +++ b/xga/sources/base.py @@ -1,18 +1,18 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 02/08/2021, 12:05. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 01/05/2023, 17:05. Copyright (c) The Contributors import os import pickle -import warnings from copy import deepcopy from itertools import product +from shutil import copyfile from typing import Tuple, List, Dict, Union +from warnings import warn, simplefilter import numpy as np import pandas as pd from astropy import wcs from astropy.coordinates import SkyCoord -from astropy.cosmology import Planck15 from astropy.cosmology.core import Cosmology from astropy.units import Quantity, UnitBase, Unit, UnitConversionError, deg from fitsio import FITS @@ -20,9 +20,10 @@ from regions import SkyRegion, EllipseSkyRegion, CircleSkyRegion, EllipsePixelRegion, CirclePixelRegion from regions import read_ds9, PixelRegion -from .. import xga_conf +from .. import xga_conf, BLACKLIST from ..exceptions import NotAssociatedError, NoValidObservationsError, MultipleMatchError, \ - NoProductAvailableError, NoMatchFoundError, ModelNotAssociatedError, ParameterNotAssociatedError + NoProductAvailableError, NoMatchFoundError, ModelNotAssociatedError, ParameterNotAssociatedError, \ + NotSampleMemberError from ..imagetools.misc import pix_deg_scale from ..imagetools.misc import sky_deg_scale from ..imagetools.profile import annular_mask @@ -30,11 +31,12 @@ RateMap, PSFGrid, BaseProfile1D, AnnularSpectra from ..sourcetools import simple_xmm_match, nh_lookup, ang_to_rad, rad_to_ang from ..sourcetools.misc import coord_to_name -from ..utils import ALLOWED_PRODUCTS, XMM_INST, dict_search, xmm_det, xmm_sky, OUTPUT, CENSUS +from ..utils import ALLOWED_PRODUCTS, XMM_INST, dict_search, xmm_det, xmm_sky, OUTPUT, CENSUS, SRC_REGION_COLOURS, \ + DEFAULT_COSMO # This disables an annoying astropy warning that pops up all the time with XMM images # Don't know if I should do this really -warnings.simplefilter('ignore', wcs.FITSFixedWarning) +simplefilter('ignore', wcs.FITSFixedWarning) class BaseSource: @@ -50,18 +52,46 @@ class BaseSource: proper distance units such as kpc cannot be used. :param str name: The name of the source, default is None in which case a name will be assembled from the coordinates given. - :param cosmology: An astropy cosmology object to use for analysis of this source, default is Planck15. + :param Cosmology cosmology: An astropy cosmology object to use for analysis of this source, default is a + concordance flat LambdaCDM model. :param bool load_products: Should existing XGA generated products for this source be loaded in, default is True. :param bool load_fits: Should existing XSPEC fits for this source be loaded in, will only work if load_products is True. Default is False. + :param bool in_sample: A boolean argument that tells the source whether it is part of a sample or not, setting + to True suppresses some warnings so that they can be displayed at the end of the sample progress bar. Default + is False. User should only set to True to remove warnings. """ - def __init__(self, ra: float, dec: float, redshift: float = None, name: str = None, cosmology=Planck15, - load_products: bool = True, load_fits: bool = False): + def __init__(self, ra: float, dec: float, redshift: float = None, name: str = None, + cosmology: Cosmology = DEFAULT_COSMO, load_products: bool = True, load_fits: bool = False, + in_sample: bool = False): """ The init method for the BaseSource, the most general type of XGA source which acts as a superclass for all others. - """ + + :param float ra: The right ascension (in degrees) of the source. + :param float dec: The declination (in degrees) of the source. + :param float redshift: The redshift of the source, default is None. Not supplying a redshift means that + proper distance units such as kpc cannot be used. + :param str name: The name of the source, default is None in which case a name will be assembled from the + coordinates given. + :param Cosmology cosmology: An astropy cosmology object to use for analysis of this source, default is a + concordance flat LambdaCDM model. + :param bool load_products: Should existing XGA generated products for this source be loaded in, default + is True. + :param bool load_fits: Should existing XSPEC fits for this source be loaded in, will only work if + load_products is True. Default is False. + :param bool in_sample: A boolean argument that tells the source whether it is part of a sample or + not, setting to True suppresses some warnings so that they can be displayed at the end of the sample + progress bar. Default is False. User should only set to True to remove warnings. + """ + # This tells the source that it is a part of a sample, which we will check to see whether to suppress warnings + self._samp_member = in_sample + # This is what the warnings (or warning codes) are stored in instead, so an external process working on the + # sample (or in the sample init) can look up what warnings are there. + self._supp_warn = [] + + # This sets up the user-defined coordinate attribute self._ra_dec = np.array([ra, dec]) if name is not None: # We don't be liking spaces in source names, we also don't like underscores @@ -87,20 +117,76 @@ def __init__(self, ra: float, dec: float, redshift: float = None, name: str = No # Only want ObsIDs, not pointing coordinates as well # Don't know if I'll always use the simple method - matches = simple_xmm_match(ra, dec) + matches, excluded = simple_xmm_match(ra, dec) + + # This will store information on the observations that were never included in analysis (so it's distinct from + # the disassociated_obs information) - I don't know if this is the solution I'll stick with, but it'll do + blacklisted_obs = {} + for row_ind, row in excluded.iterrows(): + # Just blacklist all instruments because for an ObsID to be in the excluded return + # from simple_xmm_match this has to be the case + blacklisted_obs[row['ObsID']] = ['pn', 'mos1', 'mos2'] + + # This checks that the observations have at least one usable instrument obs = matches["ObsID"].values instruments = {o: [] for o in obs} for o in obs: - if matches[matches["ObsID"] == o]["USE_PN"].values[0]: + # As the simple_xmm_match will only tell us about observations in which EVERY instrument is + # blacklisted, I have to check in the blacklist to see whether some individual instruments + # have to be excluded + excl_pn = False + excl_mos1 = False + excl_mos2 = False + if o in BLACKLIST['ObsID'].values: + if BLACKLIST[BLACKLIST['ObsID'] == o]['EXCLUDE_PN'].values[0] == 'T': + excl_pn = True + if BLACKLIST[BLACKLIST['ObsID'] == o]['EXCLUDE_MOS1'].values[0] == 'T': + excl_mos1 = True + if BLACKLIST[BLACKLIST['ObsID'] == o]['EXCLUDE_MOS2'].values[0] == 'T': + excl_mos2 = True + + # Here we see if PN is allowed by the census (things like CalClosed observations are excluded in + # the census) and if PN is allowed by the blacklist (individual instruments can be blacklisted). + if matches[matches["ObsID"] == o]["USE_PN"].values[0] and not excl_pn: instruments[o].append("pn") - if matches[matches["ObsID"] == o]["USE_MOS1"].values[0]: + # If excluded by the blacklist, then that needs + elif excl_pn: + # The behaviour writing PN to the dictionary changes slightly depending on whether the ObsID + # has an entry yet or not + if o not in blacklisted_obs: + blacklisted_obs[o] = ["pn"] + else: + blacklisted_obs[o] += ['pn'] + + # Now we repeat the same process for MOS1 and 2 - its quite clunky and there's probably a more + # elegant way that I could write this, but ah well + if matches[matches["ObsID"] == o]["USE_MOS1"].values[0] and not excl_mos1: instruments[o].append("mos1") - if matches[matches["ObsID"] == o]["USE_MOS2"].values[0]: + # If excluded by the blacklist, then that needs + elif excl_mos1: + # The behaviour writing MOS1 to the dictionary changes slightly depending on whether the ObsID + # has an entry yet or not + if o not in blacklisted_obs: + blacklisted_obs[o] = ["mos1"] + else: + blacklisted_obs[o] += ['mos1'] + + if matches[matches["ObsID"] == o]["USE_MOS2"].values[0] and not excl_mos2: instruments[o].append("mos2") + # If excluded by the blacklist, then that needs + elif excl_mos2: + # The behaviour writing MOS2 to the dictionary changes slightly depending on whether the ObsID + # has an entry yet or not + if o not in blacklisted_obs: + blacklisted_obs[o] = ["mos2"] + else: + blacklisted_obs[o] += ['mos2'] - # This checks that the observations have at least one usable instrument + # Information about which ObsIDs/instruments are available, and which have been blacklisted, is stored + # in class attributes here. self._obs = [o for o in obs if len(instruments[o]) > 0] self._instruments = {o: instruments[o] for o in self._obs if len(instruments[o]) > 0} + self._blacklisted_obs = blacklisted_obs # self._obs can be empty after this cleaning step, so do quick check and raise error if so. if len(self._obs) == 0: @@ -120,7 +206,7 @@ def __init__(self, ra: float, dec: float, redshift: float = None, name: str = No # Check in a box of half-side 5 arcminutes, should give an idea of which are on-axis try: - on_axis_match = simple_xmm_match(ra, dec, Quantity(5, 'arcmin'))["ObsID"].values + on_axis_match = simple_xmm_match(ra, dec, Quantity(5, 'arcmin'))[0]["ObsID"].values except NoMatchFoundError: on_axis_match = np.array([]) self._onaxis = list(np.array(self._obs)[np.isin(self._obs, on_axis_match)]) @@ -128,6 +214,10 @@ def __init__(self, ra: float, dec: float, redshift: float = None, name: str = No # nhlookup returns average and weighted average values, so just take the first self._nH = nh_lookup(self.ra_dec)[0] self._redshift = redshift + # This method uses the instruments attribute to check and see whether a particular ObsID-Instrument + # combination is allowed for this source. As that attribute was constructed using the blacklist information + # we can be sure that every ObsID-Instrument combination loaded in here is allowed to be here. The only + # other way for them to change is through using the dissociate observation capability self._products, region_dict, self._att_files = self._initial_products() # Want to update the ObsIDs associated with this source after seeing if all files are present @@ -201,6 +291,9 @@ def __init__(self, ra: float, dec: float, redshift: float = None, name: str = No self._peak_lo_en = Quantity(0.5, 'keV') self._peak_hi_en = Quantity(2.0, 'keV') + # Peaks don't really have any meaning for the BaseSource class, so even though this is a boolean variable + # when populated properly I set it to None here + self._use_peak = None # These attributes pertain to the cleaning of observations (as in disassociating them from the source if # they don't include enough of the object we care about). @@ -324,6 +417,9 @@ def read_default_products(en_lims: tuple) -> Tuple[str, dict]: continue evt_key = "clean_{}_evts".format(inst) evt_file = xga_conf["XMM_FILES"][evt_key].format(obs_id=obs_id) + # This is the path to the region file specified in the configuration file, but the next step is that + # we make a local copy (if the original file exists) and then make use of that so that any modifications + # don't harm the original file. reg_file = xga_conf["XMM_FILES"]["region_file"].format(obs_id=obs_id) # Attitude file is a special case of data product, only SAS should ever need it, so it doesn't @@ -339,9 +435,18 @@ def read_default_products(en_lims: tuple) -> Tuple[str, dict]: # Dictionary updated with derived product names map_ret = map(read_default_products, en_comb) obs_dict[obs_id][inst].update({gen_return[0]: gen_return[1] for gen_return in map_ret}) - if os.path.exists(reg_file): - # Regions dictionary updated with path to region file, if it exists - reg_dict[obs_id] = reg_file + + # As mentioned above, we make a local copy of the region file if the original file path exists + # and if a local copy DOESN'T already exist + reg_copy_path = OUTPUT+"{o}/{o}_xga_copy.reg".format(o=obs_id) + if os.path.exists(reg_file) and not os.path.exists(reg_copy_path): + # A local copy of the region file is made and used + copyfile(reg_file, reg_copy_path) + # Regions dictionary updated with path to local region file, if it exists + reg_dict[obs_id] = reg_copy_path + # In the case where there is already a local copy of the region file + elif os.path.exists(reg_copy_path): + reg_dict[obs_id] = reg_copy_path else: reg_dict[obs_id] = None @@ -352,8 +457,9 @@ def read_default_products(en_lims: tuple) -> Tuple[str, dict]: " files.".format(s=self.name, n=len(self._obs), a=", ".join(self._obs))) return obs_dict, reg_dict, att_dict - def update_products(self, prod_obj: Union[BaseProduct, BaseAggregateProduct, BaseProfile1D, - List[BaseProduct], List[BaseAggregateProduct], List[BaseProfile1D]]): + def update_products(self, prod_obj: Union[BaseProduct, BaseAggregateProduct, BaseProfile1D, List[BaseProduct], + List[BaseAggregateProduct], List[BaseProfile1D]], + update_inv: bool = True): """ Setter method for the products attribute of source objects. Cannot delete existing products, but will overwrite existing products. Raises errors if the ObsID is not associated @@ -364,6 +470,9 @@ def update_products(self, prod_obj: Union[BaseProduct, BaseAggregateProduct, Bas :param BaseProduct/BaseAggregateProduct/BaseProfile1D/List[BaseProduct]/List[BaseProfile1D] prod_obj: The new product object(s) to be added to the source object. + :param bool update_inv: This flag is to avoid unnecessary read-writes when this method is called by a method + (such as _existing_xga_products) which want to add products to the source storage structure, but don't + want the inventory file altered (as they know the product is already in there). """ # Aggregate products are things like PSF grids and sets of annular spectra. if not isinstance(prod_obj, (BaseProduct, BaseAggregateProduct, BaseProfile1D, list)) and prod_obj is not None: @@ -477,9 +586,8 @@ def update_products(self, prod_obj: Union[BaseProduct, BaseAggregateProduct, Bas if isinstance(po, BaseProfile1D) and not os.path.exists(po.save_path): po.save() - # Here we make sure to store a record of the added product in the relevant inventory file - if isinstance(po, BaseProduct) and po.obs_id != 'combined': + if isinstance(po, BaseProduct) and po.obs_id != 'combined' and update_inv: inven = pd.read_csv(OUTPUT + "{}/inventory.csv".format(po.obs_id), dtype=str) # Don't want to store a None value as a string for the info_key @@ -503,15 +611,16 @@ def update_products(self, prod_obj: Union[BaseProduct, BaseAggregateProduct, Bas # Creates new pandas series to be appended to the inventory dataframe new_line = pd.Series([f_name, po.obs_id, po.instrument, info_key, s_name, po.type], ['file_name', 'obs_id', 'inst', 'info_key', 'src_name', 'type'], dtype=str) - # Appends the series - inven = inven.append(new_line, ignore_index=True) + # Concatenates the series with the inventory dataframe + inven = pd.concat([inven, new_line.to_frame().T], ignore_index=True) + # Checks for rows that are exact duplicates, this should never happen as far as I can tell, but # if it did I think it would cause problems so better to be safe and add this. inven.drop_duplicates(subset=None, keep='first', inplace=True) # Saves the updated inventory file inven.to_csv(OUTPUT + "{}/inventory.csv".format(po.obs_id), index=False) - elif isinstance(po, BaseProduct) and po.obs_id == 'combined': + elif isinstance(po, BaseProduct) and po.obs_id == 'combined' and update_inv: inven = pd.read_csv(OUTPUT + "combined/inventory.csv".format(po.obs_id), dtype=str) # Don't want to store a None value as a string for the info_key @@ -538,11 +647,12 @@ def update_products(self, prod_obj: Union[BaseProduct, BaseAggregateProduct, Bas # Creates new pandas series to be appended to the inventory dataframe new_line = pd.Series([f_name, o_str, i_str, info_key, s_name, po.type], ['file_name', 'obs_ids', 'insts', 'info_key', 'src_name', 'type'], dtype=str) - inven = inven.append(new_line, ignore_index=True) + # Concatenates the series with the inventory dataframe + inven = pd.concat([inven, new_line.to_frame().T], ignore_index=True) inven.drop_duplicates(subset=None, keep='first', inplace=True) inven.to_csv(OUTPUT + "combined/inventory.csv".format(po.obs_id), index=False) - elif isinstance(po, BaseProfile1D) and po.obs_id != 'combined': + elif isinstance(po, BaseProfile1D) and po.obs_id != 'combined' and update_inv: inven = pd.read_csv(OUTPUT + "profiles/{}/inventory.csv".format(self.name), dtype=str) # Don't want to store a None value as a string for the info_key @@ -557,11 +667,12 @@ def update_products(self, prod_obj: Union[BaseProduct, BaseAggregateProduct, Bas # Creates new pandas series to be appended to the inventory dataframe new_line = pd.Series([f_name, o_str, i_str, info_key, po.src_name, po.type], ['file_name', 'obs_ids', 'insts', 'info_key', 'src_name', 'type'], dtype=str) - inven = inven.append(new_line, ignore_index=True) + # Concatenates the series with the inventory dataframe + inven = pd.concat([inven, new_line.to_frame().T], ignore_index=True) inven.drop_duplicates(subset=None, keep='first', inplace=True) inven.to_csv(OUTPUT + "profiles/{}/inventory.csv".format(self.name), index=False) - elif isinstance(po, BaseProfile1D) and po.obs_id == 'combined': + elif isinstance(po, BaseProfile1D) and po.obs_id == 'combined' and update_inv: inven = pd.read_csv(OUTPUT + "profiles/{}/inventory.csv".format(self.name), dtype=str) # Don't want to store a None value as a string for the info_key @@ -576,7 +687,8 @@ def update_products(self, prod_obj: Union[BaseProduct, BaseAggregateProduct, Bas # Creates new pandas series to be appended to the inventory dataframe new_line = pd.Series([f_name, o_str, i_str, info_key, po.src_name, po.type], ['file_name', 'obs_ids', 'insts', 'info_key', 'src_name', 'type'], dtype=str) - inven = inven.append(new_line, ignore_index=True) + # Concatenates the series with the inventory dataframe + inven = pd.concat([inven, new_line.to_frame().T], ignore_index=True) inven.drop_duplicates(subset=None, keep='first', inplace=True) inven.to_csv(OUTPUT + "profiles/{}/inventory.csv".format(self.name), index=False) @@ -659,7 +771,7 @@ def parse_image_like(file_path: str, exact_type: str, merged: bool = False) -> B # Fetches lines of the inventory which match the current ObsID and instrument rel_ims = im_lines[(im_lines['obs_id'] == obs) & (im_lines['inst'] == i)] for r_ind, r in rel_ims.iterrows(): - self.update_products(parse_image_like(cur_d+r['file_name'], r['type'])) + self.update_products(parse_image_like(cur_d+r['file_name'], r['type']), update_inv=False) # For spectra we search for products that have the name of this object in, as they are for # specific parts of the observation. @@ -723,7 +835,7 @@ def parse_image_like(file_path: str, exact_type: str, merged: bool = False) -> B # I split the 'spec' part of the end of the name of the spectrum, and can use the parts of the # file name preceding it to search for matching arf/rmf files - sp_info_str = sp.split('_spec')[0] + sp_info_str = cur_d + sp.split('/')[-1].split('_spec')[0] # Fairly self explanatory, need to find all the separate products needed to define an XGA # spectrum @@ -765,7 +877,7 @@ def parse_image_like(file_path: str, exact_type: str, merged: bool = False) -> B # And adding it to the source storage structure, but only if its not a member # of an AnnularSpectra try: - self.update_products(obj) + self.update_products(obj, update_inv=False) except NotAssociatedError: pass @@ -787,12 +899,16 @@ def parse_image_like(file_path: str, exact_type: str, merged: bool = False) -> B # And adding it to the source storage structure, but only if its not a member # of an AnnularSpectra try: - self.update_products(obj) + self.update_products(obj, update_inv=False) except NotAssociatedError: pass else: - warnings.warn("{src} spectrum {sp} cannot be loaded in due to a mismatch in available" - " ancillary files".format(src=self.name, sp=sp)) + warn_text = "{src} spectrum {sp} cannot be loaded in due to a mismatch in available" \ + " ancillary files".format(src=self.name, sp=sp) + if not self._samp_member: + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) if "ident" in sp.split("/")[-1]: set_id = int(sp.split('ident')[-1].split('_')[0]) ann_spec_usable[set_id] = False @@ -803,12 +919,21 @@ def parse_image_like(file_path: str, exact_type: str, merged: bool = False) -> B os.chdir(OUTPUT + "profiles/{}".format(self.name)) saved_profs = [pf for pf in os.listdir('.') if '.xga' in pf and 'profile' in pf and self.name in pf] for pf in saved_profs: - with open(pf, 'rb') as reado: - temp_prof = pickle.load(reado) - try: - self.update_products(temp_prof) - except NotAssociatedError: - pass + try: + with open(pf, 'rb') as reado: + temp_prof = pickle.load(reado) + try: + self.update_products(temp_prof, update_inv=False) + except NotAssociatedError: + pass + except (EOFError, pickle.UnpicklingError): + warn_text = "A profile save ({}) appears to be corrupted, it has not been " \ + "loaded; you can safely delete this file".format(os.getcwd() + '/' + pf) + if not self._samp_member: + # If these errors have been raised then I think that the pickle file has been broken (see issue #935) + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) os.chdir(og_dir) # If spectra that should be a part of annular spectra object(s) have been found, then I need to create @@ -820,7 +945,7 @@ def parse_image_like(file_path: str, exact_type: str, merged: bool = False) -> B if self._redshift is not None: # If we know the redshift we will add the radii to the annular spectra in proper distance units ann_spec_obj.proper_radii = self.convert_radius(ann_spec_obj.radii, 'kpc') - self.update_products(ann_spec_obj) + self.update_products(ann_spec_obj, update_inv=False) # Here we load in any combined images and exposure maps that may have been generated os.chdir(OUTPUT + 'combined') @@ -844,7 +969,8 @@ def parse_image_like(file_path: str, exact_type: str, merged: bool = False) -> B # the src_oi_set, and if that is the same length as the original src_oi_set then we know that they match # exactly and the product can be loaded if len(src_oi_set) == len(test_oi_set) and len(src_oi_set | test_oi_set) == len(src_oi_set): - self.update_products(parse_image_like(cur_d+row['file_name'], row['type'], merged=True)) + self.update_products(parse_image_like(cur_d+row['file_name'], row['type'], merged=True), + update_inv=False) os.chdir(og_dir) @@ -937,8 +1063,12 @@ def parse_image_like(file_path: str, exact_type: str, merged: bool = False) -> B self.add_fit_data(model, global_results, chosen_lums, sp_key) except (OSError, NoProductAvailableError, IndexError, NotAssociatedError): chosen_lums = {} - warnings.warn("{src} fit {f} could not be loaded in as there are no matching spectra " - "available".format(src=self.name, f=fit_name)) + warn_text = "{src} fit {f} could not be loaded in as there are no matching spectra " \ + "available".format(src=self.name, f=fit_name) + if not self._samp_member: + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) fit_data.close() if len(ann_results) != 0: @@ -960,9 +1090,13 @@ def parse_image_like(file_path: str, exact_type: str, merged: bool = False) -> B # met_prof = rel_ann_spec.generate_profile(model, 'Abundanc', '') # self.update_products(met_prof) except (NoProductAvailableError, ValueError): - warnings.warn("A previous annular spectra profile fit for {src} was not successful, or no " - "matching spectrum has been loaded, so it cannot be read " - "in".format(src=self.name)) + warn_text = "A previous annular spectra profile fit for {src} was not successful, or no " \ + "matching spectrum has been loaded, so it cannot be read " \ + "in".format(src=self.name) + if not self._samp_member: + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) os.chdir(og_dir) @@ -983,12 +1117,30 @@ def parse_image_like(file_path: str, exact_type: str, merged: bool = False) -> B # by going to a set (because there will be two columns for each ObsID+Instrument, rate and Lx) # First two columns are skipped because they are energy limits combos = list(set([c.split("_")[1] for c in res_table.columns[2:]])) - # Getting the spectra for each column, then assigning rates and lums - for comb in combos: - spec = self.get_products("spectrum", comb[:10], comb[10:], extra_key=storage_key)[0] - spec.add_conv_factors(res_table["lo_en"].values, res_table["hi_en"].values, - res_table["rate_{}".format(comb)].values, - res_table["Lx_{}".format(comb)].values, model) + # Getting the spectra for each column, then assigning rates and luminosities. + # Due to the danger of a fit using a piece of data (an ObsID-instrument combo) that isn't currently + # associated with the source, we first fetch the spectra, then in a second loop we assign the factors + rel_spec = [] + try: + for comb in combos: + spec = self.get_products("spectrum", comb[:10], comb[10:], extra_key=storage_key)[0] + rel_spec.append(spec) + + for comb_ind, comb in enumerate(combos): + rel_spec[comb_ind].add_conv_factors(res_table["lo_en"].values, res_table["hi_en"].values, + res_table["rate_{}".format(comb)].values, + res_table["Lx_{}".format(comb)].values, model) + + # This triggers in the case of something like issue #738, where a previous fit used data that is + # not loaded into this source (either because it was manually removed, or because the central + # position has changed etc.) + except NotAssociatedError: + warn_text = "Existing fit for {s} could not be loaded due to a mismatch in available " \ + "data".format(s=self.name) + if not self._samp_member: + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) def get_products(self, p_type: str, obs_id: str = None, inst: str = None, extra_key: str = None, just_obj: bool = True) -> List[BaseProduct]: @@ -1088,6 +1240,20 @@ def dist_from_source(reg): for obs_id in reg_paths: if reg_paths[obs_id] is not None: ds9_regs = read_ds9(reg_paths[obs_id]) + # Grab all images for the ObsID, instruments across an ObsID have the same WCS (other than in cases + # where they were generated with different resolutions). + # TODO see issue #908, figure out how to support different resolutions of image + try: + ims = self.get_images(obs_id) + except NoProductAvailableError: + raise NoProductAvailableError("There is no image available for observation {o}, associated " + "with {n}. An image is currently required to check for sky " + "coordinates being present within a sky region - though hopefully " + "no-one will ever see this because I'll have fixed " + "it!".format(o=obs_id, n=self.name)) + w = None + else: + w = ims[0].radec_wcs # Apparently can happen that there are no regions in a region file, so if that is the case # then I just set the ds9_regs to [None] because I know the rest of the code can deal with that. # It can't deal with an empty list @@ -1101,17 +1267,11 @@ def dist_from_source(reg): # one of the images supplied in the config file, not anything that XGA generates. # But as this method is only run once, before XGA generated products are loaded in, it # should be fine - inst = [k for k in self._products[obs_id] if k in ["pn", "mos1", "mos2"]][0] - en = [k for k in self._products[obs_id][inst] if "-" in k][0] - # Making an assumption here, that if there are regions there will be images - # Getting the radec_wcs property from the Image object - im = [i for i in self.get_products("image", obs_id, inst, just_obj=False) if en in i] - - if len(im) != 1: + if w is None: raise NoProductAvailableError("There is no image available for observation {o}, associated " - "with {n}. An image is require to translate pixel regions " + "with {n}. An image is currently required to translate pixel regions " "to RA-DEC.".format(o=obs_id, n=self.name)) - w = im[0][-1].radec_wcs + sky_regs = [reg.to_sky(w) for reg in ds9_regs] reg_dict[obs_id] = np.array(sky_regs) elif isinstance(ds9_regs[0], SkyRegion): @@ -1121,8 +1281,15 @@ def dist_from_source(reg): reg_dict[obs_id] = np.array([None]) # Here we add the custom sources to the source list, we know they are sky regions as we have - # already enforced it - reg_dict[obs_id] = np.append(reg_dict[obs_id], custom_regs) + # already enforced it. If there was no region list for a particular ObsID (detected by the first + # entry in the reg dict being None) and there IS a custom region, we just replace the None with the + # custom region + if reg_dict[obs_id][0] is not None: + reg_dict[obs_id] = np.append(reg_dict[obs_id], custom_regs) + elif reg_dict[obs_id][0] is None and len(custom_regs) != 0: + reg_dict[obs_id] = custom_regs + else: + reg_dict[obs_id] = np.array([None]) # I'm going to ensure that all regions are elliptical, I don't want to hunt through every place in XGA # where I made that assumption @@ -1136,7 +1303,7 @@ def dist_from_source(reg): reg_dict[obs_id][reg_ind] = new_reg # Hopefully this bodge doesn't have any unforeseen consequences - if reg_dict[obs_id][0] is not None: + if reg_dict[obs_id][0] is not None and len(reg_dict[obs_id]) > 1: # Quickly calculating distance between source and center of regions, then sorting # and getting indices. Thus I only match to the closest 5 regions. diff_sort = np.array([dist_from_source(r) for r in reg_dict[obs_id]]).argsort() @@ -1151,6 +1318,12 @@ def dist_from_source(reg): # Expands it so it can be used as a mask on the whole set of regions for this observation within = np.pad(within, [0, len(diff_sort) - len(within)]) match_dict[obs_id] = within + # In the case of only one region being in the list, we simplify the above expression + elif reg_dict[obs_id][0] is not None and len(reg_dict[obs_id]) == 1: + if reg_dict[obs_id][0].contains(SkyCoord(*self._ra_dec, unit='deg'), w): + match_dict[obs_id] = np.array([True]) + else: + match_dict[obs_id] = np.array([False]) else: match_dict[obs_id] = np.array([False]) @@ -1253,6 +1426,17 @@ def obs_ids(self) -> List[str]: """ return self._obs + @property + def blacklisted(self) -> Dict: + """ + A property getter that returns the dictionary of ObsIDs and their instruments which have been + blacklisted, and thus not considered for use in any analysis of this source. + + :return: The dictionary (with ObsIDs as keys) of blacklisted data. + :rtype: Dict + """ + return self._blacklisted_obs + def _source_type_match(self, source_type: str) -> Tuple[Dict, Dict, Dict]: """ A method that looks for matches not just based on position, but also on the type of source @@ -1267,18 +1451,17 @@ def _source_type_match(self, source_type: str) -> Tuple[Dict, Dict, Dict]: :rtype: Tuple[Dict, Dict, Dict] """ # Definitions of the colours of XCS regions can be found in the thesis of Dr Micheal Davidson - # University of Edinburgh - 2005. + # University of Edinburgh - 2005. These are the default XGA colour meanings # Red - Point source # Green - Extended source # Magenta - PSF-sized extended source # Blue - Extended source with significant point source contribution # Cyan - Extended source with significant Run1 contribution # Yellow - Extended source with less than 10 counts - if source_type == "ext": - allowed_colours = ["green", "magenta", "blue", "cyan", "yellow"] - elif source_type == "pnt": - allowed_colours = ["red"] - else: + try: + # Gets the allowed colours for the current source type + allowed_colours = SRC_REGION_COLOURS[source_type] + except KeyError: raise ValueError("{} is not a recognised source type, please " "don't use this internal function!".format(source_type)) @@ -1294,14 +1477,27 @@ def _source_type_match(self, source_type: str) -> Tuple[Dict, Dict, Dict]: # with missing ObsIDs for obs in self.obs_ids: if obs in self._initial_regions: - # If there are no matches then the returned result is just None - if len(self._initial_regions[obs][self._initial_region_matches[obs]]) == 0: + # This sets up an array of matched regions, accounting for the problems that can occur when + # there is only one region in the region list (numpy's indexing gets very angry). The array + # of matched region(s) set up here is used in this method. + if len(self._initial_regions[obs]) == 1 and not self._initial_region_matches[obs][0]: + init_region_matches = np.array([]) + elif len(self._initial_regions[obs]) == 1 and self._initial_region_matches[obs][0]: + init_region_matches = self._initial_regions[obs] + elif len(self._initial_regions[obs][self._initial_region_matches[obs]]) == 0: + init_region_matches = np.array([]) + else: + init_region_matches = self._initial_regions[obs][self._initial_region_matches[obs]] + + # If there are no matches then the returned result is just None. + if len(init_region_matches) == 0: results_dict[obs] = None else: interim_reg = [] # The only solution I could think of is to go by the XCS standard of region files, so green # is extended, red is point etc. - not ideal but I'll just explain in the documentation - for entry in self._initial_regions[obs][self._initial_region_matches[obs]]: + # for entry in self._initial_regions[obs][self._initial_region_matches[obs]]: + for entry in init_region_matches: if entry.visual["color"] in allowed_colours: interim_reg.append(entry) @@ -1317,10 +1513,14 @@ def _source_type_match(self, source_type: str) -> Tuple[Dict, Dict, Dict]: # Hence I choose that one for pnt source multi-matches like this, see comment 2 of issue #639 # for an example. results_dict[obs] = interim_reg[0] - warnings.warn("{ns} matches for the point source {n} are found in the {o} region " - "file. The source nearest to the passed coordinates is accepted, all others " - "will be placed in the alternate match category and will not be removed " - "by masks.".format(o=obs, n=self.name, ns=len(interim_reg))) + warn_text = "{ns} matches for the point source {n} are found in the {o} region " \ + "file. The source nearest to the passed coordinates is accepted, all others " \ + "will be placed in the alternate match category and will not be removed " \ + "by masks.".format(o=obs, n=self.name, ns=len(interim_reg)) + if not self._samp_member: + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) elif len(interim_reg) > 1 and source_type == "ext": raise MultipleMatchError("More than one match for {n} is found in the region file " @@ -1328,8 +1528,7 @@ def _source_type_match(self, source_type: str) -> Tuple[Dict, Dict, Dict]: "for extended sources.".format(o=obs, n=self.name)) # Alt match is used for when there is a secondary match to a point source - alt_match_reg = [entry for entry in self._initial_regions[obs][self._initial_region_matches[obs]] - if entry != results_dict[obs]] + alt_match_reg = [entry for entry in init_region_matches if entry != results_dict[obs]] alt_match_dict[obs] = alt_match_reg # These are all the sources that aren't a match, and so should be removed from any analysis @@ -1345,7 +1544,7 @@ def _source_type_match(self, source_type: str) -> Tuple[Dict, Dict, Dict]: return results_dict, alt_match_dict, anti_results_dict @property - def detected(self) -> bool: + def detected(self) -> dict: """ A property getter to return if a match of the correct type has been found. @@ -1358,6 +1557,16 @@ def detected(self) -> bool: else: return self._detected + @property + def matched_regions(self) -> dict: + """ + Property getter for the matched regions associated with this particular source. + + :return: A dictionary of matching regions, or None if such a match has not been performed. + :rtype: dict + """ + return self._regions + def source_back_regions(self, reg_type: str, obs_id: str = None, central_coord: Quantity = None) \ -> Tuple[SkyRegion, SkyRegion]: """ @@ -1373,7 +1582,7 @@ def source_back_regions(self, reg_type: str, obs_id: str = None, central_coord: # Doing an initial check so I can throw a warning if the user wants a region-list region AND has supplied # custom central coordinates if reg_type == "region" and central_coord is not None: - warnings.warn("You cannot use custom central coordinates with a region from supplied region files") + warn("You cannot use custom central coordinates with a region from supplied region files", stacklevel=2) if central_coord is None: central_coord = self._default_coord @@ -1455,6 +1664,33 @@ def within_region(self, region: SkyRegion) -> List[SkyRegion]: return reg_within + def get_interloper_regions(self, flattened: bool = False) -> Union[List, Dict]: + """ + This get method provides a way to access the regions that have been designated as interlopers (i.e. + not the source region that a particular Source has been designated to investigate) for all observations. + They can either be retrieved in a dictionary with ObsIDs as the keys, or a flattened single list with no + ObsID context. + + :param bool flattened: If true then the regions are returned as a single list of region objects. Otherwise + they are returned as a dictionary with ObsIDs as keys. Default is False. + :return: Either a list of region objects, or a dictionary with ObsIDs as keys. + :rtype: Union[List,Dict] + """ + if type(self) == BaseSource: + raise TypeError("BaseSource objects don't have enough information to know which sources " + "are interlopers.") + + # If flattened then a list is returned rather than the original dictionary with + if not flattened: + ret_reg = self._other_regions + else: + # Iterate through the ObsIDs in the dictionary and add the resulting lists together + ret_reg = [] + for o in self._other_regions: + ret_reg += self._other_regions[o] + + return ret_reg + def get_source_mask(self, reg_type: str, obs_id: str = None, central_coord: Quantity = None) \ -> Tuple[np.ndarray, np.ndarray]: """ @@ -1476,7 +1712,6 @@ def get_source_mask(self, reg_type: str, obs_id: str = None, central_coord: Quan # mask does all the checks anyway src_reg, bck_reg = self.source_back_regions(reg_type, obs_id, central_coord) - # I assume that if no ObsID is supplied, then the user wishes to have a mask for the combined data if obs_id is None: comb_images = self.get_products("combined_image") @@ -1711,6 +1946,68 @@ def get_snr(self, outer_radius: Union[Quantity, str], central_coord: Quantity = return sn + def get_counts(self, outer_radius: Union[Quantity, str], central_coord: Quantity = None, lo_en: Quantity = None, + hi_en: Quantity = None, obs_id: str = None, inst: str = None, psf_corr: bool = False, + psf_model: str = "ELLBETA", psf_bins: int = 4, psf_algo: str = "rl", psf_iter: int = 15) -> Quantity: + """ + This takes a region type and central coordinate and calculates the background subtracted X-ray counts. + The background region is constructed using the back_inn_rad_factor and back_out_rad_factor + values, the defaults of which are 1.05*radius and 1.5*radius respectively. + + :param Quantity/str outer_radius: The radius that counts should be calculated within, this can either be a + named radius such as r500, or an astropy Quantity. + :param Quantity central_coord: The central coordinate of the region. + :param Quantity lo_en: The lower energy bound of the ratemap to use to calculate the counts. Default is None, + in which case the lower energy bound for peak finding will be used (default is 0.5keV). + :param Quantity hi_en: The upper energy bound of the ratemap to use to calculate the counts. Default is None, + in which case the upper energy bound for peak finding will be used (default is 2.0keV). + :param str obs_id: An ObsID of a specific ratemap to use for the counts calculation. Default is None, which + means the combined ratemap will be used. Please note that inst must also be set to use this option. + :param str inst: The instrument of a specific ratemap to use for the counts calculation. Default is None, which + means the combined ratemap will be used. + :param bool psf_corr: Sets whether you wish to use a PSF corrected ratemap or not. + :param str psf_model: If the ratemap you want to use is PSF corrected, this is the PSF model used. + :param int psf_bins: If the ratemap you want to use is PSF corrected, this is the number of PSFs per + side in the PSF grid. + :param str psf_algo: If the ratemap you want to use is PSF corrected, this is the algorithm used. + :param int psf_iter: If the ratemap you want to use is PSF corrected, this is the number of iterations. + :return: The background subtracted counts. + :rtype: Quantity + """ + # Checking if the user passed any energy limits of their own + if lo_en is None: + lo_en = self._peak_lo_en + if hi_en is None: + hi_en = self._peak_hi_en + + # Parsing the ObsID and instrument options, see if they want to use a specific ratemap + if all([obs_id is None, inst is None]): + # Here the user hasn't set ObsID or instrument, so we use the combined data + rt = self.get_combined_ratemaps(lo_en, hi_en, psf_corr, psf_model, psf_bins, psf_algo, psf_iter) + + elif all([obs_id is not None, inst is not None]): + # Both ObsID and instrument have been set by the user + rt = self.get_ratemaps(obs_id, inst, lo_en, hi_en, psf_corr, psf_model, psf_bins, psf_algo, psf_iter) + else: + raise ValueError("If you wish to use a specific ratemap for {s}'s signal to noise calculation, please " + " pass both obs_id and inst.".format(s=self.name)) + + if isinstance(outer_radius, str): + # Grabs the interloper removed source and background region masks. If the ObsID is None the get_mask + # method understands that means it should return the mask for the combined data + src_mask, bck_mask = self.get_mask(outer_radius, obs_id, central_coord) + else: + # Here we have the case where the user has passed a custom outer radius, so we need to generate a + # custom mask for it + src_mask = self.get_custom_mask(outer_radius, obs_id=obs_id, central_coord=central_coord) + bck_mask = self.get_custom_mask(outer_radius*self._back_out_factor, outer_radius*self._back_inn_factor, + obs_id=obs_id, central_coord=central_coord) + + # We use the ratemap's built in background subtracted counts calculation method + cnts = rt.background_subtracted_counts(src_mask, bck_mask) + + return cnts + def regions_within_radii(self, inner_radius: Quantity, outer_radius: Quantity, deg_central_coord: Quantity, regions_to_search: Union[np.ndarray, list] = None) -> np.ndarray: """ @@ -2345,8 +2642,24 @@ def disassociate_obs(self, to_remove: Union[dict, str, list]): # that the rest of the function requires if isinstance(to_remove, str): to_remove = {to_remove: deepcopy(self.instruments[to_remove])} + # Here is where they have just passed a list of ObsIDs, and we need to fill in the blanks with the instruments + # currently loaded for those ObsIDs elif isinstance(to_remove, list): to_remove = {o: deepcopy(self.instruments[o]) for o in to_remove} + # Here deals with when someone might have passed a dictionary where there is a single instrument, and + # they haven't put it in a list; e.g. {'0201903501': 'pn'}. This detects instances like that and then + # puts the individual instrument in a list as is expected by the rest of the function + elif isinstance(to_remove, dict) and not all([isinstance(v, list) for v in to_remove.values()]): + new_to_remove = {} + for o in to_remove: + if not isinstance(to_remove[o], list): + new_to_remove[o] = [deepcopy(to_remove[o])] + else: + new_to_remove[o] = deepcopy(to_remove[o]) + + # I use deepcopy again because there have been issues with this function still pointing to old memory + # addresses, so I'm quite paranoid in this bit of code + to_remove = deepcopy(new_to_remove) # We also check to make sure that the data we're being asked to remove actually is associated with the # source. We shall be forgiving if it isn't, and just issue a warning to let the user know that they are @@ -2354,14 +2667,14 @@ def disassociate_obs(self, to_remove: Union[dict, str, list]): # Iterating through the keys (ObsIDs) in to_remove for o in to_remove: if o not in self.obs_ids: - warnings.warn("{o} data cannot be removed from {s} as they are not associated with " - "it.".format(o=o, s=self.name)) + warn("{o} data cannot be removed from {s} as they are not associated with " + "it.".format(o=o, s=self.name), stacklevel=2) # Check to see whether any of the instruments for o are not actually associated with the source elif any([i not in self.instruments[o] for i in to_remove[o]]): bad_list = [i for i in to_remove[o] if i not in self.instruments[o]] bad_str = "/".join(bad_list) - warnings.warn("{o}-{ib} data cannot be removed from {s} as they are not associated " - "with it.".format(o=o, ib=bad_str, s=self.name)) + warn("{o}-{ib} data cannot be removed from {s} as they are not associated " + "with it.".format(o=o, ib=bad_str, s=self.name), stacklevel=2) # Sets the attribute that tells us whether any data has been removed if not self._disassociated: @@ -3049,9 +3362,9 @@ def get_profiles(self, profile_type: str, obs_id: str = None, inst: str = None, :rtype: Union[BaseProfile1D, List[BaseProfile1D]] """ if "profile" in profile_type: - warnings.warn("The profile_type you passed contains the word 'profile', which is appended onto " + warn("The profile_type you passed contains the word 'profile', which is appended onto " "a profile type by XGA, you need to try this again without profile on the end, unless" - " you gave a generic profile a type with 'profile' in.") + " you gave a generic profile a type with 'profile' in.", stacklevel=2) search_key = profile_type + "_profile" if all([obs_id is None, inst is None]): @@ -3059,8 +3372,8 @@ def get_profiles(self, profile_type: str, obs_id: str = None, inst: str = None, search_key = profile_type + "_profile" if search_key not in ALLOWED_PRODUCTS: - warnings.warn("{} seems to be a custom profile, not an XGA default type. If this is not " - "true then you have passed an invalid profile type.".format(search_key)) + warn("{} seems to be a custom profile, not an XGA default type. If this is not " + "true then you have passed an invalid profile type.".format(search_key), stacklevel=2) matched_prods = self._get_prof_prod(search_key, obs_id, inst, central_coord, radii, lo_en, hi_en) if len(matched_prods) == 1: @@ -3092,15 +3405,15 @@ def get_combined_profiles(self, profile_type: str, central_coord: Quantity = Non :rtype: Union[BaseProfile1D, List[BaseProfile1D]] """ if "profile" in profile_type: - warnings.warn("The profile_type you passed contains the word 'profile', which is appended onto " + warn("The profile_type you passed contains the word 'profile', which is appended onto " "a profile type by XGA, you need to try this again without profile on the end, unless" - " you gave a generic profile a type with 'profile' in.") + " you gave a generic profile a type with 'profile' in.", stacklevel=2) search_key = "combined_" + profile_type + "_profile" if search_key not in ALLOWED_PRODUCTS: - warnings.warn("That profile type seems to be a custom profile, not an XGA default type. If this is not " - "true then you have passed an invalid profile type.") + warn("That profile type seems to be a custom profile, not an XGA default type. If this is not " + "true then you have passed an invalid profile type.", stacklevel=2) matched_prods = self._get_prof_prod(search_key, None, None, central_coord, radii, lo_en, hi_en) if len(matched_prods) == 1: @@ -3170,6 +3483,45 @@ def snr_ranking(self, outer_radius: Union[Quantity, str], lo_en: Quantity = None # And return our ordered dictionaries return obs_inst, snrs + def count_ranking(self, outer_radius: Union[Quantity, str], lo_en: Quantity = None, + hi_en: Quantity = None) -> Tuple[np.ndarray, Quantity]: + """ + This method generates a list of ObsID-Instrument pairs, ordered by the counts measured for the + given region, with element zero being the lowest counts, and element N being the highest. + + :param Quantity/str outer_radius: The radius that counts should be calculated within, this can either be a + named radius such as r500, or an astropy Quantity. + :param Quantity lo_en: The lower energy bound of the ratemap to use to calculate the counts. Default is None, + in which case the lower energy bound for peak finding will be used (default is 0.5keV). + :param Quantity hi_en: The upper energy bound of the ratemap to use to calculate the counts. Default is None, + in which case the upper energy bound for peak finding will be used (default is 2.0keV). + :return: Two arrays, the first an N by 2 array, with the ObsID, Instrument combinations in order + of ascending counts, then an array containing the order counts ratios. + :rtype: Tuple[np.ndarray, Quantity] + """ + # Set up some lists for the ObsID-Instrument combos and their cnts respectively + obs_inst = [] + cnts = [] + # We loop through the ObsIDs associated with this source and the instruments associated with those ObsIDs + for obs_id in self.instruments: + for inst in self.instruments[obs_id]: + cnts.append(self.get_counts(outer_radius, self.default_coord, lo_en, hi_en, obs_id, inst)) + obs_inst.append([obs_id, inst]) + + # Make our storage lists into arrays, easier to work with that way + obs_inst = np.array(obs_inst) + cnts = Quantity(cnts) + + # We want to order the output by counts, with the lowest being first and the highest being last, so we + # use a numpy function to output the index order needed to re-order our two arrays + reorder_cnts = np.argsort(cnts) + # Then we use that to re-order them + cnts = cnts[reorder_cnts] + obs_inst = obs_inst[reorder_cnts] + + # And return our ordered dictionaries' + return obs_inst, cnts + def offset(self, off_unit: Union[Unit, str] = "arcmin") -> Quantity: """ This method calculates the separation between the user supplied ra_dec coordinates, and the peak @@ -3192,6 +3544,52 @@ def offset(self, off_unit: Union[Unit, str] = "arcmin") -> Quantity: # Return the converted separation return conv_sep + @property + def peak_lo_en(self) -> Quantity: + """ + This property returns the lower energy bound of the image used for peak finding. + + :return: A quantity containing the lower energy limit used for peak finding. + :rtype: Quantity + """ + return self._peak_lo_en + + @property + def peak_hi_en(self) -> Quantity: + """ + This property returns the upper energy bound of the image used for peak finding. + + :return: A quantity containing the upper energy limit used for peak finding. + :rtype: Quantity + """ + return self._peak_hi_en + + @property + def use_peak(self) -> bool: + """ + This property shows whether a particular XGA source object has been setup to use peak coordinates + or not. The property is either True, False, or None (if its a BaseSource). + + :return: If the source is set to use peaks, True, otherwise False. + :rtype: bool + """ + return self._use_peak + + @property + def suppressed_warnings(self) -> List[str]: + """ + A property getter (with no setter) for the warnings that have been suppressed from display to the user as + the source was declared as a member of a sample. + + :return: The list of suppressed warnings for this source. + :rtype: List[str] + """ + if not self._samp_member: + raise NotSampleMemberError("The source for {n} is not a member of a sample, and as such warnings have " + "not been suppressed.".format(n=self.name)) + else: + return self._supp_warn + def info(self): """ Very simple function that just prints a summary of important information related to the source object.. @@ -3347,6 +3745,11 @@ def __init__(self, obs: List[str] = None): self._obs = [o for o in obs if len(instruments[o]) > 0] self._instruments = {o: instruments[o] for o in self._obs if len(instruments[o]) > 0} + # Here we check to make sure that there is at least one valid ObsID remaining + if len(self._obs) == 0: + raise NoValidObservationsError("After checks using the XGA census, all ObsIDs associated with this " + "NullSource are considered unusable.") + # The SAS generation routine might need this information self._att_files = {o: xga_conf["XMM_FILES"]["attitude_file"].format(obs_id=o) for o in self._obs} diff --git a/xga/sources/extended.py b/xga/sources/extended.py index 8e345a66..5a5f5570 100644 --- a/xga/sources/extended.py +++ b/xga/sources/extended.py @@ -1,15 +1,16 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 06/01/2022, 11:31. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 28/04/2023, 10:18. Copyright (c) The Contributors -import warnings from typing import Union, List, Tuple, Dict +from warnings import warn, simplefilter import numpy as np from astropy import wcs -from astropy.cosmology import Planck15 +from astropy.cosmology import Cosmology from astropy.units import Quantity, UnitConversionError, kpc from .general import ExtendedSource +from .. import DEFAULT_COSMO from ..exceptions import NoRegionsError, NoProductAvailableError from ..imagetools import radial_brightness from ..products import Spectrum, BaseProfile1D @@ -19,7 +20,7 @@ # This disables an annoying astropy warning that pops up all the time with XMM images # Don't know if I should do this really -warnings.simplefilter('ignore', wcs.FITSFixedWarning) +simplefilter('ignore', wcs.FITSFixedWarning) class GalaxyCluster(ExtendedSource): @@ -62,16 +63,56 @@ class GalaxyCluster(ExtendedSource): :param bool regen_merged: Should merged images/exposure maps be regenerated after cleaning. Default is True. :param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default is hierarchical, simple may also be passed. + :param bool in_sample: A boolean argument that tells the source whether it is part of a sample or not, setting + to True suppresses some warnings so that they can be displayed at the end of the sample progress bar. Default + is False. User should only set to True to remove warnings. """ def __init__(self, ra, dec, redshift, name=None, r200: Quantity = None, r500: Quantity = None, r2500: Quantity = None, richness: float = None, richness_err: float = None, wl_mass: Quantity = None, wl_mass_err: Quantity = None, custom_region_radius=None, use_peak=True, peak_lo_en=Quantity(0.5, "keV"), peak_hi_en=Quantity(2.0, "keV"), back_inn_rad_factor=1.05, - back_out_rad_factor=1.5, cosmology=Planck15, load_products=True, load_fits=False, + back_out_rad_factor=1.5, cosmology: Cosmology = DEFAULT_COSMO, load_products=True, load_fits=False, clean_obs=True, clean_obs_reg="r200", clean_obs_threshold=0.3, regen_merged: bool = True, - peak_find_method: str = "hierarchical"): + peak_find_method: str = "hierarchical", in_sample: bool = False): """ The init of the GalaxyCluster specific XGA class, takes information on the cluster to enable analyses. + + :param float ra: The right-ascension of the cluster, in degrees. + :param float dec: The declination of the cluster, in degrees. + :param float redshift: The redshift of the cluster, required for cluster analysis. + :param str name: The name of the cluster, optional. Name will be constructed from position if None. + :param Quantity r200: A value for the R200 of the source. At least one overdensity radius must be passed. + :param Quantity r500: A value for the R500 of the source. At least one overdensity radius must be passed. + :param Quantity r2500: A value for the R2500 of the source. At least one overdensity radius must be passed. + :param richness: An optical richness of the cluster, optional. + :param richness_err: An uncertainty on the optical richness of the cluster, optional. + :param Quantity wl_mass: A weak lensing mass of the cluster, optional. + :param Quantity wl_mass_err: An uncertainty on the weak lensing mass of the cluster, optional. + :param Quantity custom_region_radius: A custom analysis region radius for this cluster, optional. + :param bool use_peak: Whether peak position should be found and used. + :param Quantity peak_lo_en: The lower energy bound for the RateMap to calculate peak position + from. Default is 0.5keV + :param Quantity peak_hi_en: The upper energy bound for the RateMap to calculate peak position + from. Default is 2.0keV. + :param float back_inn_rad_factor: This factor is multiplied by an analysis region radius, and gives the inner + radius for the background region. Default is 1.05. + :param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer + radius for the background region. Default is 1.5. + :param cosmology: An astropy cosmology object for use throughout analysis of the source. + :param bool load_products: Whether existing products should be loaded from disk. + :param bool load_fits: Whether existing fits should be loaded from disk. + :param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default + is hierarchical, simple may also be passed. + :param bool clean_obs: Should the observations be subjected to a minimum coverage check, i.e. whether a + certain fraction of a certain region is covered by an ObsID. Default is True. + :param str clean_obs_reg: The region to use for the cleaning step, default is R200. + :param float clean_obs_threshold: The minimum coverage fraction for an observation to be kept for analysis. + :param bool regen_merged: Should merged images/exposure maps be regenerated after cleaning. Default is True. + :param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default + is hierarchical, simple may also be passed. + :param bool in_sample: A boolean argument that tells the source whether it is part of a sample or not, setting + to True suppresses some warnings so that they can be displayed at the end of the sample progress bar. Default + is False. User should only set to True to remove warnings. """ self._radii = {} if r200 is None and r500 is None and r2500 is None: @@ -112,7 +153,7 @@ def __init__(self, ra, dec, redshift, name=None, r200: Quantity = None, r500: Qu super().__init__(ra, dec, redshift, name, custom_region_radius, use_peak, peak_lo_en, peak_hi_en, back_inn_rad_factor, back_out_rad_factor, cosmology, load_products, load_fits, - peak_find_method) + peak_find_method, in_sample) # Reading observables into their attributes, if the user doesn't pass a value for a particular observable # it will be None. @@ -144,12 +185,11 @@ def __init__(self, ra, dec, redshift, name=None, r200: Quantity = None, r500: Qu from ..sas import emosaic emosaic(self, "image", self._peak_lo_en, self._peak_hi_en, disable_progress=True) emosaic(self, "expmap", self._peak_lo_en, self._peak_hi_en, disable_progress=True) + self._all_peaks(peak_find_method) - if use_peak: - rt = self.get_combined_ratemaps(peak_lo_en, peak_hi_en) - peak = self.find_peak(rt) - self.peak = peak - self.default_coord = peak + # And finally this sets the default coordinate to the peak if use peak is True + if self._use_peak: + self._default_coord = self.peak # Throws an error if a poor choice of region has been made elif clean_obs and clean_obs_reg not in self._radii: @@ -195,9 +235,9 @@ def dist_from_source(reg): # The 0.66 and 2.25 factors are intended to shift the r200 and r2500 values to approximately r500, and were # decided on by dividing the Arnaud et al. 2005 R-T relations by one another and finding the mean factor - if self._radii['r500'] is not None: + if 'r500' in self._radii: check_rad = self.convert_radius(self._radii['r500'] * 0.15, 'deg') - elif self._radii['r200'] is not None: + elif 'r200' in self._radii: check_rad = self.convert_radius(self._radii['r200'] * 0.66 * 0.15, 'deg') else: check_rad = self.convert_radius(self._radii['r2500'] * 2.25 * 0.15, 'deg') @@ -218,14 +258,23 @@ def dist_from_source(reg): # fraction of the chosen characteristic radius of the cluster then we assume it is a poorly handled # cool core and allow it to stay in the analysis if reg_obj.visual["color"] == 'red' and dist < check_rad: - # We do print a warning though - warnings.warn("A point source has been detected in {o} and is very close to the user supplied " - "coordinates of {s}. It will not be excluded from analysis due to the possibility " - "of a mis-identified cool core".format(s=self.name, o=obs)) + warn_text = "A point source has been detected in {o} and is very close to the user supplied " \ + "coordinates of {s}. It will not be excluded from analysis due to the possibility " \ + "of a mis-identified cool core".format(s=self.name, o=obs) + if not self._samp_member: + # We do print a warning though + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) + elif reg_obj.visual["color"] == "magenta" and dist < check_rad: - warnings.warn("A PSF sized extended source has been detected in {o} and is very close to the " - "user supplied coordinates of {s}. It will not be excluded from analysis due " - "to the possibility of a mis-identified cool core".format(s=self.name, o=obs)) + warn_text = "A PSF sized extended source has been detected in {o} and is very close to the " \ + "user supplied coordinates of {s}. It will not be excluded from analysis due " \ + "to the possibility of a mis-identified cool core".format(s=self.name, o=obs) + if not self._samp_member: + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) else: new_anti_results[obs].append(reg_obj) @@ -263,8 +312,28 @@ def dist_from_source(reg): return results_dict, alt_match_dict, new_anti_results - # Property getters for the over density radii, they don't get setters as various things are defined on init - # that I don't want to call again. + def _new_rad_checks(self, new_rad: Quantity) -> Tuple[Quantity, Quantity]: + """ + An internal method to check and convert new values of overdensity radii passed to the setters + of those overdensity radii properties. Purely to avoid repeating the same code multiple times. + + :param Quantity new_rad: The new radius that the user has passed to the property setter. + :return: The radius converted to kpc, and to degrees. + :rtype: Tuple[Quantity, Quantity] + """ + if not isinstance(new_rad, Quantity): + raise TypeError("New overdensity radii must be an astropy Quantity") + + # This will make sure that the radius is converted into kpc, and will throw errors if the new_rad is in + # stupid units, so I don't need to do those checks here. + converted_kpc_rad = self.convert_radius(new_rad, 'kpc') + # For some reason I setup the _radii internal dictionary to have units of degrees, so I convert to that as well + converted_deg_rad = self.convert_radius(new_rad, 'deg') + + return converted_kpc_rad, converted_deg_rad + + # Property getters for the over density radii. I've added property setters to allow overdensity radii to be + # set by external processes that might have measured a new value - for instance an iterative mass pipeline @property def r200(self) -> Quantity: """ @@ -275,6 +344,21 @@ def r200(self) -> Quantity: """ return self._r200 + @r200.setter + def r200(self, new_value: Quantity): + """ + The getter for the R200 property of the GalaxyCluster source class. This checks to make sure that the + new value is an astropy Quantity, converts it to kpc, then updates all relevant attributes of this class. + + :param Quantity new_value: + """ + # This checks that the input is a Quantity, then converts to kpc and to degrees + new_value_kpc, new_value_deg = self._new_rad_checks(new_value) + # For some reason these have to be set separately, stupid design on my part, but they are in different units + # so I guess I must have had some plan + self._r200 = new_value_kpc + self._radii['r200'] = new_value_deg + @property def r500(self) -> Quantity: """ @@ -285,6 +369,21 @@ def r500(self) -> Quantity: """ return self._r500 + @r500.setter + def r500(self, new_value: Quantity): + """ + The getter for the R500 property of the GalaxyCluster source class. This checks to make sure that the + new value is an astropy Quantity, converts it to kpc, then updates all relevant attributes of this class. + + :param Quantity new_value: + """ + # This checks that the input is a Quantity, then converts to kpc and to degrees + new_value_kpc, new_value_deg = self._new_rad_checks(new_value) + # For some reason these have to be set separately, stupid design on my part, but they are in different units + # so I guess I must have had some plan + self._r500 = new_value_kpc + self._radii['r500'] = new_value_deg + @property def r2500(self) -> Quantity: """ @@ -295,6 +394,21 @@ def r2500(self) -> Quantity: """ return self._r2500 + @r2500.setter + def r2500(self, new_value: Quantity): + """ + The getter for the R2500 property of the GalaxyCluster source class. This checks to make sure that the + new value is an astropy Quantity, converts it to kpc, then updates all relevant attributes of this class. + + :param Quantity new_value: + """ + # This checks that the input is a Quantity, then converts to kpc and to degrees + new_value_kpc, new_value_deg = self._new_rad_checks(new_value) + # For some reason these have to be set separately, stupid design on my part, but they are in different units + # so I guess I must have had some plan + self._r2500 = new_value_kpc + self._radii['r2500'] = new_value_deg + # Property getters for other observables I've allowed to be passed in. @property def weak_lensing_mass(self) -> Quantity: @@ -573,7 +687,7 @@ def get_3d_temp_profiles(self, radii: Quantity = None, group_spec: bool = True, if len(matched_prods) == 1: matched_prods = matched_prods[0] elif len(matched_prods) == 0: - raise NoProductAvailableError("No matching 1D projected temperature profiles can be found.") + raise NoProductAvailableError("No matching 3D temperature profiles can be found.") return matched_prods diff --git a/xga/sources/general.py b/xga/sources/general.py index 0e5c8b8a..44704a28 100644 --- a/xga/sources/general.py +++ b/xga/sources/general.py @@ -1,17 +1,18 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 12/05/2021, 15:50. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 23:14. Copyright (c) The Contributors -import warnings from typing import Tuple, List, Union +from warnings import warn, simplefilter import numpy as np from astropy import wcs from astropy.coordinates import SkyCoord -from astropy.cosmology import Planck15 +from astropy.cosmology import Cosmology from astropy.units import Quantity, UnitBase, deg, UnitConversionError from numpy import ndarray from .base import BaseSource +from .. import DEFAULT_COSMO from ..exceptions import NotAssociatedError, PeakConvergenceFailedError, NoRegionsError, NoValidObservationsError, \ NoProductAvailableError from ..products import RateMap @@ -19,7 +20,7 @@ # This disables an annoying astropy warning that pops up all the time with XMM images # Don't know if I should do this really -warnings.simplefilter('ignore', wcs.FITSFixedWarning) +simplefilter('ignore', wcs.FITSFixedWarning) class ExtendedSource(BaseSource): @@ -47,18 +48,45 @@ class ExtendedSource(BaseSource): :param bool load_fits: Whether existing fits should be loaded from disk. :param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default is hierarchical, simple may also be passed. + :param bool in_sample: A boolean argument that tells the source whether it is part of a sample or not, setting + to True suppresses some warnings so that they can be displayed at the end of the sample progress bar. Default + is False. User should only set to True to remove warnings. """ def __init__(self, ra: float, dec: float, redshift: float = None, name: str = None, custom_region_radius: Quantity = None, use_peak: bool = True, peak_lo_en: Quantity = Quantity(0.5, "keV"), peak_hi_en: Quantity = Quantity(2.0, "keV"), - back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, cosmology=Planck15, - load_products: bool = True, load_fits: bool = False, peak_find_method: str = "hierarchical"): + back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, + cosmology: Cosmology = DEFAULT_COSMO, load_products: bool = True, load_fits: bool = False, + peak_find_method: str = "hierarchical", in_sample: bool = False): """ The init for the general extended source XGA class, takes information on the position (and optionally redshift) of source of interest, matches to extended regions, and optionally performs peak finding. + + :param float ra: The right-ascension of the source, in degrees. + :param float dec: The declination of the source, in degrees. + :param float redshift: The redshift of the source, optional. Default is None. + :param str name: The name of the source, optional. Name will be constructed from position if None. + :param Quantity custom_region_radius: A custom analysis region radius for this source, optional. + :param bool use_peak: Whether peak position should be found and used. + :param Quantity peak_lo_en: The lower energy bound for the RateMap to calculate peak position + from. Default is 0.5keV + :param Quantity peak_hi_en: The upper energy bound for the RateMap to calculate peak position + from. Default is 2.0keV. + :param float back_inn_rad_factor: This factor is multiplied by an analysis region radius, and gives the inner + radius for the background region. Default is 1.05. + :param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer + radius for the background region. Default is 1.5. + :param Cosmology cosmology: An astropy cosmology object for use throughout analysis of the source. + :param bool load_products: Whether existing products should be loaded from disk. + :param bool load_fits: Whether existing fits should be loaded from disk. + :param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default + is hierarchical, simple may also be passed. + :param bool in_sample: A boolean argument that tells the source whether it is part of a sample or not, setting + to True suppresses some warnings so that they can be displayed at the end of the sample progress bar. Default + is False. User should only set to True to remove warnings. """ # Calling the BaseSource init method - super().__init__(ra, dec, redshift, name, cosmology, load_products, load_fits) + super().__init__(ra, dec, redshift, name, cosmology, load_products, load_fits, in_sample) self._custom_region_radius = None # Setting up the custom region radius attributes @@ -106,8 +134,12 @@ def __init__(self, ra: float, dec: float, redshift: float = None, name: str = No # Run through any alternative matches and raise warnings for o in self._alt_match_regions: if len(self._alt_match_regions[o]) > 0: - warnings.warn("There are {0} alternative matches for observation {1}, associated with " - "source {2}".format(len(self._alt_match_regions[o]), o, self.name)) + warn_text = "There are {0} alternative matches for observation {1}, associated with " \ + "source {2}".format(len(self._alt_match_regions[o]), o, self.name) + if not self._samp_member: + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) self._interloper_masks = {} for obs_id in self.obs_ids: @@ -121,13 +153,22 @@ def __init__(self, ra: float, dec: float, redshift: float = None, name: str = No # If in some of the observations the source has not been detected, a warning will be raised if True in self._detected.values() and False in self._detected.values(): - warnings.warn("{n} has not been detected in all region files, so generating and fitting products" - " with the 'region' reg_type will not use all available data".format(n=self.name)) + warn_text = "{n} has not been detected in all region files, so generating and fitting products" \ + " with the 'region' reg_type will not use all available data".format(n=self.name) + if not self._samp_member: + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) + # If the source wasn't detected in ALL of the observations, then we have to rely on a custom region, # and if no custom region options are passed by the user then an error is raised. elif all([det is False for det in self._detected.values()]) and self._custom_region_radius is not None: - warnings.warn("{n} has not been detected in ANY region files, so generating and fitting products" - " with the 'region' reg_type will not work".format(n=self.name)) + warn_text = "{n} has not been detected in ANY region files, so generating and fitting products" \ + " with the 'region' reg_type will not work".format(n=self.name) + if not self._samp_member: + warn(warn_text, stacklevel=2) + else: + self._supp_warn.append(warn_text) elif all([det is False for det in self._detected.values()]) and self._custom_region_radius is None \ and "GalaxyCluster" not in repr(self): raise NoRegionsError("{n} has not been detected in ANY region files, and no custom region or " @@ -235,7 +276,7 @@ def _all_peaks(self, method: str): # Updating nH for new coord, probably won't make a difference most of the time self._nH = nh_lookup(coord)[0] else: - # If we don't care about peak finding then this is the boi to go for + # If we don't care about peak finding then this is the one to go for coord = self.ra_dec near_edge = comb_rt.near_edge(coord) converged = True @@ -435,18 +476,53 @@ class PointSource(BaseSource): radius for the background region. Default is 1.05. :param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer radius for the background region. Default is 1.5. - :param cosmology: An astropy cosmology object for use throughout analysis of the source. + :param Cosmology cosmology: An astropy cosmology object for use throughout analysis of the source. :param bool load_products: Whether existing products should be loaded from disk. :param bool load_fits: Whether existing fits should be loaded from disk. :param bool regen_merged: Should merged images/exposure maps be regenerated after cleaning. Default is True. This option is here so that sample objects can regenerate all merged products at once, which is more efficient as it can exploit parallelisation more fully - user probably doesn't need to touch this. + :param bool in_sample: A boolean argument that tells the source whether it is part of a sample or not, setting + to True suppresses some warnings so that they can be displayed at the end of the sample progress bar. Default + is False. User should only set to True to remove warnings. """ def __init__(self, ra, dec, redshift=None, name=None, point_radius=Quantity(30, 'arcsec'), use_peak=False, peak_lo_en=Quantity(0.5, "keV"), peak_hi_en=Quantity(2.0, "keV"), back_inn_rad_factor=1.05, - back_out_rad_factor=1.5, cosmology=Planck15, load_products=True, load_fits=False, - regen_merged: bool = True): - super().__init__(ra, dec, redshift, name, cosmology, load_products, load_fits) + back_out_rad_factor=1.5, cosmology: Cosmology = DEFAULT_COSMO, load_products=True, load_fits=False, + regen_merged: bool = True, in_sample: bool = False): + """ + The init of the general XGA point source class. + + :param float ra: The right-ascension of the point source, in degrees. + :param float dec: The declination of the point source, in degrees. + :param float redshift: The redshift of the point source, optional. Default is None. + :param str name: The name of the point source, optional. If no names are supplied + then they will be constructed from the supplied coordinates. + :param Quantity point_radius: The point source analysis region radius for this sample. An astropy quantity + containing the radius should be passed; remember that units like kpc will also need redshift + information. Default is 30 arcsecond radius. + :param bool use_peak: Whether peak position should be found and used. For PointSource the 'simple' peak + finding method is the only one available, other methods are allowed for extended sources. + :param Quantity peak_lo_en: The lower energy bound for the RateMap to calculate peak + position from. Default is 0.5keV. + :param Quantity peak_hi_en: The upper energy bound for the RateMap to calculate peak + position from. Default is 2.0keV. + :param float back_inn_rad_factor: This factor is multiplied by an analysis region radius, and gives the inner + radius for the background region. Default is 1.05. + :param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer + radius for the background region. Default is 1.5. + :param cosmology: An astropy cosmology object for use throughout analysis of the source. + :param bool load_products: Whether existing products should be loaded from disk. + :param bool load_fits: Whether existing fits should be loaded from disk. + :param bool regen_merged: Should merged images/exposure maps be regenerated after cleaning. Default is + True. This option is here so that sample objects can regenerate all merged products at once, which is + more efficient as it can exploit parallelisation more fully - user probably doesn't need to touch this. + :param bool in_sample: A boolean argument that tells the source whether it is part of a sample or not, setting + to True suppresses some warnings so that they can be displayed at the end of the sample progress bar. Default + is False. User should only set to True to remove warnings. + """ + + super().__init__(ra, dec, redshift, name, cosmology, load_products, load_fits, in_sample) # This uses the added context of the type of source to find (or not find) matches in region files # This is the internal dictionary where all regions, defined by reg-files or by users, will be stored self._regions, self._alt_match_regions, self._other_regions = self._source_type_match("pnt") @@ -475,6 +551,13 @@ def __init__(self, ra, dec, redshift=None, name=None, point_radius=Quantity(30, raise UnitConversionError("Can't convert {u} to a XGA supported length unit".format(u=point_radius.unit)) self._radii["search"] = search_aperture + # This generates masks to remove interloper regions + self._interloper_masks = {} + for obs_id in self.obs_ids: + # Generating and storing these because they should only + cur_im = self.get_products("image", obs_id)[0] + self._interloper_masks[obs_id] = self._generate_interloper_mask(cur_im) + # Here we automatically clean the observations, to make sure the point source does actually lie # on the detector and not just near it # Use a pretty harsh acceptance fraction diff --git a/xga/sources/point.py b/xga/sources/point.py index bc9bfdb2..f4555735 100644 --- a/xga/sources/point.py +++ b/xga/sources/point.py @@ -1,17 +1,14 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 01/09/2020, 16:11. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 23:13. Copyright (c) The Contributors -import warnings -from typing import Tuple, List, Union, Dict +from typing import Tuple, Dict import numpy as np -from astropy import wcs -from astropy.coordinates import SkyCoord -from astropy.cosmology import Planck15 -from astropy.units import Quantity, UnitBase, deg, UnitConversionError -from numpy import ndarray +from astropy.cosmology import Cosmology +from astropy.units import Quantity, UnitConversionError from .general import PointSource +from .. import DEFAULT_COSMO class Star(PointSource): @@ -45,19 +42,58 @@ class Star(PointSource): radius for the background region. Default is 1.05. :param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer radius for the background region. Default is 1.5. - :param cosmology: An astropy cosmology object for use throughout analysis of the source. + :param Cosmology cosmology: An astropy cosmology object for use throughout analysis of the source. :param bool load_products: Whether existing products should be loaded from disk. :param bool load_fits: Whether existing fits should be loaded from disk. :param bool regen_merged: Should merged images/exposure maps be regenerated after cleaning. Default is True. This option is here so that sample objects can regenerate all merged products at once, which is more efficient as it can exploit parallelisation more fully - user probably doesn't need to touch this. + :param bool in_sample: A boolean argument that tells the source whether it is part of a sample or not, setting + to True suppresses some warnings so that they can be displayed at the end of the sample progress bar. Default + is False. User should only set to True to remove warnings. """ def __init__(self, ra: float, dec: float, distance: Quantity = None, name: str = None, proper_motion: Quantity = None, point_radius: Quantity = Quantity(30, 'arcsec'), match_radius: Quantity = Quantity(10, 'arcsec'), use_peak: bool = False, peak_lo_en: Quantity = Quantity(0.5, "keV"), peak_hi_en: Quantity = Quantity(2.0, "keV"), - back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, cosmology=Planck15, - load_products: bool = True, load_fits: bool = False, regen_merged: bool = True): + back_inn_rad_factor: float = 1.05, back_out_rad_factor: float = 1.5, + cosmology: Cosmology = DEFAULT_COSMO, load_products: bool = True, load_fits: bool = False, + regen_merged: bool = True, in_sample: bool = False): + """ + An init of the XGA Star source class. + + :param float ra: The right-ascension of the star, in degrees. + :param float dec: The declination of the star, in degrees. + :param Quantity distance: A proper distance to the star. Default is None. + :param Quantity proper_motion: An astropy quantity describing the star's movement across the sky. This may + have either one (for the magnitude of proper motion) or two (for an RA Dec proper motion vector) + components. It must be in units that can be converted to arcseconds per year. Default is None. + :param str name: The name of the star, optional. If no names are supplied then they will be constructed + from the supplied coordinates. + :param Quantity point_radius: The point source analysis region radius for this sample. An astropy quantity + containing the radius should be passed; default is 30 arcsecond radius. + :param Quantity match_radius: The radius within which point source regions are accepted as a match to the + RA and Dec passed by the user. The default value is 10 arcseconds. + :param bool use_peak: Whether peak position should be found and used. For Star the 'simple' peak + finding method is the only one available. + :param Quantity peak_lo_en: The lower energy bound for the RateMap to calculate peak + position from. Default is 0.5keV. + :param Quantity peak_hi_en: The upper energy bound for the RateMap to calculate peak + position from. Default is 2.0keV. + :param float back_inn_rad_factor: This factor is multiplied by an analysis region radius, and gives the inner + radius for the background region. Default is 1.05. + :param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer + radius for the background region. Default is 1.5. + :param cosmology: An astropy cosmology object for use throughout analysis of the source. + :param bool load_products: Whether existing products should be loaded from disk. + :param bool load_fits: Whether existing fits should be loaded from disk. + :param bool regen_merged: Should merged images/exposure maps be regenerated after cleaning. Default is + True. This option is here so that sample objects can regenerate all merged products at once, which is + more efficient as it can exploit parallelisation more fully - user probably doesn't need to touch this. + :param bool in_sample: A boolean argument that tells the source whether it is part of a sample or not, setting + to True suppresses some warnings so that they can be displayed at the end of the sample progress bar. Default + is False. User should only set to True to remove warnings. + """ # This is before the super init call so that the changed _source_type_match method has a matching radius # attribute to use @@ -74,7 +110,7 @@ def __init__(self, ra: float, dec: float, distance: Quantity = None, name: str = # Run the init of the PointSource superclass super().__init__(ra, dec, None, name, point_radius, use_peak, peak_lo_en, peak_hi_en, back_inn_rad_factor, - back_out_rad_factor, cosmology, load_products, load_fits, regen_merged) + back_out_rad_factor, cosmology, load_products, load_fits, regen_merged, in_sample) # Checking that the distance argument (as redshift isn't really valid for objects within our galaxy) is # in a unit that we understand and approve of diff --git a/xga/sourcetools/__init__.py b/xga/sourcetools/__init__.py index 1822e9ad..4c0195dd 100644 --- a/xga/sourcetools/__init__.py +++ b/xga/sourcetools/__init__.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 02/09/2020, 14:05. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from .match import simple_xmm_match from .misc import rad_to_ang, ang_to_rad, nh_lookup diff --git a/xga/sourcetools/_common.py b/xga/sourcetools/_common.py new file mode 100644 index 00000000..4c1159b2 --- /dev/null +++ b/xga/sourcetools/_common.py @@ -0,0 +1,127 @@ +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/04/2023, 14:15. Copyright (c) The Contributors + +from typing import Union, List +from warnings import warn + +from astropy.units import Quantity + +from .misc import model_check +from .. import NUM_CORES +from ..exceptions import ModelNotAssociatedError +from ..imagetools.psf import rl_psf +from ..models import BaseModel1D +from ..samples import ClusterSample +from ..sas import region_setup +from ..sources import BaseSource, GalaxyCluster +from ..sourcetools.density import inv_abel_fitted_model +from ..sourcetools.temperature import onion_deproj_temp_prof +from ..xspec.fit import single_temp_apec + + +def _setup_global(sources, outer_radius, global_radius, abund_table: str, group_spec: bool, min_counts: int, + min_sn: float, over_sample: float, num_cores: int): + + out_rads = region_setup(sources, outer_radius, Quantity(0, 'arcsec'), False, '')[-1] + global_out_rads = region_setup(sources, global_radius, Quantity(0, 'arcsec'), False, '')[-1] + + # If it's a single source I shove it in a list, so I can just iterate over the sources parameter + # like I do when it's a Sample object + if isinstance(sources, BaseSource): + sources = [sources] + + # We also want to make sure that everything has a PSF corrected image, using all the default settings + rl_psf(sources) + + # We do this here (even though its also in the density measurement), because if we can't measure a global + # temperature then its absurdly unlikely that we'll be able to measure a temperature profile, so we can avoid + # even trying and save some time. + single_temp_apec(sources, global_radius, min_counts=min_counts, min_sn=min_sn, over_sample=over_sample, + num_cores=num_cores, abund_table=abund_table, group_spec=group_spec) + + has_glob_temp = [] + for src_ind, src in enumerate(sources): + try: + src.get_temperature(global_out_rads[src_ind], 'constant*tbabs*apec', group_spec=group_spec, + min_counts=min_counts, min_sn=min_sn, over_sample=over_sample) + has_glob_temp.append(True) + except ModelNotAssociatedError: + warn("The global temperature fit for {} has failed, which means a temperature profile from annular " + "spectra is unlikely to be possible, and we will not attempt it.".format(src.name), stacklevel=2) + has_glob_temp.append(False) + + return sources, out_rads, has_glob_temp + + +def _setup_inv_abel_dens_onion_temp(sources: Union[GalaxyCluster, ClusterSample], outer_radius: Union[str, Quantity], + sb_model: Union[str, List[str], BaseModel1D, List[BaseModel1D]], + dens_model: Union[str, List[str], BaseModel1D, List[BaseModel1D]], + temp_model: Union[str, List[str], BaseModel1D, List[BaseModel1D]], + global_radius: Quantity, + fit_method: str = "mcmc", num_walkers: int = 20, num_steps: int = 20000, + sb_pix_step: int = 1, sb_min_snr: Union[int, float] = 0.0, + inv_abel_method: str = None, + temp_annulus_method: str = 'min_snr', temp_min_snr: float = 30, + temp_min_cnt: Union[int, Quantity] = Quantity(1000, 'ct'), + temp_min_width: Quantity = Quantity(20, 'arcsec'), temp_use_combined: bool = True, + temp_use_worst: bool = False, freeze_met: bool = True, abund_table: str = "angr", + temp_lo_en: Quantity = Quantity(0.3, 'keV'), + temp_hi_en: Quantity = Quantity(7.9, 'keV'), + group_spec: bool = True, spec_min_counts: int = 5, spec_min_sn: float = None, + over_sample: float = None, one_rmf: bool = True, num_cores: int = NUM_CORES, + show_warn: bool = True): + + sources, outer_rads, has_glob_temp = _setup_global(sources, outer_radius, global_radius, abund_table, group_spec, + spec_min_counts, spec_min_sn, over_sample, num_cores) + rads_dict = {str(sources[r_ind]): r for r_ind, r in enumerate(outer_rads)} + + # This checks and sets up a predictable structure for the models needed for this measurement. + sb_model = model_check(sources, sb_model) + dens_model = model_check(sources, dens_model) + temp_model = model_check(sources, temp_model) + + # I also set up dictionaries, so that models for specific clusters (as you can pass individual model instances + # for different clusters) are assigned to the right source when we start cutting down the sources based on + # whether a measurement has been successful + sb_model_dict = {str(sources[m_ind]): m for m_ind, m in enumerate(sb_model)} + dens_model_dict = {str(sources[m_ind]): m for m_ind, m in enumerate(dens_model)} + temp_model_dict = {str(sources[m_ind]): m for m_ind, m in enumerate(temp_model)} + + # Here we take only the sources that have a successful global temperature measurement + cut_sources = [src for src_ind, src in enumerate(sources) if has_glob_temp[src_ind]] + cut_rads = Quantity([rads_dict[str(src)] for src in cut_sources]) + if len(cut_sources) == 0: + raise ValueError("No sources have a successful global temperature measurement.") + + # Attempt to measure their 3D temperature profiles + temp_profs = onion_deproj_temp_prof(cut_sources, cut_rads, temp_annulus_method, temp_min_snr, temp_min_cnt, + temp_min_width, temp_use_combined, temp_use_worst, min_counts=spec_min_counts, + min_sn=spec_min_sn, over_sample=over_sample, one_rmf=one_rmf, + abund_table=abund_table, num_cores=num_cores, freeze_met=freeze_met, + temp_lo_en=temp_lo_en, temp_hi_en=temp_hi_en) + + # This just allows us to quickly lookup the temperature profile we need later + temp_prof_dict = {str(cut_sources[p_ind]): p for p_ind, p in enumerate(temp_profs)} + + # Now we take only the sources that have successful 3D temperature profiles. We do the temperature profile + # stuff first because its more difficult, and why should we waste time on a density profile if the temperature + # profile cannot even be measured. + cut_cut_sources = [cut_sources[prof_ind] for prof_ind, prof in enumerate(temp_profs) if prof is not None] + cut_cut_rads = Quantity([rads_dict[str(src)] for src in cut_cut_sources]) + + # And checking again if this stage of the measurement worked out + if len(cut_cut_sources) == 0: + raise ValueError("No sources have a successful temperature profile measurement.") + + # We also need to setup the sb model list for our cut sample + sb_models_cut = [sb_model_dict[str(src)] for src in cut_cut_sources] + # Now we run the inverse abel density profile generator + dens_profs = inv_abel_fitted_model(cut_cut_sources, sb_models_cut, fit_method, cut_cut_rads, pix_step=sb_pix_step, + min_snr=sb_min_snr, abund_table=abund_table, num_steps=num_steps, + num_walkers=num_walkers, group_spec=group_spec, min_counts=spec_min_counts, + min_sn=spec_min_sn, over_sample=over_sample, conv_outer_radius=global_radius, + inv_abel_method=inv_abel_method, num_cores=num_cores, show_warn=show_warn) + # Set this up to lookup density profiles based on source + dens_prof_dict = {str(cut_cut_sources[p_ind]): p for p_ind, p in enumerate(dens_profs)} + + return sources, dens_prof_dict, temp_prof_dict, dens_model_dict, temp_model_dict diff --git a/xga/sourcetools/density.py b/xga/sourcetools/density.py index 3314c9ff..27235ec5 100644 --- a/xga/sourcetools/density.py +++ b/xga/sourcetools/density.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 07/09/2021, 12:00. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from typing import Union, List, Tuple from warnings import warn @@ -11,7 +11,7 @@ from tqdm import tqdm from .misc import model_check -from .temperature import min_snr_proj_temp_prof, ALLOWED_ANN_METHODS +from .temperature import min_snr_proj_temp_prof, min_cnt_proj_temp_prof, ALLOWED_ANN_METHODS from ..exceptions import NoProductAvailableError, ModelNotAssociatedError, ParameterNotAssociatedError from ..imagetools.profile import radial_brightness from ..models import BaseModel1D @@ -539,14 +539,17 @@ def inv_abel_fitted_model(sources: Union[GalaxyCluster, ClusterSample], def ann_spectra_apec_norm(sources: Union[GalaxyCluster, ClusterSample], outer_radii: Union[Quantity, List[Quantity]], - num_dens: bool = True, annulus_method: str = 'min_snr', min_snr: float = 20, + num_dens: bool = True, annulus_method: str = 'min_snr', min_snr: float = 30, + min_cnt: Union[int, Quantity] = Quantity(1000, 'ct'), min_width: Quantity = Quantity(20, 'arcsec'), use_combined: bool = True, use_worst: bool = False, lo_en: Quantity = Quantity(0.5, 'keV'), hi_en: Quantity = Quantity(2, 'keV'), psf_corr: bool = False, psf_model: str = "ELLBETA", psf_bins: int = 4, psf_algo: str = "rl", psf_iter: int = 15, allow_negative: bool = False, exp_corr: bool = True, group_spec: bool = True, min_counts: int = 5, min_sn: float = None, - over_sample: float = None, one_rmf: bool = True, abund_table: str = "angr", - num_data_real: int = 10000, sigma: int = 1, num_cores: int = NUM_CORES) -> List[GasDensity3D]: + over_sample: float = None, one_rmf: bool = True, freeze_met: bool = True, + abund_table: str = "angr", temp_lo_en: Quantity = Quantity(0.3, 'keV'), + temp_hi_en: Quantity = Quantity(7.9, 'keV'), num_data_real: int = 10000, sigma: int = 1, + num_cores: int = NUM_CORES) -> List[GasDensity3D]: """ A method of measuring density profiles using XSPEC fits of a set of Annular Spectra. First checks whether the required annular spectra already exist and have been fit using XSPEC, if not then they are generated and fitted, @@ -563,39 +566,49 @@ def ann_spectra_apec_norm(sources: Union[GalaxyCluster, ClusterSample], outer_ra :param bool num_dens: If True then a number density profile will be generated, otherwise a mass density profile will be generated. :param str annulus_method: The method by which the annuli are designated, this can be 'min_snr' (which will use - the min_snr_proj_temp_prof function), or 'growth' (which will use the grow_ann_proj_temp_prof function). - :param float min_snr: The minimum signal to noise which is allowable in a given annulus. + the min_snr_proj_temp_prof function), or 'min_cnt' (which will use the min_cnt_proj_temp_prof function). + :param float min_snr: The minimum signal-to-noise which is allowable in a given annulus, used if annulus_method + is set to 'min_snr'. + :param int/Quantity min_cnt: The minimum background subtracted counts which are allowable in a given annulus, used + if annulus_method is set to 'min_cnt'. :param Quantity min_width: The minimum allowable width of an annulus. The default is set to 20 arcseconds to try and avoid PSF effects. - :param bool use_combined: If True then the combined RateMap will be used for signal to noise annulus - calculations, this is overridden by use_worst. - :param bool use_worst: If True then the worst observation of the cluster (ranked by global signal to noise) will - be used for signal to noise annulus calculations. - :param Quantity lo_en: The lower energy bound of the ratemap to use for the signal to noise calculations. - :param Quantity hi_en: The upper energy bound of the ratemap to use for the signal to noise calculations. - :param bool psf_corr: Sets whether you wish to use a PSF corrected ratemap or not. - :param str psf_model: If the ratemap you want to use is PSF corrected, this is the PSF model used. - :param int psf_bins: If the ratemap you want to use is PSF corrected, this is the number of PSFs per + :param bool use_combined: If True (and annulus_method is set to 'min_snr') then the combined RateMap will be + used for signal-to-noise annulus calculations, this is overridden by use_worst. If True (and annulus_method + is set to 'min_cnt') then combined RateMaps will be used for annulus count calculations, if False then + the median observation (in terms of counts) will be used. + :param bool use_worst: If True then the worst observation of the cluster (ranked by global signal-to-noise) will + be used for signal-to-noise annulus calculations. Used if annulus_method is set to 'min_snr'. + :param Quantity lo_en: The lower energy bound of the RateMap to use for the signal-to-noise or background + subtracted count calculations. + :param Quantity hi_en: The upper energy bound of the RateMap to use for the signal-to-noise or background + subtracted count calculations. + :param bool psf_corr: Sets whether you wish to use a PSF corrected RateMap or not. + :param str psf_model: If the RateMap you want to use is PSF corrected, this is the PSF model used. + :param int psf_bins: If the RateMap you want to use is PSF corrected, this is the number of PSFs per side in the PSF grid. - :param str psf_algo: If the ratemap you want to use is PSF corrected, this is the algorithm used. - :param int psf_iter: If the ratemap you want to use is PSF corrected, this is the number of iterations. + :param str psf_algo: If the RateMap you want to use is PSF corrected, this is the algorithm used. + :param int psf_iter: If the RateMap you want to use is PSF corrected, this is the number of iterations. :param bool allow_negative: Should pixels in the background subtracted count map be allowed to go below - zero, which results in a lower signal to noise (and can result in a negative signal to noise). + zero, which results in a lower signal-to-noise (and can result in a negative signal-to-noise). :param bool exp_corr: Should signal to noises be measured with exposure time correction, default is True. I recommend that this be true for combined observations, as exposure time could change quite dramatically across the combined product. :param bool group_spec: A boolean flag that sets whether generated spectra are grouped or not. :param float min_counts: If generating a grouped spectrum, this is the minimum number of counts per channel. To disable minimum counts set this parameter to None. - :param float min_sn: If generating a grouped spectrum, this is the minimum signal to noise in each channel. - To disable minimum signal to noise set this parameter to None. + :param float min_sn: If generating a grouped spectrum, this is the minimum signal-to-noise in each channel. + To disable minimum signal-to-noise set this parameter to None. :param float over_sample: The minimum energy resolution for each group, set to None to disable. e.g. if over_sample=3 then the minimum width of a group is 1/3 of the resolution FWHM at that energy. :param bool one_rmf: This flag tells the method whether it should only generate one RMF for a particular ObsID-instrument combination - this is much faster in some circumstances, however the RMF does depend slightly on position on the detector. + :param bool freeze_met: Whether the metallicity parameter in the fits to annuli in XSPEC should be frozen. :param str abund_table: The abundance table to use both for the conversion from n_exn_p to n_e^2 during density calculation, and the XSPEC fit. + :param Quantity temp_lo_en: The lower energy limit for the XSPEC fits to annular spectra. + :param Quantity temp_hi_en: The upper energy limit for the XSPEC fits to annular spectra. :param int num_data_real: The number of random realisations to generate when propagating profile uncertainties. :param int sigma: What sigma uncertainties should newly created profiles have, the default is 2σ. :param int num_cores: The number of cores to use (if running locally), default is set to 90% of available. @@ -612,7 +625,13 @@ def ann_spectra_apec_norm(sources: Union[GalaxyCluster, ClusterSample], outer_ra # This returns the boundary radii for the annuli ann_rads = min_snr_proj_temp_prof(sources, outer_radii, min_snr, min_width, use_combined, use_worst, lo_en, hi_en, psf_corr, psf_model, psf_bins, psf_algo, psf_iter, allow_negative, - exp_corr, group_spec, min_counts, min_sn, over_sample, one_rmf, abund_table, + exp_corr, group_spec, min_counts, min_sn, over_sample, one_rmf, freeze_met, + abund_table, temp_lo_en, temp_hi_en, num_cores) + elif annulus_method == 'min_cnt': + # This returns the boundary radii for the annuli, based on a minimum number of counts per annulus + ann_rads = min_cnt_proj_temp_prof(sources, outer_radii, min_cnt, min_width, use_combined, lo_en, hi_en, + psf_corr, psf_model, psf_bins, psf_algo, psf_iter, group_spec, min_counts, + min_sn, over_sample, one_rmf, freeze_met, abund_table, temp_lo_en, temp_hi_en, num_cores) elif annulus_method == "growth": raise NotImplementedError("This method isn't implemented yet") @@ -623,7 +642,6 @@ def ann_spectra_apec_norm(sources: Union[GalaxyCluster, ClusterSample], outer_ra # Don't need to check abundance table input because that happens in min_snr_proj_temp_prof and the # gas_density_profile method of APECNormalisation1D - final_dens_profs = [] with tqdm(desc="Generating density profiles from annular spectra", total=len(sources)) as dens_prog: for src_ind, src in enumerate(sources): @@ -635,17 +653,24 @@ def ann_spectra_apec_norm(sources: Union[GalaxyCluster, ClusterSample], outer_ra obs_id = 'combined' inst = 'combined' - # Seeing as we're here, I might as well make a density profile from the apec normalisation profile + # Seeing as we're here, I might as well make a density profile from the apec normalisation profile dens_prof = apec_norm_prof.gas_density_profile(src.redshift, src.cosmo, abund_table, num_data_real, sigma, num_dens) # Then I store it in the source src.update_products(dens_prof) final_dens_profs.append(dens_prof) + # It is possible that no normalisation profile exists because the spectral fitting failed, we account + # for that here except NoProductAvailableError: warn("{s} doesn't have a matching apec normalisation profile, skipping.") final_dens_profs.append(None) - continue + + # It's also possible that the gas_density_profile method of our normalisation profile is going to + # throw a ValueError because some values are infinite or NaNs - we have to catch that too + except ValueError: + warn("{s}'s density profile has NaN values in it, skipping.", stacklevel=2) + final_dens_profs.append(None) dens_prog.update(1) diff --git a/xga/sourcetools/deproj.py b/xga/sourcetools/deproj.py index 7064e8d9..2c063efc 100644 --- a/xga/sourcetools/deproj.py +++ b/xga/sourcetools/deproj.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 16/06/2021, 14:57. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from typing import Union diff --git a/xga/sourcetools/entropy.py b/xga/sourcetools/entropy.py new file mode 100644 index 00000000..d9fe7f30 --- /dev/null +++ b/xga/sourcetools/entropy.py @@ -0,0 +1,179 @@ +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/04/2023, 15:07. Copyright (c) The Contributors + +from typing import Union, List +from warnings import warn + +from astropy.units import Quantity +from tqdm import tqdm + +from .. import NUM_CORES +from ..exceptions import XGAFitError +from ..models import BaseModel1D +from ..products.profile import SpecificEntropy +from ..samples import ClusterSample +from ..sources import GalaxyCluster +from ..sourcetools._common import _setup_inv_abel_dens_onion_temp + + +def entropy_inv_abel_dens_onion_temp(sources: Union[GalaxyCluster, ClusterSample], outer_radius: Union[str, Quantity], + sb_model: Union[str, List[str], BaseModel1D, List[BaseModel1D]], + dens_model: Union[str, List[str], BaseModel1D, List[BaseModel1D]], + temp_model: Union[str, List[str], BaseModel1D, List[BaseModel1D]], + global_radius: Quantity, + fit_method: str = "mcmc", num_walkers: int = 20, num_steps: int = 20000, + sb_pix_step: int = 1, sb_min_snr: Union[int, float] = 0.0, + inv_abel_method: str = None, + temp_annulus_method: str = 'min_snr', temp_min_snr: float = 30, + temp_min_cnt: Union[int, Quantity] = Quantity(1000, 'ct'), + temp_min_width: Quantity = Quantity(20, 'arcsec'), temp_use_combined: bool = True, + temp_use_worst: bool = False, freeze_met: bool = True, abund_table: str = "angr", + temp_lo_en: Quantity = Quantity(0.3, 'keV'), + temp_hi_en: Quantity = Quantity(7.9, 'keV'), + group_spec: bool = True, spec_min_counts: int = 5, spec_min_sn: float = None, + over_sample: float = None, one_rmf: bool = True, num_cores: int = NUM_CORES, + show_warn: bool = True) -> Union[List[SpecificEntropy], SpecificEntropy]: + """ + A convenience function that should allow the user to easily measure specific entropy profiles for a sample of + galaxy clusters, elegantly dealing with any sources that have inadequate data or aren't fit properly. For + the sake of convenience, I have taken away a lot of choices that can be passed into the density and temperature + measurement routines, and if you would like more control then please manually define a specific entropy profile + object. + + This function uses the inv_abel_fitted_model density measurement function, and the onion_deproj_temp_prof + temperature measurement function (with the minimum signal to noise criteria for deciding on the annular + spectra sizes). + + The bulk of this code is the same as the hydrostatic mass measurement convenience function that also uses the + inverse Abel density method, and the onion peeling temperature method, as the same physical information is + required to measure the entropy. + + :param GalaxyCluster/ClusterSample sources: The galaxy cluster, or sample of galaxy clusters, that you wish to + measure specific entropy profiles for. + :param str/Quantity outer_radius: The radius out to which you wish to measure gas density and temperature + profiles. This can either be a string radius name (like 'r500') or an astropy quantity. That quantity should + have as many entries as there are sources. + :param str/List[str]/BaseModel1D/List[BaseModel1D] sb_model: The model(s) to be fit to the cluster surface + profile(s). You may pass the string name of a model (for single or multiple clusters), a single instance + of an XGA model class (for single or multiple clusters), a list of string names (one entry for each cluster + being analysed), or a list of XGA model instances (one entry for each cluster being analysed). + :param str/List[str]/BaseModel1D/List[BaseModel1D] dens_model: The model(s) to be fit to the cluster density + profile(s). You may pass the string name of a model (for single or multiple clusters), a single instance + of an XGA model class (for single or multiple clusters), a list of string names (one entry for each cluster + being analysed), or a list of XGA model instances (one entry for each cluster being analysed). + :param str/List[str]/BaseModel1D/List[BaseModel1D] temp_model: The model(s) to be fit to the cluster temperature + profile(s). You may pass the string name of a model (for single or multiple clusters), a single instance + of an XGA model class (for single or multiple clusters), a list of string names (one entry for each cluster + being analysed), or a list of XGA model instances (one entry for each cluster being analysed). + :param str/Quantity global_radius: This is a radius for a 'global' temperature measurement, which is both used as + an initial check of data quality, and feeds into the conversion factor required for density measurements. This + may also be passed as either a named radius or a quantity. + :param str fit_method: The method to use for fitting profiles within this function, default is 'mcmc'. + :param int num_walkers: If fit_method is 'mcmc' this is the number of walkers to initialise for + the ensemble sampler. + :param int num_steps: If fit_method is 'mcmc' this is the number steps for each walker to take. + :param int sb_pix_step: The width (in pixels) of each annular bin for the surface brightness profiles, default is 1. + :param int/float sb_min_snr: The minimum allowed signal to noise for the surface brightness profiles. Default + is 0, which disables automatic re-binning. + :param str inv_abel_method: The method which should be used for the inverse abel transform of the model which + is fitted to the surface brightness profile. This overrides the default method for the model, which is either + 'analytical' for models with an analytical solution to the inverse abel transform, or 'direct' for + models which don't have an analytical solution. Default is None. + :param str temp_annulus_method: The method by which the temperature profile annuli are designated, this can + be 'min_snr' (which will use the min_snr_proj_temp_prof function), or 'min_cnt' (which will use the + min_cnt_proj_temp_prof function). + :param int/float temp_min_snr: The minimum signal-to-noise for a temperature measurement annulus, default is 30. + :param int/Quantity temp_min_cnt: The minimum background subtracted counts which are allowable in a given + temperature annulus, used if temp_annulus_method is set to 'min_cnt'. + :param Quantity temp_min_width: The minimum allowable width of a temperature annulus. The default is set to + 20 arcseconds to try and avoid PSF effects. + :param bool temp_use_combined: If True (and temp_annulus_method is set to 'min_snr') then the combined + RateMap will be used for signal-to-noise annulus calculations, this is overridden by temp_use_worst. If + True (and temp_annulus_method is set to 'min_cnt') then combined RateMaps will be used for temperature + annulus count calculations, if False then the median observation (in terms of counts) will be used. + :param bool temp_use_worst: If True then the worst observation of the cluster (ranked by global signal-to-noise) + will be used for signal-to-noise temperature annulus calculations. Used if temp_annulus_method is set + to 'min_snr'. + :param bool freeze_met: Whether the metallicity parameter in the fits to annuli in XSPEC should be frozen. + :param str abund_table: The abundance table to use for fitting, and the conversion factor required during density + calculations. + :param Quantity temp_lo_en: The lower energy limit for the XSPEC fits to annular spectra. + :param Quantity temp_hi_en: The upper energy limit for the XSPEC fits to annular spectra. + :param bool group_spec: A boolean flag that sets whether generated spectra are grouped or not. + :param int spec_min_counts: If generating a grouped spectrum, this is the minimum number of counts per channel. + To disable minimum counts set this parameter to None. + :param float spec_min_sn: If generating a grouped spectrum, this is the minimum signal to noise in each channel. + To disable minimum signal to noise set this parameter to None. + :param bool over_sample: The minimum energy resolution for each group, set to None to disable. e.g. if + over_sample=3 then the minimum width of a group is 1/3 of the resolution FWHM at that energy. + :param bool one_rmf: This flag tells the method whether it should only generate one RMF for a particular + ObsID-instrument combination - this is much faster in some circumstances, however the RMF does depend + slightly on position on the detector. + :param int num_cores: The number of cores on your local machine which this function is allowed, default is + 90% of the cores in your system. + :param bool show_warn: Should profile fit warnings be shown, or only stored in the profile models. + :return: A list of the specific entropy profiles measured by this function, though if the measurement was not + successful an entry of None will be added to the list. + :rtype: List[SpecificEntropy]/SpecificEntropy + """ + # Call this common function which checks for whether temperature profiles/density profiles exist, if not creates + # them, and tries to fit the requested models to them - implemented like this because it is an identical process + # to that required by the hydrostatic mass function of similar name + sources, dens_prof_dict, temp_prof_dict, dens_model_dict, \ + temp_model_dict = _setup_inv_abel_dens_onion_temp(sources, outer_radius, sb_model, dens_model, temp_model, + global_radius, fit_method, num_walkers, num_steps, + sb_pix_step, sb_min_snr, inv_abel_method, temp_annulus_method, + temp_min_snr, temp_min_cnt, temp_min_width, temp_use_combined, + temp_use_worst, freeze_met, abund_table, temp_lo_en, + temp_hi_en, group_spec, spec_min_counts, spec_min_sn, + over_sample, one_rmf, num_cores, show_warn) + + # So I can return a list of profiles, a tad more elegant than fetching them from the sources sometimes + final_entropy_profs = [] + # Better to use a with statement for tqdm, so it shut down if something fails inside + prog_desc = "Generating {} specific entropy profile" + with tqdm(desc=prog_desc.format("None"), total=len(sources)) as onwards: + for src in sources: + onwards.set_description(prog_desc.format(src.name)) + # If every stage of this analysis has worked then we setup the entropy profile + if str(src) in dens_prof_dict and dens_prof_dict[str(src)] is not None: + # This fetches out the correct density and temperature profiles + d_prof = dens_prof_dict[str(src)] + t_prof = temp_prof_dict[str(src)] + + # And the appropriate temperature and density models + d_model = dens_model_dict[str(src)] + t_model = temp_model_dict[str(src)] + + # Set up the specific entropy profile using the temperature radii as they will tend to be spaced a lot + # wider than the density radii. + try: + rads = t_prof.radii.copy()[1:] + rad_errs = t_prof.radii_err.copy()[1:] + deg_rads = src.convert_radius(rads, 'deg') + entropy = SpecificEntropy(t_prof, t_model, d_prof, d_model, rads, rad_errs, deg_rads, fit_method, + num_walkers, num_steps, show_warn=show_warn, progress=False) + # Add the profile to the source storage structure + src.update_products(entropy) + # Also put it into a list for returning + final_entropy_profs.append(entropy) + except XGAFitError: + warn("A fit failure occurred in the specific entropy profile definition.", stacklevel=2) + final_entropy_profs.append(None) + + # If the density generation failed we give a warning here + elif str(src) in dens_prof_dict: + warn("The density profile for {} could not be generated".format(src.name), stacklevel=2) + # No density means no entropy, so we append None to the list + final_entropy_profs.append(None) + else: + # And again this is a failure state, so we append a None to the list + final_entropy_profs.append(None) + + onwards.update(1) + onwards.set_description("Complete") + + # In case only one source is being analysed + if len(final_entropy_profs) == 1: + final_entropy_profs = final_entropy_profs[0] + return final_entropy_profs diff --git a/xga/sourcetools/mass.py b/xga/sourcetools/mass.py index faa8efce..aed336b7 100644 --- a/xga/sourcetools/mass.py +++ b/xga/sourcetools/mass.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 07/09/2021, 12:00. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/04/2023, 15:07. Copyright (c) The Contributors from typing import Union, List from warnings import warn @@ -7,52 +7,13 @@ from astropy.units import Quantity from tqdm import tqdm -from .misc import model_check +from ._common import _setup_inv_abel_dens_onion_temp from .. import NUM_CORES -from ..exceptions import ModelNotAssociatedError, XGAFitError -from ..imagetools.psf import rl_psf +from ..exceptions import XGAFitError from ..models import BaseModel1D from ..products.profile import HydrostaticMass from ..samples import ClusterSample -from ..sas import region_setup -from ..sources import BaseSource, GalaxyCluster -from ..sourcetools.density import inv_abel_fitted_model -from ..sourcetools.temperature import onion_deproj_temp_prof -from ..xspec.fit import single_temp_apec - - -def _setup_global(sources, outer_radius, global_radius, abund_table: str, group_spec: bool, min_counts: int, - min_sn: float, over_sample: float, num_cores: int): - - out_rads = region_setup(sources, outer_radius, Quantity(0, 'arcsec'), False, '')[-1] - global_out_rads = region_setup(sources, global_radius, Quantity(0, 'arcsec'), False, '')[-1] - - # If its a single source I shove it in a list so I can just iterate over the sources parameter - # like I do when its a Sample object - if isinstance(sources, BaseSource): - sources = [sources] - - # We also want to make sure that everything has a PSF corrected image, using all the default settings - rl_psf(sources) - - # We do this here (even though its also in the density measurement), because if we can't measure a global - # temperature then its absurdly unlikely that we'll be able to measure a temperature profile, so we can avoid - # even trying and save some time. - single_temp_apec(sources, global_radius, min_counts=min_counts, min_sn=min_sn, over_sample=over_sample, - num_cores=num_cores, abund_table=abund_table, group_spec=group_spec) - - has_glob_temp = [] - for src_ind, src in enumerate(sources): - try: - src.get_temperature(global_out_rads[src_ind], 'constant*tbabs*apec', group_spec=group_spec, - min_counts=min_counts, min_sn=min_sn, over_sample=over_sample) - has_glob_temp.append(True) - except ModelNotAssociatedError: - warn("The global temperature fit for {} has failed, and as such we're very unlikely to be able to measure " - "a mass and we're not even going to try.".format(src.name)) - has_glob_temp.append(False) - - return sources, out_rads, has_glob_temp +from ..sources import GalaxyCluster def inv_abel_dens_onion_temp(sources: Union[GalaxyCluster, ClusterSample], outer_radius: Union[str, Quantity], @@ -61,9 +22,14 @@ def inv_abel_dens_onion_temp(sources: Union[GalaxyCluster, ClusterSample], outer temp_model: Union[str, List[str], BaseModel1D, List[BaseModel1D]], global_radius: Quantity, fit_method: str = "mcmc", num_walkers: int = 20, num_steps: int = 20000, sb_pix_step: int = 1, sb_min_snr: Union[int, float] = 0.0, inv_abel_method: str = None, - temp_min_snr: float = 20, abund_table: str = "angr", group_spec: bool = True, - spec_min_counts: int = 5, spec_min_sn: float = None, over_sample: float = None, - num_cores: int = NUM_CORES, show_warn: bool = True) -> List[HydrostaticMass]: + temp_annulus_method: str = 'min_snr', temp_min_snr: float = 30, + temp_min_cnt: Union[int, Quantity] = Quantity(1000, 'ct'), + temp_min_width: Quantity = Quantity(20, 'arcsec'), temp_use_combined: bool = True, + temp_use_worst: bool = False, freeze_met: bool = True, abund_table: str = "angr", + temp_lo_en: Quantity = Quantity(0.3, 'keV'), temp_hi_en: Quantity = Quantity(7.9, 'keV'), + group_spec: bool = True, spec_min_counts: int = 5, spec_min_sn: float = None, + over_sample: float = None, one_rmf: bool = True, num_cores: int = NUM_CORES, + show_warn: bool = True) -> Union[List[HydrostaticMass], HydrostaticMass]: """ A convenience function that should allow the user to easily measure hydrostatic masses of a sample of galaxy clusters, elegantly dealing with any sources that have inadequate data or aren't fit properly. For the sake @@ -106,9 +72,26 @@ def inv_abel_dens_onion_temp(sources: Union[GalaxyCluster, ClusterSample], outer is fitted to the surface brightness profile. This overrides the default method for the model, which is either 'analytical' for models with an analytical solution to the inverse abel transform, or 'direct' for models which don't have an analytical solution. Default is None. - :param int/float temp_min_snr: The minimum signal to noise for a temperature measurement annulus, default is 30. + :param str temp_annulus_method: The method by which the temperature profile annuli are designated, this can + be 'min_snr' (which will use the min_snr_proj_temp_prof function), or 'min_cnt' (which will use the + min_cnt_proj_temp_prof function). + :param int/float temp_min_snr: The minimum signal-to-noise for a temperature measurement annulus, default is 30. + :param int/Quantity temp_min_cnt: The minimum background subtracted counts which are allowable in a given + temperature annulus, used if temp_annulus_method is set to 'min_cnt'. + :param Quantity temp_min_width: The minimum allowable width of a temperature annulus. The default is set to + 20 arcseconds to try and avoid PSF effects. + :param bool temp_use_combined: If True (and temp_annulus_method is set to 'min_snr') then the combined + RateMap will be used for signal-to-noise annulus calculations, this is overridden by temp_use_worst. If + True (and temp_annulus_method is set to 'min_cnt') then combined RateMaps will be used for temperature + annulus count calculations, if False then the median observation (in terms of counts) will be used. + :param bool temp_use_worst: If True then the worst observation of the cluster (ranked by global signal-to-noise) + will be used for signal-to-noise temperature annulus calculations. Used if temp_annulus_method is set + to 'min_snr'. + :param bool freeze_met: Whether the metallicity parameter in the fits to annuli in XSPEC should be frozen. :param str abund_table: The abundance table to use for fitting, and the conversion factor required during density calculations. + :param Quantity temp_lo_en: The lower energy limit for the XSPEC fits to annular spectra. + :param Quantity temp_hi_en: The upper energy limit for the XSPEC fits to annular spectra. :param bool group_spec: A boolean flag that sets whether generated spectra are grouped or not. :param int spec_min_counts: If generating a grouped spectrum, this is the minimum number of counts per channel. To disable minimum counts set this parameter to None. @@ -116,62 +99,27 @@ def inv_abel_dens_onion_temp(sources: Union[GalaxyCluster, ClusterSample], outer To disable minimum signal to noise set this parameter to None. :param bool over_sample: The minimum energy resolution for each group, set to None to disable. e.g. if over_sample=3 then the minimum width of a group is 1/3 of the resolution FWHM at that energy. + :param bool one_rmf: This flag tells the method whether it should only generate one RMF for a particular + ObsID-instrument combination - this is much faster in some circumstances, however the RMF does depend + slightly on position on the detector. :param int num_cores: The number of cores on your local machine which this function is allowed, default is 90% of the cores in your system. :param bool show_warn: Should profile fit warnings be shown, or only stored in the profile models. :return: A list of the hydrostatic mass profiles measured by this function, though if the measurement was not successful an entry of None will be added to the list. - :rtype: List[HydrostaticMass] + :rtype: List[HydrostaticMass]/HydrostaticMass """ - sources, outer_rads, has_glob_temp = _setup_global(sources, outer_radius, global_radius, abund_table, group_spec, - spec_min_counts, spec_min_sn, over_sample, num_cores) - rads_dict = {str(sources[r_ind]): r for r_ind, r in enumerate(outer_rads)} - - # This checks and sets up a predictable structure for the models needed for this measurement. - sb_model = model_check(sources, sb_model) - dens_model = model_check(sources, dens_model) - temp_model = model_check(sources, temp_model) - - # I also set up dictionaries, so that models for specific clusters (as you can pass individual model instances - # for different clusters) are assigned to the right source when we start cutting down the sources based on - # whether a measurement has been successful - sb_model_dict = {str(sources[m_ind]): m for m_ind, m in enumerate(sb_model)} - dens_model_dict = {str(sources[m_ind]): m for m_ind, m in enumerate(dens_model)} - temp_model_dict = {str(sources[m_ind]): m for m_ind, m in enumerate(temp_model)} - - # Here we take only the sources that have a successful global temperature measurement - cut_sources = [src for src_ind, src in enumerate(sources) if has_glob_temp[src_ind]] - cut_rads = Quantity([rads_dict[str(src)] for src in cut_sources]) - if len(cut_sources) == 0: - raise ValueError("No sources have a successful global temperature measurement.") - - # Attempt to measure their 3D temperature profiles - temp_profs = onion_deproj_temp_prof(cut_sources, cut_rads, min_snr=temp_min_snr, min_counts=spec_min_counts, - min_sn=spec_min_sn, over_sample=over_sample, abund_table=abund_table, - num_cores=num_cores) - # This just allows us to quickly lookup the temperature profile we need later - temp_prof_dict = {str(cut_sources[p_ind]): p for p_ind, p in enumerate(temp_profs)} - - # Now we take only the sources that have successful 3D temperature profiles. We do the temperature profile - # stuff first because its more difficult, and why should we waste time on a density profile if the temperature - # profile cannot even be measured. - cut_cut_sources = [cut_sources[prof_ind] for prof_ind, prof in enumerate(temp_profs) if prof is not None] - cut_cut_rads = Quantity([rads_dict[str(src)] for src in cut_cut_sources]) - - # And checking again if this stage of the measurement worked out - if len(cut_cut_sources) == 0: - raise ValueError("No sources have a successful temperature profile measurement.") - - # We also need to setup the sb model list for our cut sample - sb_models_cut = [sb_model_dict[str(src)] for src in cut_cut_sources] - # Now we run the inverse abel density profile generator - dens_profs = inv_abel_fitted_model(cut_cut_sources, sb_models_cut, fit_method, cut_cut_rads, pix_step=sb_pix_step, - min_snr=sb_min_snr, abund_table=abund_table, num_steps=num_steps, - num_walkers=num_walkers, group_spec=group_spec, min_counts=spec_min_counts, - min_sn=spec_min_sn, over_sample=over_sample, conv_outer_radius=global_radius, - inv_abel_method=inv_abel_method, num_cores=num_cores, show_warn=show_warn) - # Set this up to lookup density profiles based on source - dens_prof_dict = {str(cut_cut_sources[p_ind]): p for p_ind, p in enumerate(dens_profs)} + # Call this common function which checks for whether temperature profiles/density profiles exist, if not creates + # them, and tries to fit the requested models to them - implemented like this because it is an identical process + # to that required by the specific entropy function of similar name + sources, dens_prof_dict, temp_prof_dict, dens_model_dict, \ + temp_model_dict = _setup_inv_abel_dens_onion_temp(sources, outer_radius, sb_model, dens_model, temp_model, + global_radius, fit_method, num_walkers, num_steps, + sb_pix_step, sb_min_snr, inv_abel_method, temp_annulus_method, + temp_min_snr, temp_min_cnt, temp_min_width, temp_use_combined, + temp_use_worst, freeze_met, abund_table, temp_lo_en, + temp_hi_en, group_spec, spec_min_counts, spec_min_sn, + over_sample, one_rmf, num_cores, show_warn) # So I can return a list of profiles, a tad more elegant than fetching them from the sources sometimes final_mass_profs = [] @@ -203,16 +151,16 @@ def inv_abel_dens_onion_temp(sources: Union[GalaxyCluster, ClusterSample], outer # Also put it into a list for returning final_mass_profs.append(hy_mass) except XGAFitError: - warn("A fit failure occurred in the hydrostatic mass profile definition.") + warn("A fit failure occurred in the hydrostatic mass profile definition.", stacklevel=2) final_mass_profs.append(None) except ValueError: warn("A mass of less than zero was measured by a hydrostatic mass profile, this is not physical" - " and the profile is not valid.") + " and the profile is not valid.", stacklevel=2) final_mass_profs.append(None) # If the density generation failed we give a warning here elif str(src) in dens_prof_dict: - warn("The density profile for {} could not be generated".format(src.name)) + warn("The density profile for {} could not be generated".format(src.name), stacklevel=2) # No density means no mass, so we append None to the list final_mass_profs.append(None) else: diff --git a/xga/sourcetools/match.py b/xga/sourcetools/match.py index ef0eb6f4..32ffbacc 100644 --- a/xga/sourcetools/match.py +++ b/xga/sourcetools/match.py @@ -1,32 +1,666 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 04/01/2021, 20:04. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 05/03/2023, 21:48. Copyright (c) The Contributors +import gc +import os +from copy import deepcopy +from multiprocessing import Pool +from typing import Union, Tuple, List import numpy as np +import pandas as pd +from astropy.coordinates import SkyCoord from astropy.units.quantity import Quantity +from exceptiongroup import ExceptionGroup from pandas import DataFrame +from regions import read_ds9, PixelRegion +from tqdm import tqdm -from .. import CENSUS, BLACKLIST -from ..exceptions import NoMatchFoundError +from .. import CENSUS, BLACKLIST, NUM_CORES, OUTPUT, xga_conf +from ..exceptions import NoMatchFoundError, NoValidObservationsError, NoRegionsError, XGAConfigError +from ..utils import SRC_REGION_COLOURS -def simple_xmm_match(src_ra: float, src_dec: float, distance: Quantity = Quantity(30.0, 'arcmin')) -> DataFrame: +def _dist_from_source(search_ra: float, search_dec: float, cur_reg): + """ + Calculates the euclidean distance between the centre of a supplied region, and the + position of the source. + + :param reg: A region object. + :return: Distance between region centre and source position. + """ + r_ra = cur_reg.center.ra.value + r_dec = cur_reg.center.dec.value + return np.sqrt(abs(r_ra - search_ra) ** 2 + abs(r_dec - search_dec) ** 2) + + +def _process_init_match(src_ra: Union[float, np.ndarray], src_dec: Union[float, np.ndarray], + initial_results: Union[DataFrame, List[DataFrame]]): + """ + An internal function that takes the results of a simple match and assembles a list of unique ObsIDs which are of + interest to the coordinate(s) we're searching for data for. Sets of RA and Decs that were found to be near XMM data by + the initial simple match are also created and returned. + + :param float/np.ndarray src_ra: RA coordinate(s) of the source(s), in degrees. To find matches for multiple + coordinate pairs, pass an array. + :param float/np.ndarray src_dec: Dec coordinate(s) of the source(s), in degrees. To find matches for multiple + coordinate pairs, pass an array. + :param DataFrame/List[DataFrame] initial_results: The result of a simple_xmm_match run. + :return: The simple match initial results (normalised so that they are a list of dataframe, even if only one + source is being searched for), a list of unique ObsIDs, unique string representations generated from RA and + Dec for the positions we're looking at, an array of dataframes for those coordinates that are near an + XMM observation according to the initial match, and the RA and Decs that are near an XMM observation + according to the initial simple match. The final output is a dictionary with ObsIDs as keys, and arrays of + source coordinates that are an initial match with them. + """ + # If only one coordinate was passed, the return from simple_xmm_match will just be a dataframe, and I want + # a list of dataframes because its iterable and easier to deal with more generally + if isinstance(initial_results, pd.DataFrame): + initial_results = [initial_results] + # This is a horrible bodge. I add an empty DataFrame to the end of the list of DataFrames returned by + # simple_xmm_match. This is because (when I turn init_res into a numpy array), if all of the DataFrames have + # data (so if all the input coordinates fall near an observation) then numpy tries to be clever and just turns + # it into a 3D array - this throws off the rest of the code. As such I add a DataFrame at the end with no data + # to makes sure numpy doesn't screw me over like that. + initial_results += [DataFrame(columns=['ObsID', 'RA_PNT', 'DEC_PNT', 'USE_PN', 'USE_MOS1', 'USE_MOS2', 'dist'])] + + # These reprs are what I use as dictionary keys to store matching information in a dictionary during + # the multithreading approach, I construct a list of them for ALL of the input coordinates, regardless of + # whether they passed the initial call to simple_xmm_match or not + all_repr = [repr(src_ra[ind]) + repr(src_dec[ind]) for ind in range(0, len(src_ra))] + + # This constructs a masking array that tells us which of the coordinates had any sort of return from + # simple_xmm_match - if further check is True then a coordinate fell near an observation and the exposure + # map should be investigated. + further_check = np.array([len(t) > 0 for t in initial_results]) + # Incidentally we don't need to check whether these are all False, because simple_xmm_match will already have + # errored if that was the case + # Now construct cut down initial matching result, ra, and dec arrays + rel_res = np.array(initial_results, dtype=object)[further_check] + # The further_check masking array is ignoring the last entry here because that corresponds to the empty DataFrame + # that I artificially added to bodge numpy slightly further up in the code + rel_ra = src_ra[further_check[:-1]] + rel_dec = src_dec[further_check[:-1]] + + # Nested list comprehension that extracts all the ObsIDs that are mentioned in the initial matching + # information, turning that into a set removes any duplicates, and then it gets turned back into a list. + # This is just used to know what exposure maps need to be generated + obs_ids = list(set([o for t in initial_results for o in t['ObsID'].values])) + + # This assembles a big numpy array with every source coordinate and its ObsIDs (source coordinate will have one + # entry per ObsID associated with them). + repeats = [len(cur_res) for cur_res in rel_res] + full_info = np.vstack([np.concatenate([cur_res['ObsID'].to_numpy() for cur_res in rel_res]), + np.repeat(rel_ra, repeats), np.repeat(rel_dec, repeats)]).T + + # This assembles a dictionary that links source coordinates to ObsIDs (ObsIDs are the keys) + obs_id_srcs = {o: full_info[np.where(full_info[:, 0] == o)[0], :][:, 1:] for o in obs_ids} + + return initial_results, obs_ids, all_repr, rel_res, rel_ra, rel_dec, obs_id_srcs + + +def _simple_search(ra: float, dec: float, search_rad: float) -> Tuple[float, float, DataFrame, DataFrame]: + """ + Internal function used to multithread the simple XMM match function. + + :param float ra: The right-ascension around which to search for observations, as a float in units of degrees. + :param float dec: The declination around which to search for observations, as a float in units of degrees. + :param float search_rad: The radius in which to search for observations, as a float in units of degrees. + :return: The input RA, input dec, ObsID match dataframe, and the completely blacklisted array (ObsIDs that + were relevant but have ALL instruments blacklisted). + :rtype: Tuple[float, float, DataFrame, DataFrame] + """ + # Making a copy of the census because I add a distance-from-coords column - don't want to do that for the + # original census especially when this is being multi-threaded + local_census = CENSUS.copy() + local_blacklist = BLACKLIST.copy() + local_census["dist"] = np.sqrt((local_census["RA_PNT"] - ra) ** 2 + + (local_census["DEC_PNT"] - dec) ** 2) + # Select any ObsIDs within (or at) the search radius input to the function + matches = local_census[local_census["dist"] <= search_rad] + # Locate any ObsIDs that are in the blacklist, then test to see whether ALL the instruments are to be excluded + in_bl = local_blacklist[ + local_blacklist['ObsID'].isin(matches[matches["ObsID"].isin(local_blacklist["ObsID"])]['ObsID'])] + # This will find relevant blacklist entries that have specifically ALL instruments excluded. In that case + # the ObsID shouldn't be returned + all_excl = in_bl[(in_bl['EXCLUDE_PN'] == 'T') & (in_bl['EXCLUDE_MOS1'] == 'T') & (in_bl['EXCLUDE_MOS2'] == 'T')] + + # These are the observations that a) match (within our criteria) to the supplied coordinates, and b) have at + # least some usable data. + matches = matches[~matches["ObsID"].isin(all_excl["ObsID"])] + + del local_census + del local_blacklist + return ra, dec, matches, all_excl + + +def _on_obs_id(ra: float, dec: float, obs_id: Union[str, list, np.ndarray]) -> Tuple[float, float, np.ndarray]: + """ + Internal function used by the on_xmm_match function to check whether a passed coordinate falls directly on a + camera for a single (or set of) ObsID(s). Checks whether exposure time is 0 at the coordinate. It cycles through + cameras (PN, then MOS1, then MOS2), so if exposure time is 0 on PN it'll go to MOS1, etc. to try and + account for chip gaps in different cameras. + + :param float ra: The right-ascension of the coordinate that may fall on the ObsID. + :param float dec: The declination of the coordinate that may fall on the ObsID. + :param str/list/np.ndarray obs_id: The ObsID(s) which we want to check whether the passed coordinate falls on. + :return: The input RA, input dec, and ObsID match array. + :rtype: Tuple[float, float, np.ndarray] + """ + # Insert my standard complaint about not wanting to do an import here + from ..products import ExpMap + + # Makes sure that the obs_id variable is iterable, whether there is just one ObsID or a set, makes it easier + # to write just one piece of code that deals with either type of input + if isinstance(obs_id, str): + obs_id = [obs_id] + + # Less convinced I actually need to do this here, as I don't modify the census dataframe + local_census = CENSUS.copy() + + # Set up a list to store detection information + det = [] + # We loop through the ObsID(s) - if just one was passed it'll only loop once (I made sure that ObsID was a list + # a few lines above this) + for o in obs_id: + # This variable describes whether the RA-Dec has a non-zero exposure for this current ObsID, starts off False + cur_det = False + # Get the relevant census row for this ObsID, we specifically want to know which instruments XGA thinks + # that it is allowed to use + rel_row = local_census[local_census['ObsID'] == o].iloc[0] + # Loops through the census column names describing whether instruments can be used or not + for col in ['USE_PN', 'USE_MOS1', 'USE_MOS2']: + # If the current instrument is allowed to be used (in the census), and ONLY if we haven't already + # found a non-zero exposure for this ObsID, then we proceed + if rel_row[col] and not cur_det: + # Get the actual instrument name by splitting the column + inst = col.split('_')[1].lower() + + # Define an XGA exposure map - we can assume that it already exists because this internal function + # will only be called from other functions that have already made sure the exposure maps are generated + epath = OUTPUT + "{o}/{o}_{i}_0.5-2.0keVexpmap.fits".format(o=o, i=inst) + ex = ExpMap(epath, o, inst, '', '', '', Quantity(0.5, 'keV'), Quantity(2.0, 'keV')) + # Then check to see if the exposure time is non-zero, if so then the coordinate lies on the current + # XMM camera. The try-except is there to catch instances where the requested coordinate is outside + # the data array, which is expected to happen sometimes. + try: + if ex.get_exp(Quantity([ra, dec], 'deg')) != 0: + cur_det = True + except ValueError: + pass + + # Don't know if this is necessary, but I delete the exposure map to try and minimise the memory usage + del ex + + # When we've looped through all possible instruments for an ObsID, and if the coordinate falls on a + # camera, then we add the ObsID to the list of ObsIDs 'det' - meaning that there is at least some data + # in that observation for the input coordinates + if cur_det: + det.append(o) + + # If the list of ObsIDs with data is empty then its set to None, otherwise turned into a numpy array + if len(det) == 0: + det = None + else: + det = np.array(det) + + return ra, dec, det + + +def _in_region(ra: Union[float, List[float], np.ndarray], dec: Union[float, List[float], np.ndarray], obs_id: str, + allowed_colours: List[str]) -> Tuple[str, dict]: + """ + Internal function to search a particular ObsID's region files for matches to the sources defined in the RA + and Dec arguments. This is achieved using the Regions module, and a region is a 'match' to a source if the + source coordinates fall somewhere within the region, and the region is of an acceptable coloru (defined in + allowed_colours). This requires that both images and region files are properly setup in the XGA config file. + + :param float/List[float]/np.ndarray ra: The set of source RA coords to match with the obs_id's regions. + :param float/List[float]/np.ndarray dec: The set of source DEC coords to match with the obs_id's regions. + :param str obs_id: The ObsID whose regions we are matching to. + :param List[str] allowed_colours: The colours of region that should be accepted as a match. + :return: The ObsID that was being searched, and a dictionary of matched regions (the keys are unique + representations of the sources passed in), and the values are lists of region objects. + :rtype: Tuple[str, dict] + """ + from ..products import Image + + if isinstance(ra, float): + ra = [ra] + dec = [dec] + + # From that ObsID construct a path to the relevant region file using the XGA config + reg_path = xga_conf["XMM_FILES"]["region_file"].format(obs_id=obs_id) + im_path = None + # We need to check whether any of the images in the config file exist for this ObsID - have to use the + # pre-configured images in case the region files are in pixel coordinates + for key in ['pn_image', 'mos1_image', 'mos2_image']: + for en_comb in zip(xga_conf["XMM_FILES"]["lo_en"], xga_conf["XMM_FILES"]["hi_en"]): + cur_path = xga_conf["XMM_FILES"][key].format(obs_id=obs_id, lo_en=en_comb[0], hi_en=en_comb[1]) + if os.path.exists(cur_path): + im_path = cur_path + + # This dictionary stores the match regions for each coordinate + matched = {} + + # If there is a region file to search then we can proceed + if os.path.exists(reg_path) and im_path is not None: + # onwards.write("None of the specified image files for {} can be located - skipping region match " + # "search.".format(obs_id)) + + # Reading in the region file using the Regions module + og_ds9_regs = read_ds9(reg_path) + + # Bodged declaration, the instrument and energy bounds don't matter - all I need this for is the + # nice way it extracts the WCS information that I need + im = Image(im_path, obs_id, '', '', '', '', Quantity(0, 'keV'), Quantity(1, 'keV'), ) + + # There's nothing for us to do if there are no regions in the region file, so we continue onto the next + # possible ObsID match (if there is one) - same deal if there is no WCS information in the image + if len(og_ds9_regs) != 0 and im.radec_wcs is not None: + + # Make sure to convert the regions to sky coordinates if they in pixels (just on principle in case + # any DO match and are returned to the user, I would much give them image agnostic regions). + if any([isinstance(r, PixelRegion) for r in og_ds9_regs]): + og_ds9_regs = np.array([reg.to_sky(im.radec_wcs) for reg in og_ds9_regs]) + + # This cycles through every ObsID in the possible matches for the current object + for r_ind, cur_ra in enumerate(ra): + cur_dec = dec[r_ind] + cur_repr = repr(cur_ra) + repr(cur_dec) + + # Make a local (to this iteration) copy as this array is modified during the checking process + ds9_regs = deepcopy(og_ds9_regs) + + # Hopefully this bodge doesn't have any unforeseen consequences + if ds9_regs[0] is not None and len(ds9_regs) > 1: + # Quickly calculating distance between source and center of regions, then sorting + # and getting indices. Thus I only match to the closest 5 regions. + diff_sort = np.array([_dist_from_source(cur_ra, cur_dec, r) for r in ds9_regs]).argsort() + # Unfortunately due to a limitation of the regions module I think you need images + # to do this contains match... + within = np.array([reg.contains(SkyCoord(cur_ra, cur_dec, unit='deg'), im.radec_wcs) + for reg in ds9_regs[diff_sort[0:5]]]) + + # Make sure to re-order the region list to match the sorted within array + ds9_regs = ds9_regs[diff_sort] + + # Expands it so it can be used as a mask on the whole set of regions for this observation + within = np.pad(within, [0, len(diff_sort) - len(within)]) + match_within = ds9_regs[within] + # In the case of only one region being in the list, we simplify the above expression + elif ds9_regs[0] is not None and len(ds9_regs) == 1 and \ + ds9_regs[0].contains(SkyCoord(cur_ra, cur_dec, unit='deg'), im.radec_wcs): + match_within = ds9_regs + else: + match_within = np.array([]) + + match_within = [r for r in match_within if r.visual['color'] in allowed_colours] + if len(match_within) != 0: + matched[cur_repr] = match_within + + del im + + gc.collect() + return obs_id, matched + + +def simple_xmm_match(src_ra: Union[float, np.ndarray], src_dec: Union[float, np.ndarray], + distance: Quantity = Quantity(30.0, 'arcmin'), num_cores: int = NUM_CORES) \ + -> Tuple[Union[DataFrame, List[DataFrame]], Union[DataFrame, List[DataFrame]]]: """ Returns ObsIDs within a given distance from the input ra and dec values. - :param float src_ra: RA coordinate of the source, in degrees. - :param float src_dec: DEC coordinate of the source, in degrees. + :param float/np.ndarray src_ra: RA coordinate(s) of the source(s), in degrees. To find matches for multiple + coordinate pairs, pass an array. + :param float/np.ndarray src_dec: DEC coordinate(s) of the source(s), in degrees. To find matches for multiple + coordinate pairs, pass an array. :param Quantity distance: The distance to search for XMM observations within, default should be able to match a source on the edge of an observation to the centre of the observation. - :return: The ObsID, RA_PNT, and DEC_PNT of matching XMM observations. - :rtype: DataFrame + :param int num_cores: The number of cores to use, default is set to 90% of system cores. This is only relevant + if multiple coordinate pairs are passed. + :return: A dataframe containing ObsID, RA_PNT, and DEC_PNT of matching XMM observations, and a dataframe + containing information on observations that would have been a match, but that are in the blacklist. + :rtype: Tuple[Union[DataFrame, List[DataFrame]], Union[DataFrame, List[DataFrame]]] """ + + # Extract the search distance as a float, specifically in degrees rad = distance.to('deg').value - local_census = CENSUS.copy() - local_census["dist"] = np.sqrt((local_census["RA_PNT"] - src_ra)**2 - + (local_census["DEC_PNT"] - src_dec)**2) - matches = local_census[local_census["dist"] <= rad] - matches = matches[~matches["ObsID"].isin(BLACKLIST["ObsID"])] - if len(matches) == 0: - raise NoMatchFoundError("No XMM observation found within {a} of ra={r} " - "dec={d}".format(r=round(src_ra, 4), d=round(src_dec, 4), a=distance)) - return matches + + # Here we perform a check to see whether a set of coordinates is being passed, and if so are the two + # arrays the same length + if isinstance(src_ra, np.ndarray) and isinstance(src_dec, np.ndarray) and len(src_ra) != len(src_dec): + raise ValueError("If passing multiple pairs of coordinates, src_ra and src_dec must be of the same length.") + # Just one coordinate is also allowed, but still want it to be iterable so put it in an array + elif isinstance(src_ra, float) and isinstance(src_dec, float): + src_ra = np.array([src_ra]) + src_dec = np.array([src_dec]) + num_cores = 1 + # Don't want one input being a single number and one being an array + elif type(src_ra) != type(src_dec): + raise TypeError("src_ra and src_dec must be the same type, either both floats or both arrays.") + + # The prog_dis variable controls whether the tqdm progress bar is displayed or not, don't want it to be there + # for single coordinate pairs + if len(src_ra) != 1: + prog_dis = False + else: + prog_dis = True + + # The dictionary stores match dataframe information, with the keys comprised of the str(ra)+str(dec) + c_matches = {} + # This dictionary stores any ObsIDs that were COMPLETELY blacklisted (i.e. all instruments were excluded) for + # a given coordinate. So they were initially found as being nearby, but then completely removed + fully_blacklisted = {} + + # This helps keep track of the original coordinate order, so we can return information in the same order it + # was passed in + order_list = [] + # If we only want to use one core, we don't set up a pool as it could be that a pool is open where + # this function is being called from + if num_cores == 1: + # Set up the tqdm instance in a with environment + with tqdm(desc='Searching for observations near source coordinates', total=len(src_ra), + disable=prog_dis) as onwards: + # Simple enough, just iterates through the RAs and Decs calling the search function and stores the + # results in the dictionary + for ra_ind, r in enumerate(src_ra): + d = src_dec[ra_ind] + search_results = _simple_search(r, d, rad) + c_matches[repr(r) + repr(d)] = search_results[2] + fully_blacklisted[repr(r) + repr(d)] = search_results[3] + order_list.append(repr(r)+repr(d)) + onwards.update(1) + else: + # This is all equivalent to what's above, but with function calls added to the multiprocessing pool + with tqdm(desc="Searching for observations near source coordinates", total=len(src_ra)) as onwards, \ + Pool(num_cores) as pool: + def match_loop_callback(match_info): + nonlocal onwards # The progress bar will need updating + nonlocal c_matches + c_matches[repr(match_info[0]) + repr(match_info[1])] = match_info[2] + fully_blacklisted[repr(match_info[0]) + repr(match_info[1])] = match_info[3] + + onwards.update(1) + + for ra_ind, r in enumerate(src_ra): + d = src_dec[ra_ind] + order_list.append(repr(r)+repr(d)) + pool.apply_async(_simple_search, args=(r, d, rad), callback=match_loop_callback) + + pool.close() # No more tasks can be added to the pool + pool.join() # Joins the pool, the code will only move on once the pool is empty. + + # Changes the order of the results to the original pass in order and stores them in a list + results = [c_matches[n] for n in order_list] + bl_results = [fully_blacklisted[n] for n in order_list] + del c_matches + del fully_blacklisted + + # Result length of one means one coordinate was passed in, so we should pass back out a single dataframe + # rather than a single dataframe in a list + if len(results) == 1: + results = results[0] + bl_results = bl_results[0] + + # Checks whether the dataframe inside the single result is length zero, if so then there are no relevant ObsIDs + if len(results) == 0: + raise NoMatchFoundError("No XMM observation found within {a} of ra={r} " + "dec={d}".format(r=round(src_ra[0], 4), d=round(src_dec[0], 4), a=distance)) + # If all the dataframes in the results list are length zero, then none of the coordinates has a + # valid ObsID + elif all([len(r) == 0 for r in results]): + raise NoMatchFoundError("No XMM observation found within {a} of any input coordinate pairs".format(a=distance)) + + return results, bl_results + + +def on_xmm_match(src_ra: Union[float, np.ndarray], src_dec: Union[float, np.ndarray], num_cores: int = NUM_CORES): + """ + An extension to the simple_xmm_match function, this first finds ObsIDs close to the input coordinate(s), then it + generates exposure maps for those observations, and finally checks to see whether the value of the exposure maps + at an input coordinate is zero. If the value is zero for all the instruments of an observation, then that + coordinate does not fall on the observation, otherwise if even one of the instruments has a non-zero exposure, the + coordinate does fall on the observation. + + :param float/np.ndarray src_ra: RA coordinate(s) of the source(s), in degrees. To find matches for multiple + coordinate pairs, pass an array. + :param float/np.ndarray src_dec: Dec coordinate(s) of the source(s), in degrees. To find matches for multiple + coordinate pairs, pass an array. + :param int num_cores: The number of cores to use, default is set to 90% of system cores. This is only relevant + if multiple coordinate pairs are passed. + :return: For a single input coordinate, a numpy array of ObsID(s) will be returned. For multiple input coordinates + an array of arrays of ObsID(s) and None values will be returned. Each entry corresponds to the input coordinate + array, a None value indicates that the coordinate did not fall on an XMM observation at all. + :rtype: np.ndarray + """ + # Boohoo local imports very sad very sad, but stops circular import errors. NullSource is a basic Source class + # that allows for a list of ObsIDs to be passed rather than coordinates + from ..sources import NullSource + from ..sas import eexpmap + + # Checks whether there are multiple input coordinates or just one. If one then the floats are turned into + # an array of length one to make later code easier to write (i.e. everything is iterable regardless) + if isinstance(src_ra, float) and isinstance(src_dec, float): + src_ra = np.array([src_ra]) + src_dec = np.array([src_dec]) + num_cores = 1 + + # Again if there's just one source I don't really care about a progress bar, so I turn it off + if len(src_ra) != 1: + prog_dis = False + else: + prog_dis = True + + # This is the initial call to the simple_xmm_match function. This gives us knowledge of which coordinates are + # worth checking further, and which ObsIDs should be checked for those coordinates. + init_res, init_bl = simple_xmm_match(src_ra, src_dec, num_cores=num_cores) + + # This function constructs a list of unique ObsIDs that we have determined are of interest to us using the simple + # match function, then sets up a NullSource and generate exposure maps that will be used to figure out if the + # sources are actually on an XMM pointing. Also returns cut down lists of RA, Decs etc. that are the sources + # which the simple match found to be near XMM data + init_res, obs_ids, all_repr, rel_res, rel_ra, rel_dec = _process_init_match(src_ra, src_dec, init_res) + + # I don't super like this way of doing it, but this is where exposure maps generated by XGA will be stored, so + # we check and remove any ObsID that already has had exposure maps generated for that ObsID. Normally XGA sources + # do this automatically, but NullSource is not as clever as that + epath = OUTPUT + "{o}/{o}_{i}_0.5-2.0keVexpmap.fits" + obs_ids = [o for o in obs_ids if not os.path.exists(epath.format(o=o, i='pn')) + and not os.path.exists(epath.format(o=o, i='mos1')) and not os.path.exists(epath.format(o=o, i='mos2'))] + + try: + # Declaring the NullSource with all the ObsIDs that; a) we need to use to check whether coordinates fall on + # an XMM camera, and b) don't already have exposure maps generated + obs_src = NullSource(obs_ids) + # Run exposure map generation for those ObsIDs + eexpmap(obs_src, num_cores=num_cores) + except NoValidObservationsError: + pass + + # This is all the same deal as in simple_xmm_match, but calls the _on_obs_id internal function + e_matches = {} + order_list = [] + if num_cores == 1: + with tqdm(desc='Confirming coordinates fall on an observation', total=len(rel_ra), + disable=prog_dis) as onwards: + for ra_ind, r in enumerate(rel_ra): + d = rel_dec[ra_ind] + o = rel_res[ra_ind]['ObsID'].values + e_matches[repr(r) + repr(d)] = _on_obs_id(r, d, o)[2] + order_list.append(repr(r) + repr(d)) + onwards.update(1) + else: + with tqdm(desc="Confirming coordinates fall on an observation", total=len(rel_ra)) as onwards, \ + Pool(num_cores) as pool: + def match_loop_callback(match_info): + nonlocal onwards # The progress bar will need updating + nonlocal e_matches + e_matches[repr(match_info[0]) + repr(match_info[1])] = match_info[2] + onwards.update(1) + + for ra_ind, r in enumerate(rel_ra): + d = rel_dec[ra_ind] + o = rel_res[ra_ind]['ObsID'].values + order_list.append(repr(r) + repr(d)) + pool.apply_async(_on_obs_id, args=(r, d, o), callback=match_loop_callback) + + pool.close() # No more tasks can be added to the pool + pool.join() # Joins the pool, the code will only move on once the pool is empty. + + # Makes sure that the results list contains entries for ALL the input coordinates, not just those ones + # that we investigated further with exposure maps + results = [] + for rpr in all_repr: + if rpr in e_matches: + results.append(e_matches[rpr]) + else: + results.append(None) + del e_matches + + # Again it's all the same deal as in simple_xmm_match + if len(results) == 1: + results = results[0] + + if results is None: + raise NoMatchFoundError("The coordinates ra={r} dec={d} do not fall on the camera of an XMM " + "observation".format(r=round(src_ra[0], 4), d=round(src_dec[0], 4))) + elif all([r is None or len(r) == 0 for r in results]): + raise NoMatchFoundError("None of the input coordinates fall on the camera of an XMM observation.") + + results = np.array(results, dtype=object) + return results + + +def xmm_region_match(src_ra: Union[float, np.ndarray], src_dec: Union[float, np.ndarray], + src_type: Union[str, List[str]], num_cores: int = NUM_CORES) -> np.ndarray: + """ + A function which, if XGA has been configured with access to pre-generated region files, will search for region + matches for a set of source coordinates passed in by the user. A region match is defined as when a source + coordinate falls within a source region with a particular colour (largely used to represent point vs + extended) - the type of region that should be matched to can be defined using the src_type argument. + + The simple_xmm_match function will be run before the source matching process, to narrow down the sources which + need to have the more expensive region matching performed, as well as to identify which ObsID(s) should be + examined for each source. + + :param float/np.ndarray src_ra: RA coordinate(s) of the source(s), in degrees. To find matches for multiple + coordinate pairs, pass an array. + :param float/np.ndarray src_dec: Dec coordinate(s) of the source(s), in degrees. To find matches for multiple + coordinate pairs, pass an array. + :param str/List[str] src_type: The type(s) of region that should be matched to. Pass either 'ext' or 'pnt' or + a list containing both. + :param int num_cores: The number of cores that can be used for the matching process. + :return: An array the same length as the sets of input coordinates (ordering is the same). If there are no + matches for a source then the element will be None, if there are matches then the element will be a + dictionary, with the key(s) being ObsID(s) and the values being a list of region objects (or more + likely just one object). + :rtype: np.ndarray + """ + # Checks the input src_type argument, and makes it a list even if it is just a single string - easier + # to deal with it like that! + if isinstance(src_type, str): + src_type = [src_type] + + # Also checks to make sure that no illegal values for src_type have been passed (SRC_REGION_COLOURS basically + # maps from region colours to source types). + if any([st not in SRC_REGION_COLOURS for st in src_type]): + raise ValueError("The values supported for 'src_type' are " + "{}".format(', '.join(list(SRC_REGION_COLOURS.keys())))) + + # Ugly but oh well, constructs the list of region colours that we can match to from the source types + # that the user chose + allowed_colours = [] + for st in src_type: + allowed_colours += SRC_REGION_COLOURS[st] + + # Checks to make sure that the user has actually pointed XGA at a set of region files (and images they were + # generated from, in case said region files are in pixel coordinates). + if xga_conf["XMM_FILES"]["region_file"] == "/this/is/optional/xmm_obs/regions/{obs_id}/regions.reg": + raise NoRegionsError("The configuration file does not contain information on region files, so this function " + "cannot continue.") + elif xga_conf["XMM_FILES"]['pn_image'] == "/this/is/optional/xmm_obs/regions/{obs_id}/regions.reg" and \ + xga_conf["XMM_FILES"]['mos1_image'] == "/this/is/optional/xmm_obs/regions/{obs_id}/regions.reg" and \ + xga_conf["XMM_FILES"]['mos2_image'] == "/this/is/optional/xmm_obs/regions/{obs_id}/regions.reg": + raise XGAConfigError("This function requires at least one set of images (PN, MOS1, or MOS2) be referenced in " + "the XGA configuration file.") + + # This runs the simple xmm match and gathers the results. + s_match, s_match_bl = simple_xmm_match(src_ra, src_dec, num_cores=num_cores) + # The initial results are then processed into some more useful formats. + s_match, uniq_obs_ids, all_repr, rel_res, rel_ra, rel_dec, \ + obs_id_srcs = _process_init_match(src_ra, src_dec, s_match) + + # This is the dictionary in which matching information is stored + reg_match_info = {rp: {} for rp in all_repr} + # If the user only wants us to use one core, then we don't make a Pool because that would just add overhead + if num_cores == 1: + with tqdm(desc="Searching for ObsID region matches", total=len(uniq_obs_ids)) as onwards: + # Here we iterate through the ObsIDs that the initial match found to possibly have sources on - I + # considered this more efficient than iterating through the sources and possibly reading in WCS + # information for the same ObsID in many different processes (the non-parallelised version just calls + # the same internal function so its setup the same). + for cur_obs_id in obs_id_srcs: + cur_ra_arr = obs_id_srcs[cur_obs_id][:, 0] + cur_dec_arr = obs_id_srcs[cur_obs_id][:, 1] + # Runs the matching function + match_inf = _in_region(cur_ra_arr, cur_dec_arr, cur_obs_id, allowed_colours) + # Adds to the match storage dictionary, but so that the top keys are source representations, and + # the lower level keys are ObsIDs + for cur_repr in match_inf[1]: + reg_match_info[cur_repr][match_inf[0]] = match_inf[1][cur_repr] + onwards.update(1) + + else: + # This is to store exceptions that are raised in separate processes, so they can all be raised at the end. + search_errors = [] + # We setup a Pool with the number of cores the user specified (or the default). + with tqdm(desc="Searching for ObsID region matches", total=len(uniq_obs_ids)) as onwards, Pool( + num_cores) as pool: + # This is called when a match process finished successfully, and the results need storing + def match_loop_callback(match_info): + nonlocal onwards # The progress bar will need updating + nonlocal reg_match_info + # Adds to the match storage dictionary, but so that the top keys are source representations, and + # the lower level keys are ObsIDs + for cur_repr in match_info[1]: + reg_match_info[cur_repr][match_info[0]] = match_info[1][cur_repr] + + onwards.update(1) + + # This is called when a process errors out. + def error_callback(err): + nonlocal onwards + nonlocal search_errors + # Stores the exception object in a list for later. + search_errors.append(err) + onwards.update(1) + + for cur_obs_id in obs_id_srcs: + # Here we iterate through the ObsIDs that the initial match found to possibly have sources on - I + # considered this more efficient than iterating through the sources and possibly reading in WCS + # information for the same ObsID in many different processes. + cur_ra_arr = obs_id_srcs[cur_obs_id][:, 0] + cur_dec_arr = obs_id_srcs[cur_obs_id][:, 1] + pool.apply_async(_in_region, args=(cur_ra_arr, cur_dec_arr, cur_obs_id, allowed_colours), + callback=match_loop_callback, error_callback=error_callback) + + pool.close() # No more tasks can be added to the pool + pool.join() # Joins the pool, the code will only move on once the pool is empty. + + # If any errors occurred during the matching process, they are all raised here as a grouped exception + if len(search_errors) != 0: + ExceptionGroup("The following exceptions were raised in the multi-threaded region finder", search_errors) + + # This formats the match and no-match information so that the output is the same length and order as the input + # source lists + to_return = [] + for cur_repr in all_repr: + if len(reg_match_info[cur_repr]) != 0: + to_return.append(reg_match_info[cur_repr]) + else: + to_return.append(None) + + # Makes it into an array rather than a list + to_return = np.array(to_return) + + return to_return diff --git a/xga/sourcetools/misc.py b/xga/sourcetools/misc.py index cfb1c2b8..6610c4b3 100644 --- a/xga/sourcetools/misc.py +++ b/xga/sourcetools/misc.py @@ -1,15 +1,16 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 08/10/2021, 18:23. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 14/04/2023, 09:06. Copyright (c) The Contributors import warnings from copy import deepcopy from subprocess import Popen, PIPE from typing import Union, List from astropy.coordinates import SkyCoord -from astropy.cosmology import Planck15 +from astropy.cosmology import Cosmology from astropy.units import Quantity from numpy import array, ndarray, pi +from .. import DEFAULT_COSMO from ..exceptions import HeasoftError from ..models import BaseModel1D @@ -66,12 +67,12 @@ def nh_lookup(coord_pair: Quantity) -> ndarray: return nh_vals -def rad_to_ang(rad: Quantity, z: float, cosmo=Planck15) -> Quantity: +def rad_to_ang(rad: Quantity, z: float, cosmo: Cosmology = DEFAULT_COSMO) -> Quantity: """ Converts radius in length units to radius on sky in degrees. :param Quantity rad: Radius for conversion. - :param Cosmology cosmo: An instance of an astropy cosmology, the default is Planck15. + :param Cosmology cosmo: An instance of an astropy cosmology, the default is a flat LambdaCDM concordance model. :param float z: The _redshift of the source. :return: The radius in degrees. :rtype: Quantity @@ -81,12 +82,12 @@ def rad_to_ang(rad: Quantity, z: float, cosmo=Planck15) -> Quantity: return Quantity(ang_rad, 'deg') -def ang_to_rad(ang: Quantity, z: float, cosmo=Planck15) -> Quantity: +def ang_to_rad(ang: Quantity, z: float, cosmo: Cosmology = DEFAULT_COSMO) -> Quantity: """ The counterpart to rad_to_ang, this converts from an angle to a radius in kpc. :param Quantity ang: Angle to be converted to radius. - :param Cosmology cosmo: An instance of an astropy cosmology, the default is Planck15. + :param Cosmology cosmo: An instance of an astropy cosmology, the default is a flat LambdaCDM concordance model. :param float z: The _redshift of the source. :return: The radius in kpc. :rtype: Quantity @@ -151,8 +152,8 @@ def coord_to_name(coord_pair: Quantity, survey: str = None) -> str: return name -def model_check(sources, - model: Union[str, List[str], BaseModel1D, List[BaseModel1D]]) -> Union[List[BaseModel1D], List[str]]: +def model_check(sources, model: Union[str, List[str], BaseModel1D, List[BaseModel1D]]) \ + -> Union[List[BaseModel1D], List[str]]: """ Very simple function that checks if a passed set of models is appropriately structured for the number of sources that have been passed. I can't imagine why a user would need this directly, its only here as these checks @@ -163,12 +164,22 @@ def model_check(sources, :return: A list of model instances, or names of models. :rtype: Union[List[BaseModel1D], List[str]] """ + + # This is when there is a single model instance or model name given for a single source. Obviously this is + # fine, but we need to put it in a list because the functions that use this want everything to be iterable if isinstance(model, (str, BaseModel1D)) and len(sources) == 1: model = [model] + # Here we deal with a single model name for a SET of sources - as the fit method will use strings to declare + # model instances we just make a list of the same length as the sample we're analysing (full of the same string) elif isinstance(model, str) and len(sources) != 1: model = [model]*len(sources) + # Here we deal with a single model INSTANCE for a SET of sources - this is slightly more complex, as we don't want + # to just fill a list full of a bunch of pointers to the same memory address (instance). As such we store copies + # of the model in the list, one for each source elif isinstance(model, BaseModel1D) and len(sources) != 1: model = [deepcopy(model) for s_ind in range(len(sources))] + # These next conditionals just catch when the user has done something silly - you can figure it out from the + # error messages elif isinstance(model, list) and len(model) != len(sources): raise ValueError("If you pass a list of model names (or model instances), then that list must be the same" " length as the number of sources passed for analysis.") diff --git a/xga/sourcetools/stack.py b/xga/sourcetools/stack.py index 80ca8d75..aecd67fe 100644 --- a/xga/sourcetools/stack.py +++ b/xga/sourcetools/stack.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 21/04/2021, 17:37. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from multiprocessing.dummy import Pool from typing import List, Tuple, Union diff --git a/xga/sourcetools/temperature.py b/xga/sourcetools/temperature.py index 1f8b60ba..6c40b6c7 100644 --- a/xga/sourcetools/temperature.py +++ b/xga/sourcetools/temperature.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 16/06/2021, 14:57. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from typing import Tuple, Union, List from warnings import warn @@ -18,20 +18,19 @@ from ..sources import BaseSource, GalaxyCluster from ..xspec.fit import single_temp_apec_profile -ALLOWED_ANN_METHODS = ['min_snr', 'growth'] +ALLOWED_ANN_METHODS = ['min_snr', 'min_cnt'] -def _snr_bins(source: BaseSource, outer_rad: Quantity, min_snr: float, min_width: Quantity, lo_en: Quantity, - hi_en: Quantity, obs_id: str = None, inst: str = None, psf_corr: bool = False, psf_model: str = "ELLBETA", - psf_bins: int = 4, psf_algo: str = "rl", psf_iter: int = 15, - allow_negative: bool = False, exp_corr: bool = True) -> Tuple[Quantity, np.ndarray, int]: +def _ann_bins_setup(source: BaseSource, outer_rad: Quantity, min_width: Quantity, lo_en: Quantity, hi_en: Quantity, + obs_id: str = None, inst: str = None, psf_corr: bool = False, psf_model: str = "ELLBETA", + psf_bins: int = 4, psf_algo: str = "rl", psf_iter: int = 15): """ - An internal function that will find the radii required to create annuli with a certain minimum signal to noise - and minimum annulus width. + This method just sets up radii, masks, etc. for annular binning functions in this file. The operations in + this function are shared by multiple other binning functions, hence they have been put in a function of their + own to minimise duplication. - :param BaseSource source: The source object which needs annuli generating for it. + :param BaseSource source: The source object to generate annuli for. :param Quantity outer_rad: The outermost radius of the source region we will generate annuli within. - :param float min_snr: The minimum signal to noise which is allowable in a given annulus. :param Quantity min_width: The minimum allowable width of the annuli. This can be set to try and avoid PSF effects. :param Quantity lo_en: The lower energy bound of the ratemap to use for the signal to noise calculations. @@ -46,14 +45,8 @@ def _snr_bins(source: BaseSource, outer_rad: Quantity, min_snr: float, min_width side in the PSF grid. :param str psf_algo: If the ratemap you want to use is PSF corrected, this is the algorithm used. :param int psf_iter: If the ratemap you want to use is PSF corrected, this is the number of iterations. - :param bool allow_negative: Should pixels in the background subtracted count map be allowed to go below - zero, which results in a lower signal to noise (and can result in a negative signal to noise). - :param bool exp_corr: Should signal to noises be measured with exposure time correction, default is True. I - recommend that this be true for combined observations, as exposure time could change quite dramatically - across the combined product. - :return: The radii of the requested annuli, the final snr values, and the original maximum number - based on min_width. - :rtype: Tuple[Quantity, np.ndarray, int] + :return: The various variables that this function sets up + :rtype: """ # Parsing the ObsID and instrument options, see if they want to use a specific ratemap if all([obs_id is None, inst is None]): @@ -73,23 +66,23 @@ def _snr_bins(source: BaseSource, outer_rad: Quantity, min_snr: float, min_width # the other way around pix_to_deg = pix_deg_scale(source.default_coord, rt.radec_wcs) - # Making sure to go up to the whole number, pixels have to be integer of course and I think its + # Making sure to go up to the whole number, pixels have to be integer of course, and I think it's # better to err on the side of caution here and make things slightly wider than requested - outer_rad = int(np.ceil(outer_rad/pix_to_deg).value) - min_width = int(np.ceil(min_width/pix_to_deg).value) + outer_rad = int(np.ceil(outer_rad / pix_to_deg).value) + min_width = int(np.ceil(min_width / pix_to_deg).value) # The maximum possible number of annuli, based on the input outer radius and minimum width # We have already made sure that the outer radius and minimum width allowed are integers by using # np.ceil, so we know max_ann is going to be a whole number of annuli - max_ann = int(outer_rad/min_width) + max_ann = int(outer_rad / min_width) # These are the initial bins, with imposed minimum width, I have to add one to max_ann because linspace wants the # total number of values to generate, and while there are max_ann annuli, there are max_ann+1 radial boundaries - init_rads = np.linspace(0, outer_rad, max_ann+1).astype(int) + init_rads = np.linspace(0, outer_rad, max_ann + 1).astype(int) # Converts the source's default analysis coordinates to pixels pix_centre = rt.coord_conv(source.default_coord, 'pix') # Sets up a mask to correct for interlopers and weird edge effects - corr_mask = interloper_mask*rt.edge_mask + corr_mask = interloper_mask * rt.edge_mask # Setting up our own background region back_inn_rad = np.array([np.ceil(source.background_radius_factors[0] * outer_rad)]).astype(int) @@ -100,9 +93,53 @@ def _snr_bins(source: BaseSource, outer_rad: Quantity, min_snr: float, min_width back_mask = annular_mask(pix_centre, back_inn_rad, back_out_rad, rt.shape) * corr_mask # Generates the requested annular masks, making sure to apply the correcting mask - ann_masks = annular_mask(pix_centre, init_rads[:-1], init_rads[1:], rt.shape)*corr_mask[..., None] + ann_masks = annular_mask(pix_centre, init_rads[:-1], init_rads[1:], rt.shape) * corr_mask[..., None] cur_rads = init_rads.copy() + + return rt, cur_rads, max_ann, ann_masks, back_mask, pix_centre, corr_mask, pix_to_deg + + +def _snr_bins(source: BaseSource, outer_rad: Quantity, min_snr: float, min_width: Quantity, lo_en: Quantity, + hi_en: Quantity, obs_id: str = None, inst: str = None, psf_corr: bool = False, psf_model: str = "ELLBETA", + psf_bins: int = 4, psf_algo: str = "rl", psf_iter: int = 15, + allow_negative: bool = False, exp_corr: bool = True) -> Tuple[Quantity, np.ndarray, int]: + """ + An internal function that will find the radii required to create annuli with a certain minimum signal to noise + and minimum annulus width. + + :param BaseSource source: The source object to generate annuli for. + :param Quantity outer_rad: The outermost radius of the source region we will generate annuli within. + :param float min_snr: The minimum signal to noise which is allowable in a given annulus. + :param Quantity min_width: The minimum allowable width of the annuli. This can be set to try and avoid + PSF effects. + :param Quantity lo_en: The lower energy bound of the ratemap to use for the signal to noise calculations. + :param Quantity hi_en: The upper energy bound of the ratemap to use for the signal to noise calculations. + :param str obs_id: An ObsID of a specific ratemap to use for the SNR calculations. Default is None, which + means the combined ratemap will be used. Please note that inst must also be set to use this option. + :param str inst: The instrument of a specific ratemap to use for the SNR calculations. Default is None, which + means the combined ratemap will be used. + :param bool psf_corr: Sets whether you wish to use a PSF corrected ratemap or not. + :param str psf_model: If the ratemap you want to use is PSF corrected, this is the PSF model used. + :param int psf_bins: If the ratemap you want to use is PSF corrected, this is the number of PSFs per + side in the PSF grid. + :param str psf_algo: If the ratemap you want to use is PSF corrected, this is the algorithm used. + :param int psf_iter: If the ratemap you want to use is PSF corrected, this is the number of iterations. + :param bool allow_negative: Should pixels in the background subtracted count map be allowed to go below + zero, which results in a lower signal to noise (and can result in a negative signal to noise). + :param bool exp_corr: Should signal to noises be measured with exposure time correction, default is True. I + recommend that this be true for combined observations, as exposure time could change quite dramatically + across the combined product. + :return: The radii of the requested annuli, the final snr values, and the original maximum number + based on min_width. + :rtype: Tuple[Quantity, np.ndarray, int] + """ + + # This calls a function that just sets things up for this (and other annular binning) function + rt, cur_rads, max_ann, ann_masks, back_mask, pix_centre, corr_mask, \ + pix_to_deg = _ann_bins_setup(source, outer_rad, min_width, lo_en, hi_en, obs_id, inst, psf_corr, psf_model, + psf_bins, psf_algo, psf_iter) + if max_ann > 4: # This will be modified by the loop until it describes annuli which all have an acceptable signal to noise acceptable = False @@ -140,7 +177,7 @@ def _snr_bins(source: BaseSource, outer_rad: Quantity, min_snr: float, min_width acceptable = True # We work from the outside of the bad list inwards, and if the outermost bad bin is the one right on the # end of the SNR profile, then we merge that leftwards into the N-1th annuli - elif len(bad_snrs) != 0 and bad_snrs[-1] == cur_num_ann-1: + elif len(bad_snrs) != 0 and bad_snrs[-1] == cur_num_ann - 1: cur_rads = np.delete(cur_rads, -2) ann_masks = annular_mask(pix_centre, cur_rads[:-1], cur_rads[1:], rt.shape) * corr_mask[..., None] # Otherwise if the outermost bad annulus is NOT right at the end of the profile, we merge to the right @@ -159,14 +196,117 @@ def _snr_bins(source: BaseSource, outer_rad: Quantity, min_snr: float, min_width return final_rads, snrs, max_ann +def _cnt_bins(source: BaseSource, outer_rad: Quantity, min_cnt: Union[int, Quantity], + min_width: Quantity, lo_en: Quantity, hi_en: Quantity, obs_id: str = None, inst: str = None, + psf_corr: bool = False, psf_model: str = "ELLBETA", psf_bins: int = 4, psf_algo: str = "rl", + psf_iter: int = 15) -> Tuple[Quantity, Quantity, int]: + """ + An internal function that will find the radii required to create annuli with a certain minimum number of counts + and minimum annulus width. + + :param BaseSource source: The source object to generate annuli for. + :param Quantity outer_rad: The outermost radius of the source region we will generate annuli within. + :param float min_cnt: The minimum number of counts which are allowable in a given annulus. + :param Quantity min_width: The minimum allowable width of the annuli. This can be set to try and avoid + PSF effects. + :param Quantity lo_en: The lower energy bound of the ratemap to use for the background subtracted count + calculations. + :param Quantity hi_en: The upper energy bound of the ratemap to use for the background subtracted count + calculations. + :param str obs_id: An ObsID of a specific ratemap to use for the background subtracted count + calculations. Default is None, which means the combined ratemap will be used. Please note that inst + must also be set to use this option. + :param str inst: The instrument of a specific ratemap to use for the background subtracted count + calculations. Default is None, which means the combined ratemap will be used. + :param bool psf_corr: Sets whether you wish to use a PSF corrected ratemap or not. + :param str psf_model: If the ratemap you want to use is PSF corrected, this is the PSF model used. + :param int psf_bins: If the ratemap you want to use is PSF corrected, this is the number of PSFs per + side in the PSF grid. + :param str psf_algo: If the ratemap you want to use is PSF corrected, this is the algorithm used. + :param int psf_iter: If the ratemap you want to use is PSF corrected, this is the number of iterations. + :return: The radii of the requested annuli, the final count values, and the original maximum number + based on min_width. + :rtype: Tuple[Quantity, Quantity, int] + """ + + # This just makes sure that the min_cnt variable is the astropy quantity that we expect it to be, otherwise + # some of the comparisons made between it and the values returned by background_subtracted_counts will fail + if type(min_cnt) == int: + min_cnt = Quantity(min_cnt, 'ct') + elif (type(min_cnt) == Quantity and not min_cnt.unit.is_equivalent('ct')) or not type(min_cnt) == Quantity: + raise TypeError("The min_cnt argument must be either an integer, or an astropy Quantity in units of 'ct'.") + + # Run the setup function for these functions that create different annular bins + rt, cur_rads, max_ann, ann_masks, back_mask, pix_centre, corr_mask, \ + pix_to_deg = _ann_bins_setup(source, outer_rad, min_width, lo_en, hi_en, obs_id, inst, psf_corr, psf_model, + psf_bins, psf_algo, psf_iter) + + if max_ann > 4: + # This will be modified by the loop until it describes annuli which all have an acceptable signal to noise + acceptable = False + else: + # If there are already 4 or less annuli present then we don't do the reduction while loop, and just take it + # as they are, while also issuing a warning + acceptable = True + warn("The min_width combined with the outer radius of the source means that there are only {} initial" + " annuli, normally four is the minimum number I will allow, so I will do no re-binning.".format(max_ann)) + cur_num_ann = ann_masks.shape[2] + cnts = [] + for i in range(cur_num_ann): + # We're calling the background subtracted counts calculation method of the ratemap for all of our annuli + cnts.append(rt.background_subtracted_counts(ann_masks[:, :, i], back_mask)) + # Becomes an astropy quantity (and so behaves like a numpy array) because they're nicer to work with + cnts = Quantity(cnts) + + while not acceptable: + # How many annuli are there at this point in the loop? + cur_num_ann = ann_masks.shape[2] + + # Just a list for the counts to live in + cnts = [] + for i in range(cur_num_ann): + # We're calling the background subtracted count calculation method of the ratemap + # for all of our annuli + cnts.append(rt.background_subtracted_counts(ann_masks[:, :, i], back_mask)) + # Becomes an astropy Quantity (behaves like a numpy array) because they're nicer to work with + cnts = Quantity(cnts) + # We find any indices of the array (== annuli) where the counts are not above our minimum + bad_cnts = np.where(cnts < min_cnt)[0] + + # If there are no annuli below our count threshold then all is good and joyous, and we + # accept the current radii + if len(bad_cnts) == 0: + acceptable = True + # We work from the outside of the bad list inwards, and if the outermost bad bin is the one right on the + # end of the count profile, then we merge that leftwards into the N-1th annuli + elif len(bad_cnts) != 0 and bad_cnts[-1] == cur_num_ann - 1: + cur_rads = np.delete(cur_rads, -2) + ann_masks = annular_mask(pix_centre, cur_rads[:-1], cur_rads[1:], rt.shape) * corr_mask[..., None] + # Otherwise if the outermost bad annulus is NOT right at the end of the profile, we merge to the right + else: + cur_rads = np.delete(cur_rads, bad_cnts[-1]) + ann_masks = annular_mask(pix_centre, cur_rads[:-1], cur_rads[1:], rt.shape) * corr_mask[..., None] + + if ann_masks.shape[2] == 4 and not acceptable: + warn("The requested annuli for {s} cannot be created, the data quality is too low. As such a set " + "of four annuli will be returned".format(s=source.name)) + break + + # Now of course, pixels must become a more useful unit again + final_rads = (Quantity(cur_rads, 'pix') * pix_to_deg).to("arcsec") + + return final_rads, cnts, max_ann + + def min_snr_proj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_radii: Union[Quantity, List[Quantity]], min_snr: float = 20, min_width: Quantity = Quantity(20, 'arcsec'), use_combined: bool = True, use_worst: bool = False, lo_en: Quantity = Quantity(0.5, 'keV'), hi_en: Quantity = Quantity(2, 'keV'), psf_corr: bool = False, psf_model: str = "ELLBETA", psf_bins: int = 4, psf_algo: str = "rl", psf_iter: int = 15, allow_negative: bool = False, exp_corr: bool = True, group_spec: bool = True, min_counts: int = 5, min_sn: float = None, - over_sample: float = None, one_rmf: bool = True, abund_table: str = "angr", - num_cores: int = NUM_CORES) -> List[Quantity]: + over_sample: float = None, one_rmf: bool = True, freeze_met: bool = True, + abund_table: str = "angr", temp_lo_en: Quantity = Quantity(0.3, 'keV'), + temp_hi_en: Quantity = Quantity(7.9, 'keV'), num_cores: int = NUM_CORES) -> List[Quantity]: """ This is a convenience function that allows you to quickly and easily start measuring projected temperature profiles of galaxy clusters, deciding on the annular bins using signal to noise measurements @@ -210,7 +350,10 @@ def min_snr_proj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_r :param bool one_rmf: This flag tells the method whether it should only generate one RMF for a particular ObsID-instrument combination - this is much faster in some circumstances, however the RMF does depend slightly on position on the detector. + :param bool freeze_met: Whether the metallicity parameter in the fits to annuli in XSPEC should be frozen. :param str abund_table: The abundance table to use during the XSPEC fits. + :param Quantity temp_lo_en: The lower energy limit for the XSPEC fits to annular spectra. + :param Quantity temp_hi_en: The upper energy limit for the XSPEC fits to annular spectra. :param int num_cores: The number of cores to use (if running locally), default is set to 90% of available. :return: A list of non-scalar astropy quantities containing the annular radii used to generate the projected temperature profiles created by this function. Each Quantity element of the list corresponds @@ -264,15 +407,128 @@ def min_snr_proj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_r sources = sources[0] single_temp_apec_profile(sources, all_rads, group_spec=group_spec, min_counts=min_counts, min_sn=min_sn, - over_sample=over_sample, one_rmf=one_rmf, num_cores=num_cores, abund_table=abund_table) + over_sample=over_sample, one_rmf=one_rmf, num_cores=num_cores, abund_table=abund_table, + lo_en=temp_lo_en, hi_en=temp_hi_en, freeze_met=freeze_met) return all_rads -def grow_ann_proj_temp_prof(sources: Union[BaseSource, BaseSample], outer_radii: Union[Quantity, List[Quantity]], - growth_factor: float = 1.3, start_radius: Quantity = Quantity(20, 'arcsec'), - num_ann: int = None, group_spec: bool = True, min_counts: int = 5, min_sn: float = None, - over_sample: float = None, one_rmf: bool = True, num_cores: int = NUM_CORES): +def min_cnt_proj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_radii: Union[Quantity, List[Quantity]], + min_cnt: Union[int, Quantity] = Quantity(1000, 'ct'), + min_width: Quantity = Quantity(20, 'arcsec'), use_combined: bool = True, + lo_en: Quantity = Quantity(0.5, 'keV'), hi_en: Quantity = Quantity(2, 'keV'), + psf_corr: bool = False, psf_model: str = "ELLBETA", psf_bins: int = 4, psf_algo: str = "rl", + psf_iter: int = 15, group_spec: bool = True, min_counts: int = 5, min_sn: float = None, + over_sample: float = None, one_rmf: bool = True, freeze_met: bool = True, + abund_table: str = "angr", temp_lo_en: Quantity = Quantity(0.3, 'keV'), + temp_hi_en: Quantity = Quantity(7.9, 'keV'), num_cores: int = NUM_CORES) -> List[Quantity]: + """ + This is a convenience function that allows you to quickly and easily start measuring projected + temperature profiles of galaxy clusters, deciding on the annular bins using X-ray count measurements + from photometric products. This function calls single_temp_apec_profile, but doesn't necessarily expose + all of single_temp_apec_profile's variables, so if you want more control, then use single_temp_apec_profile + directly. The projected temperature profiles which are generated are added to their source's storage structure. + + :param GalaxyCluster/ClusterSample sources: An individual or sample of sources to measure projected + temperature profiles for. + :param str/Quantity outer_radii: The name or value of the outer radius to use for the generation of + the spectra (for instance 'r200' would be acceptable for a GalaxyCluster, or Quantity(1000, 'kpc')). If + 'region' is chosen (to use the regions in region files), then any inner radius will be ignored. If you are + generating for multiple sources then you can also pass a Quantity with one entry per source. + :param float min_cnt: The minimum counts allowable in a given annulus. + :param Quantity min_width: The minimum allowable width of an annulus. The default is set to 20 arcseconds to try + and avoid PSF effects. + :param bool use_combined: If True then the combined RateMap will be used for count annulus calculations. + Default is True, if False then the median ObsID-Instrument combo (in terms of background-subtracted counts + within outer_radii) will be used to generate the annuli. + :param Quantity lo_en: The lower energy bound of the ratemap to use for the count calculations. + :param Quantity hi_en: The upper energy bound of the ratemap to use for the count calculations. + :param bool psf_corr: Sets whether you wish to use a PSF corrected ratemap or not. + :param str psf_model: If the ratemap you want to use is PSF corrected, this is the PSF model used. + :param int psf_bins: If the ratemap you want to use is PSF corrected, this is the number of PSFs per + side in the PSF grid. + :param str psf_algo: If the ratemap you want to use is PSF corrected, this is the algorithm used. + :param int psf_iter: If the ratemap you want to use is PSF corrected, this is the number of iterations. + :param bool group_spec: A boolean flag that sets whether generated spectra are grouped or not. + :param float min_counts: If generating a grouped spectrum, this is the minimum number of counts per channel. + To disable minimum counts set this parameter to None. + :param float min_sn: If generating a grouped spectrum, this is the minimum signal to noise in each channel. + To disable minimum signal to noise set this parameter to None. + :param float over_sample: The minimum energy resolution for each group, set to None to disable. e.g. if + over_sample=3 then the minimum width of a group is 1/3 of the resolution FWHM at that energy. + :param bool one_rmf: This flag tells the method whether it should only generate one RMF for a particular + ObsID-instrument combination - this is much faster in some circumstances, however the RMF does depend + slightly on position on the detector. + :param bool freeze_met: Whether the metallicity parameter in the fits to annuli in XSPEC should be frozen. + :param str abund_table: The abundance table to use during the XSPEC fits. + :param Quantity temp_lo_en: The lower energy limit for the XSPEC fits to annular spectra. + :param Quantity temp_hi_en: The upper energy limit for the XSPEC fits to annular spectra. + :param int num_cores: The number of cores to use (if running locally), default is set to 90% of available. + :return: A list of non-scalar astropy quantities containing the annular radii used to generate the + projected temperature profiles created by this function. Each Quantity element of the list corresponds + to a source. + :rtype: List[Quantity] + """ + + if outer_radii != 'region': + inn_rad_vals, out_rad_vals = region_setup(sources, outer_radii, Quantity(0, 'arcsec'), True, '')[1:] + else: + raise NotImplementedError("I don't currently support fitting region spectra") + + if abund_table not in ABUND_TABLES: + avail_abund = ", ".join(ABUND_TABLES) + raise ValueError("{a} is not a valid abundance table choice, please use one of the " + "following; {av}".format(a=abund_table, av=avail_abund)) + + # Makes sure that sources is iterable, even if its just a single source - makes writing the rest of this + # function a bit neater. + if isinstance(sources, BaseSource): + sources = [sources] + + all_rads = [] + for src_ind, src in enumerate(sources): + if use_combined: + # This is the simplest option, we just use the combined ratemap to decide on the annuli with minimum counts + rads, cnts, ma = _cnt_bins(src, out_rad_vals[src_ind], min_cnt, min_width, lo_en, hi_en, psf_corr=psf_corr, + psf_model=psf_model, psf_bins=psf_bins, psf_algo=psf_algo, psf_iter=psf_iter) + else: + # Use the source's built in count ranking method (which in turn uses some RateMap class methods) to rank + # the individual observations (cnt_rnk is ObsID, Instrument combinations in order of ascending counts). + # We then use the counts measured for each ObsID-Instrument combo (which are returned and stored in cnts) + # to decide upon the median observation. + cnt_rnk, cnts = src.count_ranking(out_rad_vals[src_ind], lo_en, hi_en) + # Obviously the median counts will not necessarily line up with any particular ObsID-instrument, but we + # can use the interpolation feature of numpy percentile to find the nearest existing counts to the + # median counts. + med_obs_ind = np.argwhere(cnts == np.percentile(cnts, 50, interpolation='nearest'))[0] + # This pulls out the ObsID and instrument that we have chosen to base the annular bins on. + med_obs_id = cnt_rnk[med_obs_ind, 0][0] + med_obs_inst = cnt_rnk[med_obs_ind, 1][0] + + # In this instance though, we use the median (in terms of background subtracted counts) + # individual (as in individual instrument too) observation to construct the annuli. + rads, cnts, ma = _cnt_bins(src, out_rad_vals[src_ind], min_cnt, min_width, lo_en, hi_en, med_obs_id, + med_obs_inst, psf_corr, psf_model, psf_bins, psf_algo, psf_iter) + + # Shoves the annuli we've decided upon into a list for single_temp_apec_profile to use + all_rads.append(rads) + + # Reverses a bodge employed at the beginning of this function + if len(sources) == 1: + sources = sources[0] + + # This runs the fitting (and generation, if that has not already occurred) of the annular spectra. + single_temp_apec_profile(sources, all_rads, group_spec=group_spec, min_counts=min_counts, min_sn=min_sn, + over_sample=over_sample, one_rmf=one_rmf, num_cores=num_cores, abund_table=abund_table, + lo_en=temp_lo_en, hi_en=temp_hi_en, freeze_met=freeze_met) + + return all_rads + + +def _grow_ann_proj_temp_prof(sources: Union[BaseSource, BaseSample], outer_radii: Union[Quantity, List[Quantity]], + growth_factor: float = 1.3, start_radius: Quantity = Quantity(20, 'arcsec'), + num_ann: int = None, group_spec: bool = True, min_counts: int = 5, min_sn: float = None, + over_sample: float = None, one_rmf: bool = True, num_cores: int = NUM_CORES): """ This is a convenience function that allows you to quickly and easily start measuring projected temperature profiles of galaxy clusters where the outer radius of each annulus is some factor larger than that of the @@ -331,11 +587,11 @@ def grow_ann_proj_temp_prof(sources: Union[BaseSource, BaseSample], outer_radii: / np.log(growth_factor))) cur_growth_factor = growth_factor else: - cur_growth_factor = np.power(out_rad_vals[src_ind].to('arcsec').value / cur_start.value, 1/num_ann) + cur_growth_factor = np.power(out_rad_vals[src_ind].to('arcsec').value / cur_start.value, 1 / num_ann) cur_num_ann = num_ann rads = [cur_start.value] - rads += [cur_start.value*ann_ind*cur_growth_factor for ann_ind in range(1, cur_num_ann+1)] + rads += [cur_start.value * ann_ind * cur_growth_factor for ann_ind in range(1, cur_num_ann + 1)] print(Quantity(rads, 'arcsec')) print('') @@ -345,13 +601,16 @@ def grow_ann_proj_temp_prof(sources: Union[BaseSource, BaseSample], outer_radii: def onion_deproj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_radii: Union[Quantity, List[Quantity]], annulus_method: str = 'min_snr', min_snr: float = 30, + min_cnt: Union[int, Quantity] = Quantity(1000, 'ct'), min_width: Quantity = Quantity(20, 'arcsec'), use_combined: bool = True, use_worst: bool = False, lo_en: Quantity = Quantity(0.5, 'keV'), hi_en: Quantity = Quantity(2, 'keV'), psf_corr: bool = False, psf_model: str = "ELLBETA", psf_bins: int = 4, psf_algo: str = "rl", psf_iter: int = 15, allow_negative: bool = False, exp_corr: bool = True, group_spec: bool = True, min_counts: int = 5, min_sn: float = None, - over_sample: float = None, one_rmf: bool = True, abund_table: str = "angr", - num_data_real: int = 300, sigma: int = 1, num_cores: int = NUM_CORES) \ + over_sample: float = None, one_rmf: bool = True, freeze_met: bool = True, + abund_table: str = "angr", temp_lo_en: Quantity = Quantity(0.3, 'keV'), + temp_hi_en: Quantity = Quantity(7.9, 'keV'), num_data_real: int = 300, + sigma: int = 1, num_cores: int = NUM_CORES) \ -> List[GasTemperature3D]: """ This function will generate de-projected, three-dimensional, gas temperature profiles of galaxy clusters using @@ -369,24 +628,31 @@ def onion_deproj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_r 'region' is chosen (to use the regions in region files), then any inner radius will be ignored. If you are generating for multiple sources then you can also pass a Quantity with one entry per source. :param str annulus_method: The method by which the annuli are designated, this can be 'min_snr' (which will use - the min_snr_proj_temp_prof function), or 'growth' (which will use the grow_ann_proj_temp_prof function). - :param float min_snr: The minimum signal to noise which is allowable in a given annulus. + the min_snr_proj_temp_prof function), or 'min_cnt' (which will use the min_cnt_proj_temp_prof function). + :param float min_snr: The minimum signal-to-noise which is allowable in a given annulus, used if annulus_method + is set to 'min_snr'. + :param int/Quantity min_cnt: The minimum background subtracted counts which are allowable in a given annulus, used + if annulus_method is set to 'min_cnt'. :param Quantity min_width: The minimum allowable width of an annulus. The default is set to 20 arcseconds to try and avoid PSF effects. - :param bool use_combined: If True then the combined RateMap will be used for signal to noise annulus - calculations, this is overridden by use_worst. - :param bool use_worst: If True then the worst observation of the cluster (ranked by global signal to noise) will - be used for signal to noise annulus calculations. - :param Quantity lo_en: The lower energy bound of the ratemap to use for the signal to noise calculations. - :param Quantity hi_en: The upper energy bound of the ratemap to use for the signal to noise calculations. - :param bool psf_corr: Sets whether you wish to use a PSF corrected ratemap or not. - :param str psf_model: If the ratemap you want to use is PSF corrected, this is the PSF model used. - :param int psf_bins: If the ratemap you want to use is PSF corrected, this is the number of PSFs per + :param bool use_combined: If True (and annulus_method is set to 'min_snr') then the combined RateMap will be + used for signal-to-noise annulus calculations, this is overridden by use_worst. If True (and annulus_method + is set to 'min_cnt') then combined RateMaps will be used for annulus count calculations, if False then + the median observation (in terms of counts) will be used. + :param bool use_worst: If True then the worst observation of the cluster (ranked by global signal-to-noise) will + be used for signal-to-noise annulus calculations. Used if annulus_method is set to 'min_snr'. + :param Quantity lo_en: The lower energy bound of the RateMap to use for the signal-to-noise or background + subtracted count calculations. + :param Quantity hi_en: The upper energy bound of the RateMap to use for the signal-to-noise or background + subtracted count calculations. + :param bool psf_corr: Sets whether you wish to use a PSF corrected RateMap or not. + :param str psf_model: If the RateMap you want to use is PSF corrected, this is the PSF model used. + :param int psf_bins: If the RateMap you want to use is PSF corrected, this is the number of PSFs per side in the PSF grid. - :param str psf_algo: If the ratemap you want to use is PSF corrected, this is the algorithm used. - :param int psf_iter: If the ratemap you want to use is PSF corrected, this is the number of iterations. + :param str psf_algo: If the RateMap you want to use is PSF corrected, this is the algorithm used. + :param int psf_iter: If the RateMap you want to use is PSF corrected, this is the number of iterations. :param bool allow_negative: Should pixels in the background subtracted count map be allowed to go below - zero, which results in a lower signal to noise (and can result in a negative signal to noise). + zero, which results in a lower signal-to-noise (and can result in a negative signal-to-noise). :param bool exp_corr: Should signal to noises be measured with exposure time correction, default is True. I recommend that this be true for combined observations, as exposure time could change quite dramatically across the combined product. @@ -400,8 +666,11 @@ def onion_deproj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_r :param bool one_rmf: This flag tells the method whether it should only generate one RMF for a particular ObsID-instrument combination - this is much faster in some circumstances, however the RMF does depend slightly on position on the detector. + :param bool freeze_met: Whether the metallicity parameter in the fits to annuli in XSPEC should be frozen. :param str abund_table: The abundance table to use both for the conversion from n_exn_p to n_e^2 during density calculation, and the XSPEC fit. + :param Quantity temp_lo_en: The lower energy limit for the XSPEC fits to annular spectra. + :param Quantity temp_hi_en: The upper energy limit for the XSPEC fits to annular spectra. :param int num_data_real: The number of random realisations to generate when propagating profile uncertainties. :param int sigma: What sigma uncertainties should newly created profiles have, the default is 1σ. :param int num_cores: The number of cores to use (if running locally), default is set to 90% of available. @@ -418,7 +687,13 @@ def onion_deproj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_r # This returns the boundary radii for the annuli ann_rads = min_snr_proj_temp_prof(sources, outer_radii, min_snr, min_width, use_combined, use_worst, lo_en, hi_en, psf_corr, psf_model, psf_bins, psf_algo, psf_iter, allow_negative, - exp_corr, group_spec, min_counts, min_sn, over_sample, one_rmf, abund_table, + exp_corr, group_spec, min_counts, min_sn, over_sample, one_rmf, freeze_met, + abund_table, temp_lo_en, temp_hi_en, num_cores) + elif annulus_method == 'min_cnt': + # This returns the boundary radii for the annuli, based on a minimum number of counts per annulus + ann_rads = min_cnt_proj_temp_prof(sources, outer_radii, min_cnt, min_width, use_combined, lo_en, hi_en, + psf_corr, psf_model, psf_bins, psf_algo, psf_iter, group_spec, min_counts, + min_sn, over_sample, one_rmf, freeze_met, abund_table, temp_lo_en, temp_hi_en, num_cores) elif annulus_method == "growth": raise NotImplementedError("This method isn't implemented yet") @@ -472,8 +747,8 @@ def onion_deproj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_r vol_intersects = shell_ann_vol_intersect(cur_rads, cur_rads) # Then its a simple inverse problem to recover the 3D temperatures - temp_3d = (np.linalg.inv(vol_intersects.T)@(proj_temp.values*em_prof.values)) / (np.linalg.inv( - vol_intersects.T)@em_prof.values) + temp_3d = (np.linalg.inv(vol_intersects.T) @ (proj_temp.values * em_prof.values)) / (np.linalg.inv( + vol_intersects.T) @ em_prof.values) # I generate random realisations of the projected temperature profile and the emission measure profile # to help me with error propagation @@ -485,12 +760,12 @@ def onion_deproj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_r temp_3d_reals = Quantity(np.zeros(proj_temp_reals.shape), proj_temp_reals.unit) for i in range(0, num_data_real): # Calculate and store the 3D temperature profile realisations - interim = (np.linalg.inv(vol_intersects.T)@(proj_temp_reals[i, :]*em_reals[i, :])) / (np.linalg.inv( - vol_intersects.T)@em_reals[i, :]) + interim = (np.linalg.inv(vol_intersects.T) @ (proj_temp_reals[i, :] * em_reals[i, :])) / (np.linalg.inv( + vol_intersects.T) @ em_reals[i, :]) temp_3d_reals[i, :] = interim # Calculate a standard deviation for each bin to use as the uncertainty - temp_3d_sigma = np.std(temp_3d_reals, axis=0)*sigma + temp_3d_sigma = np.std(temp_3d_reals, axis=0) * sigma # And finally actually set up a 3D temperature profile temp_3d_prof = GasTemperature3D(proj_temp.radii, temp_3d, proj_temp.centre, src.name, obs_id, inst, @@ -504,6 +779,3 @@ def onion_deproj_temp_prof(sources: Union[GalaxyCluster, ClusterSample], outer_r all_3d_temp_profs.append(None) return all_3d_temp_profs - - - diff --git a/xga/tools/__init__.py b/xga/tools/__init__.py new file mode 100644 index 00000000..4db9667c --- /dev/null +++ b/xga/tools/__init__.py @@ -0,0 +1,4 @@ +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 14:51. Copyright (c) The Contributors + +from .clusters import * diff --git a/xga/tools/clusters/LT.py b/xga/tools/clusters/LT.py new file mode 100644 index 00000000..571f0625 --- /dev/null +++ b/xga/tools/clusters/LT.py @@ -0,0 +1,405 @@ +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 28/04/2023, 10:26. Copyright (c) The Contributors +from typing import Tuple +from warnings import warn + +import numpy as np +import pandas as pd +from astropy.cosmology import Cosmology +from astropy.units import Quantity, Unit, UnitConversionError + +from xga import DEFAULT_COSMO, NUM_CORES +from xga.exceptions import ModelNotAssociatedError, SASGenerationError +from xga.products import ScalingRelation +from xga.relations.clusters.RT import arnaud_r500 +from xga.samples import ClusterSample +from xga.sas import evselect_spectrum +from xga.xspec import single_temp_apec + +# This just sets the data columns that MUST be present in the sample data passed by the user +LT_REQUIRED_COLS = ['ra', 'dec', 'name', 'redshift'] + + +def luminosity_temperature_pipeline(sample_data: pd.DataFrame, start_aperture: Quantity, use_peak: bool = False, + peak_find_method: str = "hierarchical", convergence_frac: float = 0.1, + min_iter: int = 3, max_iter: int = 10, rad_temp_rel: ScalingRelation = arnaud_r500, + lum_en: Quantity = Quantity([[0.5, 2.0], [0.01, 100.0]], "keV"), + core_excised: bool = False, save_samp_results_path: str = None, + save_rad_history_path: str = None, cosmo: Cosmology = DEFAULT_COSMO, + timeout: Quantity = Quantity(1, 'hr'), num_cores: int = NUM_CORES) \ + -> Tuple[ClusterSample, pd.DataFrame, pd.DataFrame]: + """ + This is the XGA pipeline for measuring overdensity radii, and the temperatures and luminosities within the + radii, for a sample of clusters. No knowledge of the overdensity radii of the clusters is required + beforehand, only the position and redshift of the objects. A name is also required for each of them. + + The pipeline works by measuring a temperature from a spectrum generated with radius equal to the + 'start_aperture', and the using the radius temperature relation ('rad_temp_rel') to infer a value for the + overdensity radius you are targeting. The cluster's overdensity radius is set equal to the new radius estimate + and we repeat the process. + + A cluster radius measurement is accepted if the 'current' estimate of the radius is considered to be converged + with the last estimate. For instance if 'convergence_frac' is set to 0.1, convergence occurs when a change of + less than 10% from the last radius estimate is measured. The radii cannot be assessed for convergence until + at least 'min_iter' iterations have been passed, and the iterative process will end if the number of iterations + reaches 'max_iter'. + + This pipeline will only work for clusters that we can successfully measure temperatures for, which requires a + minimum data quality - as such you may find that some do not achieve successful radius measurements with this + pipeline. In these cases the pipeline should not error, but the failure will be recorded in the results and + radius history dataframes returned from the function (and optionally written to CSV files). The pipeline will + also gracefully handle SAS spectrum generation failures, removing the offending clusters from the sample being + analysed and warning the user of the failure. + + As with all XGA sources and samples, the XGA luminosity-temperature pipeline DOES NOT require all objects + passed in the sample_data to have X-ray observations. Those that do not will simply be filtered out. + + :param pd.DataFrame sample_data: A dataframe of information on the galaxy clusters. The columns 'ra', 'dec', + 'name', and 'redshift' are required for this pipeline to work. + :param Quantity start_aperture: This is the radius used to generate the first set of spectra for each + cluster, which in turn are fit to produce the first temperature estimate. + :param bool use_peak: If True then XGA will measure an X-ray peak coordinate and use that as the centre for + spectrum generation and fitting, and the peak coordinate will be included in the results dataframe/csv. + If False then the coordinate in sample_data will be used. Default is False. + :param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default is + 'hierarchical' (uses XGA's hierarchical clustering peak finder), 'simple' may also be passed in which + case the brightest unmasked pixel within the source region will be selected. + :param float convergence_frac: This defines how close a current radii estimate must be to the last + radii measurement for it to count as converged. The default value is 0.1, which means the current-to-last + estimate ratio must be between 0.9 and 1.1. + :param int min_iter: The minimum number of iterations before a radius can converge and be accepted. The + default is 3. + :param int max_iter: The maximum number of iterations before the loop exits and the pipeline moves on. This + makes sure that the loop has an exit condition and won't continue on forever. The default is 10. + :param ScalingRelation rad_temp_rel: The scaling relation used to convert a cluster temperature measurement + for into an estimate of an overdensity radius. The y-axis must be radii, and the x-axis must be temperature. + The pipeline will attempt to determine the overdensity radius you are attempting to measure for by checking + the name of the y-axis; it must contain 2500, 500, or 200 to indicate the overdensity. The default is the + R500-Tx Arnaud et al. 2005 relation. + :param Quantity lum_en: The energy bands in which to measure luminosity. The default is + Quantity([[0.5, 2.0], [0.01, 100.0]], 'keV'), corresponding to the 0.5-2.0keV and bolometric bands. + :param bool core_excised: Should final measurements of temperature and luminosity be made with core-excision in + addition to measurements within the overdensity radius specified by the scaling relation. This will involve + multiplying the radii by 0.15 to determine the inner radius. Default is False. + :param str save_samp_results_path: The path to save the final results (temperatures, luminosities, radii) to. + The default is None, in which case no file will be created. This information is also returned from this + function. + :param str save_rad_history_path: The path to save the radii history for all clusters. This specifies what the + estimated radius was for each cluster at each iteration step, in kpc. The default is None, in which case no + file will be created. This information is also returned from this function. + :param Cosmology cosmo: The cosmology to use for sample declaration, and thus for all analysis. The default + cosmology is a flat LambdaCDM concordance model. + :param Quantity timeout: This sets the amount of time an XSPEC fit can run before it is timed out, the default + is 1 hour. + :param int num_cores: The number of cores that can be used for spectrum generation and fitting. The default is + 90% of the cores detected on the system. + :return: The GalaxyCluster sample object used for this analysis, the dataframe of results for all input + objects (even those for which the pipeline was unsuccessful), and the radius history dataframe for the + clusters. + :rtype: Tuple[ClusterSample, pd.DataFrame, pd.DataFrame] + """ + # I want the sample to be passed in as a DataFrame, so I can easily extract the information I need + if not isinstance(sample_data, pd.DataFrame): + raise TypeError("The sample_data argument must be a Pandas DataFrame, with the following columns; " + "{}".format(', '.join(LT_REQUIRED_COLS))) + + # Also have to make sure that the required information exists in the dataframe, otherwise obviously this tool + # is not going to work + if not set(LT_REQUIRED_COLS).issubset(sample_data.columns): + raise KeyError("Not all required columns ({}) are present in the sample_data " + "DataFrame.".format(', '.join(LT_REQUIRED_COLS))) + + if (sample_data['name'].str.contains(' ') | sample_data['name'].str.contains('_')).any(): + warn("One or more cluster name has been modified. Empty spaces (' ') are removed, and underscores ('_') are " + "replaced with hyphens ('-').") + sample_data['name'] = sample_data['name'].apply(lambda x: x.replace(" ", "").replace("_", "-")) + + # A key part of this process is a relation between the temperature we measure, and the overdensity radius. As + # scaling relations can be between basically any two parameters, and I want this relation object to be an XGA + # scaling relation instance, I need to check some things with the rad_temp_rel passed by the user + if not isinstance(rad_temp_rel, ScalingRelation): + raise TypeError("The rad_temp_rel argument requires an XGA ScalingRelation instance.") + elif not rad_temp_rel.x_unit.is_equivalent(Unit('keV')): + raise UnitConversionError("This pipeline requires a radius-temperature relation, but the x-unit of the " + "rad_temp_rel relation is {bu}. It cannot be converted to " + "keV.".format(bu=rad_temp_rel.x_unit.to_string())) + elif not rad_temp_rel.y_unit.is_equivalent(Unit('kpc')): + raise UnitConversionError("This pipeline requires a radius-temperature relation, but the y-unit of the " + "rad_temp_rel relation is {bu}. It cannot be converted to " + "kpc.".format(bu=rad_temp_rel.y_unit.to_string())) + + # I'm going to make sure that the user isn't allowed to request that it not iterate at all + if min_iter < 2: + raise ValueError("The minimum number of iterations set by 'min_iter' must be 2 or more.") + + # Also have to make sure the user hasn't something daft like make min_iter larger than max_iter + if max_iter <= min_iter: + raise ValueError("The max_iter value ({mai}) is less than or equal to the min_iter value " + "({mii}).".format(mai=max_iter, mii=min_iter)) + + # Trying to determine the targeted overdensity based on the name of the scaling relation y-axis label + y_name = rad_temp_rel.y_name.lower() + if 'r' in y_name and '2500' in y_name: + o_dens = 'r2500' + elif 'r' in y_name and '500' in y_name: + o_dens = 'r500' + elif 'r' in y_name and '200' in y_name: + o_dens = 'r200' + else: + raise ValueError("The y-axis label of the scaling relation ({ya}) does not seem to contain 2500, 500, or " + "200; it has not been possible to determine the overdensity.".format(ya=rad_temp_rel.y_name)) + + # Overdensity radius argument for the declaration of the sample + o_dens_arg = {o_dens: start_aperture} + + # Just a little warning to a user who may have made a silly decision + if core_excised and o_dens == 'r2500': + warn("You may not measure reliable core-excised results when iterating on R2500 - the radii can be small " + " enough that multiplying by 0.15 for an inner radius will result in too small of a " + "radius.", stacklevel=2) + + # Keeps track of the current iteration number + iter_num = 0 + + # Set up the ClusterSample to be used for this process (I did consider setting up a new one each time but that + # adds overhead, and I think that this way should work fine). + samp = ClusterSample(sample_data['ra'].values, sample_data['dec'].values, sample_data['redshift'].values, + sample_data['name'].values, use_peak=use_peak, peak_find_method=peak_find_method, + clean_obs_threshold=0.7, clean_obs_reg=o_dens, load_fits=True, cosmology=cosmo, **o_dens_arg) + + # As it is possible some clusters in the sample_data dataframe don't actually have X-ray data, we copy + # the sample_data and cut it down, so it only contains entries for clusters that were loaded in the sample at the + # beginning of this process + loaded_samp_data = sample_data.copy() + loaded_samp_data = loaded_samp_data[loaded_samp_data['name'].isin(samp.names)] + + # This is a boolean array of whether the current radius has been accepted or not - starts off False + acc_rad = np.full(len(samp), False) + + # In this dictionary we're going to keep a record of the radius history for all clusters for each step. The + # keys are names, and the initial setup will have the start aperture as the first entry in the list of + # radii for each cluster + rad_hist = {n: [start_aperture.value] for n in samp.names} + + # This while loop (never thought I'd be using one of them in XGA!) will keep going either until all radii have been + # accepted OR until we reach the maximum number of iterations + while acc_rad.sum() != len(samp) and iter_num < max_iter: + # We have a try-except looking for SAS generation errors - they will only be thrown once all the spectrum + # generation processes have finished, so we know that the spectra that didn't throw an error exist and + # are fine + try: + # Run the spectrum generation for the current values of the over density radius + evselect_spectrum(samp, samp.get_radius(o_dens), num_cores=num_cores, one_rmf=False) + # If the end of evselect_spectrum doesn't throw a SASGenerationError then we know we're all good, so we + # define the not_bad_gen_ind to just contain an index for all the clusters + not_bad_gen_ind = np.nonzero(samp.names) + except SASGenerationError as err: + # Otherwise if something went wrong we can parse the error messages and extract the names of the sources + # for which the error occurred + poss_bad_gen = list(set([me.message.split(' is the associated source')[0].split('- ')[-1] + for i_err in err.message for me in i_err])) + # Do also need to check that the entries in poss_bad_gen are actually source names - as XGA is raising + # the errors we're parsing, we SHOULD be able to rely on them being a certain format, but we had better + # be safe + bad_gen = [en for en in poss_bad_gen if en in samp.names] + if len(bad_gen) != len(poss_bad_gen): + # If there are entries in poss_bad_gen that ARE NOT names in the sample, then something has gone wrong + # with the error parsing and we need to warn the user. + problem = [en for en in poss_bad_gen if en not in samp.names] + warn("SASGenerationError parsing has recovered a string that is not a source name, a " + "problem source may not have been removed from the sample (contact the development team). The " + "offending strings are, {}".format(', '.join(problem)), stacklevel=2) + + # Just to be safe I'm adding a check to make sure bad_gen has entries + if len(bad_gen) == 0: + raise SASGenerationError("Failed to identify sources for which SAS spectrum generation failed.") + + # We define the indices that WON'T have been removed from the sample (so these can be used to address + # things like the pr_rs quantity we defined up top + not_bad_gen_ind = np.nonzero(~np.isin(samp.names, bad_gen)) + acc_rad = acc_rad[not_bad_gen_ind] + + # Then we can cycle through those names and delete the sources from the sample (throwing a hopefully + # useful warning as well). + for bad_name in bad_gen: + if bad_name in samp.names: + del samp[bad_name] + warn("Some sources ({}) have been removed because of spectrum generation " + "failures.".format(', '.join(bad_gen)), stacklevel=2) + + # We generate and fit spectra for the current value of the overdensity radius + single_temp_apec(samp, samp.get_radius(o_dens), one_rmf=False, num_cores=num_cores, timeout=timeout, + lum_en=lum_en) + + # Just reading out the temperatures, not the uncertainties at the moment + txs = samp.Tx(samp.get_radius(o_dens), quality_checks=False)[:, 0] + + # This uses the scaling relation to predict the overdensity radius from the measured temperatures + pr_rs = rad_temp_rel.predict(txs, samp.redshifts, samp.cosmo) + + # It is possible that some of these radius entries are going to be NaN - the result of NaN temperature values + # passed through the 'predict' method of the scaling relation. As such we identify any NaN results and + # remove the radii from the pr_rs array as we're going to do the same for the clusters in the sample + bad_pr_rs = np.where(np.isnan(pr_rs))[0] + pr_rs = np.delete(pr_rs, bad_pr_rs) + acc_rad = np.delete(acc_rad, bad_pr_rs) + + # I am also actually going to remove the clusters with NaN results from the sample - if the NaN was caused + # by something like a fit not converging then it's going to keep trying over and over again and that could + # slow everything down. + # I make sure not to try to remove clusters which I've ALREADY removed further up because their spectral + # generation failed. + for name in samp.names[bad_pr_rs]: + del samp[name] + + # The basis of this method is that we measure a temperature, starting in some user-specified fixed aperture, + # and then use that to predict an overdensity radius (something far more useful than a fixed aperture). This + # process is repeated until the radius fraction converges to within the user-specified limit. + # It should also be noted that each cluster is made to iterate at least `min_iter` times, nothing will be + # allowed to just accept the first result + rad_rat = pr_rs / samp.get_radius(o_dens) + + # Make a copy of the currently set radius values from the sample - these will then be modified with the + # new predicted values if the particular cluster's radius isn't already considered 'accepted' - i.e. it + # reached the required convergence in a previous iteration + new_rads = samp.get_radius(o_dens).copy() + # The clusters which DON'T have previously accepted radii have their radii updated from those predicted from + # temperature + new_rads[~acc_rad] = pr_rs[~acc_rad] + # Use the new radius value inferred from the temperature + scaling relation and add it to the ClusterSample (or + # just re-adding the same value as is already here if that radius has converged and been accepted). + if o_dens == 'r500': + samp.r500 = new_rads + elif o_dens == 'r2500': + samp.r2500 = new_rads + elif o_dens == 'r200': + samp.r200 = new_rads + + # If there have been enough iterations, then we need to start checking whether any of the radii have + # converged to within the user-specified fraction. If they have then we accept them and those radii won't + # be changed the next time around. + if iter_num >= min_iter: + acc_rad = ((rad_rat > (1 - convergence_frac)) & (rad_rat < (1 + convergence_frac))) | acc_rad + + rad_hist = {n: vals + [samp[n].get_radius(o_dens, 'kpc').value] if n in samp.names else vals + for n, vals in rad_hist.items()} + # Got to increment the counter otherwise the while loop may go on and on forever :O + iter_num += 1 + + # Throw a warning if the maximum number of iterations being reached was the reason the loop exited + if iter_num == max_iter: + warn("The radius measurement process reached the maximum number of iterations; as such one or more clusters " + "may have unconverged radii.") + + # At this point we've exited the loop - the final radii have been decided on. However, we cannot guarantee that + # the final radii have had spectra generated/fit for them, so we run single_temp_apec again one last time + single_temp_apec(samp, samp.get_radius(o_dens), one_rmf=False, lum_en=lum_en, num_cores=num_cores) + + # We also check to see whether the user requested core-excised measurements also be performed. If so then we'll + # just multiply the current radius by 0.15 and use that for the inner radius. + if core_excised: + single_temp_apec(samp, samp.get_radius(o_dens), samp.get_radius(o_dens)*0.15, one_rmf=False, lum_en=lum_en, + num_cores=num_cores) + + # Now to assemble the final sample information dataframe - note that the sample does have methods for the bulk + # retrieval of temperature and luminosity values, but they aren't so useful here because I know that some of the + # original entries in sample_data might have been deleted from the sample object itself + for row_ind, row in loaded_samp_data.iterrows(): + # We're iterating through the rows of the sample information passed in, because we want there to be an + # entry even if the LT pipeline didn't succeed. As such we have to check if the current row's cluster + # is actually still a part of the sample + if row['name'] in samp.names: + # Grab the relevant source out of the ClusterSample object + rel_src = samp[row['name']] + rel_rad = rel_src.get_radius(o_dens, 'kpc') + + # These will be to store the read-out temperature and luminosity values, and their corresponding + # column names for the dataframe - we make sure that the measure radius is present in the data + vals = [rel_rad.value] + cols = [o_dens] + + # If the user let XGA determine a peak coordinate for the cluster, we will need to add it to the results + # as all the spectra for the cluster were generated with that as the central point + if use_peak: + vals += [*rel_src.peak.value] + cols += ['peak_ra', 'peak_dec'] + + # We have to use try-excepts here, because even at this stage it is possible that we have a failed + # spectral fit to contend with - if there are no successful fits then the entry for the current + # cluster will be NaN + try: + # The temperature measured within the overdensity radius, with its - and + uncertainties are read out + vals += list(rel_src.get_temperature(rel_rad).value) + # We add columns with informative names + cols += ['Tx' + o_dens[1:] + p_fix for p_fix in ['', '-', '+']] + + # Cycle through every available luminosity, this will return all luminosities in all energy bands + # requested by the user with lum_en + for lum_name, lum in rel_src.get_luminosities(rel_rad).items(): + # The luminosity and its uncertainties gets added to the values list + vals += list(lum.value) + # Then the column names get added + cols += ['Lx' + o_dens[1:] + lum_name.split('bound')[-1] + p_fix for p_fix in ['', '-', '+']] + + except ModelNotAssociatedError: + pass + + # Now we repeat the above process, but only if we know the user requested core-excised values as well + if core_excised: + try: + # Adding temperature value and uncertainties + vals += list(rel_src.get_temperature(rel_rad, inner_radius=0.15*rel_rad).value) + # Corresponding column names (with ce now included to indicate core-excised). + cols += ['Tx' + o_dens[1:] + 'ce' + p_fix for p_fix in ['', '-', '+']] + + # The same process again for core-excised luminosities + lce_res = rel_src.get_luminosities(rel_rad, inner_radius=0.15*rel_rad) + for lum_name, lum in lce_res.items(): + vals += list(lum.value) + cols += ['Lx' + o_dens[1:] + 'ce' + lum_name.split('bound')[-1] + p_fix + for p_fix in ['', '-', '+']] + + except ModelNotAssociatedError: + pass + + # We know that at least the radius will always be there to be added to the dataframe, so we add the + # information in vals and cols + loaded_samp_data.loc[row_ind, cols] = vals + + # If the user wants to save the resulting dataframe to disk then we do so + if save_samp_results_path is not None: + loaded_samp_data.to_csv(save_samp_results_path, index=False) + + # Finally, we put together the radius history throughout the iteration-convergence process + radius_hist_df = pd.DataFrame.from_dict(rad_hist, orient='index') + + # There is already an array detailing whether particular radii have been 'accepted' (i.e. converged) or not, but + # it only contains entries for those clusters which are still loaded in the ClusterSample - in the next part + # of this pipeline I assemble a radius history dataframe (for all clusters that were initially in the + # ClusterSample), and want the final column to declare if they converged or not. + rad_hist_acc_rad = [] + for row_ind, row in loaded_samp_data.iterrows(): + if row['name'] in samp.names: + # Did this radius converge? + converged = acc_rad[np.argwhere(samp.names == row['name'])[0][0]] + else: + converged = False + rad_hist_acc_rad.append(converged) + + # We add the final column which just tells the user whether the radius was converged or not + radius_hist_df['converged'] = rad_hist_acc_rad + + # And if the user wants this saved as well they can + if save_rad_history_path is not None: + # This one I keep indexing set to True, because the names of the clusters are acting as the index for + # this dataframe + radius_hist_df.to_csv(save_rad_history_path, index=True, index_label='name') + + return samp, loaded_samp_data, radius_hist_df + + + + + + + diff --git a/xga/tools/clusters/__init__.py b/xga/tools/clusters/__init__.py new file mode 100644 index 00000000..03426d45 --- /dev/null +++ b/xga/tools/clusters/__init__.py @@ -0,0 +1,4 @@ +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 14:51. Copyright (c) The Contributors + +from .LT import luminosity_temperature_pipeline diff --git a/xga/utils.py b/xga/utils.py index f9e42862..944a5efc 100644 --- a/xga/utils.py +++ b/xga/utils.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 15/06/2021, 14:04. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 13/04/2023, 15:12. Copyright (c) The Contributors import json import os @@ -12,6 +12,7 @@ import pandas as pd import pkg_resources from astropy.constants import m_p, m_e +from astropy.cosmology import LambdaCDM from astropy.units import Quantity, def_unit, add_enabled_units from astropy.wcs import WCS from fitsio import read_header @@ -57,6 +58,8 @@ ALLOWED_PRODUCTS = ["spectrum", "grp_spec", "regions", "events", "psf", "psfgrid", "ratemap", "combined_spectrum", ] + ENERGY_BOUND_PRODUCTS + PROFILE_PRODUCTS + COMBINED_PROFILE_PRODUCTS XMM_INST = ["pn", "mos1", "mos2"] +# This list contains banned filter types - these occur in observations that I don't want XGA to try and use +BANNED_FILTS = ['CalClosed', 'Closed'] # Here we read in files that list the errors and warnings in SAS errors = pd.read_csv(pkg_resources.resource_filename(__name__, "files/sas_errors.csv"), header="infer") @@ -91,6 +94,9 @@ # A centralised constant to define what radius labels are allowed RAD_LABELS = ["region", "r2500", "r500", "r200", "custom", "point"] +# Adding a default concordance cosmology set up here - this replaces the original default choice of Planck15 +DEFAULT_COSMO = LambdaCDM(70, 0.3, 0.7) + def xmm_obs_id_test(test_string: str) -> bool: """ @@ -137,9 +143,18 @@ def observation_census(config: ConfigParser) -> Tuple[pd.DataFrame, pd.DataFrame # Creates black list file if one doesn't exist, then reads it in if not os.path.exists(BLACKLIST_FILE): with open(BLACKLIST_FILE, 'w') as bl: - bl.write("ObsID") + bl.write("ObsID,EXCLUDE_PN,EXCLUDE_MOS1,EXCLUDE_MOS2") blacklist = pd.read_csv(BLACKLIST_FILE, header="infer", dtype=str) + # This part here is to support blacklists used by older versions of XGA, where only a full ObsID was excluded. + # Now we support individual instruments of ObsIDs being excluded from use, so there are extra columns expected + if len(blacklist.columns) == 1: + # Adds the three new columns, all with a default value of True. So any ObsID already in the blacklist + # will have the same behaviour as before, all instruments for the ObsID are excluded + blacklist[["EXCLUDE_PN", "EXCLUDE_MOS1", "EXCLUDE_MOS2"]] = 'T' + # If we have even gotten to this stage then the actual blacklist file needs re-writing, so I do + blacklist.to_csv(BLACKLIST_FILE, index=False) + # Need to find out which observations are available, crude way of making sure they are ObsID directories # This also checks that I haven't run them before obs_census = [entry for entry in os.listdir(config["XMM_FILES"]["root_xmm_dir"]) if xmm_obs_id_test(entry) @@ -153,7 +168,7 @@ def observation_census(config: ConfigParser) -> Tuple[pd.DataFrame, pd.DataFrame if os.path.exists(evt_path): evts_header = read_header(evt_path) try: - # Reads out the filter header, if it is CalClosed then we can't use it + # Reads out the filter header, if it is CalClosed/Closed then we can't use it filt = evts_header["FILTER"] submode = evts_header["SUBMODE"] info['ra'] = evts_header["RA_PNT"] @@ -164,7 +179,7 @@ def observation_census(config: ConfigParser) -> Tuple[pd.DataFrame, pd.DataFrame filt = "CalClosed" # TODO Decide if I want to disallow small window mode observations - if filt != "CalClosed": + if filt not in BANNED_FILTS: info["the_rest"].append("T") else: info["the_rest"].append("F") @@ -424,6 +439,16 @@ def find_all_wcs(hdr: FITSHDR) -> List[WCS]: except: XSPEC_VERSION = None - + # This defines the meaning of different colours of region - this will eventually be user configurable in the + # configuration file, but putting it here means that the user can still change the definitions programmatically + # Definitions of the colours of XCS regions can be found in the thesis of Dr Micheal Davidson + # University of Edinburgh - 2005. + # Red - Point source + # Green - Extended source + # Magenta - PSF-sized extended source + # Blue - Extended source with significant point source contribution + # Cyan - Extended source with significant Run1 contribution + # Yellow - Extended source with less than 10 counts + SRC_REGION_COLOURS = {'pnt': ["red"], 'ext': ["green", "magenta", "blue", "cyan", "yellow"]} diff --git a/xga/xspec/__init__.py b/xga/xspec/__init__.py index 791440d8..5e1137a5 100644 --- a/xga/xspec/__init__.py +++ b/xga/xspec/__init__.py @@ -1,8 +1,6 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 21/01/2021, 11:45. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 27/04/2023, 12:16. Copyright (c) The Contributors -from .fit import single_temp_apec, power_law, single_temp_apec_profile +from .fit import single_temp_apec, power_law, single_temp_apec_profile, blackbody, multi_temp_dem_apec, \ + single_temp_mekal from .run import execute_cmd, xspec_call - - - diff --git a/xga/xspec/fakeit.py b/xga/xspec/fakeit.py index 2423075b..f5dcf098 100644 --- a/xga/xspec/fakeit.py +++ b/xga/xspec/fakeit.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 12/05/2021, 16:41. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors import os from random import randint diff --git a/xga/xspec/fit/__init__.py b/xga/xspec/fit/__init__.py index ef4c9d9b..ce7ac1e2 100644 --- a/xga/xspec/fit/__init__.py +++ b/xga/xspec/fit/__init__.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 22/01/2021, 15:13. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors from .general import * # Perhaps this one will be removed diff --git a/xga/xspec/fit/common.py b/xga/xspec/fit/_common.py similarity index 95% rename from xga/xspec/fit/common.py rename to xga/xspec/fit/_common.py index 7dc81350..209cb0c0 100644 --- a/xga/xspec/fit/common.py +++ b/xga/xspec/fit/_common.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 21/04/2021, 14:42. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 27/04/2023, 11:02. Copyright (c) The Contributors import os import warnings @@ -125,7 +125,8 @@ def _write_xspec_script(source: BaseSource, spec_storage_key: str, model: str, a specs: str, lo_en: Quantity, hi_en: Quantity, par_names: str, par_values: str, linking: str, freezing: str, par_fit_stat: float, lum_low_lims: str, lum_upp_lims: str, lum_conf: float, redshift: float, pre_check: bool, check_par_names: str, check_par_lo_lims: str, - check_par_hi_lims: str, check_par_err_lims: str, norm_scale: bool) -> Tuple[str, str]: + check_par_hi_lims: str, check_par_err_lims: str, norm_scale: bool, + which_par_nh: str = 'None') -> Tuple[str, str]: """ This writes out a configured XSPEC script, and is common to all fit functions. @@ -159,6 +160,8 @@ def _write_xspec_script(source: BaseSource, spec_storage_key: str, model: str, a uncertainties. :param bool norm_scale: Is there an extra constant designed to account for the differences in normalisation you can get from different observations of a cluster. + :param str which_par_nh: The parameter IDs of the nH parameters values which should be zeroed for the calculation + of unabsorbed luminosities. :return: The paths to the output file and the script file. :rtype: Tuple[str, str] """ @@ -182,7 +185,8 @@ def _write_xspec_script(source: BaseSource, spec_storage_key: str, model: str, a hi_cut=hi_en.to("keV").value, m=model, pn=par_names, pv=par_values, lk=linking, fr=freezing, el=par_fit_stat, lll=lum_low_lims, lul=lum_upp_lims, of=out_file, redshift=redshift, lel=lum_conf, check=pre_check, cps=check_par_names, - cpsl=check_par_lo_lims, cpsh=check_par_hi_lims, cpse=check_par_err_lims, ns=norm_scale) + cpsl=check_par_lo_lims, cpsh=check_par_hi_lims, cpse=check_par_err_lims, ns=norm_scale, + nhmtz=which_par_nh) # Write out the filled-in template to its destination with open(script_file, 'w') as xcm: diff --git a/xga/xspec/fit/general.py b/xga/xspec/fit/general.py index 1f0b7109..2baf980a 100644 --- a/xga/xspec/fit/general.py +++ b/xga/xspec/fit/general.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 10/06/2021, 11:19. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 27/04/2023, 12:16. Copyright (c) The Contributors import warnings from typing import List, Union @@ -7,7 +7,7 @@ import astropy.units as u from astropy.units import Quantity -from .common import _check_inputs, _write_xspec_script, _pregen_spectra +from ._common import _check_inputs, _write_xspec_script, _pregen_spectra from ..run import xspec_call from ... import NUM_CORES from ...exceptions import NoProductAvailableError, ModelNotAssociatedError @@ -150,11 +150,328 @@ def single_temp_apec(sources: Union[BaseSource, BaseSample], outer_radius: Union check_hi_lims = "{}" check_err_lims = "{}" + # This sets the list of parameter IDs which should be zeroed at the end to calculate unabsorbed luminosities. I + # am only specifying parameter 2 here (though there will likely be multiple models because there are likely + # multiple spectra) because I know that nH of tbabs is linked in this setup, so zeroing one will zero + # them all. + nh_to_zero = "{2}" + + out_file, script_file = _write_xspec_script(source, spec_objs[0].storage_key, model, abund_table, fit_method, + specs, lo_en, hi_en, par_names, par_values, linking, freezing, + par_fit_stat, lum_low_lims, lum_upp_lims, lum_conf, source.redshift, + spectrum_checking, check_list, check_lo_lims, check_hi_lims, + check_err_lims, True, nh_to_zero) + + # If the fit has already been performed we do not wish to perform it again + try: + res = source.get_results(out_rad_vals[src_ind], model, inn_rad_vals[src_ind], 'kT', group_spec, min_counts, + min_sn, over_sample) + except ModelNotAssociatedError: + script_paths.append(script_file) + outfile_paths.append(out_file) + src_inds.append(src_ind) + + run_type = "fit" + return script_paths, outfile_paths, num_cores, run_type, src_inds, None, timeout + + +@xspec_call +def single_temp_mekal(sources: Union[BaseSource, BaseSample], outer_radius: Union[str, Quantity], + inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'), + start_temp: Quantity = Quantity(3.0, "keV"), start_met: float = 0.3, + lum_en: Quantity = Quantity([[0.5, 2.0], [0.01, 100.0]], "keV"), + freeze_nh: bool = True, freeze_met: bool = True, lo_en: Quantity = Quantity(0.3, "keV"), + hi_en: Quantity = Quantity(7.9, "keV"), par_fit_stat: float = 1., lum_conf: float = 68., + abund_table: str = "angr", fit_method: str = "leven", group_spec: bool = True, + min_counts: int = 5, min_sn: float = None, over_sample: float = None, one_rmf: bool = True, + num_cores: int = NUM_CORES, spectrum_checking: bool = True, + timeout: Quantity = Quantity(1, 'hr')): + """ + This is a convenience function for fitting an absorbed single temperature mekal model(constant*tbabs*mekal) to an + object. It would be possible to do the exact same fit using the custom_model function, but as it will + be a very common fit a dedicated function is in order. If there are no existing spectra with the passed + settings, then they will be generated automatically. + + If the spectrum checking step of the XSPEC fit is enabled (using the boolean flag spectrum_checking), then + each individual spectrum available for a given source will be fitted, and if the measured temperature is less + than or equal to 0.01keV, or greater than 20keV, or the temperature uncertainty is greater than 15keV, then + that spectrum will be rejected and not included in the final fit. Spectrum checking also involves rejecting any + spectra with fewer than 10 noticed channels. + + Switch is set to 1, so the fit will compute the spectrum by interpolating on a pre-calculated mekal table. + + :param List[BaseSource] sources: A single source object, or a sample of sources. + :param str/Quantity outer_radius: The name or value of the outer radius of the region that the + desired spectrum covers (for instance 'r200' would be acceptable for a GalaxyCluster, + or Quantity(1000, 'kpc')). If 'region' is chosen (to use the regions in region files), then any value + passed for inner_radius is ignored, and the fit performed on spectra for the entire region. If you are + fitting for multiple sources then you can also pass a Quantity with one entry per source. + :param str/Quantity inner_radius: The name or value of the inner radius of the region that the + desired spectrum covers (for instance 'r200' would be acceptable for a GalaxyCluster, + or Quantity(1000, 'kpc')). By default this is zero arcseconds, resulting in a circular spectrum. If + you are fitting for multiple sources then you can also pass a Quantity with one entry per source. + :param Quantity start_temp: The initial temperature for the fit. + :param start_met: The initial metallicity for the fit (in ZSun). + :param Quantity lum_en: Energy bands in which to measure luminosity. + :param bool freeze_nh: Whether the hydrogen column density should be frozen. + :param bool freeze_met: Whether the metallicity parameter in the fit should be frozen. + :param Quantity lo_en: The lower energy limit for the data to be fitted. + :param Quantity hi_en: The upper energy limit for the data to be fitted. + :param float par_fit_stat: The delta fit statistic for the XSPEC 'error' command, default is 1.0 which should be + equivelant to 1σ errors if I've understood (https://heasarc.gsfc.nasa.gov/xanadu/xspec/manual/XSerror.html) + correctly. + :param float lum_conf: The confidence level for XSPEC luminosity measurements. + :param str abund_table: The abundance table to use for the fit. + :param str fit_method: The XSPEC fit method to use. + :param bool group_spec: A boolean flag that sets whether generated spectra are grouped or not. + :param float min_counts: If generating a grouped spectrum, this is the minimum number of counts per channel. + To disable minimum counts set this parameter to None. + :param float min_sn: If generating a grouped spectrum, this is the minimum signal to noise in each channel. + To disable minimum signal to noise set this parameter to None. + :param float over_sample: The minimum energy resolution for each group, set to None to disable. e.g. if + over_sample=3 then the minimum width of a group is 1/3 of the resolution FWHM at that energy. + :param bool one_rmf: This flag tells the method whether it should only generate one RMF for a particular + ObsID-instrument combination - this is much faster in some circumstances, however the RMF does depend + slightly on position on the detector. + :param int num_cores: The number of cores to use (if running locally), default is set to 90% of available. + :param bool spectrum_checking: Should the spectrum checking step of the XSPEC fit (where each spectrum is fit + individually and tested to see whether it will contribute to the simultaneous fit) be activated? + :param Quantity timeout: The amount of time each individual fit is allowed to run for, the default is one hour. + Please note that this is not a timeout for the entire fitting process, but a timeout to individual source + fits. + """ + sources, inn_rad_vals, out_rad_vals = _pregen_spectra(sources, outer_radius, inner_radius, group_spec, min_counts, + min_sn, over_sample, one_rmf, num_cores) + sources = _check_inputs(sources, lum_en, lo_en, hi_en, fit_method, abund_table, timeout) + + # This function is for a set model, absorbed mekal, so I can hard code all of this stuff. + # These will be inserted into the general XSPEC script template, so lists of parameters need to be in the form + # of TCL lists. + model = "constant*tbabs*mekal" + par_names = "{factor nH kT nH Abundanc Redshift switch norm}" + lum_low_lims = "{" + " ".join(lum_en[:, 0].to("keV").value.astype(str)) + "}" + lum_upp_lims = "{" + " ".join(lum_en[:, 1].to("keV").value.astype(str)) + "}" + + script_paths = [] + outfile_paths = [] + src_inds = [] + # This function supports passing multiple sources, so we have to setup a script for all of them. + for src_ind, source in enumerate(sources): + # Find matching spectrum objects associated with the current source + spec_objs = source.get_spectra(out_rad_vals[src_ind], inner_radius=inn_rad_vals[src_ind], + group_spec=group_spec, min_counts=min_counts, min_sn=min_sn, + over_sample=over_sample) + # This is because many other parts of this function assume that spec_objs is iterable, and in the case of + # a cluster with only a single valid instrument for a single valid observation this may not be the case + if isinstance(spec_objs, Spectrum): + spec_objs = [spec_objs] + + # Obviously we can't do a fit if there are no spectra, so throw an error if that's the case + if len(spec_objs) == 0: + raise NoProductAvailableError("There are no matching spectra for {s} object, you " + "need to generate them first!".format(s=source.name)) + + # Turn spectra paths into TCL style list for substitution into template + specs = "{" + " ".join([spec.path for spec in spec_objs]) + "}" + # For this model, we have to know the redshift of the source. + if source.redshift is None: + raise ValueError("You cannot supply a source without a redshift to this model.") + + # Whatever start temperature is passed gets converted to keV, this will be put in the template + t = start_temp.to("keV", equivalencies=u.temperature_energy()).value + # Another TCL list, this time of the parameter start values for this model. + par_values = "{{{0} {1} {2} {3} {4} {5} {6} {7}}}".format(1., source.nH.to("10^22 cm^-2").value, t, 1, + start_met, source.redshift, 1, 1.) + + # Set up the TCL list that defines which parameters are frozen, dependent on user input + freezing = "{{F {n} F T {ab} T T F}}".format(n='T' if freeze_nh else 'F', ab='T' if freeze_met else 'F') + + # Set up the TCL list that defines which parameters are linked across different spectra, only the + # multiplicative constant that accounts for variation in normalisation over different observations is not + # linked + linking = "{F T T T T T T T}" + + # If the user wants the spectrum cleaning step to be run, then we have to setup some acceptable + # limits. For this function they will be hardcoded, for simplicities sake, and we're only going to + # check the temperature, as its the main thing we're fitting for with constant*tbabs*mekal + if spectrum_checking: + check_list = "{kT}" + check_lo_lims = "{0.01}" + check_hi_lims = "{20}" + check_err_lims = "{15}" + else: + check_list = "{}" + check_lo_lims = "{}" + check_hi_lims = "{}" + check_err_lims = "{}" + + # This sets the list of parameter IDs which should be zeroed at the end to calculate unabsorbed luminosities. I + # am only specifying parameter 2 here (though there will likely be multiple models because there are likely + # multiple spectra) because I know that nH of tbabs is linked in this setup, so zeroing one will zero + # them all. + nh_to_zero = "{2}" + out_file, script_file = _write_xspec_script(source, spec_objs[0].storage_key, model, abund_table, fit_method, specs, lo_en, hi_en, par_names, par_values, linking, freezing, par_fit_stat, lum_low_lims, lum_upp_lims, lum_conf, source.redshift, spectrum_checking, check_list, check_lo_lims, check_hi_lims, - check_err_lims, True) + check_err_lims, True, nh_to_zero) + + # If the fit has already been performed we do not wish to perform it again + try: + res = source.get_results(out_rad_vals[src_ind], model, inn_rad_vals[src_ind], 'kT', group_spec, min_counts, + min_sn, over_sample) + except ModelNotAssociatedError: + script_paths.append(script_file) + outfile_paths.append(out_file) + src_inds.append(src_ind) + + run_type = "fit" + return script_paths, outfile_paths, num_cores, run_type, src_inds, None, timeout + + +@xspec_call +def multi_temp_dem_apec(sources: Union[BaseSource, BaseSample], outer_radius: Union[str, Quantity], + inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'), + start_max_temp: Quantity = Quantity(5.0, "keV"), start_met: float = 0.3, + start_t_rat: float = 0.1, start_inv_em_slope: float = 0.25, + lum_en: Quantity = Quantity([[0.5, 2.0], [0.01, 100.0]], "keV"), + freeze_nh: bool = True, freeze_met: bool = True, lo_en: Quantity = Quantity(0.3, "keV"), + hi_en: Quantity = Quantity(7.9, "keV"), par_fit_stat: float = 1., lum_conf: float = 68., + abund_table: str = "angr", fit_method: str = "leven", group_spec: bool = True, + min_counts: int = 5, min_sn: float = None, over_sample: float = None, one_rmf: bool = True, + num_cores: int = NUM_CORES, spectrum_checking: bool = True, + timeout: Quantity = Quantity(1, 'hr')): + """ + This is a convenience function for fitting an absorbed multi temperature apec model (constant*tbabs*wdem) to + spectra generated for XGA sources. The wdem model uses a power law distribution of the differential emission + measure distribution. It may be a good empirical approximation for the spectra in cooling cores of + clusters of galaxies. This implementation sets the 'switch' to 2, which means that the APEC model is used. + + If there are no existing spectra with the passed settings, then they will be generated automatically. + + :param List[BaseSource] sources: A single source object, or a sample of sources. + :param str/Quantity outer_radius: The name or value of the outer radius of the region that the + desired spectrum covers (for instance 'r200' would be acceptable for a GalaxyCluster, + or Quantity(1000, 'kpc')). If 'region' is chosen (to use the regions in region files), then any value + passed for inner_radius is ignored, and the fit performed on spectra for the entire region. If you are + fitting for multiple sources then you can also pass a Quantity with one entry per source. + :param str/Quantity inner_radius: The name or value of the inner radius of the region that the + desired spectrum covers (for instance 'r200' would be acceptable for a GalaxyCluster, + or Quantity(1000, 'kpc')). By default this is zero arcseconds, resulting in a circular spectrum. If + you are fitting for multiple sources then you can also pass a Quantity with one entry per source. + :param Quantity start_max_temp: The initial maximum temperature for the fit. + :param float start_met: The initial metallicity for the fit (in ZSun). + :param float start_t_rat: The initial minimum to maximum temperature ratio (beta) for the fit. + :param float start_inv_em_slope: The initial inverse slope value of the emission measure for the fit. + :param Quantity lum_en: Energy bands in which to measure luminosity. + :param bool freeze_nh: Whether the hydrogen column density should be frozen. + :param bool freeze_met: Whether the metallicity parameter in the fit should be frozen. + :param Quantity lo_en: The lower energy limit for the data to be fitted. + :param Quantity hi_en: The upper energy limit for the data to be fitted. + :param float par_fit_stat: The delta fit statistic for the XSPEC 'error' command, default is 1.0 which should be + equivalent to 1σ errors if I've understood (https://heasarc.gsfc.nasa.gov/xanadu/xspec/manual/XSerror.html) + correctly. + :param float lum_conf: The confidence level for XSPEC luminosity measurements. + :param str abund_table: The abundance table to use for the fit. + :param str fit_method: The XSPEC fit method to use. + :param bool group_spec: A boolean flag that sets whether generated spectra are grouped or not. + :param float min_counts: If generating a grouped spectrum, this is the minimum number of counts per channel. + To disable minimum counts set this parameter to None. + :param float min_sn: If generating a grouped spectrum, this is the minimum signal to noise in each channel. + To disable minimum signal to noise set this parameter to None. + :param float over_sample: The minimum energy resolution for each group, set to None to disable. e.g. if + over_sample=3 then the minimum width of a group is 1/3 of the resolution FWHM at that energy. + :param bool one_rmf: This flag tells the method whether it should only generate one RMF for a particular + ObsID-instrument combination - this is much faster in some circumstances, however the RMF does depend + slightly on position on the detector. + :param int num_cores: The number of cores to use (if running locally), default is set to 90% of available. + :param bool spectrum_checking: Should the spectrum checking step of the XSPEC fit (where each spectrum is fit + individually and tested to see whether it will contribute to the simultaneous fit) be activated? + :param Quantity timeout: The amount of time each individual fit is allowed to run for, the default is one hour. + Please note that this is not a timeout for the entire fitting process, but a timeout to individual source + fits. + """ + sources, inn_rad_vals, out_rad_vals = _pregen_spectra(sources, outer_radius, inner_radius, group_spec, min_counts, + min_sn, over_sample, one_rmf, num_cores) + sources = _check_inputs(sources, lum_en, lo_en, hi_en, fit_method, abund_table, timeout) + + # This function is for a set model, absorbed apec, so I can hard code all of this stuff. + # These will be inserted into the general XSPEC script template, so lists of parameters need to be in the form + # of TCL lists. + model = "constant*tbabs*wdem" + par_names = "{factor nH Tmax beta inv_slope nH abundanc Redshift switch norm}" + lum_low_lims = "{" + " ".join(lum_en[:, 0].to("keV").value.astype(str)) + "}" + lum_upp_lims = "{" + " ".join(lum_en[:, 1].to("keV").value.astype(str)) + "}" + + script_paths = [] + outfile_paths = [] + src_inds = [] + # This function supports passing multiple sources, so we have to setup a script for all of them. + for src_ind, source in enumerate(sources): + # Find matching spectrum objects associated with the current source + spec_objs = source.get_spectra(out_rad_vals[src_ind], inner_radius=inn_rad_vals[src_ind], + group_spec=group_spec, min_counts=min_counts, min_sn=min_sn, + over_sample=over_sample) + # This is because many other parts of this function assume that spec_objs is iterable, and in the case of + # a cluster with only a single valid instrument for a single valid observation this may not be the case + if isinstance(spec_objs, Spectrum): + spec_objs = [spec_objs] + + # Obviously we can't do a fit if there are no spectra, so throw an error if that's the case + if len(spec_objs) == 0: + raise NoProductAvailableError("There are no matching spectra for {s} object, you " + "need to generate them first!".format(s=source.name)) + + # Turn spectra paths into TCL style list for substitution into template + specs = "{" + " ".join([spec.path for spec in spec_objs]) + "}" + # For this model, we have to know the redshift of the source. + if source.redshift is None: + raise ValueError("You cannot supply a source without a redshift to this model.") + + # Whatever start temperature is passed gets converted to keV, this will be put in the template + t = start_max_temp.to("keV", equivalencies=u.temperature_energy()).value + # Another TCL list, this time of the parameter start values for this model. + par_values = "{{{0} {1} {2} {3} {4} {5} {6} {7} {8} {9}}}".format(1., source.nH.to("10^22 cm^-2").value, t, + start_t_rat, start_inv_em_slope, + 1, start_met, source.redshift, 2, 1.) + + # Set up the TCL list that defines which parameters are frozen, dependant on user input + freezing = "{{F {n} F F F T {ab} T T F}}".format(n='T' if freeze_nh else 'F', + ab='T' if freeze_met else 'F') + + # Set up the TCL list that defines which parameters are linked across different spectra, only the + # multiplicative constant that accounts for variation in normalisation over different observations is not + # linked + linking = "{F T T T T T T T T T}" + + # If the user wants the spectrum cleaning step to be run, then we have to setup some acceptable + # limits. The check limits here are somewhat of a guesstimate based on my understanding of the model + # rather than on practical experience with it + if spectrum_checking: + check_list = "{Tmax beta inv_slope}" + check_lo_lims = "{0.01 0.01 0.1}" + check_hi_lims = "{20 1 20}" + check_err_lims = "{15 5 5}" + else: + check_list = "{}" + check_lo_lims = "{}" + check_hi_lims = "{}" + check_err_lims = "{}" + + # This sets the list of parameter IDs which should be zeroed at the end to calculate unabsorbed luminosities. I + # am only specifying parameter 2 here (though there will likely be multiple models because there are likely + # multiple spectra) because I know that nH of tbabs is linked in this setup, so zeroing one will zero + # them all. + nh_to_zero = "{2}" + + # This internal function writes out the XSPEC script with all the information we've assembled in this + # function - filling out the XSPEC template and writing to disk + out_file, script_file = _write_xspec_script(source, spec_objs[0].storage_key, model, abund_table, fit_method, + specs, lo_en, hi_en, par_names, par_values, linking, freezing, + par_fit_stat, lum_low_lims, lum_upp_lims, lum_conf, source.redshift, + spectrum_checking, check_list, check_lo_lims, check_hi_lims, + check_err_lims, True, nh_to_zero) # If the fit has already been performed we do not wish to perform it again try: @@ -198,7 +515,7 @@ def power_law(sources: Union[BaseSource, BaseSample], outer_radius: Union[str, Q :param float start_pho_index: The starting value for the photon index of the powerlaw. :param Quantity lo_en: The lower energy limit for the data to be fitted. :param Quantity hi_en: The upper energy limit for the data to be fitted. - :param bool freeze_nh: Whether the hydrogen column density should be frozen. :param start_pho_index: + :param bool freeze_nh: Whether the hydrogen column density should be frozen. :param float par_fit_stat: The delta fit statistic for the XSPEC 'error' command, default is 1.0 which should be equivelant to 1sigma errors if I've understood (https://heasarc.gsfc.nasa.gov/xanadu/xspec /manual/XSerror.html) correctly. @@ -289,10 +606,16 @@ def power_law(sources: Union[BaseSource, BaseSample], outer_radius: Union[str, Q warnings.warn("{s} has no redshift information associated, so luminosities from this fit" " will be invalid, as redshift has been set to one.".format(s=source.name)) + # This sets the list of parameter IDs which should be zeroed at the end to calculate unabsorbed luminosities. I + # am only specifying parameter 2 here (though there will likely be multiple models because there are likely + # multiple spectra) because I know that nH of tbabs is linked in this setup, so zeroing one will zero + # them all. + nh_to_zero = "{2}" + out_file, script_file = _write_xspec_script(source, spec_objs[0].storage_key, model, abund_table, fit_method, specs, lo_en, hi_en, par_names, par_values, linking, freezing, par_fit_stat, lum_low_lims, lum_upp_lims, lum_conf, z, False, "{}", - "{}", "{}", "{}", True) + "{}", "{}", "{}", True, nh_to_zero) # If the fit has already been performed we do not wish to perform it again try: @@ -307,3 +630,148 @@ def power_law(sources: Union[BaseSource, BaseSample], outer_radius: Union[str, Q return script_paths, outfile_paths, num_cores, run_type, src_inds, None, timeout +@xspec_call +def blackbody(sources: Union[BaseSource, BaseSample], outer_radius: Union[str, Quantity], + inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'), redshifted: bool = False, + lum_en: Quantity = Quantity([[0.5, 2.0], [0.01, 100.0]], "keV"), start_temp: Quantity = Quantity(1, "keV"), + lo_en: Quantity = Quantity(0.3, "keV"), hi_en: Quantity = Quantity(7.9, "keV"), + freeze_nh: bool = True, par_fit_stat: float = 1., lum_conf: float = 68., abund_table: str = "angr", + fit_method: str = "leven", group_spec: bool = True, min_counts: int = 5, min_sn: float = None, + over_sample: float = None, one_rmf: bool = True, num_cores: int = NUM_CORES, + timeout: Quantity = Quantity(1, 'hr')): + """ + This is a convenience function for fitting a tbabs absorbed blackbody (or zbbody if redshifted + is selected) to source spectra, with a multiplicative constant included to deal with different spectrum + normalisations (constant*tbabs*bbody, or constant*tbabs*zbbody). + + :param List[BaseSource] sources: A single source object, or a sample of sources. + :param str/Quantity outer_radius: The name or value of the outer radius of the region that the + desired spectrum covers (for instance 'point' would be acceptable for a PointSource, + or Quantity(40, 'arcsec')). If 'region' is chosen (to use the regions in region files), then any value + passed for inner_radius is ignored, and the fit performed on spectra for the entire region. If you are + fitting for multiple sources then you can also pass a Quantity with one entry per source. + :param str/Quantity inner_radius: The name or value of the inner radius of the region that the + desired spectrum covers (for instance 'point' would be acceptable for a PointSource, + or Quantity(40, 'arcsec')). By default this is zero arcseconds, resulting in a circular spectrum. If + you are fitting for multiple sources then you can also pass a Quantity with one entry per source. + :param bool redshifted: Whether the powerlaw that includes redshift (zpowerlw) should be used. + :param Quantity lum_en: Energy bands in which to measure luminosity. + :param float start_temp: The starting value for the temperature of the blackbody. + :param Quantity lo_en: The lower energy limit for the data to be fitted. + :param Quantity hi_en: The upper energy limit for the data to be fitted. + :param bool freeze_nh: Whether the hydrogen column density should be frozen. + :param float par_fit_stat: The delta fit statistic for the XSPEC 'error' command, default is 1.0 which + should be equivelant to 1sigma errors if I've understood (https://heasarc.gsfc.nasa.gov/xanadu/xspec + /manual/XSerror.html) correctly. + :param float lum_conf: The confidence level for XSPEC luminosity measurements. + :param str abund_table: The abundance table to use for the fit. + :param str fit_method: The XSPEC fit method to use. + :param bool group_spec: A boolean flag that sets whether generated spectra are grouped or not. + :param float min_counts: If generating a grouped spectrum, this is the minimum number of counts per channel. + To disable minimum counts set this parameter to None. + :param float min_sn: If generating a grouped spectrum, this is the minimum signal to noise in each channel. + To disable minimum signal to noise set this parameter to None. + :param float over_sample: The minimum energy resolution for each group, set to None to disable. e.g. if + over_sample=3 then the minimum width of a group is 1/3 of the resolution FWHM at that energy. + :param bool one_rmf: This flag tells the method whether it should only generate one RMF for a particular + ObsID-instrument combination - this is much faster in some circumstances, however the RMF does depend + slightly on position on the detector. + :param int num_cores: The number of cores to use (if running locally), default is set to 90% of available. + :param Quantity timeout: The amount of time each individual fit is allowed to run for, the default is one hour. + Please note that this is not a timeout for the entire fitting process, but a timeout to individual source + fits. + """ + sources, inn_rad_vals, out_rad_vals = _pregen_spectra(sources, outer_radius, inner_radius, group_spec, min_counts, + min_sn, over_sample, one_rmf, num_cores) + sources = _check_inputs(sources, lum_en, lo_en, hi_en, fit_method, abund_table, timeout) + + # This function is for a set model, either absorbed blackbody or absorbed zbbody + # These will be inserted into the general XSPEC script template, so lists of parameters need to be in the form + # of TCL lists. + lum_low_lims = "{" + " ".join(lum_en[:, 0].to("keV").value.astype(str)) + "}" + lum_upp_lims = "{" + " ".join(lum_en[:, 1].to("keV").value.astype(str)) + "}" + if redshifted: + model = "constant*tbabs*zbbody" + par_names = "{factor nH kT Redshift norm}" + else: + model = "constant*tbabs*bbody" + par_names = "{factor nH kT norm}" + + script_paths = [] + outfile_paths = [] + src_inds = [] + for src_ind, source in enumerate(sources): + spec_objs = source.get_spectra(out_rad_vals[src_ind], inner_radius=inn_rad_vals[src_ind], group_spec=group_spec, + min_counts=min_counts, min_sn=min_sn, over_sample=over_sample) + + # This is because many other parts of this function assume that spec_objs is iterable, and in the case of + # a source with only a single valid instrument for a single valid observation this may not be the case + if isinstance(spec_objs, Spectrum): + spec_objs = [spec_objs] + + if len(spec_objs) == 0: + raise NoProductAvailableError("There are no matching spectra for {s}, you " + "need to generate them first!".format(s=source.name)) + + # Turn spectra paths into TCL style list for substitution into template + specs = "{" + " ".join([spec.path for spec in spec_objs]) + "}" + + # Whatever start temperature is passed gets converted to keV, this will be put in the template + t = start_temp.to("keV", equivalencies=u.temperature_energy()).value + + # For this model, we have to know the redshift of the source. + if redshifted and source.redshift is None: + raise ValueError("You cannot supply a source without a redshift if you have elected to fit zbbody.") + elif redshifted and source.redshift is not None: + par_values = "{{{0} {1} {2} {3} {4}}}".format(1., source.nH.to("10^22 cm^-2").value, t, + source.redshift, 1.) + else: + par_values = "{{{0} {1} {2} {3}}}".format(1., source.nH.to("10^22 cm^-2").value, t, 1.) + + # Set up the TCL list that defines which parameters are frozen, dependant on user input + if redshifted and freeze_nh: + freezing = "{F T F T F}" + elif not redshifted and freeze_nh: + freezing = "{F T F F}" + elif redshifted and not freeze_nh: + freezing = "{F F F T F}" + elif not redshifted and not freeze_nh: + freezing = "{F F F F}" + + # Set up the TCL list that defines which parameters are linked across different spectra, + # dependant on user input + if redshifted: + linking = "{F T T T T}" + else: + linking = "{F T T T}" + + # If the blackbody with redshift has been chosen, then we use the redshift attached to the source object + # If not we just pass a filler redshift and the luminosities are invalid + if redshifted or (not redshifted and source.redshift is not None): + z = source.redshift + else: + z = 1 + warnings.warn("{s} has no redshift information associated, so luminosities from this fit" + " will be invalid, as redshift has been set to one.".format(s=source.name)) + + # This sets the list of parameter IDs which should be zeroed at the end to calculate unabsorbed luminosities. I + # am only specifying parameter 2 here (though there will likely be multiple models because there are likely + # multiple spectra) because I know that nH of tbabs is linked in this setup, so zeroing one will zero + # them all. + nh_to_zero = "{2}" + out_file, script_file = _write_xspec_script(source, spec_objs[0].storage_key, model, abund_table, fit_method, + specs, lo_en, hi_en, par_names, par_values, linking, freezing, + par_fit_stat, lum_low_lims, lum_upp_lims, lum_conf, z, False, "{}", + "{}", "{}", "{}", True, nh_to_zero) + + # If the fit has already been performed we do not wish to perform it again + try: + res = source.get_results(out_rad_vals[src_ind], model, inn_rad_vals[src_ind], None, group_spec, min_counts, + min_sn, over_sample) + except ModelNotAssociatedError: + script_paths.append(script_file) + outfile_paths.append(out_file) + src_inds.append(src_ind) + + run_type = "fit" + return script_paths, outfile_paths, num_cores, run_type, src_inds, None, timeout diff --git a/xga/xspec/fit/profile.py b/xga/xspec/fit/profile.py index e2ac713d..2d0ed604 100644 --- a/xga/xspec/fit/profile.py +++ b/xga/xspec/fit/profile.py @@ -1,12 +1,12 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 25/05/2021, 13:55. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 27/04/2023, 12:06. Copyright (c) The Contributors from typing import List, Union import astropy.units as u from astropy.units import Quantity -from .common import _write_xspec_script, _check_inputs +from ._common import _write_xspec_script, _check_inputs from ..run import xspec_call from ... import NUM_CORES from ...exceptions import ModelNotAssociatedError @@ -50,7 +50,7 @@ def single_temp_apec_profile(sources: Union[BaseSource, BaseSample], radii: Unio :param Quantity lo_en: The lower energy limit for the data to be fitted. :param Quantity hi_en: The upper energy limit for the data to be fitted. :param float par_fit_stat: The delta fit statistic for the XSPEC 'error' command, default is 1.0 which should be - equivelant to 1σ errors if I've understood (https://heasarc.gsfc.nasa.gov/xanadu/xspec/manual/XSerror.html) + equivalent to 1σ errors if I've understood (https://heasarc.gsfc.nasa.gov/xanadu/xspec/manual/XSerror.html) correctly. :param float lum_conf: The confidence level for XSPEC luminosity measurements. :param str abund_table: The abundance table to use for the fit. @@ -152,13 +152,19 @@ def single_temp_apec_profile(sources: Union[BaseSource, BaseSample], radii: Unio check_hi_lims = "{}" check_err_lims = "{}" + # This sets the list of parameter IDs which should be zeroed at the end to calculate unabsorbed + # luminosities. I am only specifying parameter 2 here (though there will likely be multiple models + # because there are likely multiple spectra) because I know that nH of tbabs is linked in this + # setup, so zeroing one will zero them all. + nh_to_zero = "{2}" + file_prefix = spec_objs[0].storage_key + "_ident{}_".format(spec_objs[0].set_ident) \ + str(spec_objs[0].annulus_ident) out_file, script_file = _write_xspec_script(source, file_prefix, model, abund_table, fit_method, specs, lo_en, hi_en, par_names, par_values, linking, freezing, par_fit_stat, lum_low_lims, lum_upp_lims, lum_conf, source.redshift, spectrum_checking, check_list, check_lo_lims, - check_hi_lims, check_err_lims, True) + check_hi_lims, check_err_lims, True, nh_to_zero) try: res = ann_spec.get_results(0, model, 'kT') diff --git a/xga/xspec/run.py b/xga/xspec/run.py index e569092e..90c8ee6d 100644 --- a/xga/xspec/run.py +++ b/xga/xspec/run.py @@ -1,5 +1,5 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (david.turner@sussex.ac.uk) 09/06/2021, 16:34. Copyright (c) David J Turner +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/02/2023, 14:04. Copyright (c) The Contributors import os import warnings diff --git a/xga/xspec_scripts/cr_conv_calc.xcm b/xga/xspec_scripts/cr_conv_calc.xcm index d18cf881..8e725d00 100644 --- a/xga/xspec_scripts/cr_conv_calc.xcm +++ b/xga/xspec_scripts/cr_conv_calc.xcm @@ -1,4 +1,4 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). # Last modified by David J Turner (david.turner@sussex.ac.uk) 25/08/2020, 11:49. Copyright (c) David J Turner # This XSPEC script requires parameter's to be filled in by XGA before running. diff --git a/xga/xspec_scripts/general_xspec_fit.xcm b/xga/xspec_scripts/general_xspec_fit.xcm index 8602ff17..7683ab90 100644 --- a/xga/xspec_scripts/general_xspec_fit.xcm +++ b/xga/xspec_scripts/general_xspec_fit.xcm @@ -1,4 +1,4 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). # Last modified by David J Turner (david.turner@sussex.ac.uk) 26/02/2021, 10:03. Copyright (c) David J Turner # This XSPEC script template requires quite a few parameters to be filled in, using the Python formatting @@ -17,6 +17,9 @@ source {xsp} ################################################# # Setting up XSPEC ################################################# +# Turning off caching in the home directory .xspec folder +autosave off + # Set the statistic type to Cash statistic cstat @@ -90,6 +93,12 @@ set luminosity_confidence {lel} # This is where the lower and upper energy limits for the luminosity calculations go, xga_extract needs them set lum_low_lims {lll} set lum_upp_lims {lul} + +# This allows us to specify which nH parameters (with IDs specified in this variable) should be set to zero for +# the calculation of unabsorbed luminosity in the xga_extract.tcl file. The issue is that some emission +# models (e.g. mekal) have an intrinsic nH parameter, and they were being zeroed along with the +# wabs/tbabs/etc. nH parameter +set nh_par_to_zero {nhmtz} ################################################# @@ -455,6 +464,6 @@ for {{set i 0}} {{$i < [llength $lum_low_lims]}} {{incr i}} {{ }} # And finally we run the custom XGA script that extracts all the information we want -xga_extract $out_file $lum_lim_pairs $input_redshift $luminosity_confidence $model_name +xga_extract $out_file $lum_lim_pairs $input_redshift $luminosity_confidence $model_name $nh_par_to_zero ################################################# exit \ No newline at end of file diff --git a/xga/xspec_scripts/get_pars.tcl b/xga/xspec_scripts/get_pars.tcl index d4735113..c4843275 100644 --- a/xga/xspec_scripts/get_pars.tcl +++ b/xga/xspec_scripts/get_pars.tcl @@ -1,4 +1,4 @@ -# This code is a part of XMM: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). # Last modified by David J Turner (david.turner@sussex.ac.uk) 15/06/2020, 15:37. Copyright (c) David J Turner # This is a convenience script that shouldn't really be needed by anyone but me, it takes a list of XSPEC models, diff --git a/xga/xspec_scripts/xga_extract.tcl b/xga/xspec_scripts/xga_extract.tcl index 7b27b6a2..846f395a 100644 --- a/xga/xspec_scripts/xga_extract.tcl +++ b/xga/xspec_scripts/xga_extract.tcl @@ -30,6 +30,8 @@ proc xga_extract { args } { set redshift [lindex $args 2] set conf [lindex $args 3] set mod_name [lindex $args 4] + # This tells us which parameters are the nH that should be zeroed for unabsorbed luminosity calculations + set nh_pars [lindex $args 5] ################################################# # Fetching exp times and rates @@ -86,26 +88,20 @@ proc xga_extract { args } { set col_list "MODEL,TOTAL_EXPOSURE,TOTAL_COUNT_RATE,TOTAL_COUNT_RATE_ERR,NUM_UNLINKED_THAWED_VARS,FIT_STATISTIC,TEST_STATISTIC,DOF" # Now all the relevant parameter columns get named (those that are allowed to vary and are unlinked) - # Also record where-ever there is a parameter called nH, so we know which parameters to 0 later for unabsorbed - # luminosity calculations - set nh_pars {} set count 0 for {set ipar 1} {$ipar <= [array size spardel]} {incr ipar} { - set punit " " - scan [tcloutr pinfo $ipar] "%s %s" pname punit - if {$pname == "nH"} { - lappend nh_pars $ipar - } - lappend idents $ipar + set punit " " + scan [tcloutr pinfo $ipar] "%s %s" pname punit + lappend idents $ipar if { $spardel($ipar) > 0 } { - # Each parameter gets three columns; the value, the -error, and the +error + # Each parameter gets three columns; the value, the -error, and the +error set divid "|" append col_list "," $pname$divid$ipar append col_list "," $pname$divid$ipar- append col_list "," $pname$divid$ipar+ incr count - } + } } #################################################