diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 4493d69..828020d 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,40 +1,122 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: Python application - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install . - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - python -m pytest + #This workflow will install Python dependencies, run tests and lint with + # multiple versions of Python + # For more information see: + # (https://help.github.com/actions/language-and-framework-guides/ + # using-python-with-github-actions) + + name: Python application + + on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + + permissions: + contents: read + + jobs: + build: + runs-on: ubuntu-latest + env: + STARBUG_TEST_DIR: ${{ github.workspace }}/tests/dat/ + WEBBPSF_PATH: ${{ github.workspace }}/webbpsf_cache + STARBUG_DATDIR: ${{ github.workspace }}/starbug_data + + strategy: + matrix: + # Run across multiple modern versions to ensure starbug2 handles + # updates smoothly + python-version: ["3.12", "3.13"] + + steps: + # Upgraded to v4 for speed and security + - uses: actions/checkout@v4 + + # create directories for these dependency folders in envs + - name: Create DAta and Cache Directories + run: | + mkdir -p ${{ github.workspace }}/webbpsf_cache + mkdir -p ${{ github.workspace }}/starbug_data + + # Upgraded to v5 and added automatic pip caching to speed up builds + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + # Standardised dependency installation using modern pyproject.toml + # configuration + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + # Install your core pipeline dependencies from the + # requirements.txt file + if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; fi + + # Install starbug2 itself in editable mode + pip install -e . + + - name: Cache FITS test data + id: cache-fits + uses: actions/cache@v4 + with: + path: | + tests/dat/ngc6822_F770W_i2d.fits + tests/dat/image.fits + tests/dat/psf.fits + key: ${{ runner.os }}-fits-cache-v1 + + - name: Download FITS files from Private Draft Release if cache missed + if: steps.cache-fits.outputs.cache-hit != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release download TEST_DATA --repo "${{ github.repository }}" --pattern "*.fits" --dir "tests/dat" + + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined + # names + flake8 . --count --show-source --statistics --max-line-length=79 + # exit-zero treats all errors as warnings. The GitHub editor is + # 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 \ + --max-line-length=127 --statistics + + # --- LIGHTWEIGHT RAT LICENSE HEADER CHECK --- + - name: Verify License Headers (RAT Check) + run: | + echo "Checking for license headers in starbug2 source files..." + missing_headers=0 + + # Find all .py files in your source directory, ignoring + # environment folders + for file in $(find starbug2 -name "*.py"); do + if ! grep -q "Copyright (C) 2026 UKATC" "$file"; then + echo "❌ Missing license header in: $file" + missing_headers=$((missing_headers + 1)) + fi + done + + if [ $missing_headers -gt 0 ]; then + echo "Error: $missing_headers file(s) are missing the" \ + " mandatory copyright header block." + exit 1 + else + echo "✅ All source files contain valid copyright headers." + fi + + + - name: Pre-test Filter PSF setup + run: | + echo "Starting pre test filter psf setup" + starbug2 --init -s FILTER=F444W + echo "Command completed successfully!" + + - name: Test with pytest + run: | + python -m pytest \ No newline at end of file diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 66977f3..4d2c700 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -21,9 +21,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies diff --git a/.gitignore b/.gitignore index 2e40c58..07ddea1 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,9 @@ ENV/ env.bak/ venv.bak/ +# env +/star_bug_env/ + # Spyder project settings .spyderproject .spyproject @@ -138,3 +141,6 @@ dmypy.json # Pyre type checker .pyre/ + +# remove test files +/tests/dat/*.fits \ No newline at end of file diff --git a/README.md b/README.md index ab0864e..9025729 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@   • Powerful modular and simple GNU Linux standard command line interface -
+
[![Python application](https://github.com/conornally/starbug2/actions/workflows/python-app.yml/badge.svg)](https://github.com/conornally/starbug2/actions/workflows/python-app.yml) [![PyPI version fury.io](https://badge.fury.io/py/starbug2.svg)](https://pypi.python.org/pypi/starbug2/) @@ -27,21 +27,60 @@ ```bash $~ pip install starbug2 +``` + +NOTE:: +At this point star bug is fully functional for telescopes which are not JWST. +To bring into line for use with JWST, the "JWST initialization" section +needs to be followed, which can be done using the following command line. +``` $~ starbug2 --init ``` -
+ +NOTE:: I found the stpsf stuff to fail due to outdated dependnecy. needed to +do the following: +``` + pip install --upgrade pysiaf + +``` + +### From source + +If installing starbugII from source, the following commands are used to ensure +current dependencies are required. The venv name is not essential to the install +but was set up for consistency. + +- ```python3 -m venv star_bug_env``` +- ```./star_bug_env/bin/pip install --upgrade stpsf==2.2.0``` +- ```./star_bug_env/bin/pip install numpy==2.3.5 photutils==2.0.1 --force``` +- ```./star_bug_env/bin/pip install parse==1.22.0``` +- ```./star_bug_env/bin/pip install setuptools==82.0.1``` +- ```./star_bug_env/bin/pip install scikit-image==0.26.0``` +- ```./star_bug_env/bin/pip install webbpsf==2.0.0``` +- ```./star_bug_env/bin/pip install pytest==9.0.3``` +- ```./star_bug_env/bin/python -m pip install build``` + +instead of: +- ```pip install .``` +I used the following command to ensure live development. +- ```./star_bug_env/bin/python -m pip install -e . --no-deps``` + +For generating the doc images. graphviz is needed. To isntall that , run +the following commands: +``` pip isntall graphviz``` > [!IMPORTANT] -> If you make use of *StarbugII* in any published or presented work, please include a [citation](https://ui.adsabs.harvard.edu/abs/2023ascl.soft09012N/abstract). +> If you make use of *StarbugII* in any published or presented work, +> please include a [citation](https://ui.adsabs.harvard.edu/abs/2023ascl.soft09012N/abstract). > -> *StarbugII* uses methods and datatypes from [astropy](https://docs.astropy.org/en/stable/) and [photutils](https://photutils.readthedocs.io/en/stable), please acknowledge them accordingly. - -
+> *StarbugII* uses methods and datatypes from [astropy](https://docs.astropy.org/en/stable/) and +> [photutils](https://photutils.readthedocs.io/en/stable), please acknowledge them accordingly. ## Documentation -See the [full documentation](https://starbug2.readthedocs.io/en/latest/?badge=latest) for the complete installation and detailed guides to using the photometric routines. -Basic usage information is produced by running: +See the [full documentation](https://starbug2.readthedocs.io/en/latest/?badge=latest) for the complete installation and detailed +guides to using the photometric routines. Basic usage information is +produced by running: ```txt StarbugII - JWST PSF photometry @@ -73,9 +112,6 @@ usage: starbug2 [-ABDfGhMPSv] [-b bgdfile] [-d apfile] [-n ncores] [-o ouput] [- --generate-run *.fits : Generate a simple run script --version : Print starbug2 version - --apply-zeropint a.fits : Apply a zeropoint (-s ZP_MAG=1.0) to a.fits - --calc-instr-zp a.fits : Calculate and apply an instrumental zero point onto a.fits - --> typical runs $~ starbug2 -vD -p file.param image.fits //Source detect on image with a parameter file $~ starbug2 -vDM -n4 images*.fits //Source detect and match outputs of a list of images diff --git a/docs/source/_static/image_scripts/flow.py b/docs/source/_static/image_scripts/flow.py new file mode 100644 index 0000000..dcb4d1f --- /dev/null +++ b/docs/source/_static/image_scripts/flow.py @@ -0,0 +1,94 @@ +import graphviz + +# Initialize the directed graph +dot = graphviz.Digraph( + 'starbug_pipeline', + comment='Starbug2 Pipeline Flow', + format='png' +) + +# Global Graph attributes +dot.attr(rankdir='TB') +dot.attr(splines='ortho') +dot.attr(nodesep='0.6') +dot.attr(ranksep='0.4') +dot.attr(bgcolor='transparent') + +# Global Node attributes (Light blue fill, dark blue border, black text) +dot.attr('node', + fontname='Courier New, monospace', + fontsize='12', + fontcolor='#000000', + style='filled', + fillcolor='#E3F2FD', + color='#1565C0', + penwidth='1.5') + +# Global Edge attributes (Solid black arrows) +dot.attr('edge', + fontname='Courier New, monospace', + fontsize='10', + color='#000000', + arrowsize='1.0', + penwidth='1.5') + +# --- Define Nodes --- +# Terminals / Commands (Pill-shaped/Rounded boxes) +dot.node('init_param', '$~ starbug2 --local-param', shape='box', + style='filled,rounded') +dot.node('cmd_D', '$~ starbug2 -D', shape='box', style='filled,rounded') +dot.node('cmd_B', '$~ starbug2 -B', shape='box', style='filled,rounded') +dot.node('cmd_P', '$~ starbug2 -P', shape='box', style='filled,rounded') + +# Files (Document/Note shaped icons) +dot.node('image_fits', 'image.fits', shape='note') +dot.node('starbug_param', 'starbug.param', shape='note') +dot.node('image_ap', 'image-ap.fits\n(TABLE)', shape='note') +dot.node('image_bg', 'image-bg.fits\n(IMAGE)', shape='note') +dot.node('image_res', 'image-res.fits\n(IMAGE)', shape='note') +dot.node('image_psf', 'image-psf.fits\n(TABLE)', shape='note') + +# --- Structural Positioning & Constraints --- +# Force horizontal alignments for row uniformity +with dot.subgraph() as s: + s.attr(rank='same') + s.node('image_fits') + s.node('starbug_param') + +with dot.subgraph() as s: + s.attr(rank='same') + s.node('cmd_D') + s.node('image_ap') + +with dot.subgraph() as s: + s.attr(rank='same') + s.node('cmd_B') + s.node('image_bg') + +with dot.subgraph() as s: + s.attr(rank='same') + s.node('cmd_P') + s.node('image_res') + +# --- Define Connections --- +# Parameter branch +dot.edge('init_param', 'starbug_param') + + +dot.edge('starbug_param', 'cmd_D') + +# Main data flow +dot.edge('image_fits', 'cmd_D') +dot.edge('cmd_D', 'image_ap') + +dot.edge('cmd_D', 'cmd_B') +dot.edge('cmd_B', 'image_bg') + +dot.edge('cmd_B', 'cmd_P') +dot.edge('cmd_P', 'image_res') + +dot.edge('cmd_P', 'image_psf') + +# Save and compile to disk +dot.render('../images/flow', cleanup=True) +print("Pipeline chart compiled successfully as flow.png!") diff --git a/docs/source/_static/images/flow.png b/docs/source/_static/images/flow.png index e476db7..1e9fa5c 100644 Binary files a/docs/source/_static/images/flow.png and b/docs/source/_static/images/flow.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py index c56a31b..0b2bfa4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -52,7 +52,7 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] -html_favicon="_static/images/starbug.ico" +html_favicon = "_static/images/starbug.ico" html_logo = "_static/images/starbug.png" html_css_files = [ diff --git a/docs/source/install.rst b/docs/source/install.rst index 0b8304c..655cbfc 100644 --- a/docs/source/install.rst +++ b/docs/source/install.rst @@ -15,9 +15,12 @@ Installing the development version:: $~ pip install . +NOTE:: + At this point star bug is fully functional for telescopes which are not JWST. + To bring into line for use with JWST, the "JWST initialization" section + needs to be followed. - -Initialising +JWST Initialization After the package is installed, there are a few steps required to initialise *starbug2*: `WEBBPSF `_ is a dependency of *starbug* that has its own installation process which is not done automatically. This process is documented `here `_ but requires two main steps. Download the data file on the website, named something like webbpsf-data-X.X.X.tar.gz and expand it into a directory, then append to your .bashrc (or equivalent):: diff --git a/docs/source/usage/matching.rst b/docs/source/usage/matching.rst index ce2b0d0..60c4706 100644 --- a/docs/source/usage/matching.rst +++ b/docs/source/usage/matching.rst @@ -13,7 +13,7 @@ The basic usage can be displayed with :: -B --band : match in "BAND" mode (does not preserve a column for every frame) -C --cascade : match in "CASCADE" mode (left justify columns) -G --generic : match in "GENERIC" mode - + -X --exact : match in "EXACT" mode -e --error column : photometric error column ("eflux" or "stdflux") -f --full : export full catalogue -h --help : show help message diff --git a/pyproject.toml b/pyproject.toml index 2b11971..120e9d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,22 +1,23 @@ [project] name = "starbug2" version = "0.7.7" -authors = [ {name = "Conor Nally", email = "conor.nally@ed.ac.uk" } ] +authors = [ {name = "Conor Nally", email = "conor.nally@ed.ac.uk" }, + {name = "Alan Barry Stokes", email = "alan.stokes@stfc.ac.uk"}] readme = "README.md" description = "JWST PSF photometry in complex crowded fields." license = {file = "LICENSE.txt"} -requires-python = ">=3.10" +requires-python = ">=3.10,<3.14" dynamic = ["dependencies"] [project.urls] Documentation = "https://starbug2.readthedocs.io/en/latest/" -Repository = "https://github.com/conornally/starbug2.git" +Repository = "https://github.com/alan-stokes/starbug2.git" [project.scripts] -starbug2 = "starbug2.bin.main:starbug_mainentry" -starbug2-match= "starbug2.bin.match:match_mainentry" -starbug2-ast= "starbug2.bin.ast:ast_mainentry" -starbug2-plot="starbug2.bin.plot:plot_mainentry" +starbug2 = "starbug2.bin.main:starbug_main_entry" +starbug2-match= "starbug2.bin.match:match_main_entry" +starbug2-ast= "starbug2.bin.ast:ast_main_entry" +starbug2-plot="starbug2.bin.plot:plot_main_entry" [build-system] requires = ["setuptools"] @@ -24,3 +25,8 @@ build-backend = "setuptools.build_meta" [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} + +[tool.setuptools.packages.find] +where = ["."] +include = ["starbug2*"] +exclude = ["star_bug_env*", "tests*", "docs*"] diff --git a/requirements.txt b/requirements.txt index 78a2bc9..75df2ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,11 @@ -numpy -photutils==2.0.1 -astropy -parse -scipy -webbpsf -scikit-image -matplotlib +numpy>=2.0.0,<=2.4.6 +photutils==3.0.0 +astropy==7.2.0 +parse==1.22.1 +scipy==1.17.1 +webbpsf==2.0.0 +scikit-image==0.26.0 +matplotlib==3.11 +pytest==9.1.0 +stpsf==2.2.0 +requests==2.34.2 \ No newline at end of file diff --git a/starbug2/__init__.py b/starbug2/__init__.py index f9fa977..06d2fb1 100644 --- a/starbug2/__init__.py +++ b/starbug2/__init__.py @@ -1,279 +1,14 @@ -import warnings -from astropy.utils.exceptions import AstropyWarning -warnings.simplefilter("ignore",category=AstropyWarning) -warnings.simplefilter("ignore",category=RuntimeWarning) ## bit dodge that +"""Copyright (C) 2026 UKATC -logo=""" - * * __ * __ - * -- - - STARBUGII * / ___ / \ -- - - - --------- *___---. .___/ - -- - - JWST photometry in ./=== \ \. \ * - complex crowded fields | (O) | | | * - \._._/ ./ _(\) * - conor.nally@ed.ac.uk / ~--\ ----~ \ * - --- ___ --- - > %s -""" - -motd="https://starbug2.readthedocs.io/en/latest/" - -from os import getenv -_=getenv("STARBUG_DATDIR") -DATDIR=_ if _ else "%s/.local/share/starbug"%(getenv("HOME")) - - -## HASHDEFS -MIRI=1 -NIRCAM=2 - -NULL=0 -LONG=1 -SHORT=2 - -PIX=0 -ARCSEC=1 -ARCMIN=2 -DEG=3 - -## SOURCE FLAGS -SRC_GOOD=0 -SRC_BAD=0x01 -SRC_JMP=0x02 -SRC_VAR=0x04 ##source frame mean >5% differnet than median -SRC_FIX=0x08 ##psf fit with fixed centroid -SRC_UKN=0x10 ##source unknown - - -##DQ FLAGS -DQ_DO_NOT_USE=0x01 -DQ_SATURATED =0x02 -DQ_JUMP_DET =0x04 - -## DEFAULT MATCHING COLS -match_cols=["RA","DEC","flag","flux","eflux", "NUM"]#"stdflux", "NUM"] - -# ZERO POINT... -ZP={ "F070W" :[3631,0], - "F090W" :[3631,0], - "F115W" :[3631,0], - "F140M" :[3631,0], - "F150W" :[3631,0], - "F162M" :[3631,0], - "F164N" :[3631,0], - "F150W2" :[3631,0], - "F182M" :[3631,0], - "F187N" :[3631,0], - "F200W" :[3631,0], - "F210M" :[3631,0], - "F212N" :[3631,0], - "F250M" :[3631,0], - "F277W" :[3631,0], - "F300M" :[3631,0], - "F322W2" :[3631,0], - "F323N" :[3631,0], - "F335M" :[3631,0], - "F356W" :[3631,0], - "F360M" :[3631,0], - "F405N" :[3631,0], - "F410M" :[3631,0], - "F430M" :[3631,0], - "F444W" :[3631,0], - "F460M" :[3631,0], - "F466N" :[3631,0], - "F470N" :[3631,0], - "F480M" :[3631,0], - - "F560W" :[3631,0], - "F770W" :[3631,0], - "F1000W" :[3631,0], - "F1130W" :[3631,0], - "F1280W" :[3631,0], - "F1500W" :[3631,0], - "F1800W" :[3631,0], - "F2100W" :[3631,0], - "F2550W" :[3631,0], - } - -class _F: #(struct) containing JWST filter info - def __init__(self, wavelength, aFWHM, pFWHM, instr, length): - self.wavelength=wavelength - self.aFWHM=aFWHM - self.pFWHM=pFWHM - self.instr=instr - self.length=length - -filters={ #08/06/2023 -"F070W":_F(0.704 ,0.023,0.742,NIRCAM,SHORT), -"F090W":_F(0.901 ,0.030,0.968,NIRCAM,SHORT), -"F115W":_F(1.154 ,0.037,1.194,NIRCAM,SHORT), -"F140M":_F(1.404 ,0.046,1.484,NIRCAM,SHORT), -"F150W":_F(1.501 ,0.049,1.581,NIRCAM,SHORT), -"F162M":_F(1.626 ,0.053,1.710,NIRCAM,SHORT), -"F164N":_F(1.644 ,0.054,1.742,NIRCAM,SHORT), -"F150W2":_F(1.671 ,0.045,1.452,NIRCAM,SHORT), -"F182M":_F(1.845 ,0.060,1.935,NIRCAM,SHORT), -"F187N":_F(1.874 ,0.061,1.968,NIRCAM,SHORT), -"F200W":_F(1.990 ,0.064,2.065,NIRCAM,SHORT), -"F210M":_F(2.093 ,0.068,2.194,NIRCAM,SHORT), -"F212N":_F(2.120 ,0.069,2.226,NIRCAM,SHORT), -"F250M":_F(2.503 ,0.082,1.302,NIRCAM,LONG), -"F277W":_F(2.786 ,0.088,1.397,NIRCAM,LONG), -"F300M":_F(2.996 ,0.097,1.540,NIRCAM,LONG), -"F322W2":_F(3.247 ,0.096,1.524,NIRCAM,LONG), -"F323N":_F(3.237 ,0.106,1.683,NIRCAM,LONG), -"F335M":_F(3.365 ,0.109,1.730,NIRCAM,LONG), -"F356W":_F(3.563 ,0.114,1.810,NIRCAM,LONG), -"F360M":_F(3.621 ,0.118,1.873,NIRCAM,LONG), -"F405N":_F(4.055 ,0.132,2.095,NIRCAM,LONG), -"F410M":_F(4.092 ,0.133,2.111,NIRCAM,LONG), -"F430M":_F(4.280 ,0.139,2.206,NIRCAM,LONG), -"F444W":_F(4.421 ,0.140,2.222,NIRCAM,LONG), -"F460M":_F(4.624 ,0.151,2.397,NIRCAM,LONG), -"F466N":_F(4.654 ,0.152,2.413,NIRCAM,LONG), -"F470N":_F(4.707 ,0.154,2.444,NIRCAM,LONG), -"F480M":_F(4.834 ,0.157,2.492,NIRCAM,LONG), - -"F560W":_F(5.589 ,0.207,1.882,MIRI,NULL), -"F770W":_F(7.528 ,0.269,2.445,MIRI,NULL), -"F1000W":_F(9.883 ,0.328,2.982,MIRI,NULL), -"F1130W":_F(11.298 ,0.375,3.409,MIRI,NULL), -"F1280W":_F(12.712 ,0.420,3.818,MIRI,NULL), -"F1500W":_F(14.932 ,0.488,4.436,MIRI,NULL), -"F1800W":_F(17.875 ,0.591,5.373,MIRI,NULL), -"F2100W":_F(20.563 ,0.674,6.127,MIRI,NULL), -"F2550W":_F(25.147 ,0.803,7.300,MIRI,NULL), -} - - -helpstrings={ -"DETECTION":""" - Source Detection - ---------------- - - This routine locates point sources in an image. The input is a - FITS image and the output is a FITS table, containing a list of - point source locations, their geometric properties and flux/magnitude - measurements as calculated by aperture photometry. The output file - will have the suffix "-ap", note this is the same as the output - for the aperture photometry routine. - - To run this routine, use the core command: - - $~ starbug2 -D image.fits - - Alter the parameter file options under "DETECTION" to tune the - performance of starbug2. Two of the key parameters are: - - - SIGSKY : Set the background level of the image - - SIGSRC : Set the detection threshold of the sources - - Full documentation is at https://starbug2.readthedocs.io - -""", - -"BACKGROUND":""" - Diffuse Background Estimations - ------------------------------ - - This routine estimates the "dusty" emissions in an image, given - a source list. It is used to subtract from the image, thus removing - the flux contribution on a source brightness from the dusty environment. - - The routine requires a list of sources to be generated (by source - detection) or loaded with [-d sourcelist.fits] and requires a FITS - image to work on. The routine will ouput a FITS image, with the same - dimensions and spatial coverage as the input image, with the suffix - "-bgd". This background image can be used in the photometry later. - - To run the routine, use the core command: - - $~ starbug2 -B -d sourcelist.fits image.fits - - Alter the parameter file options under "BACKGROUND ESTIMATION" - to tune the performance of starbug2. Two key parameters are: - - - BGD_R : Set a fixed aperture mask radius around each source - - BOX_SIZE : Set the estimation resolution (larger will be more blurred) - - Full documentation is at https://starbug2.readthedocs.io - -""", - -"APPHOT":""" - Aperture Photometry - ------------------- - - This routine conducts aperture photometry on an image given a list - of sources. It requires a FITS image to run on and a FITS table source - list with either RA/DEC columns, or x/y_centroid or x/y_0 columns. - The routine outputs a table with the suffix "-ap". Note this filename - is the same as the source detection routine because aperture photometry - is automatically run at the end of the source detection step. The output - table contains 2flux/magnitude information on every source - - To run this routine, use the core command: - - $~ starbug2 -A -d sourcelist.fits image.fits - - Alter the parameter file options under "APERTURE PHOTOMETRY" to tune - the performance of starbug2. Three key parameters are: - - - APPHOT_R : Set the aperture radius for photometry (in pixels) - - SKY_RIN : Set the inner sky annulus radius (in pixels) - - SKY_ROUT : Set the outer sky annulus radius (in pixels) - - Full documentation is at https://starbug2.readthedocs.io - -""", - -"PSFPHOT":""" - PSF Photometry - -------------- - - This routine conducts PSF fitting photometry on an image given - a list of sources. Its requires a FITS image to run on and a FITS table - sourcelist with either RA/DEC columns, or x/y_centroid or x/y_0 columns. - The routine outputs a table with the suffix "-psf". The output table - contains 2flux/magnitude information on every source - - To run this routine, use the core command: - - $~ starbug2 -P -d sourcelist image.fits - - Alter the parameter file options under "PHOTOMETRY" to tune the - performance of starbug2. Two key parameters are: - - - FORCE_POS : Hold the cetroid positions of source fixed (forced photometry) - - GEN_RESIDUAL : Generate a residual image from all the fit source - - Full documentation is at https://starbug2.readthedocs.io - -""", - -"MATCHOUTPUTS":""" - Match Outputs - ------------- - - This option is set if the user wishes to combine all the output catalogues - from starbug together. It would be used in the case that a routine is - being ran on a list of images (either in series or parallel) and the - final catalogues should all be combined into a single source list. - It outputs two files, one with the suffix "full" and another with "match". - The first is all columns from all table preserved into a single large - catalogue, the second averages all the similar columns into a reduced - table. - - To run this routine, use the core code: - - $~ starbug2 -DM image1.fits image2.fits image3.fits ... - - Alter the parameter file options under "CATALOGUE MATCHING" to tune the - performance of starbug2. Two key parameters are: - - - MATCH_THRESH : Set the separation threshold (arcsec) to match two sources - - NEXP_THRESH : Set the minimum number of catalogues a source must be present in - -""", -} +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" diff --git a/starbug2/artificialstars.py b/starbug2/artificialstars.py index d995e47..4994fa5 100644 --- a/starbug2/artificialstars.py +++ b/starbug2/artificialstars.py @@ -1,549 +1,656 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import os import numpy as np +from typing import cast, Any, Final, List, Tuple, Callable, Dict + +from astropy.io.fits import HDUList from photutils.datasets import make_model_image, make_random_models_table -from photutils.psf import FittableImageModel -from astropy.table import Table,hstack,vstack +from photutils.psf import ImagePSF +from astropy.table import Table, hstack, QTable from astropy.io import fits +from astropy import units +from astropy.units import Quantity from scipy.optimize import curve_fit +from matplotlib.figure import Figure +from matplotlib.axes import Axes -try: import matplotlib.pyplot as plt -except: - import matplotlib; matplotlib.use("TkAgg") - import matplotlib.pyplot as plt +from starbug2.constants import ExitStates, TableColumn +from starbug2.matching.generic_match import GenericMatch +from starbug2.star_bug_interface import StarBugInterface -from starbug2.utils import printf,perror, cropHDU, get_MJysr2Jy_scalefactor, warn -from starbug2.matching import GenericMatch -class Artificial_StarsIII(): - """ - ast - """ - def __init__(self, starbug, index=-1): - ## Initialis the starbug instance - self.starbug=starbug - _=self.starbug.image - _=self.starbug.load_psf() +try: + import matplotlib.pyplot as plt +except ImportError: + import matplotlib + matplotlib.use("TkAgg") + import matplotlib.pyplot as plt - self.psf=FittableImageModel(self.starbug.psf) - self.index=index +from starbug2.utils import ( + printf, p_error, get_mj_ysr2jy_scale_factor, warn) - def __call__(self,*args,**kwargs): return self.auto_run(*args,**kwargs) - def auto_run(self, ntests, stars_per_test=1, subimage_size=-1, mag_range=(18,27), - loading_buffer=None, autosave=-1, skip_phot=0, skip_background=0): - """ - The main entry point into the artificial star test - This handles everything except the results compilation at the end, - - Parameters - ---------- - ntests : int - Number of tests to run - - stars_per_test : int - Number of stars to inject per test - - subimage_size : int - in prep. - - mag_range : tuple,list - Length two list or tuple containing the magnitude range of - injected stars. These will be uniformly sampled from within - this range. - - loading_buffer : numpy.ndarray - Length 3 array of shared memory to increment a loading bar - between multiple subprocesses.. - - autosave : int - Auto quick saving output frequency - - skip_phot : int - If true then ignore the PSF phot step and use aperture fluxes instead - - skip_background : int - If true then ignore the background substraction step - - Returns - ------- - test_result : astropy.Table - Full raw test results. Injected initial properties with measured values - """ +class ArtificialStars: + # not found + NULL: Final[int] = 0 - test_result=Table(np.full((ntests*stars_per_test,8),np.nan), names=["x_0","y_0","mag","flux","x_det","y_det","flux_det", "status"]) - scalefactor= get_MJysr2Jy_scalefactor(self.starbug.image) - base_image=self.starbug._image.copy() - base_shape=np.copy(self.starbug.image.shape) - stars_per_test=int(stars_per_test) - passed=0 + # found + DETECT: Final[int] = 1 - ZP = self.starbug.options.get("ZP_MAG") if self.starbug.options.get("ZP_MAG") else 0 - buffer=0 + # the number of columns in the test table. + N_COLUMNS: Final[int] = 8 - if mag_range[0]-mag_range[1] >=0: - warn("Detected magnitude range in wrong order, put bright limit first\n") - return None + # the column names of the table + TEST_TABLE_COLUMN_NAMES: Final[List[str]] = [ + TableColumn.X_0, TableColumn.Y_0, TableColumn.MAG, TableColumn.FLUX, + TableColumn.X_DET, TableColumn.Y_DET, TableColumn.FLUX_DET, + TableColumn.STATUS] - if any(base_shape < subimage_size): - subimage_size=min(base_shape) - perror("subimage size greater than image size, setting to 'safe' value %d.\n"%subimage_size) - - for test in range(1,int(ntests)+1): - centre=0 - centre= (base_shape[0]*np.random.random(), base_shape[1]*np.random.random()) - - #image=cropHDU( base_image.__deepcopy__(), (0,-1), (0,-1) ) - image=base_image.__deepcopy__() - #image=self.create_subimage( base_image.__deepcopy__(), subimage_size, position=centre, hdu=self.st - - shape=image[self.starbug._nHDU].shape - - sourcelist= make_random_models_table( stars_per_test, { "x_0":[buffer,shape[0]-buffer], - "y_0":[buffer,shape[1]-buffer], - "mag":mag_range}) - sourcelist.add_column( 10.0 ** ( (ZP-sourcelist["mag"])/2.5 ) , name="flux") - sourcelist.remove_column("id") - - #image[self.starbug._nHDU].data*=0 - star_overlay=make_model_image( shape, self.psf, sourcelist,model_shape=self.psf.shape)/scalefactor - image[self.starbug._nHDU].data+=star_overlay - self.starbug._image=image - - n=len(sourcelist) - result=self.single_test(image, sourcelist, skip_phot=skip_phot, skip_background=skip_background) - passed+=sum(result["status"]) - test_result[(test-1)*stars_per_test: test*stars_per_test]=result - - if loading_buffer is not None: - loading_buffer[0]+=1 - loading_buffer[2]=int(100*passed/(test*stars_per_test)) - - if autosave>0 and not test%autosave: - test_result.write("sbast-autosave%d.tmp"%self.index, overwrite=True, format="fits") - del image # is this neccessary? - return test_result + # mag ranges + MAG_RANGE_LOW: Final[int] = 18 + MAG_RANGE_HIGH: Final[int] = 27 - def single_test(self, image, contains, skip_phot=0, skip_background=0): + """ + ast + """ + def __init__(self, + starbug: StarBugInterface, + index: int = -1) -> None: + # Initials the starbug instance + self._starbug: StarBugInterface = starbug + _ = self._starbug.main_image + psf_success: int = self._starbug.load_psf() + + if psf_success != ExitStates.EXIT_SUCCESS: + warn("the psf file was not loaded. Expected failure.") + raise Exception("the psf file failed to load.") + + self._psf: ImagePSF = ImagePSF(self._starbug.psf) + self._index: int = index + + def execute_ast( + self, n_tests: int, stars_per_test, sub_image_size: int, + mag_range: Tuple[int, int], loading_buffer: np.ndarray | None, + autosave: int, skip_phot: bool | int, skip_background: bool | int, + zp_mag: float, save_image: bool, + save_image_path: str, ast_seed: int | None) -> Table | None: + """ + The main entry point into the artificial star test. + This handles everything except the results compilation at the end. + + :param n_tests: Number of tests to run. + :type n_tests: int + :param stars_per_test: Number of stars to inject per test. + :type stars_per_test: int + :param sub_image_size: In prep. + :type sub_image_size: int + :param mag_range: Length two list or tuple containing the magnitude + range of injected stars. These will be uniformly + sampled from within this range. + :type mag_range: tuple[float, float] or list[float] + :param loading_buffer: Length 3 array of shared memory to increment a + loading bar between multiple subprocesses. + :type loading_buffer: numpy.ndarray + :param autosave: Auto quick saving output frequency. + :type autosave: int + :param skip_phot: If true then ignore the PSF phot step and use + aperture fluxes instead. + :type skip_phot: bool or int + :param skip_background: If true then ignore the background + subtraction step. + :type skip_background: bool or int + :param zp_mag: the zero point magnitude + :type zp_mag: float + :param save_image: bool saying if we should save the added star image. + :type save_image: bool + :param save_image_path: the path for the save image. + :type save_image_path: str + :param ast_seed: the seed to set the artificial stars with. or None. + :type ast_seed: int | None + :return: Full raw test results. Injected initial properties with + measured values. + :rtype: astropy.table.Table + """ + return self._auto_run( + n_tests, stars_per_test, sub_image_size, mag_range, loading_buffer, + autosave, skip_phot, skip_background, zp_mag, save_image, + save_image_path, ast_seed) + + def _add_stars( + self, base_image: fits.HDUList, stars_per_test: int, buffer: int, + mag_range: Tuple[int, int], zp_mag: float, + scale_factor: float | int, save_image: bool, test: int, + save_image_path: str, ast_seed: int | None) -> QTable: + """ + adds new stars to the image. + :param base_image: copy of the current image + :type base_image: fits.HDUList + :param stars_per_test: the number of stars to put in per test. + :type stars_per_test: int + :param buffer: the buffer + :type buffer: int + :param mag_range: the magnitude ranges + :type mag_range: Tuple[int, int] + :param zp_mag: the zero point magnitude. + :type zp_mag: float + :param scale_factor: the scale factor. + :type scale_factor: float | int + :param save_image: bool saying if we should save the added star image. + :type save_image: bool + :param test: the test id + :type test: int + :param save_image_path: the path to save the image to + :type save_image_path: str + :param ast_seed: the seed to set the artificial stars with. or None. + :type ast_seed: int | None + :return: the location of the new stars. + :rtype: astrophy.QTable """ - Conduct a single test on an image with a set of initial source properties - - Parameters - ---------- - image : numpy.ndarray - 2D image array to conduct test on - contains : table - Table of initial source properties to be injected into the image. - This table must contain the columns ("x_0","y_0","flux") + image: fits.HDUList = base_image.__deepcopy__() + + shape: list[int, int] = image[self._starbug.n_hdu].shape # noqa + + source_list: QTable = make_random_models_table( + stars_per_test, { + TableColumn.X_0: [buffer, shape[0] - buffer], + TableColumn.Y_0: [buffer, shape[1] - buffer], + TableColumn.MAG: mag_range + }, ast_seed + ) + source_list.add_column( + 10.0 ** ((zp_mag - source_list[TableColumn.MAG]) / 2.5), + name=TableColumn.FLUX) + source_list.remove_column(TableColumn.ID) + + star_overlay: np.ndarray = ( + make_model_image( + shape, self._psf, source_list, + model_shape=self._psf.data.shape) + / scale_factor) + image[self._starbug.n_hdu].data += star_overlay + self._starbug.image = image + + if save_image: + image.writeto(os.path.join( + save_image_path, f"inserted_image_for_test_{test}.fits")) + + return source_list + + def _execute_test( + self, base_image: fits.HDUList, stars_per_test: int, buffer: int, + mag_range: Tuple[int, int], zp_mag: float, + scale_factor: float | int, skip_phot: bool | int, + skip_background: bool | int, passed: int, test_result: Table, + test: int, loading_buffer: np.ndarray | None, + autosave: int, save_image: bool, save_image_path: str, + ast_seed: int | None) -> None: + """ + executes a test. + :param base_image: copy of the current image + :type base_image: fits.HDUList + :param stars_per_test: the number of stars to put in per test. + :type stars_per_test: int + :param buffer: the buffer + :type buffer: int + :param mag_range: the magnitude ranges + :type mag_range: Tuple[int, int] + :param zp_mag: the zero point magnitude. + :type zp_mag: float + :param scale_factor: the scale factor. + :type scale_factor: float | int + :param skip_phot: check for if we should skip phot + :type skip_phot: bool + :param skip_background: check for if we should skip background + reduction. + :type skip_background: bool + :param passed: int to accum if the test works + :type passed: int + :param test_result: the result location for the tests output + :type test_result: Table + :param test: the test id + :type test: int + :param loading_buffer: the loading buffer + :type loading_buffer: np.ndarray | None + :param autosave: the auto save value. + :type autosave: int + :param save_image: bool saying if we should save the added star image. + :type save_image: bool + :param save_image_path: the path to save the new image to. + :type save_image_path: str + :param ast_seed: the seed to set the artificial stars with. or None. + :type ast_seed: int | None + :return: None + """ + source_list = self._add_stars( + base_image, stars_per_test, buffer, mag_range, zp_mag, + scale_factor, save_image, test, save_image_path, ast_seed) + + result: Table = self.single_test( + source_list, skip_phot=skip_phot, + skip_background=skip_background) + + # save system memory. + image: HDUList | None = self._starbug.image + assert image is not None + image.close() + + # process result. + passed += sum(result[TableColumn.STATUS]) + test_result[ + (test - 1) * stars_per_test: + test * stars_per_test] = result + + if loading_buffer is not None: + loading_buffer[0] += 1 + loading_buffer[2] = int( + 100 * passed / (test * stars_per_test)) + + if autosave > 0 and not test % autosave: + # noinspection SpellCheckingInspection + test_result.write( + "sbast-autosave%d.tmp" % self._index, overwrite=True, + format="fits") + + def _auto_run( + self, n_tests: int, stars_per_test: int, sub_image_size: int, + mag_range: Tuple[int, int], loading_buffer: np.ndarray | None, + autosave: int, skip_phot: bool | int, skip_background: bool | int, + zp_mag: float, save_image: bool, + save_image_path: str, ast_seed: int | None) -> Table | None: + """ + The main entry point into the artificial star test. + This handles everything except the results compilation at the end. + + :param n_tests: Number of tests to run. + :type n_tests: int + :param stars_per_test: Number of stars to inject per test. + :type stars_per_test: int + :param sub_image_size: In prep. + :type sub_image_size: int + :param mag_range: Length two list or tuple containing the magnitude + range of injected stars. These will be uniformly + sampled from within this range. + :type mag_range: tuple[float, float] or list[float] + :param loading_buffer: Length 3 array of shared memory to increment a + loading bar between multiple subprocesses. + :type loading_buffer: numpy.ndarray + :param autosave: Auto quick saving output frequency. + :type autosave: int + :param skip_phot: If true then ignore the PSF phot step and use + aperture fluxes instead. + :type skip_phot: bool or int + :param skip_background: If true then ignore the background + subtraction step. + :type skip_background: bool or int + :param zp_mag: the zero point magnitude + :type zp_mag: float + :param save_image: bool saying if we should save the added star image. + :type save_image: bool + :param save_image_path: path for the saving of the image + :type save_image_path: str + :param ast_seed: the seed to set the artificial stars with. or None. + :type ast_seed: int | None + :return: Full raw test results. Injected initial properties with + measured values. + :rtype: astropy.table.Table + """ - skip_phot : int - Skip the PSF phot routine + test_result: Table = Table( + np.full((n_tests * stars_per_test, self.N_COLUMNS), np.nan), + names=self.TEST_TABLE_COLUMN_NAMES) + scale_factor: float | int = ( + get_mj_ysr2jy_scale_factor(self._starbug.main_image)) + + current_image = self._starbug.image + assert current_image is not None + base_image: fits.HDUList = current_image.copy() + base_shape: np.ndarray = np.copy(self._starbug.main_image.shape) + stars_per_test: int = int(stars_per_test) + passed: int = 0 + buffer: int = 0 + + if mag_range[0] - mag_range[1] >= 0: + warn("Detected magnitude range in wrong order," + " put bright limit first\n") + return None - skip_background : int - Skip the background estimation and subtraction step + if any(base_shape < sub_image_size): + sub_image_size = min(base_shape) + p_error("sub image size greater than image size, setting to " + "'safe' value %d.\n" % sub_image_size) + + for test in range(1, int(n_tests) + 1): + self._execute_test( + base_image, stars_per_test, buffer, mag_range, zp_mag, + scale_factor, skip_phot, skip_background, passed, test_result, + test, loading_buffer, autosave, save_image, save_image_path, + ast_seed) + return test_result - Returns - ------- - result : Table - Table hoizontally stacked with the initial inputs and the detection and - photometric results. Plus column named "status", an integer flag as to - whether the source was detected or not. + def single_test( + self, contains: Table, skip_phot: bool | int = 0, + skip_background: bool | int = 0) -> Table: """ - NULL=0 - DETECT=1 - test_result=Table(np.full((len(contains),4),np.nan), names=["x_det","y_det","flux_det","status"]) - - threshold=2 - if not self.starbug.detect(): #Run detection on the image - det=self.starbug.detections - for i, src in enumerate(contains): #Check for detection in output - separations=np.sqrt( (src["x_0"]-det["xcentroid"])**2 + (src["y_0"]-det["ycentroid"])**2) - best_match=np.argmin(separations) - if separations[best_match] Table: """ Compile the results into magnitude binned values of recovery fraction - and flux error - - Parameters - ---------- - test_result : table - The output from auto_run + and flux error. - Returns - ------- - result : astropy Table - Table containing percent completeness as a function of magnitude + :param test_result: The output from auto_run. + :type test_result: astropy.table.Table + :return: A table containing per cent completeness as a function of + magnitude. + :rtype: astropy.table.Table """ - bins = np.arange( np.floor(min(test_result["mag"])), np.ceil(max(test_result["mag"])), 0.1) - percs= np.zeros(len(bins)) - errors=np.zeros(len(bins)) - offsets=np.zeros(len(bins)) - means =np.zeros(len(bins)) - - ibins = np.digitize( test_result["mag"], bins=bins) - for i in range(max(ibins)): - binned=test_result[ (ibins==i) ] - if binned: percs[i]=float(sum(binned["status"]))/len(binned) - - mag_inj= -2.5*np.log10( binned["flux"]) - mag_det= -2.5*np.log10( binned["flux_det"]) - errors[i]=np.nanstd( mag_inj-mag_det ) - means[i]=np.nanmean( mag_inj-mag_det ) - offsets[i]=np.nanmedian(binned["flux"]/binned["flux_det"]) - - - out=Table( [bins,percs,errors,offsets], names=("mag","rec","err","off"), dtype=(float,float,float,float)) + bins: np.ndarray = np.arange( + np.floor(np.nanmin(test_result[TableColumn.MAG])), + np.ceil(np.nanmax(test_result[TableColumn.MAG])), + 0.1) + percents: np.ndarray = np.zeros(len(bins)) + errors: np.ndarray = np.zeros(len(bins)) + offsets: np.ndarray = np.zeros(len(bins)) + means: np.ndarray = np.zeros(len(bins)) + + i_bins: np.ndarray = np.asarray(np.digitize( + test_result[TableColumn.MAG], bins=bins)) + for i in range(max(i_bins)): + binned: Table = test_result[(i_bins == i)] + if binned: + percents[i] = float(sum(binned[TableColumn.STATUS])) / len(binned) + + mag_inj: np.ndarray = -2.5 * np.log10(binned[TableColumn.FLUX]) + mag_det: np.ndarray = -2.5 * np.log10(binned[TableColumn.FLUX_DET]) + errors[i] = np.nanstd(mag_inj - mag_det) + means[i] = np.nanmean(mag_inj - mag_det) + offsets[i] = np.nanmedian( + binned[TableColumn.FLUX] / binned[TableColumn.FLUX_DET]) + + out: Table = Table( + [bins, percents, errors, offsets], + names=(TableColumn.MAG, TableColumn.REC, TableColumn.ERR_LOWER, + TableColumn.OFF), + dtype=(float, float, float, float)) return out -def get_spatialcompleteness(test_result,image,res=10): - """ - Produce an image array showing the spatially dependant recovery fraction - - Parameters - ---------- - test_result : table - The output from auto_run - image : numpy.ndarry - 2D image array to take the shape from - - res : int - The resolution of the spatial bins - - Returns - ------- - percs : numpy.ndarray - A 2D array the same shape as the image input, pixel values - show the fraction of injected sources recovered in this bin +def get_spatial_completeness( + test_result: Table, image: np.ndarray | None, + res: int = 10) -> np.ndarray | None: """ - if not image: return None - xbins=np.arange(min(test_result["x_0"]),max(test_result["x_0"]), int(res)) - ybins=np.arange(min(test_result["y_0"]),max(test_result["y_0"]), int(res)) - percs=np.zeros(image.shape) - for xi in xbins[:-1]: - xo=xi+res - for yi in ybins[:-1]: - yo=yi+res - mask=(test_result["x_0"]>=xi) & (test_result["x_0"]=yi) & (test_result["y_0"]= xi) & + (test_result[TableColumn.X_0] < xo) & + (test_result[TableColumn.Y_0] >= yi) & + (test_result[TableColumn.Y_0] < yo)) + binned: Table = test_result[mask] + if len(binned): + percents[int(xi): int(xo), int(yi): int(yo)] = ( + float(np.sum(binned[TableColumn.STATUS])) / len(binned)) + return percents + + +def estimate_completeness_mag(ast: Table) -> ( + Tuple[Tuple[float, float, float] | None, + Tuple[float, float, float] | None]): """ - fit=[None,None,None] - compl=[None,None,None] - fn_i=lambda y,l,k,xo: xo-(np.log((l/y)-1)/k) - - if len(set(ast.colnames) & set(("mag","rec")))==2: - try: - fit,_=curve_fit(scurve, ast["mag"], ast["rec"], [1, -1,np.median(ast["mag"])]) - compl=(fn_i(0.9,*fit),fn_i(0.7,*fit),fn_i(0.5,*fit)) - except: - warn("Unable to fit completeness fractions\n") - else: perror("Input table must have columns 'mag' and 'rec'\n") - return fit,compl - -def scurve(x,l,k,xo): + Estimate the completeness level of the artificial star test. + + :param ast: Output of Artificial_Stars.get_completeness, table must + contain columns (mag, rec). + :type ast: astropy.table.Table + :return: A tuple containing: + - **fit** (*list*): The fitting parameters to the logistic curve + $f(x) = \frac{l}{1 + exp(-k(x - x_0))}$ formatted + as ``[l, x_0, k]``. + - **complete** (*list*): Magnitude of 70% and 50% completeness. + :rtype: tuple[list, list] """ - S-curve function to fit completeness results to - - f(x)=l/(1+exp(-k(x-xo))) + fit: Tuple[float, float, float] | None = None + completeness: Tuple[float, float, float] | None = None - Parameters - ---------- - x : list - Magnitude range to input into function + # Syntax: Callable[[Param1Type, Param2Type, ...], ReturnType] + fn_i: Callable[[float, float, float, float], float] = ( + lambda y, limit, k, xo: xo - (np.log((limit / y) - 1) / k) + ) - l,xo,k : float - Function parameters - - Returns - ------- - f(x) : float + if len(set(ast.colnames) & {TableColumn.MAG, TableColumn.REC}) == 2: + try: + # need the *_ as the return tuple can be multiple sizes. The *_ + # allows the IDE to not freak out, especially as we don't care + # about the rest of the return values. + fit, *_ = curve_fit( + scurve, ast[TableColumn.MAG], ast[TableColumn.REC], + [1, -1, np.median(ast[TableColumn.MAG])]) + assert fit is not None + completeness = (fn_i(0.9, *fit), fn_i(0.7, *fit), fn_i(0.5, *fit)) + except (RuntimeError, ValueError) as e: + warn(f"Unable to fit completeness fractions: {e}\n") + else: + p_error("Input table must have columns 'mag' and 'rec'\n") + return fit, completeness + + +def scurve( + x: np.ndarray, limit: float, k: float, + xo: float) -> float | np.ndarray: """ - return l/(1+np.exp(-k*(x-xo))) + S-curve function to fit completeness results to. + + math:: f(x) = \\frac{l}{1 + \\exp(-k(x - x_0)) + + :param x: Magnitude range or array to input into the function. + :type x: list or numpy.ndarray + :param limit: Maximum value asymptote (typically representing maximum + completeness, near 1.0). + :type limit: float + :param xo: The inflection point of the curve (the magnitude where + completeness is 50%). + :type xo: float + :param k: The logistic growth rate or steepness of the curve. + :type k: float + :return: Calculated function value(s) matching the shape of the + input ``x``. + :rtype: float or numpy.ndarray + """ + return limit / (1 + np.exp(-k * (x - xo))) + -def compile_results(raw, image=None, plotast=None, fltr="m"): +def compile_results( + raw: Table, + image: np.ndarray | None = None, + plot_ast: str | None = None, + filter_string: str = "m") -> fits.HDUList: """ Compile all the raw data into usable results - Parameters - ---------- - - Returns - ------- + :param raw:raw data + :type raw: astro.table.table + :param image: the image data + :type image: np.ndarray + :param plot_ast: the save plot file name + :type plot_ast: str or None + :param filter_string: the filter string + :type filter_string: str + :return: the results + :rtype: fits.HDUList """ - completeness=get_completeness(raw) - _cfit,_compl=estim_completeness_mag(completeness) - spatial_completeness = get_spatialcompleteness(raw, image, res=10) - - head={ "COMPLETE_FN":"F(x)=l/(1+exp(-k(x-xo)))", - "l":_cfit[0], "k":_cfit[1], "xo":_cfit[2] } - for i,frac in enumerate((90,70,50)): - if _compl[i] and not np.isnan(_compl[i]): - printf("-> complete to %d%%: %s=%.2f\n"%(frac,fltr,_compl[i])) - head["COMPLETE %d%%"%frac]=_compl[i] - - results= fits.HDUList( [fits.PrimaryHDU( header=fits.Header(head)), - fits.BinTableHDU(data=completeness, name="AST"), - fits.BinTableHDU(data=raw, name="RAW"), - fits.ImageHDU(data=spatial_completeness, name="CMP")]) - - if plotast: - fig,ax=plt.subplots(1,figsize=(3.5,3),dpi=300) - ax.scatter(completeness["mag"],completeness["rec"], c='k', lw=0, s=8) - ax.plot(completeness["mag"],scurve(completeness["mag"],*_cfit),c='g',label=r"$f(x)=\frac{%.2f}{1+e^{%.2f(x-%.2f)}}$"%(_cfit[0],-_cfit[1],_cfit[2])) - ax.axvline(_compl[0], c="seagreen",ls='--', label=("90%%:%.2f"%_compl[0]),lw=0.75) - ax.axvline(_compl[1], c="seagreen",ls='-.', label=("70%%:%.2f"%_compl[1]),lw=0.75) - ax.axvline(_compl[2], c="seagreen",ls=':', label=("50%%:%.2f"%_compl[2]),lw=0.75) - ax.scatter(_compl,(0.9,0.7,0.5),marker='*', c='teal', s=10) - ax.tick_params(direction="in",top=True,right=True) + + completeness_raw: Table = get_completeness(raw) + cfit: Tuple[float, float, float] + completeness: Tuple[float, float, float] + cfit, completeness = estimate_completeness_mag(completeness_raw) + spatial_completeness: np.ndarray | None = ( + get_spatial_completeness(raw, image, res=10)) + + head: Dict[str, str | float] = { + "COMPLETE_FN": "F(x)=l/(1+exp(-k(x-xo)))", "l": cfit[0], + "k": cfit[1], "xo": cfit[2]} + for i, frac in enumerate((90, 70, 50)): + if completeness[i] and not np.isnan(completeness[i]): + printf( + "-> complete to %d%%: %s=%.2f\n" % ( + frac, filter_string, completeness[i])) + head["COMPLETE %d%%" % frac] = str(completeness[i]) + + # needed for spatial_completeness as it expects an 'array.pyi', + # got 'ndarray' instead. + results: fits.HDUList = fits.HDUList( + [fits.PrimaryHDU(header=fits.Header(head)), + fits.BinTableHDU(data=completeness_raw, name="AST"), + fits.BinTableHDU(data=raw, name="RAW"), + fits.ImageHDU(data=cast(Any, spatial_completeness), name="CMP")]) + + if plot_ast: + fig: Figure + ax: Axes + fig, ax = plt.subplots(1, figsize=(3.5, 3), dpi=300) + ax.scatter( + completeness_raw[TableColumn.MAG], + completeness_raw[TableColumn.REC], c='k', lw=0, s=8) + ax.plot(completeness_raw[TableColumn.MAG], + scurve(completeness_raw[TableColumn.MAG], *cfit), + c='g', + label=r"$f(x)=\frac{%.2f}{1+e^{%.2f("r"x-%.2f)}}$" % ( + cfit[0], cfit[1], cfit[2])) + ax.axvline( + completeness[0], c="seagreen", ls='--', + label=("90%%:%.2f" % completeness[0]), lw=0.75) + ax.axvline( + completeness[1], c="seagreen", ls='-.', + label=("70%%:%.2f" % completeness[1]), lw=0.75) + ax.axvline( + completeness[2], c="seagreen", ls=':', + label=("50%%:%.2f" % completeness[2]), lw=0.75) + ax.scatter(completeness, (0.9, 0.7, 0.5), marker='*', c='teal', s=10) + ax.tick_params(direction="in", top=True, right=True) ax.set_title("Artificial Star Test") - ax.set_xlabel(fltr) + ax.set_xlabel(filter_string) ax.set_ylabel("Fraction Recovered") - ax.set_yticks([0,.25,.5,.75,1]) - ax.legend(loc="lower left",frameon=False, fontsize=8) + ax.set_yticks([0, .25, .5, .75, 1]) + ax.legend(loc="lower left", frameon=False, fontsize=8) plt.tight_layout() - fig.savefig(plotast,dpi=300) - printf("--> %s\n"%plotast) - + fig.savefig(plot_ast, dpi=300) + printf("--> %s\n" % plot_ast) return results - - - - - - - - - - - -#class Artificial_Stars(object): -# -# def __init__(self, psf=None, detector=None, photometry=None): -# self.psf=psf -# self.detector=detector -# self.photometry=photometry -# -# -# def __call__(self, *args, **kwargs): return self.run_auto(*args,**kwargs) -# -# def run_auto(self, data, background=None, ntests=100, stars_per_test=1, -# subimage_size=100, buffer=0, flux_range=(1,1e6)): -# """ -# """ -# load=loading(ntests, msg="artificial star testing") -# test_result=None -# -# ntests=int(ntests) -# passed=0 -# stars_per_test=int(stars_per_test) -# -# test_result=Table(None, names=["x_0","y_0","flux","x_det","y_det","flux_det", "status"]) -# for test in range(1,ntests+1): -# x_edge=0 -# y_edge=0 -# -# centre= (data.shape[0]*np.random.random(), data.shape[1]*np.random.random()) -# image,x_edge,y_edge=self.create_subimage(data,subimage_size, position=centre) -# sourcelist= make_random_models_table( stars_per_test, { "x_0":[buffer,image.shape[0]-buffer], -# "y_0":[buffer,image.shape[1]-buffer], -# "flux":flux_range}) -# star_overlay=make_model_sources_image( image.shape, self.psf, sourcelist) -# -# if background is not None: -# # This feels like a bodge -# self.photometry.background,_,_=self.create_subimage(background, subimage_size, position=centre) -# -# if image.shape==star_overlay.shape: -# result=self.single_test( image+star_overlay, sourcelist, threshold=2) -# result["x_0"]+=x_edge -# result["y_0"]+=y_edge -# result["x_det"]+=x_edge -# result["y_det"]+=y_edge -# passed+=result["status"] -# test_result=vstack((test_result,result)) -# -# load.msg="recovering %d%%"%(100*passed/test) -# load() -# load.show() -# -# return test_result -# -# def single_test(self, image, contains, background=None, threshold=2): -# """ -# One single artifical star test. -# -# This will detect on the supplied image and check if the -# input stars are recovered. Photometry will be conducted on -# recovered sources, if `self.photomotry` is not None -# -# Parameters -# ---------- -# image : 2d numpy array -# The image (or subimage) onto which to place the source -# -# contains : `astropy.table.Table` -# A list of sources that have been added to the image. -# -# background : -# -# threshold : -# -# Results -# ------- -# test_result : `astropy.table.Table` -# A list of the results. -# """ -# DETECT=1 -# NULL=0 -# test_result=Table(np.full((len(contains),4),np.nan), names=["x_det","y_det","flux_det", "status"]) -# detections=self.detector(image) -# detections.rename_columns(("xcentroid","ycentroid"),("x_0","y_0")) -# -# if detections: -# for i,src in enumerate(contains): -# separations=np.sqrt( (src["x_0"]-detections["x_0"])**2 + (src["y_0"]-detections["y_0"])**2) -# best_match=np.argmin(separations) -# if separations[best_match]size/2: -# buffer=0 -# perror("buffer must be >=0 and < size/2, setting to 'safe' value zero.\n") -# -# if False: pass ## position check -# -# if method=="centre": -# x_edge = int(max( position[0]-(size/2), buffer )) -# y_edge = int(max( position[1]-(size/2), buffer )) -# x_end = int(min( position[0]+(size/2), imshape[0]-buffer)) -# y_end = int(min( position[1]+(size/2), imshape[1]-buffer)) -# -# -# elif method=="random": -# #|----------------| -# #| |=======| | -# #| | | | -# #| | x | | -# #| | | | -# #| |=======| | -# #| | -# #| | -# #|----------------| -# perror("not impleneted\n") -# raise NotImplementedError -# -# -# subimage=image[ x_edge:x_end,y_edge:y_end] -# return subimage, x_edge, y_edge -# -# def periodic_export(self,fname=""): -# return 0 -# -# -#def fnxep(x,a,b,c): -# return a*np.exp(b*x+c) diff --git a/starbug2/bin/__init__.py b/starbug2/bin/__init__.py index f466356..06d2fb1 100644 --- a/starbug2/bin/__init__.py +++ b/starbug2/bin/__init__.py @@ -1,17 +1,14 @@ -import os -from starbug2.utils import printf,perror +"""Copyright (C) 2026 UKATC -EXIT_SUCCESS=0 -EXIT_FAIL =1 -EXIT_EARLY =2 -EXIT_MIXED =3 +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. -def usage(docstring,verbose=0): - if verbose: perror(docstring) - else: perror("%s\n"%docstring.split('\n')[1]) - return 1 - -def parsecmd(args): - cmd=os.path.basename(args[0]) - return cmd,args[1:] +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" diff --git a/starbug2/bin/ast.py b/starbug2/bin/ast.py index f7e3f77..7996893 100644 --- a/starbug2/bin/ast.py +++ b/starbug2/bin/ast.py @@ -1,222 +1,304 @@ -"""StarbugII Artificial Star Testing -usage: starbug2-ast [-vhR] [-N ntests] [-n ncores] [-p file.param] [-S nstars] [-s opt=val] image.fits .. - -h --help : show help screen - -N --ntests num : number of tests to run - -n --ncores cores : number of cores to split the tests over - -o --output output : output directory or filename to export results to - -p --param file : load a parameter file - -R --recover : recover incomplete test autosave files - -S --nstars num : number of stars to inject per test - -s --set option : set parameter at runtime with syntax "-s KEY=VALUE" - -v --verbose : show verbose stdout output - - --autosave freq : frequency of quick save outputs - --no-background : turn off background estimation routine - --no-psfphot : turn off psf photometry routine -""" - -import os,sys,getopt +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import os +import sys +from multiprocessing.shared_memory import SharedMemory +from multiprocessing import Pool, Process, shared_memory +from multiprocessing.pool import Pool as PoolType + import numpy as np import glob -from multiprocessing import Pool, Process, shared_memory -from itertools import repeat from time import sleep -from astropy.io import fits from astropy.table import Table +from astropy.io.fits import HDUList -import starbug2.bin as scr +from starbug2.constants import ExitStates, TableColumn +from starbug2.star_bug_config import StarBugMainConfig from starbug2.starbug import StarbugBase -from starbug2.artificialstars import Artificial_StarsIII, compile_results -from starbug2.utils import printf,perror,warn,export_table,tabppend,fill_nan -from starbug2.param import load_params - -VERBOSE =0x01 -SHOWHELP=0x02 -STOPPROC=0x04 -KILLPROC=0x08 -NOBGD =0x10 -NOPHOT =0x20 -RECOVER =0x40 - -_c=np.array([0,0,0], dtype=np.int64) -_share=shared_memory.SharedMemory(create=True, size=_c.nbytes) -buf=np.ndarray(_c.shape, dtype=_c.dtype, buffer=_share.buf) - -def load(msg="loading"): +from starbug2.artificialstars import ArtificialStars, compile_results +from starbug2.utils import ( + printf, p_error, combine_tables, fill_nan, parse_cmd, usage) + +import photutils + +# Force photutils to strictly return standard QTables globally +photutils.future_column_names = True + + +def load(loading_buffer: np.ndarray) -> None: """ A loading bar that should be run in a subprocess It sits and watches the shared memory buffer and periodically prints out a progress bar """ - while buf[0] StarBugMainConfig: + """ + Organise the sys argv line into options, values and arguments + + :param argv: the arguments + :return: the config class + :rtype: StarBugMainConfig + """ + cmd, argv = parse_cmd(argv) + argv: list[str] + short_definition: str + long_definition: list[str] + + config: StarBugMainConfig = StarBugMainConfig() + short_definition, long_definition = ( + config.generate_ast_get_opt_definitions()) + _, argv = parse_cmd(argv) + config.populate_params( + argv, short_definition, long_definition, config.AST_FLAG_MAP) + return config -def ast_onetimeruns(options, setopt, args): + +def ast_one_time_runs(config: StarBugMainConfig) -> ExitStates: """ Set options, verify run and execute one time functions """ - if options&SHOWHELP: - scr.usage(__doc__,verbose=options&VERBOSE) - return scr.EXIT_EARLY - - if options&RECOVER: - if not args: fnames=glob.glob("sbast-autosave*.tmp") - else: fnames= [a for a in args if os.path.exists(a)] - if fnames: - printf("Recovery Mode:\n-> %s\n"%("\n-> ".join(fnames))) - raw=Table() - for fname in fnames: - raw=tabppend(raw,Table.read(fname)) - if (results:=compile_results(fill_nan(raw), plotast="recovered.pdf")): - printf("-> successful recovery!\n--> %s\n"%(fname:="recovered.fits")) - results.writeto(fname,overwrite=True) - else: perror("something went wrong\n") - else: perror("No files found to recover\n") - - - - if options & STOPPROC: return scr.EXIT_EARLY - if options & KILLPROC: - perror("..killing process\n") - return scr.EXIT_FAIL - - return scr.EXIT_SUCCESS - -def fn(args): - fname, options, setopt, index = args - out=None - if os.path.exists(fname): - sb=StarbugBase(fname, setopt.get("PARAMFILE"), options=setopt) - opt=sb.options - ast=Artificial_StarsIII(sb, index=index) - out=ast.auto_run(opt.get("NTESTS"), stars_per_test=opt.get("NSTARS"), - mag_range=(opt.get("MAX_MAG"),opt.get("MIN_MAG")), loading_buffer=buf, - autosave=opt.get("AUTOSAVE"), skip_phot=options&NOPHOT, skip_background=options&NOBGD) + if config.show_ast_help: + usage(__doc__, verbose=config.verbose_logs) + return ExitStates.EXIT_EARLY + + if config.ast_recover: + f_names: list[str] | None + if not config.fits_images: + # noinspection SpellCheckingInspection + f_names = glob.glob("sbast-autosave*.tmp") + else: + f_names = [a for a in config.fits_images if os.path.exists(a)] + if f_names: + printf("Recovery Mode:\n-> %s\n" % ("\n-> ".join(f_names))) + raw: Table | None = Table() + for f_name in f_names: + f_name: str + read_table: Table | None = Table.read(f_name) + if read_table is None: + p_error(f"failed to read table at path {f_name}") + return ExitStates.EXIT_FAIL + raw = combine_tables(raw, read_table) + results: HDUList + assert raw is not None + if (results := compile_results( + fill_nan(raw), plot_ast="recovered.pdf")): + printf("-> successful recovery!\n--> %s\n" % ( + f_name := "recovered.fits")) + results.writeto(f_name, overwrite=True) + else: + p_error("something went wrong\n") + else: + p_error("No files found to recover\n") + return ExitStates.EXIT_SUCCESS + + +def execute_artificial_stars( + f_name: str, config: StarBugMainConfig, verbose: bool, + index: int, test_count: int, ast_auto_save: int, + loading_buffer: np.ndarray) -> Table | None: + """ + Multiprocessing worker function to run artificial star tests on a given + file. + :param f_name: the file to process + :type f_name: str + :param config: the config object + :type config: StarBugMainConfig + :param verbose: bool flag if to use verbose + :type verbose: bool + :param index: the index + :type index: int + :param test_count: the amount of tests + :type test_count: int + :param ast_auto_save: how many tests between saves + :type ast_auto_save: int. + :param loading_buffer: the loading buffer + :type loading_buffer: np.ndarray + :return: The generated artificial stars recovery catalogue table, or + None if the file doesn't exist. + :rtype: astropy.table.Table or None. + """ + out: Table | None = None + if os.path.exists(f_name): + star_bug_base: StarbugBase = StarbugBase( + f_name, config, ap_file=config.ap_file, + bkg_file=config.background_file, verbose=verbose) + ast: ArtificialStars = ArtificialStars(star_bug_base, index=index) + out = ast.execute_ast( + test_count, + stars_per_test=config.stars_per_artificial_test, + mag_range=( + config.test_magnitude_bright_limit, + config.test_magnitude_faint_limit), + loading_buffer=loading_buffer, + autosave=ast_auto_save, + skip_phot=config.ast_no_psf_phot, + skip_background=config.ast_no_background, + zp_mag=config.zero_point_magnitude, + sub_image_size=config.sub_image_crop_size, + save_image=config.save_added_image, + save_image_path=config.save_added_image_path, + ast_seed=config.ast_seed) return out -def ast_main(argv): - options, setopt, args= ast_parseargv(argv) - exit_code=0 - if options or setopt: - if (exit_code:=ast_onetimeruns(options, setopt, args)): - _share.unlink() +def ast_main( + argv: list[str], share_memory: SharedMemory, + loading_buffer: np.ndarray) -> ExitStates: + + config: StarBugMainConfig = ast_parse_argv(argv) + + exit_code: ExitStates = ExitStates.EXIT_SUCCESS + + if config.use_ast_one_time_runs(): + if exit_code := ast_one_time_runs(config): + share_memory.unlink() return exit_code + config.freeze() - if (params:=load_params(setopt.get("PARAMFILE"))): - params.update(setopt) - else: - perror("Failed to load parameters from file\n") - return scr.EXIT_FAIL + print(f"{config.fits_images}") - if args: - fname=args[0] - _ntests=params.get("NTESTS") - if options & VERBOSE: + if config.fits_images: + f_name: str = config.fits_images[0] + n_tests: int = int(config.artificial_star_tests_count) + if config.verbose_logs: printf("Artificial Stars\n----------------\n") - printf("-> loading %s\n"%fname) - if setopt.get("PARAMFILE"): printf("-> parameters: %s\n"%setopt.get("PARAMFILE")) - printf("-> running %d tests with %d injections per test\n"%(_ntests,params.get("NSTARS"))) - printf("-> magnitude range: %.1f - %.1f\n"%(params.get("MAX_MAG"), params.get("MIN_MAG"))) - if options&NOPHOT:printf("-> skipping PSF photometry step\n") - if options&NOBGD: printf("-> skipping background estimation step\n") - - buf[0]=0 - buf[1]=_ntests - loading=Process(target=load, args=("ast",)) + printf("-> loading %s\n" % f_name) + if config.param_file: + printf("-> parameters: %s\n" % config.param_file) + printf("-> running %d tests with %d injections per test\n" % ( + n_tests, config.stars_per_artificial_test)) + printf("-> magnitude range: %.1f - %.1f\n" % ( + config.test_magnitude_bright_limit, + config.test_magnitude_faint_limit)) + if config.ast_no_psf_phot: + printf("-> skipping PSF photometry step\n") + if config.ast_no_background: + printf("-> skipping background estimation step\n") + + loading_buffer[0] = 0 + loading_buffer[1] = n_tests + loading: Process = Process(target=load, args=[loading_buffer]) loading.start() - if (ncores:=params.get("NCORES")) is None or ncores==1: - params["NCORES"]=1 - outs=[fn((fname,options,params,0)) for fname in args] + # Initialise output container tracking tables + outs: list[Table | None] + + if (n_cores := config.n_cores) is None or n_cores == 1: + config.unfreeze() + config.n_cores = 1 + config.freeze() + outs = ([execute_artificial_stars( + f_name, config, config.verbose_logs, index, + config.artificial_star_tests_count, config.ast_auto_save, + loading_buffer) + for index, f_name in enumerate(config.fits_images)]) else: - ncores=min(ncores,_ntests) - zip_options=np.full(ncores,options,dtype=int) - for n in range(ncores): - if n>0: zip_options[n]&=~VERBOSE - params["NTESTS"]=int(np.ceil(_ntests/ncores)) - params["AUTOSAVE"]=int(np.ceil(setopt.get("AUTOSAVE")/ncores)) - - - pool=Pool(processes=ncores) - outs=pool.map(fn, zip(repeat(fname), zip_options, repeat(params), range(1,ncores+1))) + n_cores: int = int(min(n_cores, n_tests)) + per_process_n_test: int = int(np.ceil(n_tests / n_cores)) + per_process_tests_per_save: int = int( + np.ceil(config.ast_auto_save / n_cores)) + + worker_tasks = [ + (file_name, config, index == 0, index, per_process_n_test, + per_process_tests_per_save, loading_buffer) + for index, file_name in enumerate(config.fits_images) + ] + + pool: PoolType = Pool(processes=n_cores) + outs = pool.starmap(execute_artificial_stars, worker_tasks) pool.close() + pool.join() - buf[0]=buf[1] #force finish + # force finish + loading_buffer[0] = loading_buffer[1] loading.join() - + ############################# # COMPILING ALL THE RESULTS # ############################# - raw=outs[0] - for res in outs[1:]: raw=tabppend(raw,res) - sb=StarbugBase(fname, setopt.get("PARAMFILE"), options=setopt) - if options & VERBOSE: + raw: Table | None = outs[0] + for res in outs[1:]: + raw = combine_tables(raw, res) + assert raw is not None + star_bug_base: StarbugBase = StarbugBase( + f_name, config, ap_file=config.ap_file, + bkg_file=config.background_file, verbose=config.verbose_logs) + if config.verbose_logs: printf("-> compiling results\n") - printf("-> flux recovery: %.2g\n"%(np.nanmean(raw["flux"]/raw["flux_det"]))) - - if (results:=compile_results(raw, image=sb.image, fltr=sb.filter, plotast=setopt.get("PLOTAST"))): - outdir,bname,_=StarbugBase.sort_output_names(fname, param_output=setopt.get("OUTPUT")) - if options & VERBOSE: printf("--> %s/%s-ast.fits\n"%(outdir,bname)) - results.writeto("%s/%s-ast.fits"%(outdir,bname),overwrite=True) + printf("-> flux recovery: %.2g\n" % ( + np.nanmean(raw[TableColumn.FLUX] / + raw[TableColumn.FLUX_DET]))) + + results: HDUList + filter_string: str | None = star_bug_base.filter + assert filter_string is not None + assert raw is not None + if (results := compile_results( + raw, image=star_bug_base.main_image.data, + filter_string=filter_string, + plot_ast=config.ast_plot_filename)): + out_dir: str + b_name: str + out_dir, b_name, _ = StarbugBase.sort_output_names( + f_name, param_output=config.output_file) + if config.verbose_logs: + printf("--> %s/%s-ast.fits\n" % (out_dir, b_name)) + results.writeto("%s/%s-ast.fits" % (out_dir, b_name), + overwrite=True) + + # autosave clean-up + # noinspection SpellCheckingInspection + for _f_name in glob.glob("sbast-autosave*.tmp"): + _f_name: str + os.remove(_f_name) - ## autosave cleanup - for _fname in glob.glob("sbast-autosave*.tmp"): os.remove(_fname) - - else: perror("results compilation failed\n") + else: + p_error("results compilation failed\n") else: - perror("must include a fits image to work on\n") - exit_code=scr.EXIT_FAIL - - _share.unlink() + p_error("must include a fits image to work on\n") + exit_code = ExitStates.EXIT_FAIL + + # Wrapped fix to handle rapid multiprocess teardowns safely + try: + share_memory.unlink() + except FileNotFoundError: + # The memory handle was already unlinked safely by another thread + pass return exit_code -def ast_mainentry(): + +def ast_main_entry() -> ExitStates: """Command line entry point""" - return ast_main(sys.argv) + # globals + c: np.ndarray = np.array([0, 0, 0], dtype=np.int64) + share_memory: SharedMemory = ( + shared_memory.SharedMemory(create=True, size=c.nbytes)) + loading_buffer: np.ndarray = np.ndarray( + c.shape, dtype=c.dtype, buffer=share_memory.buf) + return ast_main(sys.argv, share_memory, loading_buffer) diff --git a/starbug2/bin/main.py b/starbug2/bin/main.py index 6aa9c33..8908e05 100644 --- a/starbug2/bin/main.py +++ b/starbug2/bin/main.py @@ -1,5 +1,73 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +from multiprocessing import Pool +from multiprocessing.pool import Pool as PoolType +import os +import sys +import warnings + +from astropy.io.fits import PrimaryHDU +from astropy.io.fits.verify import VerifyWarning +from astropy.io.fits.header import Header +from astropy.table import Table +from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyWarning +import photutils + +from starbug2 import param +from starbug2.constants import ( + ExitStates, FITS_EXTENSION, HELP_STRINGS, LOGO, Modes, READ_THE_DOCS_URL) +from starbug2.initialise_psf_data import generate_psf, init_starbug_for_jwst +from starbug2.matching.generic_match import GenericMatch +from starbug2.misc import generate_runscript +from starbug2.star_bug_config import StarBugMainConfig +from starbug2.starbug import StarbugBase +from starbug2.utils import ( + combine_file_names, export_region, export_table, get_version, parse_cmd, + p_error, printf, puts, split_file_name, usage, warn) + +# Target-silence only the specific Photutils/Astropy deprecation noise +# without masking generic Runtime math errors globally. +warnings.filterwarnings( + "ignore", category=AstropyDeprecationWarning) +warnings.filterwarnings( + "ignore", message=".*contains deprecated section.*", + category=AstropyWarning) + +# Handle RuntimeWarnings elegantly: Ignore expected ones (like NaN comparisons +# during clipping), but let actual mathematical issues surface. +warnings.filterwarnings( + "ignore", message=".*invalid value encountered.*", category=RuntimeWarning) +warnings.filterwarnings( + "ignore", message=".*divide by zero.*", category=RuntimeWarning) + +# --- FITS IO FORMATTING NOISE --- +# These suppress warnings about FITS header compliance +# (e.g., truncated comments) that do not affect scientific output. +warnings.filterwarnings( + "ignore", + category=VerifyWarning, + message=".*Card is too long.*" +) + +# Force photutils to strictly return standard QTables globally +photutils.future_column_names = True + +# noinspection SpellCheckingInspection """StarbugII - JWST PSF photometry -usage: starbug2 [-ABDfGhMPSv] [-b bgdfile] [-d apfile] [-n ncores] [-o ouput] [-p file.param] [-s opt=val] image.fits ... +usage: starbug2 [-ABDfGhMPSv] [-b bgdfile] [-d apfile] [-n ncores] [-o ouput] + [-p file.param] [-s opt=val] image.fits ... -A --apphot : run aperture photometry on a source list -B --background : run background estimation -D --detect : run source detection @@ -9,7 +77,8 @@ -S --subbgd : subtract background from image -b --bgdfile : load background (-bgd.fits) file - -d --apfile ap.fits : load a source detection (-ap.fits) file to skip the source detection step + -d --apfile ap.fits : load a source detection (-ap.fits) file to skip the + source detection step -f --find : attempt to find associated -ap -bgd files -h --help : display uasage information -n --ncores num : number of CPU cores to split process between @@ -20,17 +89,25 @@ --> Single run commands --init : Initialise Starbug (post install) - --local-param : Make a local copy of the default parameter file + --local-param : Make a local copy of the default + parameter file --update-param : Update an out-of-date local parameter file - --generate-psf : Generate a single PSF. Set FILTER, DET_NAME, PSF_SIZE with -s - --generate-region a.fits : Make a ds9 region file with a detection file + --generate-psf : Generate a single PSF. Set FILTER, + DET_NAME, PSF_SIZE with -s + --generate-region a.fits : Make a ds9 region file with a detection + file --generate-run *.fits : Generate a simple run script --version : Print starbug2 version --> typical runs - $~ starbug2 -vD -p file.param image.fits //Source detect on image with a parameter file - $~ starbug2 -vDM -n4 images*.fits //Source detect and match outputs of a list of images - $~ starbug2 -vd image-ap.fits -BP image.fits //PSF photometry on an image with a source file (image-ap.fits) + //Source detect on image with a parameter file + $~ starbug2 -vD -p file.param image.fits + + //Source detect and match outputs of a list of images + $~ starbug2 -vDM -n4 images*.fits + + //PSF photometry on an image with a source file (image-ap.fits) + $~ starbug2 -vd image-ap.fits -BP image.fits To see more detailed information on an option, run [OPTION] --help: $~ starbug2 -D --help @@ -38,370 +115,364 @@ See https://starbug2.readthedocs.io for full documentation. """ -import os,sys,getopt + +# noinspection SpellCheckingInspection sys.stdout.write("\x1b[1mlaunching \x1b[36mstarbug\x1b[0m\n") -from starbug2.utils import * -from starbug2 import param -import starbug2.bin as scr - -VERBOSE =0x01 -KILLPROC=0x02 -STOPPROC=0x04 -SHOWHELP=0x08 - -DODETECT=0x100 -DOBGDEST=0x200 -DOPHOTOM=0x400 -FINDFILE=0x800 - -DOARTIFL=0x1000 -DOMATCH =0x2000 -DOAPPHOT=0x4000 -DOBGDSUB=0x8000 -DOGEOM =0x10000 - -GENRATPSF =0x100000 -GENRATRUN =0x200000 -GENRATREG =0x400000 -INITSB =0x800000 -UPDATEPRM =0x1000000 -DODEBUG =0x2000000 -CALCINSTZP=0x4000000 -APPLYZP =0x8000000 - -def starbug_parseargv(argv): - """Organise the sys argv line into options, values and arguments""" - options=0 - setopt={} - - cmd,argv=scr.parsecmd(argv) - opts,args=getopt.gnu_getopt(argv,"ABDfGhMPSvb:d:n:o:p:s:", - ( "apphot","background", "detect", "find", "geom", "help", "match", "psf", "subbgd", "verbose", "xtest", - "bgdfile=", "apfile=", "ncores=", "output=", "param=", "set=", - "init", "generate-psf", "local-param", "generate-region=", "version", "generate-run", "update-param", - "debug", "dev")) - for opt,optarg in opts: - if opt in ("-h","--help"): options|=(SHOWHELP|STOPPROC) - if opt in ("-p","--param"): setopt["PARAMFILE"]= optarg - if opt in ("-v","--verbose"):options|=VERBOSE - - if opt in ("-A","--apphot"): options |= DOAPPHOT - if opt in ("-B","--background"):options |=DOBGDEST - if opt in ("-D","--detect"): options |= DODETECT - if opt in ("-G","--geom"): options |= DOGEOM - if opt in ("-M","--match"): options |= DOMATCH - if opt in ("-P","--psf"): options |= DOPHOTOM - if opt in ("-S","--subbgd"): options |= DOBGDSUB - - if opt == "--dev": options |= DOARTIFL - - if opt in ("-d","--apfile"): - if os.path.exists(optarg): setopt["AP_FILE"]=optarg - else: perror("AP_FILE \"%s\" does not exist\n"%optarg) - - if opt in ("-b","--bgdfile"): - if os.path.exists(optarg): setopt["BGD_FILE"]=optarg - else: perror("BGD_FILE \"%s\" does not exist\n"%optarg) - - if opt in ("-f","--find"): options|=FINDFILE - if opt in ("-n","--ncores"): - setopt["NCORES"]=max(1,int(optarg)) - - if opt in ("-o","--output"): - output=optarg - setopt["OUTPUT"]=optarg - - if opt in ("-s","--set"): - if '=' in optarg: - key,val=optarg.split('=') - try: val=float(val) - except: pass - setopt[key]=val - else: - perror("unable to set parameter, use syntax -s KEY=VALUE\n") - options|=KILLPROC - - if opt=="--init": options|=(INITSB|STOPPROC) - if opt=="--generate-psf": options|=(GENRATPSF|STOPPROC) - if opt=="--update-param": options|=(UPDATEPRM|STOPPROC) - if opt=="--generate-run": options|=(GENRATRUN|STOPPROC) - if opt=="--generate-region": - setopt["REGION_TAB"]=optarg - options|=(GENRATREG|STOPPROC) - - if opt=="--local-param": - param.local_param() - printf("--> generating starbug.param\n") - options|=STOPPROC - - if opt=="--version": - printf(starbug2.logo%("starbug2-v%s"%get_version())) - options|=STOPPROC - - """ - if opt=="--calc-instr-zp": - setopt["ZP_PSF_CAT"]=optarg - options|=(CALCINSTZP|APPLYZP|STOPPROC) - if opt=="--apply-zeropoint": - setopt["ZP_PSF_CAT"]=optarg - options|=(APPLYZP|STOPPROC) - """ - - return options,setopt,args - -def starbug_onetimeruns(options, setopt, args): - """ - Options set, verify/run one time functions - """ - from starbug2.misc import init_starbug, generate_psf, generate_runscript, calc_instrumental_zeropint - - if options&SHOWHELP: - scr.usage(__doc__,verbose=options&VERBOSE) - - if options & DODETECT: perror(starbug2.helpstrings["DETECTION"]) - if options & DOBGDEST: perror(starbug2.helpstrings["BACKGROUND"]) - if options & DOAPPHOT: perror(starbug2.helpstrings["APPHOT"]) - if options & DOPHOTOM: perror(starbug2.helpstrings["PSFPHOT"]) - if options & DOMATCH: perror(starbug2.helpstrings["MATCHOUTPUTS"]) - return scr.EXIT_EARLY - - ## Load parameter files for onetime runs - if (pfile:=setopt.get("PARAMFILE"))==None: - if os.path.exists("./starbug.param"):pfile="starbug.param" - else: pfile=None - - init_parameters=param.load_params(pfile) - - if options&UPDATEPRM: - param.update_paramfile(pfile) - return scr.EXIT_EARLY - - tmp=param.load_default_params() - if set(tmp.keys())-set(init_parameters.keys()) | set(init_parameters.keys())-set(tmp.keys()): - warn("Parameter file version mismatch. Run starbug2 --update-param to update\nquitting :(\n") - return scr.EXIT_FAIL - - init_parameters.update(setopt) - if (_output:=init_parameters.get("OUTPUT")): output=_output - else: output='.' - - ######################### - # One time run commands # - ######################### - - if options&INITSB: ## Initialise or update starbug - init_starbug() - - if options&GENRATPSF: ## Generate a single PSF - if (fltr:=init_parameters.get("FILTER")): - detector=init_parameters.get("DET_NAME") - psf_size=init_parameters.get("PSF_SIZE") - printf("Generating PSF: %s %s (%d)\n"%(fltr,detector,psf_size)) - psf=generate_psf(fltr, detector=detector, fov_pixels=psf_size) - if psf: - name="%s%s.fits"%(fltr,"" if detector is None else detector) - printf("--> %s\n"%name) - psf.writeto(name, overwrite=True) - else: perror("PSF Generation failed :(\n") - else: perror("Unable to generate PSF. Set filter with '-s FILTER=FXXX'\n") - - if options&GENRATRUN: ## Generate a run script - generate_runscript(args, "starbug2 ") - if not args: perror("no files included to create runscript with\n") - - if options&GENRATREG: ## Generate a region from a table - fname=setopt.get("REGION_TAB") - if fname and os.path.exists(fname): - table=Table.read(fname,format="fits") - _,name,_=split_fname(fname) - export_region(table, colour=init_parameters["REGION_COL"], scale_radius=init_parameters["REGION_SCAL"], - region_radius=init_parameters["REGION_RAD"], xcol=init_parameters["REGION_XCOL"], - ycol=init_parameters["REGION_YCOL"], wcs=init_parameters["REGION_WCS"], fname="%s/%s.reg"%(output,name)) - printf("generating region --> %s/%s.reg\n"%(output,name)) - - ########################### - # instrumental zero point # - ########################### - if options&(APPLYZP|CALCINSTZP): perror("instrumental zero point application deprecated\n") + +# noinspection SpellCheckingInspection +def starbug_parse_argv(argv: list[str]) -> StarBugMainConfig: """ - if options&APPLYZP: - _fname=setopt.get("ZP_PSF_CAT") - _zp=init_parameters.get("ZP_MAG") - _std=0 - if _fname and os.path.exists(_fname): - psftable=Table.read(_fname, format="fits") - with fits.open(_fname) as fp: _header=fp[1].header ##thats a bit rubbish - - if (fltr:=_header.get("FILTER")) is None: - if (fltr:=setopt.get("FILTER")) is None: - perror("Unable to determine table FILTER: set manually with `-s FILTER=F000W`\n") - return scr.EXIT_FAIL - - if options&CALCINSTZP: - _aptable=None - _apfname=setopt.get("AP_FILE") - if _apfname and os.path.exists(_apfname): - _aptable=Table.read(_apfname, format="fits") - - if (res:=calc_instrumental_zeropint(psftable, _aptable, fltr=fltr)) is not None: - _zp,_std=res - - if _zp is not None: - psftable.meta["%s ZEROPOINT"%fltr]=_zp - psftable.meta["%s eZEROPOINT"%fltr]=_std - psftable[fltr]=psftable[fltr]+_zp - - dname,fname,_=split_fname(_fname) - printf("--> %s/%s-zp.fits\n"%(dname,fname)) - export_table( psftable, fname="%s/%s-zp.fits"%(dname,fname), header=_header) - else: perror("Unable to set ZEROPOINT, set it with -sZP_MAG=000\n") - else: perror("Unable to locate table \"%s\".\n"%_fname) + Organise the sys argv line into options, values and arguments + + :param argv: the arguments + :return: the config class + :rtype: StarBugMainConfig """ + config: StarBugMainConfig = StarBugMainConfig() + short_definition: str + long_definition: list[str] + short_definition, long_definition = ( + config.generate_main_get_opt_definitions()) + + _, argv = parse_cmd(argv) + config.populate_params( + argv, short_definition, long_definition, config.MAIN_FLAG_MAP) + return config - if options&STOPPROC: return scr.EXIT_EARLY ## quiet ending the process if required - if options&KILLPROC: - perror("..quitting :(\n\n") - return scr.usage(__doc__, verbose=options&VERBOSE) +def starbug_one_time_runs(config: StarBugMainConfig) -> ExitStates: + """ + Options set, verify/run one time functions + """ - return scr.EXIT_SUCCESS + if config.show_version: + printf(get_version()) + + if config.show_help: + usage(__doc__, verbose=config.verbose_logs) + + if config.do_star_detection: + p_error(HELP_STRINGS[Modes.DETECTION]) + if config.do_bgd_estimate: + p_error(HELP_STRINGS[Modes.BACKGROUND]) + if config.do_aperture_photometry: + p_error(HELP_STRINGS[Modes.APP_HOT]) + if config.do_photometry_routine: + p_error(HELP_STRINGS[Modes.PSFP_HOT]) + if config.do_matching: + p_error(HELP_STRINGS[Modes.MATCH_OUTPUTS]) + return ExitStates.EXIT_EARLY + + # Load parameter files for onetime runs + if not config.update_param: + parameter_file: str | None + if (parameter_file := config.param_file) is None: + if os.path.exists("./starbug.param"): + parameter_file = "starbug.param" + else: + parameter_file = None + config.load_params(parameter_file) + else: + param.update_param_file(config.param_file) + return ExitStates.EXIT_SUCCESS -def starbug_matchoutputs(starbugs, options, setopt): + output: int | float | str + if _output := config.output_file: + _output: int | float | str + output = _output + else: + output = '.' + + # One time run commands + + # Initialise or update starbug + if config.execute_jwst_initialisation: + init_starbug_for_jwst(config) + + # Generate a single PSF + if config.generate_psf: + if config.got_valid_psf_generation_params(): + filter_string: str | None = config.custom_filter + assert filter_string is not None + detector: str | None = config.detector_name + psf_size: int = config.psf_fit_size + if psf_size is not None: + printf( + "Generating PSF: %s %s (%d)\n" % + (filter_string, detector, psf_size)) + else: + printf( + "Generating PSF: %s %s\n" % + (filter_string, detector)) + psf: PrimaryHDU | None = generate_psf( + filter_string, detector=detector, fov_pixels=psf_size) + if psf: + name: str = ( + "%s%s.fits" % + (filter_string, "" if detector is None else detector)) + printf("--> %s\n" % name) + d_name: str = StarbugBase.get_data_path() + psf.writeto(os.path.join(d_name, name), overwrite=True) + else: + p_error("PSF Generation failed :(\n") + else: + # noinspection SpellCheckingInspection + p_error( + "Unable to generate PSF. Set filter with '-s FILTER=FXXX and " + "Set detector name with '-s DET_NAME=XXX and " + "Set psf_fit_size with '-s PSF_SIZE=XXX'\n") + + # Generate a run script + if config.generate_run: + generate_runscript(config.fits_images, "starbug2 ") + if not config.fits_images: + p_error("no files included to create runscript with\n") + + # Generate a region from a table + if config.generate_region: + file_name: str | None = config.region_file + if file_name and os.path.exists(file_name): + table: Table = Table.read(file_name, format="fits") + _, name, _ = split_file_name(file_name) + name: str + export_region( + table, colour=config.region_colour, + scale_radius=config.region_scale, + region_radius=config.region_radius, + x_col=config.region_x_column_name, + y_col=config.region_y_column_name, + wcs=config.region_uses_wcs, + f_name="%s/%s.reg" % (output, name)) + printf("generating region --> %s/%s.reg\n" % (output, name)) + + # generate local param file as requested + if config.generate_local_param_file: + config.do_generate_local_param_file() + + return ExitStates.EXIT_SUCCESS + + +def starbug_match_outputs( + starbugs: list[StarbugBase | None], config: StarBugMainConfig) -> None: """ Matching output catalogues + + :param starbugs: star bug instances + :type starbugs: list of starbugBase or None + :param config: the config object + :type config: StarBugMainConfig + :return: None """ - from starbug2.matching import GenericMatch - if options&VERBOSE: printf("Matching outputs\n") - params=param.load_params(setopt.get("PARAMFILE")) - params.update(setopt) - - if (fname:=combine_fnames( [sb.fname for sb in starbugs] )): - _,name,_=split_fname(os.path.basename(fname)) - fname="%s/%s"%(starbugs[0].outdir, name) - else: fname="out" - - header=starbugs[0].header - #colnames=starbug2.match_cols - #colnames+=[ name for name in params["MATCH_COLS"].split() if name not in colnames] - - match=GenericMatch( threshold= params["MATCH_THRESH"], colnames=None, pfile=setopt.get("PARAMFILE")) - - if options&(DODETECT|DOAPPHOT): - full=match( [sb.detections for sb in starbugs], join_type="or") - av =match.finish_matching(full, num_thresh=params["NEXP_THRESH"], zpmag=params["ZP_MAG"]) - - printf("-> %s-ap*...\n"%(fname)) - export_table(full, fname="%s-apfull.fits"%(fname), header=header) - export_table(av, fname="%s-apmatch.fits"%(fname), header=header) - - if options&DOPHOTOM: - full=match( [sb.psfcatalogue for sb in starbugs], join_type="or") - av =match.finish_matching(full, num_thresh=params["NEXP_THRESH"], zpmag=params["ZP_MAG"]) - - printf("-> %s-psf*...\n"%(fname)) - export_table(full, fname="%s-psffull.fits"%(fname), header=header) - export_table(av, fname="%s-psfmatch.fits"%(fname), header=header) - - - -#def fn(fname,options=0,setopt={}): -def fn(args): - from starbug2.starbug import StarbugBase ## Ive put this here because it takes some time - sb=None - fname,options,setopt=args - if os.path.exists(fname): - dname,bname,ext=split_fname(fname) - - if options&FINDFILE: - ap="%s/%s-ap.fits"%(dname,bname) - bgd="%s/%s-bgd.fits"%(dname,bname) - if os.path.exists(ap) and not setopt.get("AP_FILE"): setopt["AP_FILE"]=ap - if os.path.exists(bgd) and not setopt.get("BGD_FILE"): setopt["BGD_FILE"]=bgd - - ## Sorting out the stdout - if options&VERBOSE: - printf("-> showing starbug stdout for \"%s\"\n"%fname) - setopt["VERBOSE"]=1 - elif setopt.get("NCORES")>1: printf("-> hiding starbug stdout for \"%s\"\n"%fname) - else: printf("-> %s\n"%fname) - - if ext==".fits": - sb=StarbugBase(fname, pfile=setopt.get("PARAMFILE"), options=setopt) - if sb.verify(): - warn("System verification failed\n") - return None - #pass - #_input=input("Continue with warnings y/N:") - #if _input=="" or _input not in "yY": - # return#quit("..quitting :(") + if config.verbose_logs: + printf("Matching outputs\n") + + f_name: str | None - if options & DODETECT: sb.detect() - if options & DOBGDEST: sb.bgd_estimate() - if options & DOBGDSUB: sb.bgd_subtraction() - if options & DOGEOM: sb.source_geometry() + # filter out any Nones. + valid_bugs: list[StarbugBase] = [sb for sb in starbugs if sb is not None] - if options & DOAPPHOT: sb.aperture_photometry() - if options & DOPHOTOM: sb.photometry() + # get file name + if f_name := combine_file_names([sb.f_name for sb in valid_bugs]): + _, name, _ = split_file_name(os.path.basename(f_name)) + name: str + f_name = "%s/%s" % (valid_bugs[0].out_dir, name) + else: + f_name = "out" + + header: Header = valid_bugs[0].header - if options & DOARTIFL: sb.artificial_stars() + match: GenericMatch = GenericMatch( + threshold=config.match_threshold_arc_sec_as_an_arc_sec, + col_names=None, + p_file=config.param_file) - else: perror("file must be type '.fits' not %s\n"%ext) - else: perror("can't access %s\n"%fname) - return sb + if config.do_star_detection or config.do_aperture_photometry: + full: Table = match( + [sb.detections for sb in valid_bugs], join_type="or") + av: Table = match.finish_matching( + full, num_thresh=config.exposure_count_threshold, + zp_mag=config.zero_point_magnitude) + printf("-> %s-ap*...\n" % f_name) + # noinspection SpellCheckingInspection + export_table(full, f_name="%s-apfull.fits" % f_name, header=header) + # noinspection SpellCheckingInspection + export_table(av, f_name="%s-apmatch.fits" % f_name, header=header) -def starbug_main(argv): - """Command entry""" - options, setopt, args= starbug_parseargv(argv) + if config.do_photometry_routine: + full: Table = match( + [sb.psf_catalogue for sb in valid_bugs], join_type="or") + av: Table = match.finish_matching( + full, num_thresh=config.exposure_count_threshold, + zp_mag=config.zero_point_magnitude) - if options or setopt: + printf("-> %s-psf*...\n" % f_name) - if (exit_code:=starbug_onetimeruns(options,setopt, args)): - return exit_code + # noinspection SpellCheckingInspection + export_table(full, f_name="%s-psffull.fits" % f_name, header=header) - if args: - import starbug2 - from multiprocessing import Pool - from itertools import repeat - puts(starbug2.logo%starbug2.motd) - exit_code=scr.EXIT_SUCCESS + # noinspection SpellCheckingInspection + export_table(av, f_name="%s-psfmatch.fits" % f_name, header=header) - if (ncores:=setopt.get("NCORES")) is None or ncores==1 or len(args)==1: - setopt["NCORES"]=1 - starbugs=[fn((fname,options,setopt)) for fname in args] + +def execute_star_bug( + args: tuple[str, StarBugMainConfig, bool]) -> StarbugBase | None: + """ + Worker function to initialise and run standard photometry processes on a + single file. + + :param args: A tuple containing (file_name, config, use_verbose) + :type args: tuple + :return: The verified StarbugBase pipeline wrapper instance, or None + if validation fails + :rtype: starbug2.StarbugBase or None + """ + # I've put this here because it takes some time + from starbug2.starbug import StarbugBase + star_bug_base: StarbugBase | None = None + f_name: str + config: StarBugMainConfig + f_name, config, use_verbose = args + if os.path.exists(f_name): + folder, file_name, ext = split_file_name(f_name) + + ap_file: str | None = config.ap_file + background_file: str | None = config.background_file + + if config.find_file: + ap: str = "%s/%s-ap.fits" % (folder, file_name) + bgd: str = "%s/%s-bgd.fits" % (folder, file_name) + if os.path.exists(ap) and config.ap_file is None: + ap_file = ap + if os.path.exists(bgd) and config.background_file is None: + background_file = bgd + + # Sorting out the stdout + if use_verbose: + printf("-> showing starbug stdout for \"%s\"\n" % f_name) + elif config.n_cores > 1: + printf("-> hiding starbug stdout for \"%s\"\n" % f_name) else: + printf("-> %s\n" % f_name) - zip_options=np.full(len(args),options, dtype=int) - for n in range(len(args)): - if n>0: zip_options[n]&=~VERBOSE + if ext == FITS_EXTENSION: + star_bug_base = StarbugBase( + f_name, config=config, ap_file=ap_file, + bkg_file=background_file, verbose=use_verbose) + assert star_bug_base is not None + + if star_bug_base.verify(): + warn("System verification failed\n") + return None + + if config.do_star_detection: + star_bug_base.detect() + if config.do_bgd_estimate: + star_bug_base.bgd_estimate() + if config.do_bgd_subtraction: + star_bug_base.bgd_subtraction() + if config.do_source_geometry: + star_bug_base.source_geometry() + if config.do_aperture_photometry: + star_bug_base.aperture_photometry() + if config.do_photometry_routine or config.generate_residual_image: + star_bug_base.photometry_routine() + + else: + p_error("file must be type '.fits' not %s\n" % ext) + else: + p_error("can't access %s\n" % f_name) + return star_bug_base - pool=Pool(processes=ncores) - starbugs=pool.map(fn,zip( args,zip_options,repeat(setopt))) ## - pool.close() - for n,sb in enumerate(starbugs): - if not sb: - perror("FAILED: %s\n"%args[n]) - starbugs.remove(sb) - exit_code=scr.EXIT_MIXED +def starbug_main(argv: list[str]) -> ExitStates: + """ + Command-line execution orchestrator for processing astronomical image + datasets. + + :param argv: System arguments mapping configurations and input filenames + :type argv: list of str + :return: System operational termination exit code status matrix + :rtype: ExitStates + """ + config: StarBugMainConfig = starbug_main_entry_parse(argv) + return starbug_internal_main(config) + - if not starbug2: exit_code=EXIT_FAIL +def starbug_internal_main(config: StarBugMainConfig) -> ExitStates: + """ + Main control for processing astronomical image datasets. + + :param config: the starbug config object + :type config: StarBugMainConfig. + :return: System operational termination exit code status matrix + :rtype: ExitStates + """ + if config.use_main_one_time_runs(): + return starbug_one_time_runs(config) + + if config.fits_images: + # freeze the config now to avoid writers + config.freeze() + + puts(LOGO % READ_THE_DOCS_URL) + exit_code: ExitStates = ExitStates.EXIT_SUCCESS + starbugs: list[StarbugBase | None] + + if ((n_cores := config.n_cores) is None + or n_cores == 1 or len(config.fits_images) == 1): + + config.unfreeze() + config.n_cores = 1 + config.freeze() + + starbugs = ( + [execute_star_bug( + (file_name, config, config.verbose_logs)) + for file_name in config.fits_images]) + else: + pool: PoolType = Pool(processes=n_cores) + + # this ensures only the first worker executes verbose. + worker_tasks = [ + (file_name, config, index == 0) + for index, file_name in enumerate(config.fits_images) + ] + starbugs = pool.map(execute_star_bug, worker_tasks) + pool.close() + pool.join() - - if options&DOMATCH and len(starbugs)>1: - starbug_matchoutputs(starbugs, options, setopt) - + to_remove: list[StarbugBase | None] = [] + sb: StarbugBase | None + for n, sb in enumerate(starbugs): + if not sb: + p_error("FAILED: %s\n" % config.fits_images[n]) + to_remove.append(sb) + exit_code = ExitStates.EXIT_MIXED + for sb in to_remove: + starbugs.remove(sb) + + if config.do_matching and len(starbugs) > 1: + starbug_match_outputs(starbugs, config) else: - perror("fits image file must be included\n") - exit_code=scr.EXIT_FAIL + p_error("fits image file must be included\n") + exit_code = ExitStates.EXIT_FAIL return exit_code -def starbug_mainentry(): - """Entry point""" + +def starbug_main_entry_parse(argv: list[str]) -> StarBugMainConfig: + """Auxiliary entry parser execution wrapper.""" + return starbug_parse_argv(argv) + + +def starbug_main_entry() -> int: + """ + System binary path gateway routing console script entries. + """ return starbug_main(sys.argv) diff --git a/starbug2/bin/match.py b/starbug2/bin/match.py index 464b137..87f1a6c 100644 --- a/starbug2/bin/match.py +++ b/starbug2/bin/match.py @@ -1,230 +1,246 @@ -"""StarbugII Matching -usage: starbug2-match [-BCGfhvX] [-e column] [-m mask] [-o output] [-p file.param] [-s KEY=VAL] table.fits ... - -B --band : match in "BAND" mode (does not preserve a column for every frame) - -C --cascade : match in "CASCADE" mode (left justify columns) - -G --generic : match in "GENERIC" mode - -X --exact : match in "EXACTVALUE" mode - - -e --error column : photometric error column ("eflux" or "stdflux") - -f --full : export full catalogue - -h --help : show help message - -m --mask eval : column evaluation to mask out of matching e.g. -m"~np.isnan(F444W)" - -o --output file.fits : output matched catalogue - -p --param file.param : load starbug parameter file - -s --set option : set value in parameter file at runtime (-s MATCH_THRESH=1) - -v --verbose : display verbose outputs - - --band-depr : match in "old" band mode - - --> typical runs - $~ starbug2-match -Gfo outfile.fits tab1.fits tab2.fits - $~ starbug2-match -sMATCH_THRESH=0.2 -sBRIDGE_COL=F444W -Bo out.fits F*W.fits -""" -import os,sys,getopt +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import os +import sys +from typing import Any + import numpy as np -from astropy.table import Table, hstack, vstack +from astropy.table import Table, vstack +from astropy.units import Quantity from starbug2 import utils -from starbug2.matching import GenericMatch, CascadeMatch, BandMatch, ExactValueMatch, band_match, parse_mask -from starbug2 import param -import starbug2.bin as scr -import starbug2 - - -VERBOSE =0x01 -KILLPROC=0x02 -STOPPROC=0x04 -SHOWHELP=0x08 - -BANDMATCH =0x10 -BANDDEPR =0x20 -GENERICMATCH=0x40 -CASCADEMATCH=0x80 -EXACTMATCH =0x100 - -EXPFULL = 0x1000 - - -def match_parsemargv(argv): - options=0 - setopt={} - - cmd,argv=scr.parsecmd(argv) - opts,args=getopt.gnu_getopt(argv, "BCfGhvXe:m:o:p:s:", ("band","cascade","dither","exact","full","generic","help","verbose", - "error=","mask=","output=","param=","set=", - "band-depr")) - for opt,optarg in opts: - if opt in ("-h", "--help"): options|=(SHOWHELP|STOPPROC) - if opt in ("-v", "--verbose"): options|=VERBOSE - if opt in ("-o", "--output"): setopt["OUTPUT"]=optarg - if opt in ("-p", "--param"): setopt["PARAMFILE"]=optarg - - if opt in ("-e","--error"): setopt["ERRORCOLUMN"]=optarg - if opt in ("-f","--full"): options|=EXPFULL - if opt in ("-m","--mask"): setopt["MASKEVAL"]=optarg - if opt in ("-s","--set"): - if '=' in optarg: - key,val=optarg.split('=') - try: val=float(val) - except: pass - setopt[key]=val - else: utils.perror("unable to set parameter, use syntax -s KEY=VALUE\n") - - - if opt in ("-B","--band"): options|=BANDMATCH - if opt in ("-C","--cascade"): options|=CASCADEMATCH - if opt in ("-G","--generic"): options|=GENERICMATCH - if opt in ("-X","--exact") : options|=EXACTMATCH - if opt == "--band-depr": options|=BANDDEPR - return options, setopt, args - -def match_onetimeruns(options, setopt): +from starbug2.constants import ( + STAR_BUG_MIRI, NIRCAM, MATCH_COLS, TableColumn, ExitStates, HeaderTags) +from starbug2.filters import STAR_BUG_FILTERS +from starbug2.matching.band_match import BandMatch +from starbug2.matching.cascade_match import CascadeMatch +from starbug2.matching.exact_value_match import ExactValueMatch +from starbug2.matching.generic_match import GenericMatch +from starbug2.misc import parse_mask +from starbug2.star_bug_config import StarBugMainConfig +from starbug2.utils import parse_cmd, usage +import photutils + +# Force photutils to strictly return standard QTables globally +photutils.future_column_names = True + + +def starbug_parse_argv(argv: list[str]) -> StarBugMainConfig: """ - Options set, one time runs + Organise the sys argv line into options, values and arguments + + :param argv: the arguments + :return: the config class + :rtype: StarBugMainConfig """ - if options&VERBOSE: setopt["VERBOSE"]=1 - if options&SHOWHELP: - scr.usage(__doc__,verbose=options&VERBOSE) - return scr.EXIT_EARLY - - - return scr.EXIT_SUCCESS - -def match_fullbandmatch(tables, parameters): - utils.perror("THIS NEEDS A TEST\n") - tomatch={ starbug2.NIRCAM:[], starbug2.MIRI:[] } - _colnames=["RA","DEC","flag"] - dthreshold=parameters.get("MATCH_THRESH") - - for i,tab in enumerate(tables): - fltr=tab.meta.get("FILTER") - tomatch[starbug2.filters[fltr].instr].append(tab) - _colnames+=([fltr,"e%s"%fltr]) - - if tomatch[starbug2.NIRCAM] and tomatch[starbug2.MIRI]: - utils.printf("Detected NIRCam to MIRI matching\n") - nircam_matched=band_match(tomatch[starbug2.NIRCAM], colnames=_colnames) - miri_matched=band_match(tomatch[starbug2.MIRI], colnames=_colnames) + config: StarBugMainConfig = StarBugMainConfig() + short_definition: str + long_definition: list[str] + short_definition, long_definition = ( + config.generate_match_get_opt_definitions()) + + _, argv = parse_cmd(argv) + config.populate_params( + argv, short_definition, long_definition, config.MATCH_FLAG_MAP) + return config - load=utils.loading(len(miri_matched), msg="Combining NIRCAM-MIRI(%.2g\")"%dthreshold) - if (bridgecol:=parameters.get("BRIDGE_COL")): - mask= np.isnan(nircam_matched[bridgecol]) - utils.printf("-> bridging catalogues with %s\n"%bridgecol) - else: mask=np.full(len(nircam_matched), False) - m=GenericMatch(threshold=dthreshold, load=load) - full=m((nircam_matched[~mask],miri_matched)) +def match_full_band_match( + tables: list[Table], + d_threshold: np.ndarray, bridge_col: str) -> Table: + """ + Handles fallback deprecated band-matching configurations across diverse + detectors. + """ + utils.p_error("THIS NEEDS A TEST\n") + to_match: dict[int, list[Table]] = { + NIRCAM: [], + STAR_BUG_MIRI: [] + } + _col_names: list[str] = [TableColumn.RA, TableColumn.DEC, TableColumn.FLAG] + band_matcher: BandMatch = BandMatch(threshold=d_threshold) + + for tab in tables: + tab: Table + filter_string: str = str(tab.meta.get(HeaderTags.FILTER)) + to_match[STAR_BUG_FILTERS[filter_string].instr].append(tab) + _col_names += [filter_string, f"e{filter_string}"] + + matched: Table + if to_match[NIRCAM] and to_match[STAR_BUG_MIRI]: + utils.printf("Detected NIRCam to MIRI matching\n") + nir_cam_matched: Table = band_matcher.band_match( + to_match[NIRCAM], col_names=_col_names) + miri_matched: Table = band_matcher.band_match( + to_match[STAR_BUG_MIRI], col_names=_col_names) + + # noinspection SpellCheckingInspection + load: utils.Loading = utils.Loading( + len(miri_matched), + msg=f"Combining NIRCAM-MIRI({np.array2string(d_threshold)}g\")" + ) + + mask: np.ndarray + if bridge_col: + mask = np.isnan(nir_cam_matched[bridge_col]) + utils.printf("-> bridging catalogues with %s\n" % bridge_col) + else: + mask = np.full(len(nir_cam_matched), False) + + m: GenericMatch = GenericMatch(threshold=d_threshold, load=load) + full: Any = m((nir_cam_matched[~mask], miri_matched)) matched = m.finish_matching(full) - #matched,_=matching.generic_match((nircam_matched[~mask],miri_matched), threshold=dthreshold, add_src=True, load=load) - matched.remove_column("NUM") - matched=vstack((matched, nircam_matched[mask])) + matched.remove_column(TableColumn.NUM) + matched = vstack((matched, nir_cam_matched[mask])) else: - matched=band_match(tables, colnames=_colnames) - + matched = band_matcher.band_match(tables, col_names=_col_names) + return matched -def match_main(argv): - """""" - options,setopt,args = match_parsemargv(argv) - if options or setopt: - if (exit_code:=match_onetimeruns(options,setopt))!=scr.EXIT_SUCCESS: - return exit_code - - ########## - # PARAMS # - ########## - if not (pfile:=setopt.get("PARAMFILE")): - if os.path.exists("./starbug.param"): pfile="./starbug.param" - else: pfile=None - parameters=param.load_params(pfile) - parameters.update(setopt) - - ################# - # MAIN ROUTINES # - ################# - exit_code=scr.EXIT_SUCCESS - - tables=[ ] - for fname in args: - t=utils.import_table(fname, verbose=1) - if t is not None: tables.append(t) - if (raw:=parameters.get("MASKEVAL")): - masks = [ parse_mask(raw,t) for t in tables ] - for m in masks: - try: print(m, sum(m), len(m)) - except: print( m ) - else: masks=None - - - if len(tables)>1: - colnames=starbug2.match_cols - colnames+=[ name for name in parameters["MATCH_COLS"].split() if name not in colnames] - dthreshold=parameters["MATCH_THRESH"] - error_column = setopt.get("ERRORCOLUMN") if setopt.get("ERRORCOLUMN") else "eflux" - - ## snthresh=parameters["SN_THRESH"] - - ## ################# - ## # SN RATIO CUTS # - ## ################# - ## if snthresh>0: - ## utils.puts("SN Ratio Cuts") - ## for i,(tab,fltr) in enumerate(zip(tables, filters)): - - ## if fltr: - ## mask = ((tab[fltr]/tab["e%s"%fltr]) %s: Removing %d sources\n"%(fltr, sum(mask))) - ## tables[i].remove_rows(mask) - ## else: - ## utils.perror("Unable to determine filter of \"%s\"\n"%args[i]) - - if options & BANDDEPR: - av=match_fullbandmatch(tables, parameters) - full=None - options&=~EXPFULL +def match_main(argv: list[str]) -> ExitStates: + """ + Main runtime processing loop for executing cross-catalogue astronomical + source coordinate matching. + """ + config: StarBugMainConfig = starbug_parse_argv(argv) + + if config.show_match_help: + usage(__doc__, verbose=config.verbose_logs) # noqa + return ExitStates.EXIT_SUCCESS + + p_file: str | None = config.param_file + if not p_file: + config.param_file = ( + "./starbug.param" if os.path.exists("./starbug.param") else None) + + tables: list[Table] = [] + for f_name in config.fits_table: + t: Table | None = utils.import_table(f_name, verbose=True) + if t is not None: + tables.append(t) + + masks: list[np.ndarray | None] = [] + if raw := config.mask_eval: + masks = [parse_mask(raw, t) for t in tables] + for m in masks: + try: + print(str(m), sum(m), len(m)) # noqa + except (TypeError, NameError, ImportError): + print(m) # noqa + + if len(tables) > 1: + col_names: list[str] = list(MATCH_COLS) + + if config.extra_match_columns is not None: + col_names += [ + name for name in config.extra_match_columns.split() + if name not in col_names + ] + d_threshold: Quantity = config.match_threshold_arc_sec_as_an_arc_sec + error_column: str = config.error_col + + average_table: Table + output_table: Table | None = None + matcher: GenericMatch + + if config.band_deprecated: + average_table = match_full_band_match( + tables, config.match_threshold_arc_sec_as_an_array, + config.bridge_band_column) + config.full_run = True else: - if options & BANDMATCH: - if isinstance(dthreshold, str): - dthreshold=np.array(parameters["MATCH_THRESH"].split(','), float) - if parameters["FILTER"]!="": - fltr=parameters["FILTER"].split(',') - else: fltr=utils.rmduplicates( [utils.find_filter(t) for t in tables] ) - matcher=BandMatch(threshold=dthreshold, fltr=fltr, verbose=parameters["VERBOSE"]) - - elif options & CASCADEMATCH: matcher=CascadeMatch(threshold=dthreshold, colnames=colnames, verbose=parameters["VERBOSE"]) - elif options & GENERICMATCH: matcher=GenericMatch(threshold=dthreshold, colnames=colnames, verbose=parameters["VERBOSE"]) - elif options & EXACTMATCH: matcher=ExactValueMatch(value="Catalogue_Number",colnames=None, verbose=parameters["VERBOSE"]) - else: - matcher=GenericMatch(threshold=dthreshold, verbose=parameters["VERBOSE"]) - options|=EXPFULL - if options & VERBOSE: print("\n%s"%matcher) - full= matcher.match( tables, join_type="or", mask=masks ) - av = matcher.finish_matching(full, num_thresh=parameters["NEXP_THRESH"], zpmag=parameters["ZP_MAG"], error_column=error_column) - - - output=parameters.get("OUTPUT") + if config.do_band_processing: + band_threshold: np.ndarray = ( + config.match_threshold_arc_sec_as_an_array) + + filter_string: list[str] + if config.custom_filter != "": + filter_string = str(config.custom_filter).split(',') + else: + filter_string = utils.remove_duplicates( + [utils.find_filter(t) for t in tables]) + + matcher = BandMatch( + threshold=band_threshold, fltr=filter_string, + verbose=config.verbose_logs + ) + elif config.do_cascade: + matcher = CascadeMatch( + threshold=d_threshold, colnames=col_names, + verbose=config.verbose_logs + ) + elif config.generic_mode: + matcher = GenericMatch( + threshold=d_threshold, col_names=col_names, + verbose=config.verbose_logs + ) + elif config.exact_match: + matcher = ExactValueMatch( + value=TableColumn.CAT_NUM, colnames=None, + verbose=config.verbose_logs + ) + else: + matcher = GenericMatch( + threshold=d_threshold, verbose=config.verbose_logs + ) + config.full_run = True + + if config.verbose_logs: + print("\n%s" % matcher) + + output_table = matcher.match(tables, join_type="or", mask=masks) + average_table = matcher.finish_matching( + output_table, + num_thresh=config.exposure_count_threshold, + zp_mag=config.zero_point_magnitude, + error_column=error_column + ) + + output: str | None = config.output_file if output is None or output == '.': - output=utils.combine_fnames( [ name for name in args] , ntrys=100) - dname,fname,ext=utils.split_fname(output) - - suffix="" - if options&EXPFULL: - utils.export_table(full,fname="%s/%sfull.fits"%(dname,fname)) - utils.printf("-> %s/%sfull.fits\n"%(dname,fname)) - suffix="match" - if av: - utils.export_table(av,"%s/%s%s.fits"%(dname,fname,suffix)) - utils.printf("-> %s/%s%s.fits\n"%(dname,fname,suffix)) - - exit_code= scr.EXIT_SUCCESS - - elif len(tables)==1: - exit_code=scr.EXIT_EARLY + output = utils.combine_file_names( + [name for name in config.fits_images], n_mismatch=100) + if output is None: + return ExitStates.EXIT_FAIL + + d_name: str + f_name: str + ext: str + d_name, f_name, ext = utils.split_file_name(output) + + suffix: str = "" + if config.full_run and output_table is not None: + utils.export_table( + output_table, f_name="%s/%sfull.fits" % (d_name, f_name)) + utils.printf("-> %s/%sfull.fits\n" % (d_name, f_name)) + suffix = "match" + + if average_table: + utils.export_table( + average_table, "%s/%s%s.fits" % (d_name, f_name, suffix)) + utils.printf("-> %s/%s%s.fits\n" % (d_name, f_name, suffix)) + + return ExitStates.EXIT_SUCCESS + + elif len(tables) == 1: + return ExitStates.EXIT_EARLY else: - utils.perror("No tables loaded for matching.\n") - exit_code= scr.EXIT_FAIL - return exit_code + utils.p_error("No tables loaded for matching.\n") + return ExitStates.EXIT_EARLY + -def match_mainentry(): - """StarbugII-match entry""" +def match_main_entry() -> ExitStates: + """StarbugII-match entry path map setup routing wrapper.""" return match_main(sys.argv) diff --git a/starbug2/bin/plot.py b/starbug2/bin/plot.py index 491c074..92e3c10 100644 --- a/starbug2/bin/plot.py +++ b/starbug2/bin/plot.py @@ -1,165 +1,182 @@ -"""StarbugII Plotting Scripts -usage: starbug2-plot [-vhX] [-I CN000] [-o outfile] images.fits .. - -h --help : show help screen - -o --output fname : output filename - -v --verbose : verbose mode - - -I --inspect CN000 : inspect a source in an array of images - -X --test : plot a test image - - --style fname : load a custom pyplot style sheet - --dark : plot in dark mode -""" -import os,sys,getopt +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import os +import sys + import numpy as np import matplotlib.pyplot as plt from astropy.io import fits from astropy.table import Table - -import starbug2.bin as scr import starbug2 -from starbug2.plot import load_style, plot_test, plot_inspectsource -from starbug2.utils import printf, perror, warn - -VERBOSE =0x01 -SHOWHELP=0x02 -STOPPROC=0x04 -KILLPROC=0x08 +from starbug2.constants import ExitStates, TableColumn, HeaderTags +from starbug2.plot import load_style, plot_test, plot_inspect_source +from starbug2.star_bug_config import StarBugMainConfig +from starbug2.utils import p_error, warn, parse_cmd, usage +from astropy.io.fits import PrimaryHDU, ImageHDU, BinTableHDU -DARKMODE=0x10 +import photutils -PTEST= 0x1000 -PINSPECT=0x2000 +# Force photutils to strictly return standard QTables globally +photutils.future_column_names = True -def plot_parseargv(argv): - options=0 - setopt={} - cmd,argv = scr.parsecmd(argv) - opts,args = getopt.gnu_getopt(argv, "hvXI:d:o:",("help","verbose","test", - "inspect=", - "output=", "style=", "dark")) +def starbug_parse_argv(argv: list[str]) -> StarBugMainConfig: + """ + Organise the sys argv line into options, values and arguments - for opt,optarg in opts: - match(opt): - case "-h"|"--help": options|=(SHOWHELP|STOPPROC) - case "-v"|"--verbose": options|=VERBOSE - case "-o"|"--output": setopt["OUTPUT"]=optarg - case "-d"|"--apfile": setopt["APFILE"]=optarg + :param argv: the arguments + :return: the config class + :rtype: StarBugMainConfig + """ + config: StarBugMainConfig = StarBugMainConfig() + short_definition: str + long_definition: list[str] + short_definition, long_definition = ( + config.generate_plot_get_opt_definitions()) - case "-I"|"--inspect": - options|=PINSPECT - setopt["INSPECT"]=optarg - case "-X"|"--test": options|=PTEST + _, argv = parse_cmd(argv) + config.populate_params( + argv, short_definition, long_definition, config.PLOT_FLAG_MAP) + return config - case "--style": setopt["STYLESHEET"]=optarg - case "--dark": options|=DARKMODE - return options, setopt, args +def plot_one_time_runs(config: StarBugMainConfig) -> ExitStates: + """ + Handles initialisation routines such as style sheet distribution or + help menu warnings. + """ + if config.show_plot_help: + usage(__doc__, verbose=bool(config.verbose_logs)) + if config.inspect_parameter: + p_error(str(fn_pinspect.__doc__)) + return ExitStates.EXIT_EARLY + # Only throw an error if files are missing when they are explicitly + # required + if not config.test_mode and len(config.fits_images) == 0: + p_error( + "Error: Image or catalogue argument targets must be provided.\n") + return ExitStates.EXIT_EARLY -def plot_onetimeruns(options, setopt, args): - if options&SHOWHELP: - scr.usage(__doc__,verbose=options&VERBOSE) + if config.plot_style is not None: + load_style(str(config.plot_style)) - if options & PINSPECT: perror(fn_pinspect.__doc__) + if config.dark_mode: + load_style(f"{starbug2.__path__[0]}/extras/dark.style") - return scr.EXIT_EARLY + return ExitStates.EXIT_SUCCESS - if (_fname:=setopt.get("STYLESHEET")): - load_style(_fname) - if options&DARKMODE: - load_style("%s/extras/dark.style"%starbug2.__path__[0]) - - if options & STOPPROC: return scr.EXIT_EARLY - if options & KILLPROC: - perror("..killing process\n") - return scr.EXIT_FAIL +def fn_pinspect( + inspect_string: str, + images: list[fits.PrimaryHDU | fits.ImageHDU | + fits.BinTableHDU | None] | None = None, + tables: list[Table] | None = None) -> plt.Figure | None: + """ + Plot cutouts at a source position across a range of images. - return scr.EXIT_SUCCESS + This requires a source list to be loaded, a list of image + files, and the source catalogue number to be given. This will + take the form:: -def fn_pinspect(options, setopt, images=None, tables=None): + $~ starbug2-plot -I CN123 source_list.fits image*.fits + + :param inspect_string: the string to inspect + :type inspect_string: str + :param images: The list of FITS image HDUs to cut out from + :type images: list + :param tables: The source list containing coordinates matching a + 'Catalogue_Number' + :type tables: list of astropy.Table + :return: The output figure object containing rendered cutouts + :rtype: matplotlib.pyplot.Figure or None """ - Inspect Source - -------------- + fig: plt.Figure | None = None + + if inspect_string and images and tables and len(tables) > 0: + if (TableColumn.CAT_NUM in tables[0].colnames + and inspect_string in tables[0][TableColumn.CAT_NUM]): + i: np.ndarray = np.where( + tables[0][TableColumn.CAT_NUM] == inspect_string)[0] + fig = plot_inspect_source(tables[0][i], images) + else: + p_error( + f"Must include the source {TableColumn.CAT_NUM}, " + f"a list of images and a source list \n" + ) + return fig - Plot at a source position cutouts in a range of images. - This requires a source list to be loaded, a list of image - file and the source catalogue number to be given. This will - take the form:: - $~ starbug2-plot -I CN123 sourcelist.fits image*.fits +def plot_main(argv: list[str]) -> ExitStates | None: """ + Main runtime entry path configuration structure for + data visualisation parsing loops. """ - Parameters - ---------- - options : int - The starbug2.bin.plot options integar + warn("Still in development\n\n") - setopt : dict - The starbug2.bin.plot setopt dictionary + config: StarBugMainConfig = starbug_parse_argv(argv) - images : list - The list of fits image HDUs to cut out from + load_style(f"{starbug2.__path__[0]}/extras/starbug.style") - tables : list - The source list to pull the source from. Must have a column - with the name "Catalogue_Number" + if plot_one_time_runs(config) == ExitStates.EXIT_EARLY: + return ExitStates.EXIT_EARLY - Returns - ------- - fig : plt.figure - The output figure - """ - fig=None - if (cn:=setopt.get("INSPECT")) and images and tables: - if "Catalogue_Number" in tables[0].colnames and cn in tables[0]["Catalogue_Number"]: - i=np.where(tables[0]["Catalogue_Number"]==cn)[0] - fig=plot_inspectsource(tables[0][i], images) + images: list[PrimaryHDU | ImageHDU | BinTableHDU | None] = [] + tables: list[Table] = [] - else: perror("Must include the source Catalogue_Number, a list of images and a sourcelist \n") - return fig + for arg in config.fits_images: + if os.path.exists(arg): + fp: fits.HDUList = fits.open(arg) + _filter: str = fp[0].header.get(HeaderTags.FILTER) -def plot_main(argv): - warn("Still in development\n\n") - options,setopt,args=plot_parseargv(argv) - load_style("%s/extras/starbug.style"%starbug2.__path__[0]) - exit_code=0 - - if options or setopt: - if (exit_code:=plot_onetimeruns(options, setopt, args)): - return exit_code - - images=[] - tables=[] - for arg in args: - if (_fname:=os.path.exists(arg)): - fp=fits.open(arg) - _filter=fp[0].header.get("FILTER") # THIS IS A HACK + # Use type tracking alias explicitly during extraction loop blocks + hdu: PrimaryHDU | ImageHDU | BinTableHDU | None = None for hdu in fp: - if hdu.header.get("XTENSION")=="IMAGE": + if hdu is None: + continue + + if hdu.header.get(HeaderTags.EXT) == HeaderTags.IMAGE: images.append(hdu) break - if hdu.header.get("XTENSION")=="BINTABLE": + if hdu.header.get(HeaderTags.EXT) == HeaderTags.BIN_TABLE: tables.append(Table(hdu.data)) break - hdu.header["FILTER"]=_filter + if hdu is not None: + hdu.header[HeaderTags.FILTER] = _filter + + fig: plt.Figure | None = None - fig=None - if options& PTEST: - fig,ax=plt.subplots(1,figsize=(3,2.5)) - ax=plot_test(ax) + if config.test_mode: + ax: plt.Axes + fig, ax = plt.subplots(1, figsize=(3, 2.5)) + plot_test(ax) - if options& PINSPECT: fig=fn_pinspect(options, setopt, images=images, tables=tables) + inspect_string: str | None = config.inspect_parameter + if inspect_string is not None: + fig = fn_pinspect(inspect_string, images=images, tables=tables) if fig is not None: fig.tight_layout() - if (output:=setopt.get("OUTPUT")): - fig.savefig(output, dpi=300) + if output := config.output_file: + fig.savefig(str(output), dpi=300) else: plt.show() -def plot_mainentry(): - """Command Line entry point""" + return ExitStates.EXIT_SUCCESS + + +def plot_main_entry() -> ExitStates | None: + """Command Line package gateway binary endpoint entry pointer mapper.""" return plot_main(sys.argv) diff --git a/starbug2/constants.py b/starbug2/constants.py new file mode 100644 index 0000000..51aad7c --- /dev/null +++ b/starbug2/constants.py @@ -0,0 +1,715 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +# noinspection SpellCheckingInspection +from typing import List, Final +from enum import Enum +from pathlib import Path +import os + +# the filter id which we've had to adjsut the bin size to allow it to +# initilise without errors. +PROBLEMATIC_FILTER_ID = "F150W2" +PROBLEMATIC_FILTER_WARNING = ( + "Caution needed with F150W2 photometric accuracy. More info" + "can be found in (" + "https://github.com/alan-stokes/starbug2/issues/2)") + +STARBUG_DATA_DIR: Final[str] = "STARBUG_DATDIR" +WEBBPSF_PATH_ENV_VAR: Final[str] = "WEBBPSF_PATH" +STAR_BUG_PARAMS: Final[str] = "STARBUGII PARAMETERS" +STAR_BUG_TEST_DAT_ENV: Final[str] = "STARBUG_TEST_DIR" + +# default values +DEFAULT_FULL_WIDTH_HALF_MAX = 2.0 +DEFAULT_PSF_FILE_NAME = "psf.fits" +DEFAULT_COLOUR: Final[str] = "green" +# how many characters we will allow by default. +N_MIS_MATCHES: Final[int] = 10 + +# rest success +REST_SUCCESS_CODE: Final[int] = 200 + +# url to docs +URL_DOCS: Final[str] = ( + "https://raw.githubusercontent.com/conornally/starbug2/" + "refs/heads/main/docs/source/_static/images/starbug.png") + +READ_THE_DOCS_URL: Final[str] = "https://starbug2.readthedocs.io/en/latest/" + +# fit urls +JWST_MIRI_APCORR_0010_FITS_URL: Final[str] = ( + "https://jwst-crds.stsci.edu/unchecked_get/references/jwst/" + "jwst_miri_apcorr_0010.fits" +) +JWST_NIRCAM_APCORR_0004_FITS_URL: Final[str] = ( + "https://jwst-crds.stsci.edu/unchecked_get/references/jwst/" + "jwst_nircam_apcorr_0004.fits" +) + +# abvega offset urls +JWST_MIRI_ABVEGA_OFFSET_URL: Final[str] = ( + "https://jwst-crds.stsci.edu/unchecked_get/references/jwst/" + "jwst_miri_abvegaoffset_0001.asdf" +) +JWST_NIRCAM_ABVEGA_OFFSET_URL: Final[str] = ( + "https://jwst-crds.stsci.edu/unchecked_get/references/jwst/" + "jwst_nircam_abvegaoffset_0002.asdf" +) + +# paths to temp files. +TMP_OUT: Final[str] = "/tmp/out.reg" +TMP_FITS: Final[str] = "/tmp/starbug.fits" + +# the fits file extension +FITS_EXTENSION: Final[str] = ".fits" +FILE_NAME: Final[str] = "FILENAME" + +# HDU extension names +DQ: Final[str] = "DQ" +AREA: Final[str] = "AREA" +WHT: Final[str] = "WHT" +ERR: Final[str] = "ERR" + +# file types +AP_FILE: Final[str] = "AP_FILE" +BGD_FILE: Final[str] = "BGD_FILE" +PSF_FILE: Final[str] = "PSF_FILE" + + +# SOURCE FLAGS +class SourceFlags(int, Enum): + SRC_GOOD = 0 + SRC_BAD = 0x01 + SRC_JMP = 0x02 + # source frame mean >5% different from median + SRC_VAR = 0x04 + # psf fit with fixed centroid + SRC_FIX = 0x08 + # source unknown (this isnt used anywhere!) + SRC_UKN = 0x10 + + +# DQ FLAGS +class DQFlags(int, Enum): + DQ_DO_NOT_USE = 0x01 + DQ_SATURATED = 0x02 + DQ_JUMP_DET = 0x04 + + +# e name common names +SCI: Final[str] = "SCI" +BGD: Final[str] = "BGD" +RES: Final[str] = "RES" + + +# test states +class ExitStates(int, Enum): + EXIT_SUCCESS = 0 + EXIT_FAIL = 1 + EXIT_EARLY = 2 + EXIT_MIXED = 3 + + +# table column enum to be used to amtch table col names +class TableColumn(str, Enum): + """Table column names used across the pipeline.""" + + CAT_NUM = "Catalogue_Number" + RA = "RA" + DEC = "DEC" + FLUX = "flux" + E_FLUX = "eflux" + FLUX_2 = "flux_2" + X_CENTROID = "x_centroid" + Y_CENTROID = "y_centroid" + X_PEAK = "x_peak" + Y_PEAK = "y_peak" + EE_FRACTION = "eefraction" + RADIUS = "radius" + AP_CORR = "apcorr" + STD_FLUX = "stdflux" + NUM = "NUM" + FLAG = "flag" + FLUX_DET = "flux_det" + FLUX_FIT = "flux_fit" + FLUX_ERR = "flux_err" + OUT_FLUX = "outflux" + X_0 = "x_0" + Y_0 = "y_0" + X_DET = "x_det" + Y_DET = "y_det" + ID = "id" + MAG = "mag" + MAG_UPPER = "MAG" + ERROR_MAG = "eMAG" + STATUS = "status" + REC = "rec" + PARAM = "PARAM" + X_INIT = "x_init" + Y_INIT = "y_init" + XY_DEV = "xydev" + XY_DEV_ = "_xydev" + ERR_LOWER = "err" + OFF = "off" + X_FIT = "x_fit" + Y_FIT = "y_fit" + Q_FIT = "qfit" + PUPIL = "pupil" + SKY = "sky" + SMOOTHNESS = "smoothness" + SHARPNESS = "sharpness" + ROUNDNESS1 = "roundness1" + ROUNDNESS2 = "roundness2" + RA_1 = "RA_1" + RA_2 = "RA_2" + + # needed as the table system doenst seem to handle enums properly + def __str__(self) -> str: + return self.value + + # needed as the table system doenst seem to handle enums properly + def __format__(self, format_spec: str) -> str: + return self.value.__format__(format_spec) + + +# DEFAULT MATCHING COLS +MATCH_COLS: List[str] = [ + TableColumn.RA, TableColumn.DEC, TableColumn.FLAG, TableColumn.FLUX, + TableColumn.E_FLUX, TableColumn.NUM] + + +# Q table col names +class QTableColNames(str, Enum): + SUM_ERR_0 = "aperture_sum_err_0" + SUM_0 = "aperture_sum_0" + SUM_1 = "aperture_sum_1" + + # needed as the table system doenst seem to handle enums properly + def __str__(self) -> str: + return self.value + + # needed as the table system doenst seem to handle enums properly + def __format__(self, format_spec: str) -> str: + return self.value.__format__(format_spec) + + +# tag for header +class HeaderTags(str, Enum): + FILTER_LOWER = "filter" + FILTER = "FILTER" + EXT = "XTENSION" + IMAGE = "IMAGE" + BIN_TABLE = "BINTABLE" + OUTPUT = "OUTPUT" + STAR_BUG = "STARBUG" + CALIBRATION_LV = "CALIBLEVEL" + NAXIS = "NAXIS" + NAXIS1 = "NAXIS1" + NAXIS2 = "NAXIS2" + C_TYPE = "CTYPE" + OBS = "OBSERVTN" + VISIT = "VISIT" + EXPOSURE = "EXPOSURE" + + # needed as the table system doenst seem to handle enums properly + def __str__(self) -> str: + return self.value + + # needed as the table system doenst seem to handle enums properly + def __format__(self, format_spec: str) -> str: + return self.value.__format__(format_spec) + + +# tags for image header +class ImageHeaderTags(str, Enum): + DETECTOR = "DETECTOR" + TELESCOPE = "TELESCOP" + INSTRUMENT = "INSTRUME" + BUN_IT = "BUNIT" + PIXAR_A2 = "PIXAR_A2" + PIXAR_SR = "PIXAR_SR" + JWST = "JWST" + FILTER = "FILTER" + + # needed as the table system doenst seem to handle enums properly + def __str__(self) -> str: + return self.value + + # needed as the table system doenst seem to handle enums properly + def __format__(self, format_spec: str) -> str: + return self.value.__format__(format_spec) + + +# tag used for param file. +VERBOSE_TAG: Final[str] = "VERBOSE" + + +# mode labels. +class Modes(str, Enum): + DETECTION = "DETECTION" + BACKGROUND = "BACKGROUND" + APP_HOT = "APPHOT" + PSFP_HOT = "PSFPHOT" + MATCH_OUTPUTS = "MATCHOUTPUTS" + CLEAR = "CLEAR" + + +# HASHDEFS +STAR_BUG_MIRI: Final[int] = 1 +NIRCAM: Final[int] = 2 +NIRCAM_STRING: Final[str] = "NIRCAM" +MIRI_STRING: Final[str] = "MIRI" +MIRI_IMAGE = "MIRIMAGE" + + +class DetectorLengths(int, Enum): + NULL = 0 + LONG = 1 + SHORT = 2 + + +# enum unit +class Units(int, Enum): + PIX = 0 + ARCSEC = 1 + ARCMIN = 2 + DEG = 3 + + +# text based logo (using raw string to bypass escape characters) +_LOGO_PATH = Path(os.path.join( + os.path.join(Path(__file__).parent, "extras"), "logo.txt")) +LOGO: Final[str] = _LOGO_PATH.read_text(encoding="utf-8") + "%s" + +# dictionary of help strings for specific modes ( +# DETECTION, BACKGROUND, APPHOT, PSFPHOT, MATCHOUTPUTS). +HELP_STRINGS = { + Modes.DETECTION: + """ + Source Detection + ---------------- + + This routine locates point sources in an image. The input is a + FITS image and the output is a FITS table, containing a list of + point source locations, their geometric properties and + flux/magnitude measurements as calculated by aperture photometry. + The output file will have the suffix "-ap", note this is the same + as the output for the aperture photometry routine. + + To run this routine, use the core command: + + $~ starbug2 -D image.fits + + Alter the parameter file options under "DETECTION" to tune the + performance of starbug2. Two of the key parameters are: + + - SIGSKY : Set the background level of the image + - SIGSRC : Set the detection threshold of the sources + + Full documentation is at https://starbug2.readthedocs.io + """, + Modes.BACKGROUND: + """ + Diffuse Background Estimations + ------------------------------ + + This routine estimates the "dusty" emissions in an image, given + a source list. It is used to subtract from the image, thus removing + the flux contribution on a source brightness from the dusty + environment. + + The routine requires a list of sources to be generated (by source + detection) or loaded with [-d sourcelist.fits] and requires a FITS + image to work on. The routine will ouput a FITS image, with the + same dimensions and spatial coverage as the input image, with the + suffix "-bgd". This background image can be used in the photometry + later. + + To run the routine, use the core command: + + $~ starbug2 -B -d sourcelist.fits image.fits + + Alter the parameter file options under "BACKGROUND ESTIMATION" + to tune the performance of starbug2. Two key parameters are: + + - BGD_R : Set a fixed aperture mask radius around each + source + - BOX_SIZE : Set the estimation resolution (larger will be + more blurred) + + Full documentation is at https://starbug2.readthedocs.io + """, + Modes.APP_HOT: + """ + Aperture Photometry + ------------------- + + This routine conducts aperture photometry on an image given a list + of sources. It requires a FITS image to run on and a FITS table + source list with either RA/DEC columns, or x/y_centroid or x/y_0 + columns. The routine outputs a table with the suffix "-ap". Note + this filename is the same as the source detection routine because + aperture photometry is automatically run at the end of the source + detection step. The output table contains 2flux/magnitude + information on every source + + To run this routine, use the core command: + + $~ starbug2 -A -d sourcelist.fits image.fits + + Alter the parameter file options under "APERTURE PHOTOMETRY" to + tune the performance of starbug2. Three key parameters are: + + - APPHOT_R : Set the aperture radius for photometry (in pixels) + - SKY_RIN : Set the inner sky annulus radius (in pixels) + - SKY_ROUT : Set the outer sky annulus radius (in pixels) + + Full documentation is at https://starbug2.readthedocs.io + """, + Modes.PSFP_HOT: + """ + PSF Photometry + -------------- + + This routine conducts PSF fitting photometry on an image given + a list of sources. Its requires a FITS image to run on and a FITS + table sourcelist with either RA/DEC columns, or x/y_centroid or + x/y_0 columns. The routine outputs a table with the suffix "-psf". + The output table contains 2flux/magnitude information on every + source. + + To run this routine, use the core command: + + $~ starbug2 -P -d sourcelist image.fits + + Alter the parameter file options under "PHOTOMETRY" to tune the + performance of starbug2. Two key parameters are: + + - FORCE_POS : Hold the cetroid positions of source fixed + (forced photometry) + - GEN_RESIDUAL : Generate a residual image from all the fit + source + + Full documentation is at https://starbug2.readthedocs.io + """, + Modes.MATCH_OUTPUTS: + """ + Match Outputs + ------------- + + This option is set if the user wishes to combine all the output + catalogues from starbug together. It would be used in the case + that a routine is being ran on a list of images (either in series + or parallel) and the final catalogues should all be combined into + a single source list. It outputs two files, one with the suffix + "full" and another with "match". The first is all columns from all + table preserved into a single large catalogue, the second averages + all the similar columns into a reduced table. + + To run this routine, use the core code: + + $~ starbug2 -DM image1.fits image2.fits image3.fits ... + + Alter the parameter file options under "CATALOGUE MATCHING" to + tune the performance of starbug2. Two key parameters are: + + - MATCH_THRESH : Set the separation threshold (arcsec) to match + two sources + - NEXP_THRESH : Set the minimum number of catalogues a source + must be present in + """, +} + +# Named Constant Template for Default Parameter Files +DEFAULT_PARAM_TEMPLATE: Final[str] = """## STARBUG CONFIG FILE +# Generated with starbug2-v{version_str} +PARAM = STARBUGII PARAMETERS // COMMENT + +## GENERIC +// (0:false 1:true) +VERBOSE = {VERBOSE} + +// Directory or filename to output to +OUTPUT = {OUTPUT} + +// If using a non standard HDU name, name it here (str or int) +HDUNAME = {HDUNAME} + +// Set a custom filter for the image +FILTER = {FILTER} + +## DETECTION +// Custom FWHM for image (-1 to use WEBBPSF) +FWHM = {FWHM} + +// Number of sigma above the median to clip out as background +SIGSKY = {SIGSKY} + +// Source value minimum N sigma above background +SIGSRC = {SIGSRC} + +// Run background2D step (usually finds more sources but takes time) +DOBGD2D = {DOBGD2D} + +// Run convolution step (usually finds more sources) +DOCONVL = {DOCONVL} + +// Run source cleaning after detection (removes likely contaminants) +CLEANSRC = {CLEANSRC} + +// Lower limit of source sharpness (0 is not sharp) +SHARP_LO = {SHARP_LO} + +// Upper limit of source sharpness (1 is sharp) +SHARP_HI = {SHARP_HI} + +// Limit of source roundness1 (|roundness|>>0 is less round) +ROUND1_HI = {ROUND1_HI} + +// Limit of source roundness2 (|roundness|>>0 is less round) +ROUND2_HI = {ROUND2_HI} + +// Lower limit on source smoothness (0 is not smooth) +SMOOTH_LO = {SMOOTH_LO} + +// Upper limit on source smoothness (1 is smooth) +SMOOTH_HI = {SMOOTH_HI} + +// Radius (pix) of ricker wavelet +RICKER_R = {RICKER_R} + +## APERTURE PHOTOMETRY +// Radius in number of pixels +APPHOT_R = {APPHOT_R} + +// Fraction encircled energy (mutually exclusive with APPHOT_R) +ENCENERGY = {ENCENERGY} + +// Sky annulus inner radius +SKY_RIN = {SKY_RIN} + +// Sky annulus outer radius +SKY_ROUT = {SKY_ROUT} + +// Aperture correction file. See full manual for details +APCORR_FILE = {APCORR_FILE} + +## BACKGROUND ESTIMATION +// Aperture masking fixed radius (if zero, starbug will scale radii) +BGD_R = {BGD_R} + +// Aperture mask radius profile scaling factor +PROF_SCALE = {PROF_SCALE} + +// Aperture mask radius profile slope +PROF_SLOPE = {PROF_SLOPE} + +// Background estimation kernel size (pix) +BOX_SIZE = {BOX_SIZE} + +// Output region file to check the aperture mask radii +BGD_CHECKFILE = {BGD_CHECKFILE} + +## PHOTOMETRY +// Detection file to use instead of detecting +AP_FILE = {AP_FILE} + +// Background estimation file +BGD_FILE = {BGD_FILE} + +// Non default PSF file +PSF_FILE = {PSF_FILE} + +// When loading an AP_FILE, do you want to use WCS or xy values (if available) +USE_WCS = {USE_WCS} + +// Zero point (mag) to add to the magnitude columns +ZP_MAG = {ZP_MAG} + +// Minimum distance for grouping (pixels) between two sources +CRIT_SEP = {CRIT_SEP} + +// Force centroid position (1) or allow psf fitting to fit position too (0) +FORCE_POS = {FORCE_POS} + +// Maximum deviation from initial guess centroid position +MAX_XYDEV = {MAX_XYDEV} + +// Set fit size of psf (>0) or -1 to take PSF file dimensions +PSF_SIZE = {PSF_SIZE} + +// Generate a residual image +GEN_RESIDUAL = {GEN_RESIDUAL} + +## SOURCE STATS +// Run crowding metric calculation (execution time scales N^2) +CALC_CROWD = {CALC_CROWD} + +## CATALOGUE MATCHING +// Matching separation threshold in units arcsec +MATCH_THRESH = {MATCH_THRESH} + +// EXTRA columns to include in output matched table i.e sharpness +MATCH_COLS = {MATCH_COLS} + +// Keep sources that appear in NUM >= NEXP_THRESH (if -1 keep everything) +NEXP_THRESH = {NEXP_THRESH} + +// Bridge --band matching NIRCam and MIRI catalogues by ensuring NIRCam +// catalogue has a match in BRIDGE_COL +BRIDGE_COL = {BRIDGE_COL} + +## ARTIFICIAL STAR TESTS +// Number of artificial star tests +NTESTS = {NTESTS} + +// Number of stars per artificial test +NSTARS = {NSTARS} + +// Number of pixels to crop around artificial star +SUBIMAGE = {SUBIMAGE} + +// Bright limit of test magnitude +MAX_MAG = {MAX_MAG} + +// Faint limit of test magnitude +MIN_MAG = {MIN_MAG} + +// Output AST result as image with this filename +PLOTAST = {PLOTAST} + +## MISC EXTRAS +// DS9 region colour +REGION_COL = {REGION_COL} + +// Scale region to flux if possible +REGION_SCAL = {REGION_SCAL} + +// Region radius default +REGION_RAD = {REGION_RAD} + +// X column name to use for region +REGION_XCOL = {REGION_XCOL} + +// Y column name to use for region +REGION_YCOL = {REGION_YCOL} + +// If X/Y column names correspond to WCS values +REGION_WCS = {REGION_WCS} + +// detector name used within psf generation +DET_NAME = {DET_NAME} + +// region table file name for generating regions +REGION_TAB = {REGION_TAB} + +## ADDITIONAL UNSETTABLE / DERIVED PARAMETERS +// Custom analytical parameter grouping identifier string +PARAM_TAG = {PARAM} + +## PIPELINE SWITCHES (BYPASS COMMAND LINE FLAGS) +// Run aperture photometry stage (0:false 1:true) +RUN_APPHOT = {RUN_APPHOT} + +// Run background estimation stage (0:false 1:true) +RUN_BGD_EST = {RUN_BGD_EST} + +// Run star detection stage (0:false 1:true) +RUN_DETECT = {RUN_DETECT} + +// Run source geometry analysis stage (0:false 1:true) +RUN_GEOM = {RUN_GEOM} + +// Run catalogue matching stage (0:false 1:true) +RUN_MATCH = {RUN_MATCH} + +// Run PSF photometry routine (0:false 1:true) +RUN_PSFPHOT = {RUN_PSFPHOT} + +// Run background subtraction stage (0:false 1:true) +RUN_BGDSUB = {RUN_BGDSUB} + +## SYSTEM & EXECUTION CONTROL +// Number of processor cores to use for calculation +NCORES = {NCORES} + +// Find files automatically (0:false 1:true) +FIND_FILE = {FIND_FILE} + +## EXTRA RUN GENERATION COMMAND SWITCHES +// Execute JWST data initialization steps (0:false 1:true) +INIT_JWST = {INIT_JWST} + +// Trigger PSF generation logic (0:false 1:true) +GEN_PSF = {GEN_PSF} + +// Trigger automation run generation scripts (0:false 1:true) +GEN_RUN = {GEN_RUN} + +// Filename target string to output generated region file +GEN_REGION = {GEN_REGION} + +## ADVANCED ARTIFICIAL STAR TEST CONTROLS +// Recover previous artificial star test state (0:false 1:true) +AST_RECOVER = {AST_RECOVER} + +// Save frequency of progress during artificial star tests +AST_AUTOSAVE = {AST_AUTOSAVE} + +// Disable background logic during artificial star tests (0:false 1:true) +AST_NO_BGD = {AST_NO_BGD} + +// Disable PSF photometry during artificial star tests (0:false 1:true) +AST_NO_PSF = {AST_NO_PSF} + +## CATALOGUE MATCHING MODE SWITCHES +// Process matched catalogue across multiple bands (0:false 1:true) +MATCH_BAND = {MATCH_BAND} + +// Run matching in cascade execution sequence (0:false 1:true) +MATCH_CASCADE = {MATCH_CASCADE} + +// Use dither offsets during matching calculations (0:false 1:true) +MATCH_DITHER = {MATCH_DITHER} + +// Require exact row criteria matches across tables (0:false 1:true) +MATCH_EXACT = {MATCH_EXACT} + +// Force a full evaluation run across matching pipelines (0:false 1:true) +MATCH_FULL = {MATCH_FULL} + +// Use generic operating specifications for matching (0:false 1:true) +MATCH_GENERIC = {MATCH_GENERIC} + +// Error column label name target string for evaluation +MATCH_ERR_COL = {MATCH_ERR_COL} + +// Expression filter string for processing match masks +MATCH_MASK_EVAL = {MATCH_MASK_EVAL} + +## DIAGNOSTIC PLOTTING MODULE SWITCHES +// Enable interactive test mode plotting panels (0:false 1:true) +PLOT_TEST = {PLOT_TEST} + +// Dark frame visualization profile layout (0:false 1:true) +PLOT_DARK = {PLOT_DARK} + +// Visual parameter validation target inspection parameter key +PLOT_INSPECT = {PLOT_INSPECT} + +// Target design stylesheet pattern configuration profile name +PLOT_STYLE = {PLOT_STYLE} +""" diff --git a/starbug2/extras/.default.param b/starbug2/extras/.default.param index ceb46e1..254e972 100644 --- a/starbug2/extras/.default.param +++ b/starbug2/extras/.default.param @@ -42,7 +42,6 @@ ZP_MAG = 8.9 //Zero point (mag) to add to the magnitude columns CRIT_SEP = 8 //minimum distance for grouping (pixels) between two sources FORCE_POS = 0 //Force centroid position (1) or allow psf fitting to fit position too (0) -DPOS_THRESH = -1 //If allowed to fit position, max separation (arcsec) from source list centroid MAX_XYDEV = 3p //. PSF_SIZE = -1 //Set fit size of psf (>0) or -1 to take PSF file dimensions GEN_RESIDUAL= 0 //generate a residual image diff --git a/starbug2/extras/logo.txt b/starbug2/extras/logo.txt new file mode 100644 index 0000000..2e7517a --- /dev/null +++ b/starbug2/extras/logo.txt @@ -0,0 +1,8 @@ + * * __ * __ - * -- - + STARBUGII * / ___ / \ -- - - + --------- *___---. .___/ - -- - + JWST photometry in ./=== \ \. \ * + complex crowded fields | (O) | | | * + \._._/ ./ _(\) * + conor.nally@ed.ac.uk / ~--\ ----~ \ * + alan.stokes@stfc.ac.uk --- ___ --- diff --git a/starbug2/filters.py b/starbug2/filters.py new file mode 100644 index 0000000..fa93c6d --- /dev/null +++ b/starbug2/filters.py @@ -0,0 +1,105 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +from typing import Final, Dict +from starbug2.constants import NIRCAM, DetectorLengths, STAR_BUG_MIRI + + +# (struct) containing JWST filter info +class FilterStruct: + # noinspection SpellCheckingInspection, PyPep8Naming + def __init__( + self, full_width_half_max: float, instr: int, length: int, + nlambda: int | None = None): + """ + + :param full_width_half_max: + :param instr: + :param length: + :param nlambda: How many wavelengths to model for broadband? + The default depends on how wide the filter is: (5,3,1) for + types (W,M,N) respectively in webb.stpsf, and its default is 40 + """ + self._full_width_half_max: float = full_width_half_max + self._instr: int = instr + self._length: int = length + self._nlambda: int | None = nlambda + + @property + def full_width_half_max(self) -> float: + return self._full_width_half_max + + @property + def instr(self) -> int: + return self._instr + + @property + def length(self) -> int: + return self._length + + # noinspection SpellCheckingInspection, PyPep8Naming + @property + def nlambda(self) -> int | None: + return self._nlambda + + +# as of 08/06/2023 +STAR_BUG_FILTERS: Final[Dict[str, FilterStruct]] = { + "F070W": FilterStruct(0.742, NIRCAM, DetectorLengths.SHORT), + "F090W": FilterStruct(0.968, NIRCAM, DetectorLengths.SHORT), + "F115W": FilterStruct(1.194, NIRCAM, DetectorLengths.SHORT), + "F140M": FilterStruct(1.484, NIRCAM, DetectorLengths.SHORT), + "F150W": FilterStruct(1.581, NIRCAM, DetectorLengths.SHORT), + "F162M": FilterStruct(1.710, NIRCAM, DetectorLengths.SHORT), + "F164N": FilterStruct(1.742, NIRCAM, DetectorLengths.SHORT), + # NOTE: MAJOR CHANGE + # the 20 here is to resolve an issue inside stpsf calc_psf for F150W2 as + # the wave max value being 2.35 * 1e-6 and the wave lengths when + # partitioned at 40 results in a final wave length of + # 2.3626144323647297e-06 and fails validation. By making nlambda 20, ive + # added more entries into each bucket, which I think just reduces the + # samples, but keeps the structure of the PSF the same + "F150W2": FilterStruct(1.452, NIRCAM, DetectorLengths.SHORT, 20), + "F182M": FilterStruct(1.935, NIRCAM, DetectorLengths.SHORT), + "F187N": FilterStruct(1.968, NIRCAM, DetectorLengths.SHORT), + "F200W": FilterStruct(2.065, NIRCAM, DetectorLengths.SHORT), + "F210M": FilterStruct(2.194, NIRCAM, DetectorLengths.SHORT), + "F212N": FilterStruct(2.226, NIRCAM, DetectorLengths.SHORT), + "F250M": FilterStruct(1.302, NIRCAM, DetectorLengths.LONG), + "F277W": FilterStruct(1.397, NIRCAM, DetectorLengths.LONG), + "F300M": FilterStruct(1.540, NIRCAM, DetectorLengths.LONG), + "F322W2": FilterStruct(1.524, NIRCAM, DetectorLengths.LONG), + "F323N": FilterStruct(1.683, NIRCAM, DetectorLengths.LONG), + "F335M": FilterStruct(1.730, NIRCAM, DetectorLengths.LONG), + "F356W": FilterStruct(1.810, NIRCAM, DetectorLengths.LONG), + "F360M": FilterStruct(1.873, NIRCAM, DetectorLengths.LONG), + "F405N": FilterStruct(2.095, NIRCAM, DetectorLengths.LONG), + "F410M": FilterStruct(2.111, NIRCAM, DetectorLengths.LONG), + "F430M": FilterStruct(2.206, NIRCAM, DetectorLengths.LONG), + "F444W": FilterStruct(2.222, NIRCAM, DetectorLengths.LONG), + "F460M": FilterStruct(2.397, NIRCAM, DetectorLengths.LONG), + "F466N": FilterStruct(2.413, NIRCAM, DetectorLengths.LONG), + "F470N": FilterStruct(2.444, NIRCAM, DetectorLengths.LONG), + "F480M": FilterStruct(2.492, NIRCAM, DetectorLengths.LONG), + "F560W": FilterStruct(1.882, STAR_BUG_MIRI, DetectorLengths.NULL), + "F770W": FilterStruct(2.445, STAR_BUG_MIRI, DetectorLengths.NULL), + "F1000W": FilterStruct(2.982, STAR_BUG_MIRI, DetectorLengths.NULL), + "F1130W": FilterStruct(3.409, STAR_BUG_MIRI, DetectorLengths.NULL), + "F1280W": FilterStruct(3.818, STAR_BUG_MIRI, DetectorLengths.NULL), + "F1500W": FilterStruct(4.436, STAR_BUG_MIRI, DetectorLengths.NULL), + "F1800W": FilterStruct(5.373, STAR_BUG_MIRI, DetectorLengths.NULL), + "F2100W": FilterStruct(6.127, STAR_BUG_MIRI, DetectorLengths.NULL), + "F2550W": FilterStruct(7.300, STAR_BUG_MIRI, DetectorLengths.NULL), +} diff --git a/starbug2/initialise_psf_data.py b/starbug2/initialise_psf_data.py new file mode 100644 index 0000000..db6edf0 --- /dev/null +++ b/starbug2/initialise_psf_data.py @@ -0,0 +1,250 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import os +from typing import List, Optional, Any, Final + +from starbug2.constants import ( + JWST_MIRI_APCORR_0010_FITS_URL, JWST_NIRCAM_APCORR_0004_FITS_URL, + JWST_MIRI_ABVEGA_OFFSET_URL, JWST_NIRCAM_ABVEGA_OFFSET_URL, NIRCAM, + WEBBPSF_PATH_ENV_VAR, DetectorLengths, STARBUG_DATA_DIR) +from starbug2.constants import STAR_BUG_MIRI +from starbug2.filters import STAR_BUG_FILTERS, FilterStruct +from astropy.io import fits + +from starbug2.star_bug_config import StarBugMainConfig +from starbug2.starbug import StarbugBase +from starbug2.utils import printf, wget, puts, Loading, p_error +import stpsf + +# noinspection SpellCheckingInspection +# the detector labels for nircam short length detectors +NIRCAM_SHORT_DETECTORS: Final[list[str]] = [ + "NRCA1", "NRCA2", "NRCA3", "NRCA4", "NRCB1", "NRCB2", + "NRCB3", "NRCB4"] + +# noinspection SpellCheckingInspection +# the detector labels for nircam long length detectors. +NIRCAM_LONG_DETECTORS: Final[list[str]] = ["NRCA5", "NRCB5"] + + +# One time run functions +def init_starbug_for_jwst(config: StarBugMainConfig) -> None: + """ + Initialise Starbug for jwst. + - generate PSFs + - download crds files + :param config: the main config + :type config: StarBugMainConfig + :return: None + """ + printf("Initialising StarbugII\n") + + data_name: str = StarbugBase.get_data_path() + + # noinspection SpellCheckingInspection + printf("-> using %s=%s\n" % ( + STARBUG_DATA_DIR if os.getenv(STARBUG_DATA_DIR) else "DEFAULT_DIR", + data_name)) + _generate_psfs(config) + download_ap_corr_files(data_name) + + +def download_ap_corr_files(data_name: str) -> None: + """ + downloads the app files + :param data_name: the data path. + :return: None + """ + _miri_ap_corr: str = JWST_MIRI_APCORR_0010_FITS_URL + + # noinspection SpellCheckingInspection + _nircam_ap_corr: str = JWST_NIRCAM_APCORR_0004_FITS_URL + + # noinspection SpellCheckingInspection + printf("Downloading APPCORR CRDS files. NB: " + "\x1b[1mTHESE MAY NOT BE THE LATEST!\x1b[0m\n") + printf("-> %s\n" % _miri_ap_corr) + printf("-> %s\n" % _nircam_ap_corr) + + # noinspection SpellCheckingInspection + wget(_miri_ap_corr, "%s/apcorr_miri.fits" % data_name) + + # noinspection SpellCheckingInspection + wget(_nircam_ap_corr, "%s/apcorr_nircam.fits" % data_name) + + # noinspection SpellCheckingInspection + printf("Downloading ABVEGA offsets.\n") + + # noinspection SpellCheckingInspection + wget(JWST_MIRI_ABVEGA_OFFSET_URL, + "%s/abvegaoffset_miri.asdf" % data_name) + + # noinspection SpellCheckingInspection + wget(JWST_NIRCAM_ABVEGA_OFFSET_URL, + "%s/abvegaoffset_nircam.asdf" % data_name) + puts("Downloading The Junior Colour Encyclopedia of Space\n") + + +# noinspection SpellCheckingInspection +def _generate_psfs(config: StarBugMainConfig) -> None: + """ + Generate the psf files inside a given directory + + utilises the star bug data patj to generate the directory to generate info + :return: + """ + d_name: str = StarbugBase.get_data_path() + if os.getenv(WEBBPSF_PATH_ENV_VAR): + d_name = os.path.expandvars(d_name) + if not os.path.exists(d_name): + os.makedirs(d_name) + + printf("Generating PSFs --> %s\n" % d_name) + + load: Loading = Loading(145, msg="initialising") + load.show() + + # type hitns + filter_string: str + filter_data: FilterStruct + filter_string: str | None = config.custom_filter + if filter_string is None: + filter_string: str + for filter_string, filter_data in STAR_BUG_FILTERS.items(): + _generate_psf_single(filter_string, filter_data, load, d_name) + else: + if config.custom_filter in STAR_BUG_FILTERS.keys(): + filter_data: FilterStruct | None = ( + STAR_BUG_FILTERS.get(filter_string)) + assert filter_data is not None + _generate_psf_single( + filter_string, filter_data, load, d_name) + else: + p_error( + f"WARNING: Cannot generate PSFs, filter data does not " + f"exist for {filter_string}. Please select a valid Filter") + else: + p_error( + "WARNING: Cannot generate PSFs, no environment variable " + "'WEBBPSF_PATH', please see " + "https://webbpsf.readthedocs.io/en/latest/installation.html\n") + + +def _generate_psf_single( + filter_string: str, filter_data: FilterStruct, load: Loading, + d_name: str) -> None: + if filter_data.instr == NIRCAM: + if filter_data.length == DetectorLengths.SHORT: + detectors: List[Optional[str]] = NIRCAM_SHORT_DETECTORS + else: + detectors = NIRCAM_LONG_DETECTORS + else: + detectors = [None] + + det: str + for det in detectors: + load.msg = "%6s %5s" % (filter_string, det) + load.show() + psf: fits.PrimaryHDU | None = generate_psf( + filter_string, det, None) + if psf: + psf.writeto( + "%s/%s%s.fits" % ( + d_name, filter_string, "" if det is None else det), + overwrite=True) + load() + load.show() + + +# noinspection SpellCheckingInspection +def generate_psf( + filter_string: str, + detector: Optional[str] = None, + fov_pixels: Optional[int] = None) -> fits.PrimaryHDU | None: + # noinspection SpellCheckingInspection + """ + Generate a single PSF for JWST + + :param filter_string: the filter string from the default set of filters ( + e.g. F444W) + :type filter_string: str + :param detector: Instrument detector module e.g. NRCA1 + :type detector: str + :param fov_pixels: size of PSF + :type fov_pixels: int + :return: the generated psfs + :rtype fits.PrimaryHDU + """ + + # define types + psf: Optional[fits.PrimaryHDU] = None + model: Optional[stpsf.JWInstrument] = None + + # ensure fov pixels is greater than 0 + if fov_pixels is not None and fov_pixels <= 0: + fov_pixels = None + + if filter_string in list(STAR_BUG_FILTERS.keys()): + the_filter = STAR_BUG_FILTERS.get(filter_string) + assert the_filter is not None + if detector is None: + if (the_filter.instr == NIRCAM + and the_filter.length == DetectorLengths.SHORT): + detector = "NRCA1" + elif (the_filter.instr == NIRCAM + and the_filter.length == DetectorLengths.LONG): + detector = "NRCA5" + elif the_filter.instr == STAR_BUG_MIRI: + detector = "MIRIM" + else: + detector = "MIRIM" + + # need to use getattr as these are not found by the IDE automatically. + if the_filter.instr == NIRCAM: + model = getattr(stpsf, "NIRCam")() + elif the_filter.instr == STAR_BUG_MIRI: + model = getattr(stpsf, "MIRI")() + + if model: + model.filter = filter_string + if detector: + model.detector = detector + try: + # this actually works, as lower stream code will check if + # fox_pixels is set to None and utilise sensible defaults. + # so basically bad docing in dependency causes this issue. + # noinspection PyTypeChecker + image_hdu: fits.ImageHDU | Any = ( + model.calc_psf( + fov_pixels=fov_pixels, + nlambda=the_filter.nlambda)["DET_SAMP"]) + psf: fits.PrimaryHDU = ( + fits.PrimaryHDU( + data=image_hdu.data, header=image_hdu.header)) + except (KeyError, AttributeError, ValueError) as e: + p_error("\x1b[2KSomething went wrong with: %s %s with " + "error %s\n" % (filter_string, detector, str(e))) + except RuntimeError as e: + import traceback + traceback.format_exc() + p_error(f"during detector on {the_filter.instr} with wave" + f" length {filter_string}. something failed. {str(e)}") + else: + p_error( + "Unable to determine instrument from filter_string '%s'\n" % + filter_string) + else: + p_error("Unable to locate '%s' in JWST filter list\n" % filter_string) + return psf diff --git a/starbug2/mask.py b/starbug2/mask.py index a920c64..7d5548e 100644 --- a/starbug2/mask.py +++ b/starbug2/mask.py @@ -1,67 +1,121 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +from __future__ import annotations +from typing import List, Optional, Tuple import getopt import numpy as np from matplotlib.path import Path from matplotlib.patches import Polygon from astropy.table import Table + from starbug2.utils import tab2array, colour_index, fill_nan + class Mask(object): - colour='k' - def __init__(self, bounds, keys, label=None, **kwargs): - self.path=Path(bounds) - if len(keys)==2: - self.keys=keys - else: raise Exception - self.label=label - - if "colour" in kwargs: self.colour=kwargs.get("colour") - - - def apply(self, dat): - d=fill_nan(colour_index(dat,self.keys)) - return self.path.contains_points(tab2array(d)) - + @staticmethod - def from_file(fname): - with open(fname) as fp: + def from_file(f_name: str) -> Mask: + """ + makes a mask object from a file. The file must have only 1 line + in it, which must match the format defined in Mask.from_string. + + :param f_name: the file name for the mask string. + :return: A Mask instance. + :rtype: Mask + """ + with open(f_name) as fp: return Mask.from_string(fp.readline()) + @staticmethod - def from_string(string): + def from_string(string: str) -> Mask: + # noinspection SpellCheckingInspection """ - String Format: - [-x XCOL] [-y YCOL] [-l Label] : x1 y1 x2 y2 x3 y3 ... + method to create a mask object from a string. the string should be + in the following format: + + [-x XCOL] [-y YCOL] [-l Label] : x1 y1 x2 y2 x3 y3 ... + + :param string: the string to create the mask of. + :return: the constructed mask. """ - label=None - keys=[None,None] - colour='k' - _opts,_coords=string.split(':') - opts,args=getopt.getopt(_opts.split(' '), "c:l:x:y:") - for opt,optarg in opts: - if opt=="-x": keys[0]=optarg - if opt=="-y": keys[1]=optarg - if opt=="-l": label=optarg.replace('_',' ') - if opt=="-c": colour=optarg - coords=_coords.strip().rstrip().split(' ') - points=np.array(coords, dtype=float).reshape((int(len(coords)/2),2)) - return Mask(points,keys,label=label, colour=colour) - - def plot(self, ax, **kwargs): - patch=Polygon(self.path._vertices, label=self.label.replace('_',' ') if self.label else None, fill=False, edgecolor=self.colour, **kwargs) - ax.add_patch(patch) - - - - -if __name__=="__main__": - s="-yF115W -xF115W-F200W -lTestCut 0 20 1 21 1 24 0 24" - t=Table.read("/home/conor/sci/proj/ngc6822/paper1/dat/ngc6822.fits",format="fits").filled(np.nan) - m=Mask.from_string(s) - mask=m.apply(t) - import matplotlib.pyplot as plt - tt=colour_index(t,("F115W-F200W","F115W")) - plt.scatter(tt["F115W-F200W"], tt["F115W"], c='k', lw=0, s=1) - #plt.scatter(tt["F115W-F200W"][mask], tt["F115W"][mask], c='r', lw=0, s=1, label=m.label) - m.plot( plt.gca(), fill=False, edgecolor="blue", label="test") - plt.legend() - plt.show() + label: Optional[str] = None + keys: List[Optional[str]] = [None, None] + colour: str = 'k' + + # type definitions + opts_str: str + coords: str + opts: List[Tuple[str, str]] + args: List[str] + opts_str, coords = string.split(':') + opts, args = getopt.getopt(opts_str.split(' '), "c:l:x:y:") + for opt, opt_arg in opts: + if opt == "-x": + keys[0] = opt_arg + if opt == "-y": + keys[1] = opt_arg + if opt == "-l": + label = opt_arg.replace('_', ' ') + if opt == "-c": + colour = opt_arg + strip_coords: List[str] = coords.strip().rstrip().split(' ') + points: np.ndarray = ( + np.array(strip_coords, dtype=float).reshape( + (int(len(strip_coords) / 2), 2))) + return Mask(points, keys, label=label, colour=colour) + + def __init__(self, bounds, keys, label=None, colour="k") -> None: + """ + mask constructor + + :param bounds: The path vertices, as an array, masked array or + sequence of pairs. + :param keys: iterable array with 2 elements. + :param label: the mask label + :param colour: the colour of the mask. + """ + + self._path: Path = Path(bounds) + if len(keys) == 2: + self._keys: List[str] = keys + else: + raise Exception + self._label: Optional[str] = label + + self._colour: str = colour + + def apply(self, data_table) -> np.ndarray: + """ + applies a data table based off the masks keys. + :param data_table: the table to apply the mask on. + :return: length-N bool array + :rtype: ndarray + """ + d: Table = fill_nan(colour_index(data_table, self._keys)) + return self._path.contains_points(tab2array(d)) + + def plot(self, plot_axis, **kwargs) -> None: + """ + plots a polygon onto the axis. + :param plot_axis: the axis to plot the polygon onto. + :param kwargs: arbitrary polygon parameters. + :return: None + """ + patch: Polygon = Polygon( + self._path.vertices, + label=self._label.replace('_', ' ') if self._label else None, + fill=False, edgecolor=self._colour, **kwargs) + plot_axis.add_patch(patch) diff --git a/starbug2/matching.py b/starbug2/matching.py deleted file mode 100644 index 22a1a7a..0000000 --- a/starbug2/matching.py +++ /dev/null @@ -1,967 +0,0 @@ -""" -Starbug matching functions -Primarily this is the main routines for dither/band/generic matching which are at the core -of starbug2 and starbug2-match - -""" -import os -import numpy as np -import astropy.io.fits as fits -import astropy.units as u -from astropy.coordinates import SkyCoord -from astropy.table import Column, Table, hstack, vstack - -import starbug2 -from starbug2.utils import * -from starbug2.param import load_params - -class GenericMatch(object): - """ - Base matching class - - Parameters - ---------- - threshold : float - Separation threshold in arcseconds - - colnames : list - List of str column names to include in the matching. - Everything else will be discarded - - fltr : str - Specifically set the filter of the catalogues - - verbose : int - Include verbose outputs - - pfile : str - Parameter filename - - """ - method="Generic Matching" - def __init__(self, threshold=None, colnames=None, fltr=None, verbose=None, pfile=None): - options=load_params(pfile) - self.threshold =options.get("MATCH_THRESH") - self.filter =options.get("FILTER") - self.verbose =options.get("VERBOSE") - - if threshold is not None: self.threshold=threshold - self.threshold *= u.arcsec - - if fltr is not None: self.filter=fltr - if verbose is not None: self.verbose=verbose - - self.colnames=colnames - self.load=loading(1) - - def log(self,msg): - if self.verbose: printf(msg) - - def __str__(self): - s=[ "%s:"%self.method, - "Filter: %s"%self.filter, - "Colnames: %s"%self.colnames, - "Threshold: %s\""%self.threshold] - return "\n".join(s) - - def __call__(self, *args, **kwargs): - return self.match(*args, **kwargs) - - def init_catalogues(self, catalogues): - """ - This function is a bit of a "do everything" function - - It takes the input catalogues and removes any columns that arent included in - the self.colnames list. If this is None, then all columns are kept. - Additionally it initialises the loading bar with the summed length of all the - input catalogues. - Finally it attempts to set the photometric filter that is being used - - Parameters - ---------- - catalogues : list (astropy.Tables) - The input catalouges to work on - - Returns - ------- - out : list (astropy.Table) - The cleaned list of input catalogues - """ - ## Must copy here maybe? - if len(catalogues)>=2: - self.load=loading( sum( len(cat) for cat in catalogues[1:]), msg="initialising") - if self.verbose: self.load.show() - - if self.colnames is None: # initialise the column names if it wasnt already set - self.colnames=[] - for cat in catalogues: - self.colnames+=cat.colnames - self.colnames = rmduplicates(self.colnames) - #if "Catalogue_Number" in self.colnames: self.colnames.remove("Catalogue_Number") - - # clean out the column names not included in self.colnames - for n,catalogue in enumerate(catalogues): - keep=set(catalogue.colnames)&set(self.colnames) - keep=sorted( keep, key= lambda s:self.colnames.index(s)) - catalogues[n]=catalogue[keep] - #self.colnames=keep # This maybe wants to go somewhere else but it ensures that colnames doesnt contain anything not in any tables - - # Attempt to get a value for filter if not already set - if not self.filter: - if (fltr:=catalogues[0].meta.get("FILTER")) is None: - fltr="MAG" - self.filter=fltr - - return catalogues - - def mask_catalogues(self, catalogues, mask): - """ - """ - masked=Table(None) - - if mask is None or type(mask) not in (list,np.ndarray): return masked - if len(catalogues)!=len(catalogues): return masked - - for subset,cat in zip(mask,catalogues): - if subset is not None: - if type(subset)==list: - subset=np.array(subset) - if len(subset)==len(cat): - masked=vstack( (masked,cat[~subset]) ) - cat.remove_rows(~subset) - return masked - - - - def match(self, catalogues, join_type="or", mask=None, cartesian=False, **kwargs): - """ - This matching works as a basic match. Everything is included and the column - names have _N appended to the end. - - Parameters - ---------- - join_type : str - Joing method - "or" include sources in any catalogue - "and" only include sources in all catalogues - - mask : list - in prep. - - Returns - ------- - base : astropy.Table - Matched catalogue. - """ - catalogues=self.init_catalogues(catalogues) - if "Catalogue_Number" in self.colnames: self.colnames.remove("Catalogue_Number") - masked= self.mask_catalogues(catalogues, mask) - base=self.build_meta(catalogues) - - if join_type=="and": perror("join_type 'and' not fully implemented\n") - - for n,cat in enumerate(catalogues,1): # Bulk matching processes (column naming) - self.load.msg="matching: %d"%n - tmp=self._match(base,cat, join_type=join_type, cartesian=cartesian) - tmp.rename_columns( tmp.colnames, ["%s_%d"%(name,n) for name in tmp.colnames] ) - base=fill_nan(hstack((base,tmp))) - - if len(masked): # Add in any masked bits - masked.rename_columns( masked.colnames, ["%s_0"%n for n in masked.colnames]) - base=fill_nan(vstack(( base,masked))) - return base - - def _match(self, base, cat, join_type="or", cartesian=False): - """ - Base matching function between two catalogues - - Parameters - ---------- - cat1 and cat2 : `astropy.table.Table` - two astropy tables containing columns with "RA/DEC" in the column names. - This could be RA_1, RA_2 .. - If several columns are located, they will be nanmeaned together - - Returns - ------- - idx,d2d,d3d : - the same as SkyCoord.match_to_catalog_3d - """ - if not len(base): return cat.copy() - - base=fill_nan(base.copy()) - colnames=[n for n in self.colnames if n in cat.colnames] - cat=fill_nan(cat[colnames].copy()) - - if not cartesian: - _ra_cols= list( name for name in base.colnames if "RA" in name) - _dec_cols= list( name for name in base.colnames if "DEC" in name) - _ra= np.nanmean( tab2array( base, colnames=_ra_cols), axis=1) - _dec=np.nanmean( tab2array( base, colnames=_dec_cols), axis=1) - skycoord1=SkyCoord( ra=_ra*u.deg, dec=_dec*u.deg) - - _ra_cols= list( name for name in cat.colnames if "RA" in name) - _dec_cols= list( name for name in cat.colnames if "DEC" in name) - _ra= np.nanmean( tab2array( cat, colnames=_ra_cols), axis=1) - _dec=np.nanmean( tab2array( cat, colnames=_dec_cols), axis=1) - skycoord2=SkyCoord( ra=_ra*u.deg, dec=_dec*u.deg) - else: - _x_cols= list( name for name in base.colnames if name[0]=="x") - _y_cols= list( name for name in base.colnames if name[0]=="y") - _x= np.nanmean( tab2array( base, colnames=_x_cols), axis=1) - _y=np.nanmean( tab2array( base, colnames=_y_cols), axis=1) - skycoord1=SkyCoord( x=_x, y=_y, z=np.zeros(len(_x)), representation_type="cartesian") - - _x_cols= list( name for name in cat.colnames if name[0]=="x") - _y_cols= list( name for name in cat.colnames if name[0]=="y") - _x= np.nanmean( tab2array( cat, colnames=_x_cols), axis=1) - _y=np.nanmean( tab2array( cat, colnames=_y_cols), axis=1) - skycoord2=SkyCoord( x=_x, y=_y, z=np.zeros(len(_x)), representation_type="cartesian") - - - ####################### - # The actual Matching # - ####################### - idx,d2d,d3d=skycoord2.match_to_catalog_3d(skycoord1) - tmp=Table(np.full( (len(base),len(colnames)),np.nan), names=colnames, dtype=cat[colnames].dtype) - - if cartesian: - dist=d3d - threshold=self.threshold.value - else: - dist=d2d - threshold=self.threshold - - for src,IDX,sep in zip(cat, idx, dist): - self.load() - if self.verbose: self.load.show() - - if (sep<=threshold) and (sep==min(dist[idx==IDX])): ##GOODMATCH - tmp[IDX]=src - elif join_type=="or": ## Append a source - tmp.add_row(src) - - return tmp - - - def finish_matching(self, tab, error_column="eflux", num_thresh=-1, discard_outliers=False, zpmag=0, colnames=None): - """ - Averaging all the values. Combining source flags and building a NUM column - - Parameters - ---------- - tab : `astropy.table.Table` - Table to work on - - error_column : str - Column containing resultant photometric errors to be used to calculate the magnitude error - "eflux" - use the eflux column (the normal photometric error column) - "stdflux" - use "stdflux" column as error on flux - - num_thresh : int - Minimum number of matches a source must have. - NUM values smaller than this will be removed from the table. - If num_thresh<=0, no cropping will happen - - discard_outliers : bool - Choose whether to remove outling values from the averaging. - This may be usful if some flux values are wildly different from others in the set - - zpmag : float - Zero point (Magnitude) to be applied to the magnitude after it is calculated - - colnames : list - List of colnames to include in the averaging. If None, use self.colnames instead - - Returns - ------- - av : astropy.Table - An averaged version of the input table - """ - flags=np.full(len(tab),starbug2.SRC_GOOD, dtype=np.uint16) - av=Table(None)#np.full((len(tab),len(self.colnames)),np.nan), names=self.colnames) - - if colnames is None: colnames=self.colnames - for ii,name in enumerate(colnames): - #print(name,av.colnames) - if (all_cols:=find_colnames(tab,name)): - col=Column(None, name=name) - ar=tab2array(tab, colnames=all_cols) - if ar.shape[1]>1: - if name=="flux": - col=Column(np.nanmedian(ar,axis=1), name=name) - mean=np.nanmean(ar,axis=1) - if "stdflux" not in self.colnames: - av.add_column(Column(np.nanstd(ar,axis=1),name="stdflux"),index=ii+1) - ## if median and mean are >5% different, flag as SRC_VAR - flags[ np.abs(mean-col)>(col/5.0)] |= starbug2.SRC_VAR - elif name== "eflux": - col=Column(np.sqrt(np.nansum(ar*ar, axis=1)), name=name) - elif name=="stdflux": - col=Column(np.nanmedian(ar,axis=1),name=name) - elif name=="flag": - col=Column(flags, name=name) - for fcol in ar.T: flags|=fcol.astype(np.uint16) - elif name=="NUM": - col=Column(np.nansum(ar, axis=1), name=name) - elif name=="Catalogue_Number": - col=Column(all_cols[0],name=name) - else: - col=Column(np.nanmedian(ar, axis=1),name=name) - else: - col=tab[all_cols[0]] - col.name=name - - #av[name]=col - av.add_column(col,index=ii) - #else: av.remove_column(name) ## Clean empty columns in table - - av["flag"]=Column(flags,name="flag") - if "flux" in av.colnames: - ecol=av[error_column] if error_column in av.colnames else None - mag,magerr=flux2mag(av["flux"], fluxerr=ecol) - mag+=zpmag - - if self.filter in av.colnames: av.remove_column(self.filter) - if "e%s"%self.filter in av.colnames: av.remove_column("e%s"%self.filter) - av.add_column(mag,name=self.filter) - av.add_column(magerr,name="e%s"%self.filter) - - if "NUM" not in av.colnames: - narr= np.nansum( np.invert( np.isnan(tab2array(tab,find_colnames(tab,"RA")))),axis=1) - av.add_column(Column(narr, name="NUM")) - - if num_thresh>0: - av.remove_rows( av["NUM"]= list(starbug2.filters.keys()).index("F277W"): separation=0.10 - if f_id >= list(starbug2.filters.keys()).index("F560W"): separation=0.15 - if f_id >= list(starbug2.filters.keys()).index("F1000W"): separation=0.20 - if f_id >= list(starbug2.filters.keys()).index("F1500W"): separation=0.25 - - - for ii,(src,IDX,sep) in enumerate(zip(tab,idx,d2d)): - load.msg="matching:%s(%.2g\")"%(fltr,separation) - load();load.show() - if (sep<=separation*u.arcsec) and (sep==min(d2d[idx==IDX])): - for name in _colnames: tmp[IDX][name]=src[name] - else: - tmp.add_row(src[_colnames]) - #tmp=generic_match((base,tab), threshold=threshold, add_src=True, load=load) - - #base=hstack(( base,tmp[["flux","eflux"]] )) - #mag,magerr=flux2ABmag(tmp["flux"], tmp["eflux"],fltr) - #base.add_column(mag,name=fltr) - #base.add_column(magerr,name="e%s"%fltr) - - #base.rename_column("flux","%s_flux"%fltr) - #base.rename_column("eflux","%s_eflux"%fltr) - - tmp.rename_column("flag","flag_%s"%fltr) - base=hstack(( base,tmp[[fltr,"e%s"%fltr,"flag_%s"%fltr]] ))#.filled(np.nan) - base=Table(base,dtype=[float]*len(base.colnames)).filled(np.nan) - - ### Only keep the most astromectrically correct position - if "RA" not in base.colnames: base=hstack(( tmp[["RA","DEC"]], base)) - else: - _mask=np.logical_and( np.isnan(base["RA"]), tmp["RA"]!=np.nan) - base["RA"][_mask]=tmp["RA"][_mask] - base["DEC"][_mask]=tmp["DEC"][_mask] - - ## Sort out flags - flag=np.zeros(len(base),dtype=np.uint16) - for fcol in find_colnames(base,"flag"): - flag|=base[fcol].value.astype(np.uint16) - base.remove_column(fcol) - base.add_column(flag,name="flag") - - return base.filled(np.nan) - - -class ExactValueMatch(GenericMatch): - """ - Match catalogues based on a single value in one of their columns - The normal use case is matching sources based on their *Catalogue_Number* - value. - - Parameters - ---------- - value : str - Column name to take exact values from - """ - value="Catalogue_Number" - method="Exact Value Matching" - def __init__(self, value="Catalogue_Number", **kwargs): - self.value=value - super().__init__(**kwargs) - - if "colnames" in kwargs: - perror("Colnames not implemented in %s\n"%self.method) - - def __str__(self): - s=[ "%s:"%self.method, - "Value: \"%s\""%self.value, - "Colnames: %s"%self.colnames, - ] - - return "\n".join(s) - - def _match(self,base,cat): - """ - The low level matching function. - - Parameters - ---------- - base : astropy.Table - Table onto which to match *cat* - - cat : astropy.Table - Table to match to *base* - - Returns - ------- - tmp : astropy.Table - A new catalogue, it is a reordered version of *cat*, in the - correct sorting to be hstacked with *base* - """ - - tmp=Table( np.full((len(base),len(cat.colnames)),np.nan), names=cat.colnames, dtype=cat.dtype, masked=True ) - for col in tmp.columns.values(): col.mask|=True - - if not len(base): return vstack([tmp,cat]) - - for src in cat: - if self.verbose: - self.load() - self.load.show() - ii=np.where(base[self.value]==src[self.value])[0] - if len(ii): tmp[ii]=src - else: tmp.add_row(src) - return tmp - - def match(self, catalogues, **kwargs): - """ - Core matching function - - Parameters - ---------- - catalogues : list - List of astropy Tables to match together - - Returns - ------- - base : astropy.Table - Full matched catalogue - """ - catalogues=self.init_catalogues(catalogues) - base=self.build_meta(catalogues) - - if self.value not in self.colnames: - perror("Exact value '%s' not in column names.\n"%self.value) - return None - - for n,cat in enumerate(catalogues,1): - self.load.msg="matching: %d"%n - tmp=self._match(base,cat) - tmp.rename_columns( tmp.colnames, ["%s_%d"%(name,n) for name in tmp.colnames] ) - base=hstack([base,tmp]) - - if n>1: - ii= base[self.value].mask& ~base["%s_%d"%(self.value,n)].mask - base["%s"%self.value][ii]=base["%s_%d"%(self.value,n)][ii] - base.remove_column("%s_%d"%(self.value,n)) - - else: base.rename_column("%s_1"%self.value, self.value) - - return fill_nan(base) - -def sort_exposures(catalogues): - """ - - Given a list of catalogue files, this will return the fitsHDULists as a series of - nested dictionaries sorted by: - > BAND - > OBSERVATION ID - > VISIT ID - > DETECTOR -- These two have been switched - > DITHER (EXPOSURE) -- These two have been switched - """ - out={} - modules=["NRCA1","NRCA2","NRCA3","NRCA4","NRCB1","NRCB2","NRCB3","NRCB4","NRCALONG","NRCBLONG", "UNKNOWN"] - for cat in catalogues: - info=exp_info(cat) - #print(info, cat[0].header["FILENAME"]) - - if info["FILTER"] not in out.keys(): - out[info["FILTER"]]={} - - if info["OBSERVTN"] not in out[info["FILTER"]].keys(): - out[info["FILTER"]][info["OBSERVTN"]]={} - - if info["VISIT"] not in out[info["FILTER"]][info["OBSERVTN"]].keys(): - out[info["FILTER"]][info["OBSERVTN"]][info["VISIT"]]={} - - if info["DETECTOR"] not in out[info["FILTER"]][info["OBSERVTN"]][info["VISIT"]].keys(): - out[info["FILTER"]][info["OBSERVTN"]][info["VISIT"]][info["DETECTOR"]]=[] - - #if info["EXPOSURE"] not in out[info["FILTER"]][info["OBSERVTN"]][info["VISIT"]].keys(): - #out[info["FILTER"]][info["OBSERVTN"]][info["VISIT"]][info["EXPOSURE"]]= np.full(len(modules), None) - - out[info["FILTER"]][info["OBSERVTN"]][info["VISIT"]][info["DETECTOR"]].append(cat) - #index= modules.index(info["DETECTOR"]) if info["DETECTOR"] in modules else -1 - #out[info["FILTER"]][info["OBSERVTN"]][info["VISIT"]][info["EXPOSURE"]][index] = cat - return out - - -def parse_mask(string, table): - """ - Parse an commandline mask string to be passed into a matching routine - Example: --mask=F444W!=nan - - Parameters - ---------- - string : str - Raw mask sting to be parsed - - ? table : `astropy.table.Table` - Table to work on - - Returns - ------- - mask : `np.ndarray` - Boolean mask array to index into a table or array - - """ - mask=None - - for colname in table.colnames: string=string.replace(colname,"table[\"%s\"]"%colname) - #string=string.replace("nan","np.nan") - try: - mask = eval(string) - if not isinstance(mask,np.ndarray): - raise Exception - except NameError as e: - perror("Unable to create mask: %s\n"%repr(e)) - except Exception as e: - perror(repr(e)) - - return mask - - - - -#################################### -# Disgarded functions and what not # -#################################### -# -# -#def dither_match(catalogues, threshold, colnames): -# """ -# This is the match for when you simultaneously detect sources over -# a series of pipeline stage2 dithers and wich to combine it into a single catalogue -# INPUT: catalogues: a list of astropy tables -# threshold: (float) maximum separation between two sources to match -# colnames: names to include in the output catalogue -# RETURNS: a combined catalogue with paired sources appearing on the same line -# """ -# perror("Deprecated Function, use GenericMatch instead\n") -# threshold=threshold*u.arcsec -# colnames= list(name for name in colnames if name in catalogues[0].colnames)#list( set(catalogues[0].colnames) & set(colnames) ) -# base=Table( None)#, names=colnames ) -# -# for n,cat in enumerate(catalogues,1): -# if not len(base): -# tmp=cat[colnames].copy() -# else: -# idx,d2d,_=_match(base,cat) -# tmp=Table(np.full((len(base),len(colnames)),np.nan), names=colnames) -# -# for src,IDX,sep in zip(cat,idx,d2d): -# if (sep<=threshold) and (sep==min(d2d[idx==IDX])): ## GOOD MATCH -# for name in colnames: tmp[IDX][name]=src[name] -# else: ##BAD MATCH / NEW SOURCE -# tmp.add_row( src[colnames] ) -# -# tmp.rename_columns( colnames, list("%s_%d"%(name,n) for name in colnames)) -# base=hstack((base,tmp)) -# base=Table(base,dtype=[float]*len(base.colnames)).filled(np.nan) -# -# return finish_matching(base, colnames) -# -# -# -#def stage_match(stage2, stage3, threshold): -# """ -# Match together a stage 2 and stage 3 catalogue -# if the star is resolved in just the stage 3, it takes on that value -# if the star is resolved in both, the stage3 is ignored -# """ -# -# if stage2.meta.get("CALIBLEVEL")!=2: perror("WARNING: stage2 catalogue CALIBLEVEL=%s does not match\n"%stage2.meta.get("CALIB_LEVEL")) -# if stage3.meta.get("CALIBLEVEL")!=3: perror("WARNING: stage3 catalogue CALIBLEVEL=%s does not match\n"%stage2.meta.get("CALIB_LEVEL")) -# #av,tab=dither_match((stage2,stage3),threshold,stage2.colnames) -# idx,d2d,_=_match(stage2,stage3) -# -# tmp=Table(np.full((len(stage2),len(stage3.colnames)), np.nan), names=stage3.colnames) -# for src,IDX,sep in zip(stage3,idx,d2d): -# if (sep EXPOSURE, DETECTOR, FILTER -# """ -# info={ "FILTER":None, -# "OBSERVTN":0, -# "VISIT":0, -# "EXPOSURE":0, -# "DETECTOR":None -# } -# -# if type(hdulist) in (fits.ImageHDU, fits.BinTableHDU): -# hdulist=fits.HDUList(hdulist) -# -# for hdu in hdulist: -# for key in info: -# if key in hdu.header: info[key]=hdu.header[key] -# return info -# -# -#def bootstrap_match(catalogues): -# """ -# A more generalised band matching routine -# A - B -# B - C -# C - D -# """ -# print("THIS ISNT READY") -# -# output=Table() -# -# THRESHHOLD=0.3 -# filters= [c.meta.get("FILTER") for c in catalogues] -# base=None -# -# if len( catalogues ) >1: -# base=catalogues[0]["RA","DEC","%s"%filters[0],"e%s"%filters[0]] -# -# for n,cat in enumerate(catalogues[1:],1): -# f=filters[n] -# cat=cat["RA","DEC","%s"%f,"e%s"%f] -# base=generic_match( (base,cat), add_src=True, average=False) -# -# _f=filters[n-1] #fix base colnames. Lower filter columns get finalised -# base.rename_columns(("RA_1","DEC_1","%s_1"%_f,"e%s_1"%_f), -# ("RA_%s"%_f,"DEC_%s"%_f,"%s"%_f,"e%s"%_f) ) -# for cn in base.colnames: -# if cn[-2:]=="_1": base.rename_column(cn,cn[:-2]) -# -# #Get larger wavelength ready for next match -# base.rename_columns( ("RA_2","DEC_2","%s_2"%f,"e%s_2"%f), -# ("RA","DEC","%s"%f,"e%s"%f) ) -# -# print(n,len(catalogues)) -# if n==len(catalogues)-1: ##Final match -# base.rename_columns(("RA","DEC"),("RA_%s"%f,"DEC_%s"%f) ) -# -# else: -# perror("Must include more than one catalogue.\n") -# return base -# -# -# -# diff --git a/starbug2/matching/__init__.py b/starbug2/matching/__init__.py new file mode 100644 index 0000000..06d2fb1 --- /dev/null +++ b/starbug2/matching/__init__.py @@ -0,0 +1,14 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" diff --git a/starbug2/matching/band_match.py b/starbug2/matching/band_match.py new file mode 100644 index 0000000..482817b --- /dev/null +++ b/starbug2/matching/band_match.py @@ -0,0 +1,372 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +from typing import override, Final, Any + +import numpy as np +import astropy.units as u +from astropy.table import Table, hstack +from starbug2.constants import HeaderTags, TableColumn +from starbug2.filters import STAR_BUG_FILTERS +from starbug2.matching.generic_match import GenericMatch +from starbug2.utils import ( + Loading, printf, p_error, fill_nan, find_col_names, warn, puts) + +# keys for catalogue fields. +# noinspection SpellCheckingInspection +_OBS: Final[str] = "OBSERVTN" +_VISIT: Final[str] = "VISIT" +_EXPOSURE: Final[str] = "EXPOSURE" +_DEFAULT: Final[str] = "DEFAULT" + +# Hard coding separations for now +_SEPARATION_VALUES: Final[dict[str, float]] = { + "F277W": 0.1, + "F560W": 0.15, + "F1000W": 0.2, + "F1500W": 0.25, + _DEFAULT: 0.06, +} + + +class BandMatch(GenericMatch): + # filter flag for kwargs. + # noinspection SpellCheckingInspection + FILTER: Final[str] = "fltr" + THRESHOLD: Final[str] = "threshold" + + # match methods + _FIRST: Final[str] = "first" + _LAST: Final[str] = "last" + _BOOT_STRAP: Final[str] = "bootstrap" + + # warning messages + _WRONG_THRESHOLD: Final[str] = ( + "Threshold values must be scalar or list with length 1 less than the " + "catalogue list. The final element is being ignored.\n") + + def __init__(self, **kwargs: Any) -> None: + if BandMatch.FILTER in kwargs: + if not isinstance(kwargs[BandMatch.FILTER], list): + warn(f"{self.FILTER} input should be a list, " + "there may be unexpected behaviour\n") + self._filter_list: list[str] = [kwargs[BandMatch.FILTER]] + else: + self._filter_list: list[str] = kwargs[BandMatch.FILTER] + del kwargs[BandMatch.FILTER] + else: + self._filter_list: list[str] = [] + + if BandMatch.THRESHOLD in kwargs: + if isinstance(kwargs[BandMatch.THRESHOLD], list): + kwargs[BandMatch.THRESHOLD] = ( + np.array(kwargs[BandMatch.THRESHOLD], dtype=object)) + + super().__init__(**kwargs, method="Band Matching") + + @property + def filter_list(self) -> list[str]: + return self._filter_list + + def order_catalogues(self, catalogues: list[Table]) -> list[Table]: + """ + Reorder catalogue list into increasing wavelength size. + This only works for JWST bands. Unrecognised filters will be left + unchanged. The function should also set the self.FILTER variable if + possible. + + :param catalogues: List of astropy tables with meta keys 'FILTER' + :type catalogues: list[astropy.table.Table] + :return: The same list reordered by wavelength + :rtype: list[astropy.table.Table] + """ + + status: int = -1 + _ii = None + filter_list: list[str] = self.filter_list + if filter_list is None: + raise Exception("no filer was set") + + sorters = [ + # META in JWST filters + lambda t: list(STAR_BUG_FILTERS.keys()).index( + t.meta.get(HeaderTags.FILTER)), + + # col_names in JWST filters + lambda t: list(STAR_BUG_FILTERS.keys()).index( + (set(t.colnames) & set(STAR_BUG_FILTERS.keys())).pop()), + + # META in self.filters + lambda t: filter_list.index(t.meta.get(HeaderTags.FILTER)), + + # col_names in JWST filters + lambda t: filter_list.index( + (set(t.colnames) & set(filter_list)).pop()) + ] + + for n, fn in enumerate(sorters): + try: + catalogues.sort(key=fn) + _ii = map(fn, catalogues) + status = n + break + except (KeyError, AttributeError, TypeError, ValueError) as e: + warn(f"failed to use sorter {n} due to {str(e)}\n") + pass + + if status < 0: + p_error( + "Unable to reorder catalogues, leaving input order" + " untouched.\n") + elif status <= 1 and (_ii is not None): + # JWST filters + self._filter_list = [list(STAR_BUG_FILTERS.keys())[i] for i in _ii] + + self._load: Loading = Loading(sum(len(c) for c in catalogues[1:])) + + return catalogues + + def jwst_order(self, catalogues: list[Table]): + pass + + @override + def match( + self, catalogues: list[Table], method: str = "first", + **kwargs: Any) -> Table: + # noinspection SpellCheckingInspection + """ + Given a list of catalogues, it will reorder them into increasing + wavelength or to match the fltr= keyword in the initialiser. + The matching then uses the shortest wavelength available position. + I.e. If F115W, F444W, F770W are input, the F115W centroid positions + will be taken as "correct". If a source is not resolved in this band, + the next most astrometric ally accurate position is taken, i.e. F444W + + :param catalogues: List of `astropy.table.Table` objects containing + the meta item "FILTER=XXX" + :type catalogues: List of `astropy.table.Table` + :param method: Centroid method + "first" - Use the position corresponding to the earliest + appearance of the source + "last" - Use the position corresponding to the latest + appearance of the source + "bootsrap"- ??? + "average" - ??? + :type method: str + :param kwargs: + :return: Matched catalogue + :rtype: astropy.Table + """ + catalogues = self.order_catalogues(catalogues) + + printf("Bands: %s\n" % ', '.join(self.filter_list)) + + assert self._threshold is not None + assert len(self.filter_list) != 0 + if type(self._threshold) in (list, np.ndarray): + if len(self._threshold) != (len(catalogues) - 1): + warn(self._WRONG_THRESHOLD) + self._threshold = self._threshold[:-1] + else: + self._threshold = ( + np.full(len(catalogues) - 1, self._threshold) * u.arcsec) + + assert self._threshold is not None + threshold_strs: list[str] = [] + for threshold in self._threshold: + if hasattr(threshold, 'value'): + threshold_strs.append(f"{threshold.value}") + else: + threshold_strs.append(f"{threshold}") + printf(f"Thresholds: {', '.join(threshold_strs)}\n") + + if self._col_names is None: + self._col_names = [ + TableColumn.RA, TableColumn.DEC, TableColumn.FLAG, + TableColumn.NUM, + *self._filter_list, *["e%s" % f for f in self.filter_list]] + + assert self._col_names is not None + printf("Columns: %s\n" % ", ".join(self._col_names)) + + if method not in (self._FIRST, self._LAST, self._BOOT_STRAP): + method = self._FIRST + + # Begin + base: Table = self.build_meta(catalogues) + _threshold = self._threshold.copy() + for n, tab in enumerate(catalogues): + # Temporarily recast threshold + self._threshold = _threshold[n - 1] + assert self._threshold is not None + self._load.msg = ( + f"{self.filter_list[n]} (" + f"{np.array2string(self._threshold)}\")") + col_names = [ + name for name in self._col_names if name in tab.colnames] + + tmp = self.inner_match(base, tab, join_type="or") + + col_names.remove(TableColumn.RA) + col_names.remove(TableColumn.DEC) + base = fill_nan(hstack((base, tmp[col_names]))) + base.rename_columns( + col_names, ["%s_%d" % (name, n + 1) for name in col_names]) + + if TableColumn.RA not in base.colnames: + base = fill_nan(hstack( + (tmp[TableColumn.RA, TableColumn.DEC], base))) + elif method == self._FIRST: + _mask = np.logical_and(np.isnan( + base[TableColumn.RA]), tmp[TableColumn.RA] != np.nan) + base[TableColumn.RA][_mask] = tmp[TableColumn.RA][_mask] + base[TableColumn.DEC][_mask] = tmp[TableColumn.DEC][_mask] + elif method == self._LAST: + _mask = ~np.isnan(tmp[TableColumn.RA]) + base[TableColumn.RA][_mask] = tmp[TableColumn.RA][_mask] + base[TableColumn.DEC][_mask] = tmp[TableColumn.DEC][_mask] + elif method == self._BOOT_STRAP: + _mask = ~np.isnan(tmp[TableColumn.RA]) + base.rename_columns( + (TableColumn.RA, TableColumn.DEC), + ("_RA_%d" % n, "_DEC_%d" % n)) + base = hstack((base, tmp[[TableColumn.RA, TableColumn.DEC]])) + + # Set threshold back at the end + self._threshold = _threshold + + # Fix column names + for name in self._col_names: + all_cols = find_col_names(base, name) + if len(all_cols) == 1: + base.rename_column(all_cols.pop(), name) + + # Finalise NUM and flag column + tmp = self.finish_matching(base, col_names=["NUM", "flag"]) + base.remove_columns( + (*find_col_names(base, "NUM"), *find_col_names(base, "flag"))) + base.add_column(tmp["NUM"], index=2) + base.add_column(tmp["flag"], index=3) + + return base + + def band_match( + self, catalogues, col_names=(TableColumn.RA, TableColumn.DEC)): + """ + Given a list of catalogues (with filter names in the metadata), match + them in order of decreasing astrometric accuracy. If F115W, F444W, + F770W are input, the F115W centroid positions will be taken as + "correct". If a source is not resolved in this band, the next most + astrometric ally accurate position is taken, i.e. F444W + + :param catalogues: list of tables + :param col_names: the col names to match against. + :return: Matched catalogue + :rtype: astropy.Table + """ + + # ORDER the tables into the correct order (increasing wavelength) + tables = np.full(len(STAR_BUG_FILTERS), None) + mask = np.full(len(STAR_BUG_FILTERS), False) + for tab in catalogues: + if HeaderTags.FILTER in tab.meta.keys(): + if tab.meta[HeaderTags.FILTER] in STAR_BUG_FILTERS: + ii = list(STAR_BUG_FILTERS.keys()).index( + tab.meta[HeaderTags.FILTER]) + tables[ii] = tab + mask[ii] = True + else: + p_error( + "Unknown filter '%s' (skipping)..\n" % + tab.meta[HeaderTags.FILTER]) + elif _tmp := set(STAR_BUG_FILTERS.keys()) & set(tab.col_names): + ii = list(STAR_BUG_FILTERS.keys()).index(_tmp.pop()) + tables[ii] = tab + mask[ii] = True + else: + p_error("Cannot find 'FILTER' in table meta (skipping)..\n") + + # document bands + s = "Bands: " + for filter_string, tab in zip(STAR_BUG_FILTERS.keys(), tables): + if tab: + s += "%5s " % filter_string + puts(s) + + # Match in increasing wavelength order + base = Table(None) + load = Loading( + sum([len(t) for t in tables[mask][1:]]), "matching", res=100) + for filter_string, tab in zip(STAR_BUG_FILTERS.keys(), tables): + if not tab: + continue + + # removing empty magnitude rows + tab.remove_rows(np.isnan(tab[filter_string])) + load.msg = "matching:%s" % filter_string + _col_names = ( + list(name for name in tab.col_names if name in col_names)) + if not len(base): + tmp = tab[_col_names].copy() + else: + idx, d2d, _ = self.inner_match(base, tab) + tmp = Table( + np.full((len(base), len(_col_names)), np.nan), + names=_col_names) + + # Hard coding separations for now + separation = _SEPARATION_VALUES.get( + filter_string, _SEPARATION_VALUES[_DEFAULT]) + + for ii, (src, IDX, sep) in enumerate(zip(tab, idx, d2d)): + load.msg = ( + "matching:%s(%.2g\")" % (filter_string, separation)) + load() + load.show() + + if ((sep <= separation * u.arcsec) + and (sep == min(d2d[idx == IDX]))): + for name in _col_names: + tmp[IDX][name] = src[name] + else: + tmp.add_row(src[_col_names]) + + tmp.rename_column(TableColumn.FLAG, "flag_%s" % filter_string) + base = hstack(( + base, tmp[[filter_string, "e%s" % filter_string, + "flag_%s" % filter_string]] + )) + + base = (Table( + base, dtype=[float] * len(base.col_names)) + .filled(np.nan)) # type: ignore + + # Only keep the most astronomically correct position + if TableColumn.RA not in base.col_names: + base = hstack((tmp[[TableColumn.RA, TableColumn.DEC]], base)) + else: + _mask = np.logical_and( + np.isnan(base[TableColumn.RA]), + tmp[TableColumn.RA] != np.nan) + base[TableColumn.RA][_mask] = tmp[TableColumn.RA][_mask] + base[TableColumn.DEC][_mask] = tmp[TableColumn.DEC][_mask] + + # Sort out flags + flag: np.ndarray = np.zeros(len(base), dtype=np.uint16) + for f_col in find_col_names(base, TableColumn.FLAG): + flag |= base[f_col].value.astype(np.uint16) + base.remove_column(f_col) + base.add_column(flag, name=TableColumn.FLAG) + + return base.filled(np.nan) # type: ignore diff --git a/starbug2/matching/cascade_match.py b/starbug2/matching/cascade_match.py new file mode 100644 index 0000000..779f372 --- /dev/null +++ b/starbug2/matching/cascade_match.py @@ -0,0 +1,58 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +from typing import override, Any +from astropy.table import Table +from starbug2.constants import TableColumn +from starbug2.matching.generic_match import GenericMatch +from starbug2.utils import h_cascade, fill_nan + + +class CascadeMatch(GenericMatch): + """ + A simple advancement on "Generic Matching" where the number + of columns are not preserved. At the end of each sub match, the + table is left justified, to reduce the total number of columns needed. + """ + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs, method="Cascade Matching") + + @override + def match(self, catalogues: list[Table], **kwargs: Any) -> Table: + """ + Match a list of catalogues with RA and DEC columns. + + :param catalogues: The input catalogues to work on + :type catalogues: list (astropy.Tables) + :param kwargs: Keyword arguments passed to GenericMatch.match + :return: A left aligned catalogue of all the matched values + :rtype: astropy.table.Table + """ + catalogues = self.init_catalogues(catalogues) + if self._col_names and TableColumn.CAT_NUM in self._col_names: + self._col_names.remove(TableColumn.CAT_NUM) + + base: Table = self.build_meta(catalogues) + + for n, cat in enumerate(catalogues, 1): + self._load.msg = "matching: %d" % n + tmp: Table = self.inner_match(base, cat, join_type="or") + tmp.rename_columns( + tmp.colnames, [f"{name}_{n}" for name in tmp.colnames] + ) + base = h_cascade([base, tmp], col_names=self._col_names) + + base = fill_nan(base) + return base diff --git a/starbug2/matching/exact_value_match.py b/starbug2/matching/exact_value_match.py new file mode 100644 index 0000000..164a32c --- /dev/null +++ b/starbug2/matching/exact_value_match.py @@ -0,0 +1,136 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +from typing import override, Any + +import numpy as np +from astropy.table import Table, hstack, vstack + +from starbug2.constants import TableColumn +from starbug2.matching.generic_match import GenericMatch +from starbug2.utils import p_error, fill_nan + + +class ExactValueMatch(GenericMatch): + """ + Match catalogues based on a single value in one of their columns + The normal use case is matching sources based on their *Catalogue_Number* + value. + """ + + def __init__( + self, value: str = TableColumn.CAT_NUM, **kwargs: Any) -> None: + """ + Setup method. + + :param value: Column name to take exact values from + :type value: str + :param kwargs: + """ + self.value: str = value + super().__init__(**kwargs, method="Exact Value Matching") + + # noinspection SpellCheckingInspection + if "colnames" in kwargs: + # noinspection SpellCheckingInspection + p_error("Colnames not implemented in %s\n" % self.method) + + def __str__(self) -> str: + # noinspection SpellCheckingInspection + s: list[str] = [ + f"{self.method}:", + f'Value: "{self.value}"', + f"Colnames: {self._col_names}", + ] + return "\n".join(s) + + @override + def inner_match( + self, base: Table, cat: Table, join_type: str = "or", + cartesian: bool = False) -> Table: + """ + The low level matching function. + + :param base: Table onto which to match *cat* + :type base: astropy.Table + :param cat: the Table to match to *base* + :type cat: astropy.Table + :param join_type: Joining method ("or" to include all sources, "and" + for intersections) + :type join_type: str + :param cartesian: Whether to use Cartesian coordinates for matching + :type cartesian: bool. + :return: A new catalogue, it is a reordered version of *cat*, in the + correct sorting to be h-stacked with *base*. + :rtype: astropy.Table. + """ + tmp: Table = Table( + np.full((len(base), len(cat.colnames)), np.nan), + names=cat.colnames, dtype=cat.dtype, masked=True + ) + + for col in tmp.columns.values(): + col.mask = True + + if not len(base): + return vstack([tmp, cat]) + + for src in cat: + if self._verbose: + self._load() + self._load.show() + + ii: np.ndarray = np.where(base[self.value] == src[self.value])[0] + if len(ii): + tmp[ii] = src + else: + tmp.add_row(src) + return tmp + + @override + def match(self, catalogues: list[Table], **kwargs: Any) -> Table | None: + """ + Core matching function + + :param catalogues: List of astropy Tables to match together + :type catalogues: list of astropy.Table + :param kwargs: + :return: Full matched catalogue + :rtype: astropy.Table + """ + catalogues = self.init_catalogues(catalogues) + base: Table = self.build_meta(catalogues) + + if self._col_names is None or self.value not in self._col_names: + p_error("Exact value '%s' not in column names.\n" % self.value) + return None + + for n, cat in enumerate(catalogues, 1): + self._load.msg = "matching: %d" % n + tmp: Table = self.inner_match(base, cat) + tmp.rename_columns( + tmp.colnames, [f"{name}_{n}" for name in tmp.colnames] + ) + base = hstack([base, tmp]) + + if n > 1: + ii: np.ndarray = ( + base[self.value].mask & ~base[f"{self.value}_{n}"].mask + ) + base[self.value][ii] = base[f"{self.value}_{n}"][ii] + base.remove_column(f"{self.value}_{n}") + else: + base.rename_column(f"{self.value}_1", self.value) + + return fill_nan(base) diff --git a/starbug2/matching/generic_match.py b/starbug2/matching/generic_match.py new file mode 100644 index 0000000..957a802 --- /dev/null +++ b/starbug2/matching/generic_match.py @@ -0,0 +1,509 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +from typing import Any +import numpy as np +from astropy import units +from astropy.units.quantity import Quantity +from astropy.coordinates import SkyCoord +from astropy.table import Table, hstack, Column, vstack +from starbug2.constants import HeaderTags, SourceFlags, TableColumn +from starbug2.star_bug_config import StarBugMainConfig +from starbug2.utils import ( + Loading, printf, remove_duplicates, p_error, fill_nan, tab2array, + find_col_names, flux2mag) + + +class GenericMatch: + + @staticmethod + def build_meta(catalogues: list[Table]) -> Table: + """ + Extracts structural tracking headers to set up baseline combined + outputs. + """ + meta: dict[str, Any] = catalogues[0].meta + base: Table = Table(None, meta=meta) + return base + + @staticmethod + def mask_catalogues( + catalogues: list[Table], + mask: list[np.ndarray | + list[Any] | None] | np.ndarray | None) -> Table: + """ Takes catalogues and masks and removes catalogues which don't + match the mask. + + :param catalogues: the catalogues to match. + :type catalogues: list (astropy.Tables) + :param mask: the mask to apply. + :return: an astro table with masked catalogues. + :rtype: astropy.Table + """ + masked: Table = Table(None) + + if mask is None or not isinstance(mask, (list, np.ndarray)): + return masked + if len(catalogues) != len(mask): + return masked + + for subset, cat in zip(mask, catalogues): + if subset is not None: + if isinstance(subset, list): + subset = np.array(subset) + + if len(subset) == len(cat): + # 1. Grab rows where mask is False (the ones we want to + # remove) + masked_rows = cat[~subset] + masked = vstack((masked, masked_rows)) + + # 2. Extract indices where mask is False to safely strip + # them out + indices_to_remove: np.ndarray = np.where(~subset)[0] + cat.remove_rows(indices_to_remove.tolist()) + return masked + + @staticmethod + def _sky_coords_not_cartesian(base: Table) -> SkyCoord: + """ + create sky coords which are not cartesian + + :param base: the base. + :type base: astropy.table.Table + :return: a sky coords sing base values. + :rtype: SkyCoord. + """ + ra_cols: list[str] = list( + name for name in base.colnames if TableColumn.RA in name) + dec_cols: list[str] = list( + name for name in base.colnames if TableColumn.DEC in name) + ra: np.ndarray = np.nanmean( + tab2array(base, col_names=ra_cols), axis=1) + dec: np.ndarray = np.nanmean( + tab2array(base, col_names=dec_cols), axis=1) + return SkyCoord(ra=ra * units.deg, dec=dec * units.deg) + + @staticmethod + def _sky_coords_cartesian(base: Table) -> SkyCoord: + """ + create sky coords which are cartesian + + :param base: the base. + :type base: astropy.table.Table + :return: a sky coords sing base values. + :rtype: SkyCoord. + """ + x_cols: list[str] = list( + name for name in base.colnames if name[0] == "x") + y_cols: list[str] = list( + name for name in base.colnames if name[0] == "y") + x: np.ndarray = np.nanmean(tab2array(base, col_names=x_cols), axis=1) + y: np.ndarray = np.nanmean(tab2array(base, col_names=y_cols), axis=1) + return SkyCoord( + x=x, y=y, z=np.zeros(len(x)), representation_type="cartesian") + + def __init__( + self, + threshold: Quantity | np.ndarray | None = None, + col_names: list[str] | None = None, + filter_string: str | None = None, + verbose: int | None = None, + p_file: str | None = None, + method: str = "Generic Matching", + load: Loading = Loading(1) + ) -> None: + """ + constructor for the generic match. + + :param threshold: Separation threshold in arc-seconds + :type threshold: float or None + :param col_names: List of str column names to include in the matching. + Everything else will be discarded. + :type col_names: list or None + :param filter_string: Specifically set the filter of the catalogues + :type filter_string: str or None + :param verbose: Include verbose outputs + :type verbose: int or None + :param p_file: Parameter filename + :type p_file: str or None + :param load: the loading object + :type load: Loading + """ + config = StarBugMainConfig.load_params(p_file) + + self._threshold: Quantity | np.ndarray | None = threshold + self._filter: str | None = config.custom_filter + self._verbose: int | None = config.verbose_logs + self.method: str = method + + if filter_string is not None: + self._filter = filter_string + if verbose is not None: + self._verbose = verbose + + self._col_names: list[str] | None = col_names + self._load: Loading = load + + def log(self, msg: str) -> None: + """ + logs messages only when in verbose mode. + :param msg: message to log + :return: None + """ + if self._verbose: + printf(msg) + + def __str__(self) -> str: + """ + string representation fo the generic match class. + :return: str + """ + threshold_string: str + if isinstance(self._threshold, np.ndarray): + threshold_string = np.array2string(self._threshold) + else: + threshold_string = f"{self._threshold}" + + s: list[str] = [ + f"{self.method}:", + f"Filter: {self._filter}", + f"Col names: {self._col_names}", + f'Threshold: {threshold_string}"' + ] + return "\n".join(s) + + def __call__(self, *args: Any, **kwargs: Any) -> Table: + """ + main entrance method. + + :param args: args to the matching algorithm. + :param kwargs: extra args. + :return: matched catalogue + :rtype: astropy.Table + """ + return self.match(*args, **kwargs) + + def init_catalogues(self, catalogues: list[Table]) -> list[Table]: + # noinspection SpellCheckingInspection + """ + This function is a bit of a "do everything" function + + It takes the input catalogues and removes any columns that aren't + included in the self.colnames list. If this is None, then all columns + are kept. Additionally, it initialises the loading bar with the summed + length of all the input catalogues. + Finally, it attempts to set the photometric filter that is being used + + :param catalogues: The input catalogues to work on + :type catalogues: list (astropy.Tables) + :return: The cleaned list of input catalogues + :rtype: list (astropy.Table) + """ + if len(catalogues) >= 2: + self._load = Loading( + sum(len(cat) for cat in catalogues[1:]), msg="initialising") + if self._verbose: + self._load.show() + + # initialise the column names if it wasn't already set + col_names: list[str] + if self._col_names is None: + col_names = [] + for cat in catalogues: + col_names += cat.colnames + self._col_names = col_names + else: + col_names = self._col_names + + self._col_names = remove_duplicates(col_names) + + # clean out the column names not included in self._col_names + for n, catalogue in enumerate(catalogues): + keep: list[str] = list( + set(catalogue.colnames) & set(col_names)) + keep = sorted( + keep, + key=lambda s: + self._col_names.index(s) if self._col_names else 0) + catalogues[n] = catalogue[keep] + + if not self._filter: + filter_string: str | None = ( + catalogues[0].meta.get(HeaderTags.FILTER)) + if filter_string is None: + filter_string = "MAG" + self._filter = filter_string + + return catalogues + + def match( + self, + catalogues: list[Table], + join_type: str = "or", + mask: ( + list[np.ndarray | list[Any] | None] + | np.ndarray | None) = None, + cartesian: bool = False, + **kwargs: Any) -> Table: + """ + This matching works as a basic match. Everything is included and the + column names have _N appended to the end. + + :param catalogues: the catalogues to match + :type catalogues: list (astropy.Tables) + :param join_type: Joining method ("or" to include sources in any + catalogue, "and" to only include sources in all + catalogues) + :type join_type: str + :param mask: In preparation + :type mask: list + :param cartesian: if we should use cartesian coordinates + :type cartesian: bool + :param kwargs: extra args + :type kwargs: dict + :return: Matched catalogue + :rtype: astropy.table.Table + """ + catalogues = self.init_catalogues(catalogues) + if self._col_names and TableColumn.CAT_NUM in self._col_names: + self._col_names.remove(TableColumn.CAT_NUM) + + masked: Table = self.mask_catalogues(catalogues, mask) + base: Table = self.build_meta(catalogues) + + if join_type == "and": + p_error("join_type 'and' not fully implemented\n") + + # Bulk matching processes (column naming) + for n, cat in enumerate(catalogues, 1): + self._load.msg = "matching: %d" % n + tmp: Table = self.inner_match( + base, cat, join_type=join_type, cartesian=cartesian) + + tmp.rename_columns( + tmp.colnames, [f"{name}_{n}" for name in tmp.colnames]) + # check if there is data to match against. + if tmp is None or len(tmp) == 0: + printf(f"No matches were found in catalogue {n}") + continue + base = fill_nan(hstack((base, tmp))) + + # Add in any masked bits + if len(masked): + masked.rename_columns( + masked.colnames, [f"{name}_0" for name in masked.colnames]) + base = fill_nan(vstack((base, masked))) + return base + + def inner_match( + self, base: Table, cat: Table, join_type: str = "or", + cartesian: bool = False) -> Table: + """ + Base matching function between two catalogues + + :param base: The first catalogue for matching + :type base: astropy.table.Table + :param cat: The second catalogue for matching + :type cat: astropy.table.Table + :param join_type: Joining method ("or" to include all sources, "and" + for intersections) + :type join_type: str + :param cartesian: Whether to use Cartesian coordinates for matching + :type cartesian: bool + :return: Indices, 2D separation, and 3D separation + :rtype: astropy.table.Table + """ + if not len(base): + return cat.copy() + + base = fill_nan(base.copy()) + assert self._col_names is not None + col_names: list[str] = [ + n for n in self._col_names if n in cat.colnames] + cat = fill_nan(cat[col_names].copy()) + + sky_coords_1: SkyCoord + sky_coords_2: SkyCoord + if not cartesian: + sky_coords_1 = self._sky_coords_not_cartesian(base) + sky_coords_2 = self._sky_coords_not_cartesian(cat) + else: + sky_coords_1 = self._sky_coords_cartesian(base) + sky_coords_2 = self._sky_coords_cartesian(cat) + + idx: np.ndarray + d2d: units.Quantity + d3d: units.Quantity + idx, d2d, d3d = sky_coords_2.match_to_catalog_3d(sky_coords_1) + + tmp: Table = Table( + np.full((len(base), len(col_names)), np.nan), + names=col_names, dtype=cat[col_names].dtype) + + dist: np.ndarray | units.Quantity + threshold: Any + if cartesian: + dist = d3d + # If your threshold has an explicit unit wrapper, + # extract its scalar magnitude + threshold = self._threshold.value if hasattr( + self._threshold, "value") else self._threshold + else: + dist = d2d + threshold = self._threshold + + src: Any + IDX: int + sep: Any + for src, IDX, sep in zip(cat.as_array(), idx, dist): + self._load() + if self._verbose: + self._load.show() + + if (sep <= threshold) and (sep == min(dist[idx == IDX])): + tmp[IDX] = src + elif join_type == "or": + tmp.add_row(src) + + return tmp + + def finish_matching( + self, + tab: Table | None, + error_column: str = TableColumn.E_FLUX, + num_thresh: int = -1, + zp_mag: float = 0.0, + col_names: list[str] | None = None) -> Table: + """ + Averaging all the values. Combining source flags and building a NUM + column + + :param tab: Table to work on + :type tab: astropy.table.Table | None + :param error_column: Column containing resultant photometric errors + (E_FLUX or STD_FLUX) + :type error_column: str + :param num_thresh: Minimum number of matches a source must have + (no cropping if <= 0) + :type num_thresh: int + :param zp_mag: Zero point (Magnitude) to be applied to the final + magnitude + :type zp_mag: float + :param col_names: List of column names to average; uses + self.col_names if None + :type col_names: list, optional + :return: An averaged version of the input table + :rtype: astropy.table.Table + """ + if tab is None: + return Table(None) + + # have working table + flags: np.ndarray = np.full( + len(tab), SourceFlags.SRC_GOOD, dtype=np.uint16) + average_table: Table = Table(None) + + if col_names is None: + col_names = self._col_names if self._col_names else [] + + for ii, name in enumerate(col_names): + if all_cols := find_col_names(tab, name): + ar: np.ndarray = tab2array(tab, col_names=all_cols) + col: Column + + # only go forward if both tables have a common column name to + # compare + if ar.shape[1] > 1: + if name == TableColumn.FLUX: + col = Column(np.nanmedian(ar, axis=1), name=name) + mean: np.ndarray = np.nanmean(ar, axis=1) + + if (self._col_names and + TableColumn.STD_FLUX not in self._col_names): + average_table.add_column( + Column(np.nanstd(ar, axis=1), + name=TableColumn.STD_FLUX), + index=ii + 1) + # if median and mean are >5% different, flag as + # SRC_VAR. + # ABS. why are we using such an aggressive type check + # here? + flags[np.abs(mean - col) > (col / 5.0)] |= np.uint16( + SourceFlags.SRC_VAR) + elif name == TableColumn.E_FLUX: + col = Column( + np.sqrt(np.nansum(ar * ar, axis=1)), name=name) + elif name == TableColumn.STD_FLUX: + col = Column(np.nanmedian(ar, axis=1), name=name) + elif name == TableColumn.FLAG: + col = Column(flags, name=name) + f_col: np.ndarray + for f_col in ar.T: + flags |= f_col.astype(np.uint16) + elif name == TableColumn.NUM: + col = Column(np.nansum(ar, axis=1), name=name) + elif name == TableColumn.CAT_NUM: + col = Column(all_cols[0], name=name) + else: + col = Column(np.nanmedian(ar, axis=1), name=name) + else: + col = tab[all_cols[0]] + col.name = name + average_table.add_column(col, index=ii) + + average_table[TableColumn.FLAG] = Column(flags, name=TableColumn.FLAG) + if TableColumn.FLUX in average_table.colnames: + ecol: Column | None = ( + average_table[error_column] + if error_column in average_table.colnames else None) + mag: np.ndarray + mag_err: np.ndarray + mag, mag_err = flux2mag( + average_table[TableColumn.FLUX], flux_err=ecol) + mag += zp_mag + + if self._filter in average_table.colnames: + average_table.remove_column(str(self._filter)) + if f"e{self._filter}" in average_table.colnames: + average_table.remove_column(f"e{self._filter}") + average_table.add_column(mag, name=str(self._filter)) + average_table.add_column(mag_err, name=f"e{self._filter}") + + if TableColumn.NUM not in average_table.colnames: + narr: np.ndarray = np.nansum(np.invert( + np.isnan(tab2array( + tab, find_col_names(tab, TableColumn.RA)))), axis=1) + average_table.add_column(Column(narr, name=TableColumn.NUM)) + + if num_thresh > 0: + average_table.remove_rows( + average_table[TableColumn.NUM] < num_thresh) + return average_table + + @property + def col_names(self) -> list[str] | None: + return self._col_names + + @property + def filter(self) -> str | None: + return self._filter + + @property + def threshold(self) -> Any: + return self._threshold + + @property + def verbose(self) -> int | None: + return self._verbose diff --git a/starbug2/misc.py b/starbug2/misc.py index daa1a11..b458b21 100644 --- a/starbug2/misc.py +++ b/starbug2/misc.py @@ -1,167 +1,181 @@ -""" -Miscillaneous functions... -""" -import os,stat,sys,numpy as np -import starbug2 -from starbug2.utils import * -from starbug2.matching import sort_exposures, GenericMatch +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import os +import stat +import numpy as np +from typing import List, Optional, TextIO, Dict + +from starbug2.constants import ( + FITS_EXTENSION, FILE_NAME, HeaderTags, ImageHeaderTags) from astropy.io import fits +from starbug2.utils import printf, p_error, split_file_name +# A clear, Type Alias for the deep data nested structure Format: +# Dict[KeyType, ValueType], str mapping being FILTER, OBS, VISIT, DETECTOR +ExposureMapping = ( + Dict[ + Optional[int], Dict[ + Optional[int], Dict[ + Optional[int], Dict[ + Optional[int], List[fits.HDUList]]]]]) -########################## -# One time run functions # -########################## -def init_starbug(): +def generate_runscript( + f_names: List[str], args: str = "starbug2 ") -> None: """ - Initialise Starbug.. - - generate PSFs - - download crds files - INPUT: - dname : data directory - """ - printf("Initialising StarbugII\n") - - dname=starbug2.DATDIR - printf("-> using %s=%s\n"%( "STARBUG_DATDIR" if os.getenv("STARBUG_DATDIR") else "DEFAULT_DIR", dname)) - generate_psfs() - - _miri_apcorr= "https://jwst-crds.stsci.edu/unchecked_get/references/jwst/jwst_miri_apcorr_0010.fits" - _nircam_apcorr="https://jwst-crds.stsci.edu/unchecked_get/references/jwst/jwst_nircam_apcorr_0004.fits" - - printf("Downloading APPCORR CRDS files. NB: \x1b[1mTHESE MAY NOT BE THE LATEST!\x1b[0m\n") - printf("-> %s\n"%_miri_apcorr) - printf("-> %s\n"%_nircam_apcorr) - wget(_miri_apcorr, "%s/apcorr_miri.fits"%dname) - wget(_nircam_apcorr, "%s/apcorr_nircam.fits"%dname) - - printf("Downloading ABVEGA offsets.\n") - wget("https://jwst-crds.stsci.edu/unchecked_get/references/jwst/jwst_miri_abvegaoffset_0001.asdf","%s/abvegaoffset_miri.asdf"%dname) - wget("https://jwst-crds.stsci.edu/unchecked_get/references/jwst/jwst_nircam_abvegaoffset_0002.asdf","%s/abvegaoffset_nircam.asdf"%dname) - puts("Downloading The Junior Colour Encyclopedia of Space\n") + generate the run script - -def generate_psfs(): - """ - Generate the psf files inside a given directory - INPUT: - dname : directory to generate into - if None then it will generate them into the folder given - in loaded parameter file starbug2.DATDIR + :param f_names: file names + :type f_names: list of str. + :param args: arguments + :type args: str + :return: None """ - dname=starbug2.DATDIR - if os.getenv("WEBBPSF_PATH"): - dname=os.path.expandvars(dname) - if not os.path.exists(dname): - os.makedirs(dname) - - printf("Generating PSFs --> %s\n"%dname) - - load=loading(145, msg="initialising") - load.show() - for fltr,_f in starbug2.filters.items(): - if _f.instr==starbug2.NIRCAM: - if _f.length==starbug2.SHORT: detectors=["NRCA1","NRCA2","NRCA3","NRCA4","NRCB1","NRCB2","NRCB3","NRCB4"] - else: detectors=["NRCA5","NRCB5"] - else: - detectors=[None] - - for det in detectors: - load.msg="%6s %5s"%(fltr,det) - load.show() - psf=generate_psf( fltr, det, None) - if psf: - psf.writeto("%s/%s%s.fits"%(dname, fltr, "" if det is None else det), overwrite=True) - load() - load.show() + runfile: str = "./run.sh" + fits_files: List[fits.HDUList] = [] - else:perror("WARNING: Cannot generate PSFs, no environment variable 'WEBBPSF_PATH', please see https://webbpsf.readthedocs.io/en/latest/installation.html\n") + fp: TextIO = open(runfile, "w") + fp.write("#!/bin/bash\n") + fp.write("CMDS=\"-vf\"\n") + for f_name in f_names: + if os.path.exists(f_name): + d_name: str + name: str + ext: str + d_name, name, ext = split_file_name(f_name) + if ext == FITS_EXTENSION: + fits_file: fits.HDUList = fits.open(f_name) + fits_file[0].header[FILE_NAME] = f_name + fits_files.append(fits_file) + else: + p_error( + "file %s must be type '.fits' not '%s'\n" % (name, ext)) + else: + p_error("file \x1b[1;31m%s\x1b[0m not found\n" % f_name) + + sorted_exposures: ExposureMapping = sort_exposures(fits_files) + + # print exps. + for _, obs in sorted_exposures.items(): + for _, visits in obs.items(): + for _, destinations in visits.items(): + for _, exps in destinations.items(): + s = f"{args}${{CMDS}} -n{len(exps)} " + for exp in exps: + s += "%s " % exp[0].header[FILE_NAME] + fp.write("%s\n" % s) + fp.close() + os.chmod( + runfile, stat.S_IXUSR | stat.S_IWUSR | stat.S_IRUSR | stat.S_IRGRP | + stat.S_IROTH) + printf("->%s\n" % runfile) -def generate_psf(fltr, detector=None, fov_pixels=None): +def sort_exposures(catalogues: List[fits.HDUList]) -> ExposureMapping: """ - Generate a single PSF for JWST - INPUT: fltr=JWST filter e.g. F444W - detector=Instrument detector module e.g. NRCA1 - fov_pixels=size of PSF - RETURNS:fits.HDUlist containing PSF + Given a list of catalogue files, this will return the fitsHDULists as a + series of nested dictionaries sorted by: + > BAND + > OBSERVATION ID + > VISIT ID + > DETECTOR -- These two have been switched + > DITHER (EXPOSURE) -- These two have been switched + + :param catalogues: the catalogues to sort exposures of. + :type catalogues: list of fits.HDUList + :return: a dictionary of sorted catalogues + :rtype: ExposureMapping """ - import webbpsf - psf=None - model=None - if fov_pixels is not None and fov_pixels<=0: fov_pixels=None - - if fltr in list(starbug2.filters.keys()): - _f=starbug2.filters.get(fltr) - if detector is None: - if _f.instr==starbug2.NIRCAM and _f.length==starbug2.SHORT: detector="NRCA1" - elif _f.instr==starbug2.NIRCAM and _f.length==starbug2.LONG: detector="NRCA5" - elif _f.instr==starbug2.MIRI: detector="MIRIM" - - if _f.instr==starbug2.NIRCAM: model=webbpsf.NIRCam() - elif _f.instr==starbug2.MIRI: model=webbpsf.MIRI() - - if model: - model.filter=fltr - if detector: model.detector=detector - try: - psf=model.calc_psf(fov_pixels=fov_pixels)["DET_SAMP"] - psf=fits.PrimaryHDU(data=psf.data,header=psf.header) - except: perror("\x1b[2KSomething went from with: %s %s\n"%(fltr,detector)) - else: perror("Unable to determing instrument from fltr '%s'\n"%fltr) - else: perror("Unable to locate '%s' in JWST filter list\n"%fltr) - return psf - - - -def generate_runscript(fnames, args="starbug2 "): + out: ExposureMapping = {} + for cat in catalogues: + info = exp_info(cat) + + if info[HeaderTags.FILTER] not in out.keys(): + out[info[HeaderTags.FILTER]] = {} + + if info[HeaderTags.OBS] not in out[info[HeaderTags.FILTER]].keys(): + out[info[HeaderTags.FILTER]][info[HeaderTags.OBS]] = {} + + if (info[HeaderTags.VISIT] not in + out[info[HeaderTags.FILTER]][info[HeaderTags.OBS]].keys()): + out[info[HeaderTags.FILTER]][ + info[HeaderTags.OBS]][info[HeaderTags.VISIT]] = {} + + if (info[ImageHeaderTags.DETECTOR] not in + out[info[HeaderTags.FILTER]][info[HeaderTags.OBS]][ + info[HeaderTags.VISIT]].keys()): + out[info[HeaderTags.FILTER]][ + info[HeaderTags.OBS]][info[HeaderTags.VISIT]][ + info[ImageHeaderTags.DETECTOR]] = [] + out[info[HeaderTags.FILTER]][ + info[HeaderTags.OBS]][info[HeaderTags.VISIT]][ + info[ImageHeaderTags.DETECTOR]].append(cat) + return out + + +def parse_mask(string, table) -> np.ndarray | None: """ + Parse a commandline mask string to be passed into a matching routine + Example: --mask=F444W!=nan + + :param string: Raw mask sting to be parsed + :type string: str + :param table: Table to work on + :type table: astropy.table.Table + :return: Boolean mask array to index into a table or array + :rtype: np.ndarray """ - RUNFILE="./run.sh" - fitsfiles=[] + mask: Optional[np.ndarray] = None - fp=open(RUNFILE,"w") - fp.write("#!/bin/bash\n") - fp.write("CMDS=\"-vf\"\n") - for fname in fnames: - if os.path.exists(fname): - dname,name,ext=split_fname(fname) - if ext==".fits": - fitsfile=fits.open(fname) - fitsfile[0].header["FILENAME"]=fname - fitsfiles.append(fitsfile) - - else: perror("file %s must be type '.fits' not '%s'\n"%(name,ext)) - else: perror("file \x1b[1;31m%s\x1b[0m not found\n"%fname) - - sorted=sort_exposures(fitsfiles) - - for band, obs in sorted.items(): - for ob,visits in obs.items(): - for visit,dets in visits.items(): - for det,exps in dets.items(): - s=args +"${CMDS} -n%d "%len(exps) - for exp in exps: s+="%s "%exp[0].header["FILENAME"] - fp.write("%s\n"%s) - fp.close() - os.chmod(RUNFILE,stat.S_IXUSR|stat.S_IWUSR|stat.S_IRUSR|stat.S_IRGRP|stat.S_IROTH) - printf("->%s\n"%RUNFILE) + col_name: str + for col_name in table.colnames: + string: str = string.replace(col_name, "table[\"%s\"]" % col_name) + try: + mask = eval(string) + if not isinstance(mask, np.ndarray): + raise Exception + except NameError as e: + p_error("Unable to create mask: %s\n" % repr(e)) + except Exception as e: + p_error(repr(e)) + return mask -def calc_instrumental_zeropint(psftable, aptable, fltr=None ): - """ +def exp_info(hdu_list) -> Dict[str, int | None]: + """ + Get the exposure information about a hdu list + :param hdu_list: HDUList or ImageHDU or BinTableHDU + :return: dictionary of relevant information + (HeaderTags.FILTER, obs, visit exposure, detector) + :rtype dict(str, Optional[int]) """ - if fltr is None and not (fltr:=psftable.meta.get("FILTER")): - perror("Unable to determine filter, set with '--set FILTER=F000W'.\n") - return None - printf("Calculating instrumental zeropoint %s.\n"%fltr) - - m=GenericMatch(threshold=0.1, colnames=["RA","DEC",fltr]) - matched=m([psftable,aptable], join_type="and") - dist=np.array((matched["%s_2"%fltr]-matched["%s_1"%fltr]).value) - zp=np.nanmedian(dist) - std=np.nanstd(dist) - printf("-> zp=%.3f +/- %.2g\n"%(zp,std)) - return (zp,std) + info: Dict[str, int | None] = { + HeaderTags.FILTER: None, + HeaderTags.OBS: 0, + HeaderTags.VISIT: 0, + HeaderTags.EXPOSURE: 0, + ImageHeaderTags.DETECTOR: None + } + + if type(hdu_list) in (fits.ImageHDU, fits.BinTableHDU): + hdu_list: fits.HDUList = fits.HDUList(hdu_list) + + for hdu in hdu_list: + for key in info: + if key in hdu.header: + info[key] = hdu.header[key] + return info diff --git a/starbug2/param.py b/starbug2/param.py index 4016162..3a0cade 100644 --- a/starbug2/param.py +++ b/starbug2/param.py @@ -1,175 +1,91 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" import os -from parse import parse -from starbug2.utils import printf,perror,get_version - -default="""## STARBUG CONFIG FILE -# Generated with starbug2-v%s -PARAM = STARBUGII PARAMETERS //COMMENT - - -## GENERIC -VERBOSE = 0 //(0:false 1:true) -OUTPUT = . //Directory or filename to output to -HDUNAME = //If using a non standard HDU name, name it here (str or int) -FILTER = //Set a custom filter for the image - -## DETECTETION -FWHM = -1 //Custom FWHM for image (-1 to use WEBBPSF) -SIGSKY = 2.0 //Number of sigma above the median to clip out as background -SIGSRC = 5.0 //Source value mininmum N sigma above background -DOBGD2D = 1 //Run background2D step (usually finds more sources but takes time) -DOCONVL = 1 //Run convolution step (usually finds more sources) -CLEANSRC = 1 //Run source cleaning after detection (removes likely contaminants) -SHARP_LO = 0.4 //Lower limit of source sharpness (0 is not sharp) -SHARP_HI = 0.9 //Upper limit of source sharpness (1 is sharp) -ROUND1_HI = 1.0 //Limit of source roundness1 (|roundness|>>0 is less round) -ROUND2_HI = 1.0 //Limit of source roundness2 (|roundness|>>0 is less round) -SMOOTH_LO = //Lower limit on source smoothness (0 is not smooth) -SMOOTH_HI = //Upper limit on source smoothness (1 is smooth) -RICKER_R = 1.0 //Radius (pix) of ricker wavelet - -## APERTURE PHOTOMOETRY -APPHOT_R = 1.5 //Radius in number of pixels -ENCENERGY = -1 //Fraction encircled energy (mutually exclusive with APPHOT_R) -SKY_RIN = 3 //Sky annulus inner radius -SKY_ROUT = 4.5 //Sky annulus outer radius -APCORR_FILE = //Aperture correction file. See full manual for details - -## BACKGROUND ESTIMATION -BGD_R = 0 //Aperture masking fixed radius (if zero, starbug will scale radii) -PROF_SCALE = 1 //Aperture mask radius profile scaling factor -PROF_SLOPE = 0.5 //Aperture mask radius profile slope -BOX_SIZE = 2 //Background estimation kernal size (pix) -BGD_CHECKFILE= //Output region file to check the aperture mask radii - -## PHOTOMETRY -AP_FILE = //Detection file to use instead of detecting -BGD_FILE = //Background estimation file -PSF_FILE = //Non default PSF file -USE_WCS = 1 //When loading an AP_FILE, do you want to use WCS or xy values (if available) -ZP_MAG = 8.9 //Zero point (mag) to add to the magnitude columns - -CRIT_SEP = //minimum distance for grouping (pixels) between two sources -FORCE_POS = 0 //Force centroid position (1) or allow psf fitting to fit position too (0) -DPOS_THRESH = -1 //If allowed to fit position, max separation (arcsec) from source list centroid -MAX_XYDEV = 3p //Maximum deviation from initial guess centroid position -PSF_SIZE = -1 //Set fit size of psf (>0) or -1 to take PSF file dimensions -GEN_RESIDUAL= 0 //Generate a residual image - -## SOURCE STATS -CALC_CROWD = 1 //Run crowding metric calculation (execution time scales N^2) - -## CATALOGUE MATCHING -MATCH_THRESH= 0.1 // matching separation threshold in units arcsec -MATCH_COLS = // EXTRA columns to include in output matched table i.e sharpness -NEXP_THRESH = -1 // Keep sources that appear in NUM >= NEXP_THRESH (if -1 keep everything) -SN_THRESH = -1 // Remove sources with SN ratio < SN_THRESH before matching (default -1 to not apply this cut) -BRIDGE_COL = // Bridge --band matching NIRCam and MIRI catalogues by ensuring NIRCam catalogue has a match in BRIDGE_COL - -## ARTIFICAL STAR TESTS -NTESTS = 100 //Number of artificial star tests -NSTARS = 10 //Number of stars per artifical test -SUBIMAGE = 500 //number of pixels ? to crop around artificial star -MAX_MAG = 18.0 //Bright limit of test magnitude -MIN_MAG = 28.0 //Faint limit of test magnitude -PLOTAST = //Output AST result as image with this filename - -## MISC EXTRAS -REGION_COL = green //DS9 region colour -REGION_SCAL = 1 //Scale region to flux if possible -REGION_RAD = 3 //Region radius default -REGION_XCOL = RA //X column name to use for region -REGION_YCOL = DEC //Y column name to use for region -REGION_WCS = 1 //If X/Y column names correspind to WCS values -"""%get_version() - -def parse_param(line): - """ - Parse a parameter line - """ - param={} - if line and line[0] not in "# \t\n": - if "//" in line: key,value,_=parse("{}={}//{}",line) - else: key,value=parse("{}={}",line) - key=key.strip().rstrip() - value=value.strip().rstrip() - try: - if '.' in value: value=float(value) - else: value=int(value) - except: - pass - - ## Special case values - if key in ("OUTPUT", "AP_FILE","BGD_FILE","PSF_FILE"): value=os.path.expandvars(value) - param[key]=value - return param - - - -def load_default_params(): - config={} - for line in default.split('\n'): - config.update(parse_param(line)) - return config +from typing import Dict +from starbug2.star_bug_config import StarBugMainConfig +from starbug2.utils import printf, p_error, get_version + -def load_params(fname): +def _load_params_old(f_name: str | None) -> Dict[str, int | float | str]: """ Convert a parameter file into a dictionary of options - INPUT: fname=path/to/file.param - RETURN: dictionary of options + + :param f_name: path/to/file.param + :type f_name: str or None + :return: dictionary of options + :rtype: dict of string, string """ - config={} - if fname is None: - config=load_default_params() - elif os.path.exists(fname): - with open(fname, "r") as fp: + if f_name is None: + return {} + + config: Dict[str, int | float | str] = {} + if os.path.exists(f_name): + with open(f_name, "r") as fp: for line in fp.readlines(): - config.update(parse_param(line)) + config.update(StarBugMainConfig.parse_param(line)) else: - perror("config file \"%s\" does not exist\n"%fname) + p_error("config file \"%s\" does not exist\n" % f_name) return config -def local_param(): - with open("starbug.param", "w") as fp: - fp.write(default) -def update_paramfile(fname): +def update_param_file(f_name: str | None) -> None: """ When the local parameter file is from an older version, add or remove the - new or obselete keys - INPUT: fname=local file to update + new or obsolete keys + + :param f_name: local file to update + :type f_name: str + :return: None """ - default_param=load_default_params() - current_param=load_params(fname) - - if os.path.exists(fname): - printf("Updating \"%s\"\n"%fname) - fpi=open(fname, 'r') - fpo=open("/tmp/starbug.param",'w') - - add_keys=set(default_param.keys())-set(current_param.keys()) - del_keys=set(current_param.keys())-set(default_param.keys()) - if add_keys: printf("-> adding: %s \n"%(', '.join(add_keys))) - if del_keys: printf("-> removing: %s\n"%(', '.join(del_keys))) - - if not len(add_keys|del_keys): - printf("-> No updates needed\n") - return + if f_name is None: + return - for inline in default.split("\n"): - if inline and inline[0] not in "# \t\n": + default_param = StarBugMainConfig() - key,value,comment=parse("{}={}//{}",inline) - key=key.strip().rstrip() + current_param = _load_params_old(f_name) - if key not in add_keys: - value=current_param[key] - outline="%-24s"%("%-12s"%key+"= "+str(value))+" //"+comment - else: outline=inline + if os.path.exists(f_name): + printf("Updating \"%s\"\n" % f_name) + fpo = open("/tmp/starbug.param", 'w') - fpo.write("%s\n"%outline) - fpi.close() - fpo.close() - os.system("mv /tmp/starbug.param %s"%fname) - else: perror("local parameter file '%s' does not exist\n"%fname) + add_keys = ( + set(default_param.MAIN_PARAM_FILE_MAP.keys()) - + set(current_param.keys())) + del_keys = ( + set(current_param.keys()) - + set(default_param.MAIN_PARAM_FILE_MAP.keys())) + if add_keys: + printf("-> adding: %s \n" % (', '.join(add_keys))) + if del_keys: + printf("-> removing: %s\n" % (', '.join(del_keys))) + + if not len(add_keys | del_keys): + printf("-> No updates needed\n") + # remove invalid params + for key in del_keys: + current_param.pop(key, None) + + # update config object with their settings + default_param.update(current_param) + + # generate the new output and write to file. + output: str = default_param.generate_default_param_file_text( + get_version()) + fpo.write("%s\n" % output) + fpo.close() + os.system("mv /tmp/starbug.param %s" % f_name) + else: + p_error("local parameter file '%s' does not exist\n" % f_name) diff --git a/starbug2/plot.py b/starbug2/plot.py index 502a901..7277b58 100644 --- a/starbug2/plot.py +++ b/starbug2/plot.py @@ -1,174 +1,230 @@ -""" -A collection of plotting functions -""" +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" import os +from typing import List, Any + import numpy as np +from astropy.io.fits import PrimaryHDU, ImageHDU, BinTableHDU from astropy.visualization import ZScaleInterval -from scipy.interpolate import interp2d,RegularGridInterpolator -from multiprocessing import Pool +from astropy.table import Row, Table +from scipy.interpolate import RegularGridInterpolator -try: import matplotlib.pyplot as plt -except: - from matplotlib import use; use("TkAgg") - import maplotlib.pyplot +from starbug2.constants import URL_DOCS, HeaderTags, TableColumn import matplotlib.image as mpimg from matplotlib.colors import LinearSegmentedColormap from astropy.wcs import WCS -import astropy.units as u - -import starbug2 from starbug2 import utils +from starbug2.filters import STAR_BUG_FILTERS + +# try to import pyplot as plt. +try: + import matplotlib.pyplot as plt +except ImportError: + from matplotlib import use + use("TkAgg") + import matplotlib.pyplot as plt +from matplotlib.axes import Axes +from matplotlib.figure import Figure -def load_style(fname): +def load_style(f_name: str) -> None: """ Load a pyplot style sheet - - Parameters - ---------- - fname : str - Filename of the style sheet + + :param f_name: Filename of the style sheet + :type f_name: str + :return: None """ - if os.path.exists(fname): - plt.style.use(fname) + if os.path.exists(f_name): + plt.style.use(f_name) else: - utils.perror("Unable to load style sheet \"%s\"\n"%fname) + utils.p_error("Unable to load style sheet \"%s\"\n" % f_name) + +def _generate_regular_grid_interpolator( + x, y, bins) -> RegularGridInterpolator: + """ + generates a regular grid interpolator -def get_point_density(x,y,bins=30): - hist,_x,_y=np.histogram2d(x,y,bins=bins) - xx=np.linspace(min(x),max(x),bins) - yy=np.linspace(min(y),max(y),bins) - f=RegularGridInterpolator((xx,yy),hist) + :param x: x coord + :param y: y coord + :param bins: the bins. + :return: the configured RegularGridInterpolator + :rtype: a RegularGridInterpolator + """ + hist: np.ndarray + xx: np.ndarray + yy: np.ndarray + hist, _, _ = np.histogram2d(x, y, bins=bins) + xx = np.linspace(min(x), max(x), bins) + yy = np.linspace(min(y), max(y), bins) + return RegularGridInterpolator((xx, yy), hist) - with Pool(processes=8) as pool: - dens=pool.map(f,zip(x,y)) - return dens -def plot_test(ax, **kwargs): +def plot_test(axes: Axes) -> Axes: """ Just plot the starbug image - - Parameters - ---------- - ax : plt.axes - Ax to plot into - - Returns - ------- - ax : plt.axes - The working axes - """ - - if not utils.wget("https://raw.githubusercontent.com/conornally/starbug2/refs/heads/main/docs/source/_static/images/starbug.png",fname="/tmp/sb.png"): - ax.imshow(mpimg.imread("/tmp/sb.png")) - ax.set_title("starbug2 v%s"%utils.get_version()) - ax.set_axis_off() - return ax -def plot_cmd(tab, colour, mag, ax=None,col=None,hess=True, - xlim=None, ylim=None, **kwargs): - tt=utils.colour_index(tab,(colour,mag)) - mask=~(tt[colour].mask|tt[mag].mask) - cc=tt[colour][mask] - mm=tt[mag][mask] + :param axes: Axis to plot into + :type axes: plt.axes + :return: The working axes + :rtype: plt.axes + """ + if not utils.wget(URL_DOCS, f_name="/tmp/sb.png"): + axes.imshow(mpimg.imread("/tmp/sb.png")) + axes.set_title("starbug2 v%s" % utils.get_version()) + axes.set_axis_off() + return axes + + +def plot_cmd( + tab: Table, + colour: str, + mag: str, + axis: Axes | None = None, + col: str | tuple[float, ...] | None = None, + hess: bool = True, + x_lim: tuple[float, float] | None = None, + y_lim: tuple[float, float] | None = None, + **kwargs: Any) -> Axes: + """ + Plot a Colour-Magnitude Diagram (CMD) with an optional Hess-based density + colouring. + + :param tab: Astropy Table containing the stellar catalogue data + :type tab: astropy.table.Table + :param colour: The column name representing the colour index + (e.g., 'B-V' or 'g-r') + :type colour: str + :param mag: The column name representing the magnitude (e.g., 'V' or 'g') + :type mag: str + :param axis: The matplotlib Axes object to plot on, defaults to None + :type axis: matplotlib.axes.Axes or None + :param col: Custom colour string or sequence for the scatter + points/colormap gradient + :type col: str or tuple or None + :param hess: Whether to calculate and apply a density-based Hess plot + colour gradient + :type hess: bool + :param x_lim: X-axis bounds for the colour index (min, max) + :type x_lim: tuple of (float, float) or None + :param y_lim: Y-axis bounds for the magnitude (min, max) + :type y_lim: tuple of (float, float) or None + :param kwargs: Additional keyword arguments passed directly to ax.scatter + :type kwargs: dict + :return: The matplotlib Axes object with the rendered plot + :rtype: matplotlib.axes.Axes + """ + tt: Table = utils.colour_index(tab, [colour, mag]) + mask: np.ndarray = ~ (tt[colour].mask | tt[mag].mask) + cc: np.ndarray = tt[colour][mask] + mm: np.ndarray = tt[mag][mask] - if not ax: fig,ax=plt.subplots(1) + if not axis: + _, axis = plt.subplots(1) - xm=np.nanmean(cc) - dx=np.nanstd(cc) - ym=np.nanmean(mm) - dy=np.nanstd(mm) + if x_lim is None: + x_lim = (float(np.nanmin(cc)), float(np.nanmax(cc))) + if y_lim is None: + y_lim = (np.nanmin(mm), np.nanmax(mm)) - if xlim is None: xlim=(np.nanmin(cc),np.nanmax(cc))#( xm-(3*dx),xm+(5*dx)) - if ylim is None: ylim=(np.nanmin(mm),np.nanmax(mm))#( ym-(5*dy),ym+(3*dy)) + spatial_mask: np.ndarray = ( + (cc >= x_lim[0]) & (cc <= x_lim[1]) & + (mm >= y_lim[0]) & (mm <= y_lim[1])) + cc = cc[spatial_mask] + mm = mm[spatial_mask] - mask=((cc>=xlim[0])&(cc<=xlim[1]) & (mm>=ylim[0])&(mm<=ylim[1])) - cc=cc[mask] - mm=mm[mask] + # apply default colour + if col is None: + col = plt.rcParams["axes.prop_cycle"].by_key()["color"][0] - if col is None: col=plt.rcParams["axes.prop_cycle"].by_key()["color"][0] - cmap=LinearSegmentedColormap.from_list("",[plt.rcParams["axes.prop_cycle"].by_key()["color"][0],col]) + # make segmented colour map + cmap: LinearSegmentedColormap = LinearSegmentedColormap.from_list( + "", [plt.rcParams["axes.prop_cycle"].by_key()["color"][0], col]) if hess: - bins=100 - hist,_x,_y=np.histogram2d(cc,mm,bins=bins) - xx=np.linspace(min(cc),max(cc),bins) - yy=np.linspace(min(mm),max(mm),bins) - f=RegularGridInterpolator((xx,yy),hist) - col=[f([X,Y]) for X,Y in zip(cc,mm)] - pyplot_kw={"lw":0,"s":3} + bins: int = 100 + f: RegularGridInterpolator = ( + _generate_regular_grid_interpolator(cc, mm, bins)) + col = [f([X, Y]) for X, Y in zip(cc, mm)] + pyplot_kw: dict[str, int] = {"lw": 0, "s": 3} pyplot_kw.update(kwargs) - ax.scatter(cc,mm,c=col,cmap=cmap,**pyplot_kw) - + axis.scatter(cc, mm, c=col, cmap=cmap, **pyplot_kw) + axis.set_xlabel(colour) + axis.set_ylabel(mag) + axis.set_xlim(x_lim) + # Invert the Y-axis because brighter astronomical magnitudes have + # lower values + axis.set_ylim(*y_lim[::-1]) + return axis - ax.set_xlabel(colour) - ax.set_ylabel(mag) - ax.set_xlim(xlim) - ax.set_ylim(*ylim[::-1]) - return ax - - - -def plot_inspectsource(src, images): +def plot_inspect_source( + src: Row, images: List[PrimaryHDU | ImageHDU | BinTableHDU | None]): """ Show a source in an array of images - Parameters - ---------- - src : astropy.Table Row - Input source to look at - - images : list - List of fits images to inspect - - Returns - ------- - fig : plt.figure - The figure + :param src: Input source to look at + :type src: astropy.Table Row + :param images: List of fits images to inspect + :type images: HDUList + :return: The figure + :rtype: plt.figure """ - n=len(images) - fig,axs=plt.subplots(1,n, figsize=(1.7*n,2)) - if n==1: axs=[axs] - images=sorted(images,key=lambda a: list(starbug2.filters.keys()).index(a.header["FILTER"])) - - - size=0.1#arcsec? - for n,(im,ax) in enumerate(zip(images,axs)): - wcs=WCS(im) - x,y=wcs.all_world2pix(np.ones(2)*src["RA"], np.array([1,1+(size/3600)])*src["DEC"], 0) - dp=np.sqrt((x[1]-x[0])**2+(y[1]-y[0])**2)#abs(y[1]-y[0])#20# - - xmin=max(0,int(np.floor(x[0]-dp))) - xmax=min(im.data.shape[1]-1,int(np.ceil(x[0]+dp))) - ymin=max(0,int(np.floor(y[0]-dp))) - ymax=min(im.data.shape[0]-1,int(np.ceil(y[0]+dp))) - - - dat=im.data[min(ymin,ymax):max(ymin,ymax),min(xmin,xmax):max(xmin,xmax)] + n: int = len(images) + figure: Figure + axs: Axes | List[Axes] + figure, axs = plt.subplots(1, n, figsize=(1.7 * n, 2)) + if n == 1: + axs = [axs] # noqa + images: List[ImageHDU | PrimaryHDU | BinTableHDU | None] = sorted( + images, key=lambda a: + list(STAR_BUG_FILTERS.keys()).index(a.header[HeaderTags.FILTER])) + + # arcsec? + size: float = 0.1 + n: int + im: ImageHDU | PrimaryHDU + axis: Axes + assert isinstance(axs, list) + for n, (im, axis) in enumerate(zip(images, axs)): + wcs: WCS = WCS(im) + x: np.ndarray + y: np.ndarray + x, y = wcs.all_world2pix( + np.ones(2) * src["RA"], + np.array([1, 1 + (size / 3600)]) * src["DEC"], 0) + dp: np.ndarray = np.sqrt((x[1] - x[0]) ** 2 + (y[1] - y[0]) ** 2) + + x_min: int = max(0, int(np.floor(x[0] - dp))) + x_max: int = min(im.data.shape[1] - 1, int(np.ceil(x[0] + dp))) + y_min: int = max(0, int(np.floor(y[0] - dp))) + y_max: int = min(im.data.shape[0] - 1, int(np.ceil(y[0] + dp))) + + dat: np.ndarray = im.data[ + min(y_min, y_max): max(y_min, y_max), + min(x_min, x_max): max(x_min, x_max)] if all(dat.shape): - ax.imshow(ZScaleInterval()(dat), cmap="Greys_r", origin="lower") - ax.text(0,0,im.header.get("FILTER"),c="white") - - ax.set_axis_off() - fig.suptitle(src["Catalogue_Number"][0]) - fig.tight_layout() - - return fig - -if __name__=="__main__": - from astropy.table import Table - fig,ax=plt.subplots(1) - t=Table().read("/home/conor/sci/proj/ngc6822/overview/dat/ngc6822.fits") - plot_cmd(t,"F115W-F200W","F200W",ax=ax) - plt.show() - - - - + axis.imshow(ZScaleInterval()(dat), cmap="Greys_r", origin="lower") + axis.text(0, 0, im.header.get(HeaderTags.FILTER), c="white") + axis.set_axis_off() + figure.suptitle(src[TableColumn.CAT_NUM][0]) + figure.tight_layout() + return figure diff --git a/starbug2/routines.py b/starbug2/routines.py deleted file mode 100644 index 3edc4a8..0000000 --- a/starbug2/routines.py +++ /dev/null @@ -1,917 +0,0 @@ -""" -Core routines for StarbugII. -""" -import os -import sys -import time -import numpy as np -from scipy.stats import norm -from scipy.optimize import curve_fit -from scipy.ndimage import convolve -from skimage.feature import match_template - -from astropy.stats import sigma_clipped_stats, sigma_clip -from astropy.coordinates import SkyCoord -from astropy.table import Column, Table, QTable, hstack, vstack -from astropy.modeling.fitting import LevMarLSQFitter -from astropy.convolution import RickerWavelet2DKernel - -from photutils.background import Background2D, BackgroundBase -from photutils.aperture import CircularAperture, CircularAnnulus, aperture_photometry -from photutils.detection import StarFinderBase, DAOStarFinder, find_peaks -from photutils.psf import PSFPhotometry, IntegratedGaussianPRF, SourceGrouper - -#from photutils.datasets import make_model_sources_image, make_random_models_table -from photutils.datasets import make_model_image, make_random_models_table -from starbug2.utils import loading, printf, perror, warn -from starbug2 import * - -class Detection_Routine(StarFinderBase): - """ - Detection routine - - A standalone detection that runs on a 2D image. - It uses DAOStarFinder as the base for peak detection but run - several times on a series of background subtracted images. - Each run the background subtraction is differemt, bringing out a - different set of sources - - Parameters - ---------- - sig_src : float - The detection flux threshold, this sets the number of sigma above the image median flux - that a source must be brighter than. Default: sig_src=5 is a "solid" detection. - - sig_sky : float - The number of sigma above the image median flux that is still considered "sky". Pixels - below this will be cut out during the detection steps. - - fwhm: float - Full width half maximum of a standard source in the image. - - sharplo : float - Lowest bound for a source "sharpness". - - sharphi : float - Upper bound for a source "sharpness". - - round1hi : float - Upper bound for a source "roundness1", this distribution is symmetric and the lower - bound is taken as negative round1hi. - - round2hi : float - Upper bound for a source "roundness2", this distribution is symmetric and the lower - bound is taken as negative round2hi. - - smoothlo : float - Lower bound for source "smoothness". - - smoothhi : float - Upper bound for source "smoothness". - - ricker_r : float - Pixel radius for the wavelet used in the CONVL detection step. - - cleansrc : bool - Set whether to "clean" the catalogue after detection based on the above source geometric properties. - - dobgd2d : bool - Set whether to run the BGD2D detection step. - - doconvl : bool - Set whether to run the CONVL detection step. - - boxsize : int - Set kernel size for BGD2D background measuring step. - - verbose : bool - Set whether to print verbose output information. - """ - def __init__(self, sig_src=5, sig_sky=3, fwhm=2, - sharplo=0.2, sharphi=1, round1hi=1, round2hi=1, - smoothlo=-np.inf, smoothhi=np.inf, ricker_r=1.0, - verbose=0, cleansrc=1, dobgd2d=1, boxsize=2, doconvl=1): - self.sig_src=sig_src - self.sig_sky=sig_sky - self.fwhm = fwhm - self.sharphi=sharphi - self.sharplo=sharplo - self.round1hi= round1hi if round1hi is not None else np.inf - self.round2hi= round2hi if round2hi is not None else np.inf - self.smoothlo= smoothlo if smoothlo is not None else -np.inf - self.smoothhi= smoothhi if smoothhi is not None else np.inf - - self.ricker_r=ricker_r - self.cleansrc=cleansrc - - self.catalogue=Table() - self.verbose=verbose - - self.dobgd2d=dobgd2d - self.boxsize=boxsize - self.doconvl=doconvl - - def detect(self, data, bkg_estimator=None, xycoords=None, method=None): - """ - The core detection step (DAOStarFinder) - - Parameters - ---------- - data : `numpy.ndarray` - Image array to detect on - - bkg_estimator : callable function - Function to call to generate the background array the same shape as data array - - xycorrds : `astropy.table.Table` - Table of initial guesses (xcentroid,ycentroid) - - method : str - Detection method - "findpeaks" - use the photutils findpeaks method - None - Use the DAOStarFinder method - - Returns - ------- - detections : astropy.Table - Sourcelist Table - """ - bkg=np.zeros(data.shape) - if bkg_estimator: - bkg=bkg_estimator(data) - - _,median,std=sigma_clipped_stats(data,sigma=self.sig_sky) - if method=="findpeaks": - return find_peaks(data-bkg, median+std*self.sig_src, box_size=11) - - else: - roundhi=max((self.round1hi,self.round2hi)) - find=DAOStarFinder(std*self.sig_src, self.fwhm, sharplo=self.sharplo, sharphi=self.sharphi, - roundlo=-roundhi, roundhi=roundhi, peakmax=np.inf, xycoords=xycoords) - return find(data - bkg) - - - def _bkg2d(self, data): - return Background2D(data, self.boxsize, filter_size=3).background - - def match(self, base, cat): - """ - Internal function to class - Used to match detenctoins from separate background subtracted images - into the main catalogue. This will append a source if its matched separation - is above the threshold = self.fwhm - - Parameters - ---------- - base : `astropy.table.Table` - Base catalogue to match to - - cat : `astropy.table.Table` - Catalogue to be matched - - Returns - ------- - matched : astropy.Table - The matched catalogue - """ - - base_sky=SkyCoord(x=base["xcentroid"], y=base["ycentroid"], z=np.zeros(len(base)), representation_type="cartesian") - cat_sky=SkyCoord(x=cat["xcentroid"], y=cat["ycentroid"], z=np.zeros(len(cat)), representation_type="cartesian") - idx,separation,dist=cat_sky.match_to_catalog_3d(base_sky) - mask= dist.to_value() >self.fwhm - return vstack((base,cat[mask])) - - - def find_stars(self, data, mask=None): - """ - This routine runs source detection several times, but on a different form - of the data array each time. Each form has been "skewed" somehow to brighten the - most faint sources and flatten the differential background. - - 1:Plain detections - 2:Subtract Background estimation - 3:RickerWave convolution - - Parameters - ---------- - data : `numpy.ndarray` - 2D image array to detect on - - mask : `numpy.ndarray` - Pixels to mask out on the data array - """ - if data is None: return None - if mask is None: mask=np.where(np.isnan(data)) - _,median,_=sigma_clipped_stats(data,sigma=self.sig_sky) - data[mask]=median - - self.catalogue=self.detect(data) - if self.verbose: printf("-> [PLAIN] pass: %d sources\n"%len(self.catalogue)) - - if self.dobgd2d: - self.catalogue=self.match(self.catalogue, self.detect(data, self._bkg2d)) - if self.verbose: printf("-> [BGD2D] pass: %d sources\n"%len(self.catalogue)) - - ## 2nd order differential detection - if self.doconvl: - kernel=RickerWavelet2DKernel(self.ricker_r) - conv=convolve(data, kernel) - corr=match_template(conv/np.amax(conv), kernel.array) - _detections=self.detect(corr, method="findpeaks") - if _detections: - _detections["x_peak"]+=kernel.shape[0]//2 - _detections["y_peak"]+=kernel.shape[0]//2 - _detections.rename_columns( ("x_peak","y_peak"),("xcentroid","ycentroid")) - self.catalogue=self.match(self.catalogue, _detections) - if self.verbose: printf("-> [CONVL] pass: %d sources\n"%len(self.catalogue)) - - ## Now with xycoords DAOStarfinder will refit the sharp and round values at the detected locations - #self.catalogue=self.detect(data, xycoords=np.array([self.catalogue["xcentroid"],self.catalogue["ycentroid"]]).T)#, clean=0) - tmp=SourceProperties(data,self.catalogue, verbose=self.verbose).calculate_geometry(self.fwhm) - if tmp: self.catalogue=tmp - - mask=(~np.isnan(self.catalogue["xcentroid"]) & ~np.isnan(self.catalogue["ycentroid"])) - #self.catalogue.remove_rows(~mask) - - if self.cleansrc: - mask &=((self.catalogue["sharpness"]>self.sharplo) - &(self.catalogue["sharpness"] -self.round1hi) - &(self.catalogue["roundness1"]< self.round1hi) - &(self.catalogue["roundness2"]> -self.round2hi) - &(self.catalogue["roundness2"]< self.round2hi)) - if self.verbose: printf("-> cleaning %d unlikley point sources\n"%sum(~mask)) - self.catalogue.remove_rows(~mask) - - if self.verbose: printf("Total: %d sources\n"%len(self.catalogue)) - - self.catalogue.replace_column("id", Column(range(1,1+len(self.catalogue)))) - - return self.catalogue - -class APPhot_Routine(): - """ - Aperture photometry called by starbug - - Parameters - ---------- - radius : float - Pixel radius of photometric aperture. - - sky_in : float - Pixel radius of inner sky annuli. - - sky_out : float - Pixel radius of outer sky annuli. - - verbose : bool - Set whether to print verbose output information - """ - def __init__(self, radius, sky_in, sky_out, verbose=0): - if sky_in < radius: - warn("Sky annulus radii must be larger than aperture radius.\n") - sky_in=radius+1 - - if sky_in >= sky_out: - warn("Sky annulus outer radii must be larger than the inner.\n") - sky_out=sky_in+1 - - self.radius=radius - self.sky_in=sky_in - self.sky_out=sky_out - self.catalogue=Table(None)#, names=["ap_flux_r%d"%n for n in range(len(radii))]+["sky_median"]) - self.verbose=verbose - - def __call__(self, image, detections, **kwargs): - return self.run(image, detections, **kwargs) - - def run(self, image, detections, error=None, dqflags=None, apcorr=1.0, sig_sky=3): - """ - Forced aperture photometry on a list of detections - detections are a astropy.table.Table with columns xcentroid ycentroid or x_0 y_0 - This will add extra columns into this table ap_flux ap_sky - - Parameters - ---------- - detections : `astropy.table.Table` - Table with source poisitions containing xcentroid,ycentroid columns - - image : `numpy.ndarray` - 2D Image data to run photometry on - - error : `numpy.ndarray` - 2D Image array containing photometric error per pixel - - dqflags : `numpy.ndarray` - 2D Image array containing JWST data quality flags per pixel - - apcorr : float - Aperture correction to be applied to the flux - - sig_sky : float - Sigma threshold above the median to clip from sky apertures - - RETURN - ------ - Photometry catalogue - """ - if len( set(("xcentroid","ycentroid")) & set(detections.colnames))==2: - pos=[(line["xcentroid"],line["ycentroid"]) for line in detections] - elif len( set(("x_0","y_0")) & set(detections.colnames))==2: - pos=[(line["x_0"],line["y_0"]) for line in detections] - elif len( set(("x_init","y_init")) & set(detections.colnames))==2: - pos=[(line["x_init"],line["y_init"]) for line in detections] - else: - perror("Cannot identify position in detection catalogue (x_0/xcentroid)\n"); - return None - - mask=np.isnan(image) - if error is None: - error=np.sqrt(image) - - apertures=CircularAperture(pos,self.radius) - smooth_apertures=CircularAperture(pos, min(1.5*self.radius,self.sky_in)) - annulus_aperture=CircularAnnulus(pos, r_in=self.sky_in, r_out=self.sky_out) - - self.log("-> apertures: %.2g (%.2g - %.2g)\n"%(self.radius, self.sky_in, self.sky_out)) - phot=aperture_photometry(image, (apertures,smooth_apertures), error=error, mask=mask) - self.catalogue=Table(np.full((len(pos),4),np.nan),names=("smoothness","flux","eflux","sky")) - - self.log("-> calculating sky values\n") - masks=annulus_aperture.to_mask(method="center") - dat=list(map(lambda a:a.multiply(image),masks)) - - try: dat=np.array(dat).astype(float) - except: - ## Cases where the array is inhomegenoeus - ## If annulus reaches the edge of the image, it will create a mask the wrong shape - ## If for whatever reason the point lies outside the image, it will have None - ## in the list, this needs to be caught too - warn("Ran into issues with the sky annuli, trying to fix them..\n") - size=np.max( [np.shape(d) for d in dat if d is not None ]) - for i,d in enumerate(dat): - if d is None: dat[i]=np.zeros((size,size)) - elif (shape:=np.shape(d))!=(size,size): - dat[i]=np.zeros((size,size)) - dat[i][:shape[0],:shape[1]]+=d - dat=np.array(dat) - - mask=(dat>0 & np.isfinite(dat)) - dat[~mask]=np.nan - dat=sigma_clip(dat.reshape(dat.shape[0],-1), sigma=sig_sky,axis=1) - self.catalogue["sky"]=np.ma.median(dat,axis=1).filled(fill_value=0) - std=np.ma.std(dat,axis=1) - - epoisson=phot["aperture_sum_err_0"] - esky_scatter= apertures.area*std**2 - esky_mean= (std**2 * apertures.area**2) / annulus_aperture.area - - self.catalogue["eflux"]=np.sqrt( epoisson**2 +esky_scatter**2 +esky_mean**2) - self.catalogue["flux"]=apcorr*(phot["aperture_sum_0"] - (self.catalogue["sky"]*apertures.area)) - - self.catalogue["flux"][ self.catalogue["flux"]==0]=np.nan - - ###################### - # Source "smoothness", the gradient of median pixel values within the two test apertures - ###################### - self.catalogue["smoothness"] = (phot["aperture_sum_1"]/smooth_apertures.area) / (phot["aperture_sum_0"]/apertures.area) - - col=Column(np.full(len(apertures),SRC_GOOD), dtype=np.uint16, name="flag") - if dqflags is not None: - self.log("-> flagging unlikely sources\n") - for i, mask in enumerate(apertures.to_mask(method="center")): - _tmp=mask.multiply(dqflags) - if _tmp is not None: - dat=np.array(_tmp,dtype=np.uint32) - if np.sum( dat & (DQ_DO_NOT_USE|DQ_SATURATED)): col[i]|=SRC_BAD - if np.sum( dat & DQ_JUMP_DET): col[i]|=SRC_JMP - #else: col[i]|=SRC_UKN - self.catalogue.add_column(col) - return self.catalogue - - - @staticmethod - def calc_apcorr(filter, radius, table_fname=None, verbose=0): - """ - Using CRDS apcorr table, fit a curve to the radius vs apcorr - columns and then return aporr to respective input radius - """ - if not table_fname or not os.path.exists(table_fname): return 1 - tmp=Table.read(table_fname, format="fits") - - if "filter" in tmp.colnames: - t_apcorr=tmp[(tmp["filter"]==filter)] - else: t_apcorr=tmp - - - if "pupil" in t_apcorr.colnames: - t_apcorr=t_apcorr[ t_apcorr["pupil"]=="CLEAR"] - - apcorr= np.interp(radius, t_apcorr["radius"], t_apcorr["apcorr"]) - if verbose: printf("-> estimating aperture correction: %.3g\n"%apcorr) - - #eefrac= np.interp(radius, t_apcorr["radius"], t_apcorr["eefraction"]) - #if verbose: printf("-> effective encircled energy fraction: %.3g\n"%eefrac) - return apcorr - - - @staticmethod - def apcorr_from_encenergy(filter, encircled_energy, table_fname=None, verbose=0): - """ - Rather than fitting radius to the APCORR CRDS, use the closes Encircled energy value - """ - if not table_fname or not os.path.exists(table_fname): return 1 - tmp=Table.read(table_fname, format="fits") - - if "filter" in tmp.colnames: - t_apcorr=tmp[(tmp["filter"]==filter)] - else: t_apcorr=tmp - - line=t_apcorr[(np.abs(t_apcorr["eefraction"]-encircled_energy)).argmin()] - if verbose: - printf("-> best matching encircled energy %.1f, with radius %g pixels\n"%(line["eefraction"],line["radius"])) - printf("-> using aperture correction: %f\n"%line["apcorr"]) - - return line["apcorr"], line["radius"] - - def radius_from_encenrgy(filter, eefrac, table_fname): - """ - """ - if not table_fname or not os.path.exists(table_fname): return -1 - t_apcorr=Table.read(table_fname, format="fits") - - if len( set(["eefraction","radius"])&set(t_apcorr.colnames))!=2: return -1 - - if "filter" in t_apcorr.colnames: # Crop down table - t_apcorr=t_apcorr[(t_apcorr["filter"]==filter)] - - if "pupil" in t_apcorr.colnames: # Crop down table - t_apcorr=t_apcorr[ t_apcorr["pupil"]=="CLEAR"] - - return np.interp( eefrac, t_apcorr["eefraction"], t_apcorr["radius"]) - - def log(self,msg): - """ - log message if in verbose mode - """ - if self.verbose: - printf(msg) - sys.stdout.flush() - - -class BackGround_Estimate_Routine(BackgroundBase): - """ - Diffuse background emission estimator run by starbug. - - Parameters - ---------- - sourcelist : `astropy.table.Table` - List of sources in the image in a table containing "xcentroid" "ycentroid". - - boxsize : int - Size of the kernel to pass over the image in unsharp masking. - - fwhm : float - Source full width hald maximum in the image. - - sigsky : float - . - - bgd_r : float - . - - profile_scale : float - . - - profile_slope : float - . - - verbose : bool - . - """ - def __init__(self, sourcelist, boxsize=2, fwhm=2, sigsky=2, bgd_r=-1, profile_scale=1, profile_slope=0.5, verbose=0, bgd=None):#mask_r0=7, mask_r1=9 - self.sourcelist=sourcelist - self.boxsize=boxsize - self.fwhm=fwhm - self.sigsky=sigsky - self.bgd_r=bgd_r - self.A=profile_scale - self.B=profile_slope - self.verbose=verbose - self.bgd=bgd - super().__init__() - - def calc_peaks(self,im): - """ - Determine peak pixel value for each source in xy - """ - x=self.sourcelist["xcentroid"] - y=self.sourcelist["ycentroid"] - apertures=CircularAperture(np.array((x,y)).T,2).to_mask() - peaks=np.full(len(x),np.nan) - for i,mask in enumerate(apertures): - peaks[i]=np.nanmax( mask.multiply(im) ) - return peaks - - def log(self,msg): - if self.verbose: printf(msg) - - def __call__(self, data, axis=None, masked=False, output=None): - if self.sourcelist is None or data is None: return self.bgd - _data=np.copy(data) - X,Y=np.ogrid[:data.shape[1], :data.shape[0]] - - FUDGE=1 - DEFAULT_R=2*self.fwhm - - if self.bgd_r and self.bgd_r>0: - self.log("-> using BGD_R=%g masking aperture radii\n"%self.bgd_r) - rlist=self.bgd_r*np.ones(len(self.sourcelist)) - - else: - if "flux" in self.sourcelist.colnames: - self.log("-> calculating source aperture mask radii\n") - sky= self.sourcelist["sky"] if "sky" in self.sourcelist.colnames else 1.0 - rlist= self.A*self.fwhm* (np.log(self.sourcelist["flux"]/sky))**self.B - rlist[np.isnan(rlist)]=DEFAULT_R - if output: - with open(output,'w') as fp: - for i in range(len(rlist)): - fp.write("circle %f %f %f #color=green;"%(1+self.sourcelist[i]["xcentroid"],1+self.sourcelist[i]["ycentroid"], rlist[i])) - fp.write("annulus %f %f %f %f #color=white;"%(1+self.sourcelist[i]["xcentroid"],1+self.sourcelist[i]["ycentroid"], 1.5*rlist[i], 1.5*rlist[i]+1)) - self.log("-> exporting check file \"%s\"\n"%output) - else: - warn("Unable to caluclate aperture mask sizes, add '-A' to starbug command.\n") - rlist=DEFAULT_R*np.ones(len(self.sourcelist)) - - D=50 - load=loading(len(self.sourcelist), msg="masking sources", res=10)#len(self.sourcelist)/1000) - for r,src in zip(rlist,self.sourcelist): - - rin=1.5*r - rout=rin+1 - - x=round(src["xcentroid"]) - y=round(src["ycentroid"]) - _X=X[ max( x-D,0):min(x+D,data.shape[1])] - _Y=Y[ :,max( y-D,0):min(y+D,data.shape[0])] - - R=np.sqrt((_X-src["xcentroid"])**2+(_Y-src["ycentroid"])**2) - - mask=(Rrin) & (R estimating bgd2d\n") - self.bgd=Background2D(_data, self.boxsize).background - return self.bgd - - def calc_background(self,data, axis=None, masked=None): - if self.bgd is None: self.__call__(data) - return self.bgd - -#class _grouping(DAOGroup): -# """ -# Deprecated -# Overwritten DAOGroup that just holds the number of groups -# for use in verbose loading of psfphot routine -# >>> This is now a bit redundant after photoutils added progress_bar=true -# -# Issue:6 recursion during fitting. -# >>> the fitting seems to include this recursive step within a given source of sources -# if the group contains more members than the system recursion limit then it will -# unceremonially crash on a recursion error. -# >>> for now at least, I will give a warning that this would have occurred, but then -# ill override the recursion limit and avoid the crash. Hopefully -# """ -# logfile=None -# def __init__(self, *args, **kwargs): -# super().__init__(*args, **kwargs) -# self.ngroups=0 -# if os.getenv("SBIIDEBUG"): -# self.logfile=open(os.getenv("SBIIDEBUG"),'a') -# self.logfile.write("# PSF Source Grouping\n") -# -# def __call__(self, *args): -# res=super().__call__(*args) -# self.ngroups=max(res["group_id"]) -# -# #### hacking recursion error -# for gid in set(res["group_id"]): -# n_members=sum(res["group_id"]==gid) -# if self.logfile: -# self.logfile.write("GID:%d (%d)\n"%(gid,n_members)) -# -# ## It seems to not quite hit the recursion limit -# ## Crashed on 980 with a limit of 1000, so im going to try 90% -# if n_members > (0.9*sys.getrecursionlimit()): -# warn("This run will exceed the recursion depth of the system. " -# "Starbug will intervene and override the recursion limit but " -# "the parameter \"CRIT_SEP\" should be reduced to avoid this.\n" -# "Setting recursion limit %d -> %d\n"%(sys.getrecursionlimit(), int(2.0*n_members))) -# sys.setrecursionlimit(int(2.0*n_members)) -# if self.logfile: self.logfile.close() -# return res - -class _fitmodel(LevMarLSQFitter): - """ - Deprecated - """ - load=None - def __init__(self, grouper=None, verbose=1): - super().__init__() - self.grouper=grouper - if verbose: - self.load=loading(1, msg="fitting psfs") - - def __call__(self, *args, **kwargs): - if self.grouper and self.load: - self.load.setlen(self.grouper.ngroups) - if self.load is not None: - self.load() - self.load.show() - return super().__call__(*args,**kwargs) - - -class _Grouper(SourceGrouper): - """ - Overloaded SourceGrouper. This class gives a - starbug warning into stderr if there are more than CRITICAL_VAL source - in any given group. Then returns the original __call__ function resultsthe original __call__ function results. - - Parameters: - ----------- - min_separation : float>0 - The minimum distance (in pixels) such that any two sources - separated by less than this distance will be placed in the same - group if the ``min_size`` criteria is also met. - """ - CRITICAL_VAL=25 - min_separation=0 - def __init__(self,*args,**kwargs): - super().__init__(*args,**kwargs) - - def __call__(self,*args,**kwargs): - res= super().__call__(*args,**kwargs) - if (n:=sum( np.bincount(res)>self.CRITICAL_VAL)): - warn("Source grouper has %d groups larger than %d. Consider reducing \"CRIT_SEP=%g\" or fitting might take a long time.\n"%(n,self.CRITICAL_VAL, self.min_separation)) - - #n_stars_in_groups = np.bincount(res) - #ten_most_populus_groups = np.argsort(n_stars_in_groups)[-10:] - #for bin in np.flip(ten_most_populus_groups): - #print('Group {} with {} stars'.format(bin, n_stars_in_groups[bin])) - return res - -class PSFPhot_Routine(PSFPhotometry): - """ - PSF Photometry routine called by starbug - - Parameters - ---------- - psf_model : FittableImageModel - Model PSF to be used in the fitting - - fitshape : int or tuple - Size of PSF to use in pixels. Must be less than or equal to the - size of psf_model - - min_separation : float - Minimum source separation for source grouper (pixels) - - apphot_r : float - Aperture radius to be used in initial guess photometry - - force_fit : bool - Conduct forced centroid PSF fitting - - background : image - 2D array with the same dimensions as the data used in fitting - - verbose : bool, int - Show verbose outputs - """ - def __init__(self, psf_model, fitshape, apphot_r=3, min_separation=8, - force_fit=False, background=None, verbose=1): - - self.verbose=verbose - self.force_fit=force_fit - self.background=background - - grouper=_Grouper(min_separation) - - if force_fit: - psf_model.x_0.fixed=True - psf_model.y_0.fixed=True - - super().__init__(psf_model=psf_model, fit_shape=fitshape, finder=None, - progress_bar=verbose, aperture_radius=apphot_r, grouper=grouper) - - if self.verbose: - printf("-> source group separation: %g\n"%min_separation) - """ - super().__init__(grouper=group_maker, localbkg_estimator=bkg_estimator, - psf_model=psf_model, fit_shape=fitshape, - finder=None, fitter=fitter) - """ - - def __call__(self,*args,**kwargs): return self.do_photometry(*args,**kwargs) - def do_photometry(self, image, init_params=None, error=None, mask=None, progress_bar=False): - """ - """ - - if init_params is None or len(init_params)==0: - perror("Must include source list\n") - return None - - - - ### Removing completely masked sources - apertures=CircularAperture([(l["x_init"],l["y_init"]) for l in init_params],self.aperture_radius) - apmasks=aperture_photometry(~mask,apertures) - init_params.remove_rows(apmasks["aperture_sum"]==0) - - error[error==0]=sys.maxsize ## bad errors should be big not small - - if self.background is not None: image=image-self.background - if self.verbose: printf("-> fitting %d sources\n"%len(init_params)) - cat=super().__call__(image, mask=mask, init_params=init_params, error=error) - - d=np.sqrt((cat["x_init"]-cat["x_fit"])**2.0 + (cat["y_init"]-cat["y_fit"])**2.0) - cat.add_column(Column(d,name="xydev")) - - if "flux_err" not in cat.colnames: - cat.add_column(Column(np.full(len(cat),np.nan), name="eflux")) - warn("Something went wrong with PSF error fitting\n") - else: cat.rename_column("flux_err","eflux") - - cat.rename_column("flux_fit","flux") - - keep=["x_fit","y_fit","flux","eflux","xydev","qfit"] - return hstack((init_params, cat[keep])) - -class ArtificialStar_Routine(object): - """ - docstring - """ - def __init__(self, detector, psffitter, psf): - """ - detector - Detection class that fits the StarFinder base class - psffitter - PSF fitting class that fits the IterativelySubtractedPSFPhotometry base class - psf - DiscretePRF - """ - self.detector=detector - self.psffitter=psffitter - self.psf=psf - - print("WARNING: THIS IS UNDER DEVELOPMENT") - - - def run(self, image, ntests=1000, subimage_size=500, sources=None, fwhm=1, - flux_range=(0,1e5), separation_thresh=2, save_progress=1): - """ - run artificial star testing on an image - PARAMETERS - ---------- - ntests - number of tests to conduct - subimage_size - size of the cropped subimage - sources - precalculated positions to test stars - astropy table with x_0 y_0 flux columns - fwhm - FWHM of the stars to be added, - this is used to ensure the source isnt too close to the border - flux_range - range of fluxes to test - separation_thresh - number pixels above which the separation is too high and the artificial star failed - save_progress - periodically save the catalogue - - - - for each star, plaxe it into a copy of the base image. - however, the base image should be cropped in and around that new star - """ - #printf("starting artificial star testing..\n") - shape=np.array(image.shape) - psfsize=self.psf.shape - if np.any( subimage_size>shape): - warn("subimage_size bigger than image dimensions\n") - subimage_size=min(shape) - subimage_size=int(subimage_size) - - if not sources: - x_range=[ 2.0*fwhm, shape[0]-(2.0*fwhm)] - y_range=[ 2.0*fwhm, shape[1]-(2.0*fwhm)] - sources=make_random_models_table(int(ntests), {"x_0":x_range, "y_0":y_range, "flux":flux_range}, seed=int(time.time())) - - sources.add_column(Column(np.zeros(len(sources)), name="outflux")) - sources.add_column(Column(np.zeros(len(sources)), name="x_det")) - sources.add_column(Column(np.zeros(len(sources)), name="y_det")) - sources.add_column(Column(np.zeros(len(sources)), name="status")) - - load=loading(len(sources), msg="artificial star tests") - load.show() - for n,src in enumerate(sources): - - subx=0 - suby=0 - if subimage_size>0: - ## !! I might change this to be PSFSIZE not 2FWHM - subx = np.random.randint( max(0, src['x_0']+(2*fwhm)-subimage_size), min(shape[0]-subimage_size, src['x_0']-(2*fwhm))) - suby = np.random.randint( max(0, src['y_0']+(2*fwhm)-subimage_size), min(shape[1]-subimage_size, src['y_0']-(2*fwhm))) - #subx = np.random.randint( max(0, src['x_0']+(psfsize[0]/2)-subimage_size), min(shape[0]-subimage_size, src['x_0']-(psfsize[0]/2))) - #suby = np.random.randint( max(0, src['y_0']+(psfsize[1]/2)-subimage_size), min(shape[1]-subimage_size, src['y_0']-(psfsize[1]/2))) - - src_mod=Table(src)# src mod translates the position within the subimage - src_mod["x_0"]-=subx - src_mod["y_0"]-=suby - sky=image[subx:subx+subimage_size,suby:suby+subimage_size] - base=np.copy(sky)+ make_model_sources_image(2*[subimage_size], self.psf, src_mod) - #base=np.copy(image)+make_model_sources_image(shape, self.psf, Table(src)) - - detections=self.detector(base) - detections.rename_column("xcentroid", "x_0") - detections.rename_column("ycentroid", "y_0") - - separations=(src_mod["x_0"]-detections["x_0"])**2+(src_mod["y_0"]-detections["y_0"])**2 - best_match=np.argmin(separations) - if np.sqrt(separations[best_match]) <= separation_thresh: - psftab= self.psffitter(base, init_guesses=detections) - index=np.where( psftab["id"]==detections[best_match]["id"]) - - sources[n]["outflux"]=psftab[index]["flux_fit"] - sources[n]["x_det"]=psftab[index]["x_0"]+subx - sources[n]["y_det"]=psftab[index]["y_0"]+suby - - if abs(sources[n]["outflux"]-sources[n]["flux"]) < (sources[n]["flux"]/100.0): - sources[n]["status"]=1 # star matched - load() - load.show() - - if save_progress and not n%10: - export_table(sources[0:n], fname="/tmp/artificial_stars.save") - - return sources - - -class SourceProperties: - status=0 - def __init__(self, image, sourcelist, filter=None, verbose=1): - self.image=image - self.sourcelist=None - self.verbose=verbose - - if sourcelist and type(sourcelist) in (Table,QTable): - if len( set(("xcentroid","ycentroid")) & set(sourcelist.colnames))==2: - self.sourcelist=Table(sourcelist[["xcentroid","ycentroid"]]) - elif len( set(("x_0","y_0")) & set(sourcelist.colnames))==2: - self.sourcelist=Table(sourcelist[["x_0","y_0"]]) - self.sourcelist.rename_columns( ("x_0","y_0"), ("xcentroid","ycentroid")) - else: perror("no posisional columns in sourcelist\n") - else: perror("bad sourcelist type: %s\n"%type(sourcelist)) - - - def __call__(self, do_crowd=1, **kwargs): - """ - - """ - out=Table()#self.sourcelist.copy() - if do_crowd: ## This can be slow - out=hstack((out,Table([self.calculate_crowding(**kwargs)],names=["crowding"]))) - - out=hstack((out,self.calculate_geometry(**kwargs))) - return out - - def calculate_crowding(self,N=10, **kwargs): - """ - Crowding Index: Sum of magnitude of separation of N closest sources - """ - if self.sourcelist is None: - perror("no sourcelist\n") - return None - - crowd=np.zeros(len(self.sourcelist)) - load=loading(len(self.sourcelist),msg="calculating crowding", res=10) - - for i,src in enumerate(self.sourcelist): - dist=np.sqrt( (src["xcentroid"]-self.sourcelist["xcentroid"])**2 + (src["ycentroid"]-self.sourcelist["ycentroid"])**2 ) - dist.sort() - crowd[i]= sum( dist[1:N]) - load() - if self.verbose: load.show() - return crowd - - def calculate_geometry(self, fwhm=2, **kwargs): - """ - - """ - if self.sourcelist is None: - perror("no sourcelist\n") - return None - if self.verbose: printf("-> measuring source geometry\n") - xycoords=np.array((self.sourcelist["xcentroid"], self.sourcelist["ycentroid"])).T - - daofind=DAOStarFinder(-np.inf, fwhm, sharplo=-np.inf, sharphi=np.inf, roundlo=-np.inf, roundhi=np.inf, xycoords=xycoords, peakmax=np.inf) - return daofind._get_raw_catalog(self.image).to_table() - diff --git a/starbug2/routines/__init__.py b/starbug2/routines/__init__.py new file mode 100644 index 0000000..06d2fb1 --- /dev/null +++ b/starbug2/routines/__init__.py @@ -0,0 +1,14 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" diff --git a/starbug2/routines/app_hot_routine.py b/starbug2/routines/app_hot_routine.py new file mode 100644 index 0000000..3870538 --- /dev/null +++ b/starbug2/routines/app_hot_routine.py @@ -0,0 +1,378 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import os +import sys +from typing import Tuple + +import numpy as np + +from astropy.stats import sigma_clip +from astropy.table import Column, Table, Row, QTable + +from photutils.aperture import ( + CircularAperture, CircularAnnulus, aperture_photometry, ApertureMask) + +from starbug2.constants import ( + SourceFlags, DQFlags, TableColumn, HeaderTags, Modes, QTableColNames) +from starbug2.utils import printf, p_error, warn + + +class APPhotRoutine: + + @staticmethod + def calc_ap_corr( + filter_string: str, radius: float, + table_f_name: str | None = None, + verbose: int | bool = 0) -> float: + """ + Using CRDS ap_corr table, fit a curve to the radius vs ap_corr + columns and then return ap_corr to respective input radius + + :param filter_string: the filter string + :type filter_string: str + :param radius: the radius + :type radius: float + :param table_f_name: the table file name + :type table_f_name: str + :param verbose: int for verbose. + :type verbose: int + :return: the ap_corr float. + :rtype: float + :raises FileNotFoundError when the table_f_name does not exist + """ + + if not table_f_name or not os.path.exists(table_f_name): + raise FileNotFoundError("cant find the table filename") + + tmp: Table = Table.read(table_f_name, format="fits") + + t_ap_corr: Table + if HeaderTags.FILTER_LOWER in tmp.colnames: + t_ap_corr = tmp[(tmp[HeaderTags.FILTER_LOWER] == filter_string)] + else: + t_ap_corr = tmp + + if TableColumn.PUPIL in t_ap_corr.colnames: + t_ap_corr = t_ap_corr[t_ap_corr[TableColumn.PUPIL] == Modes.CLEAR] + + ap_corr: float = float(np.interp( + radius, t_ap_corr[TableColumn.RADIUS], + t_ap_corr[TableColumn.AP_CORR])) + if verbose: + printf("-> estimating aperture correction: %.3g\n" % ap_corr) + return ap_corr + + @staticmethod + def ap_corr_from_enc_energy( + filter_string: str, encircled_energy: np.ndarray, + table_f_name: str | None = None, verbose: int | bool = 0) -> ( + Tuple[np.ndarray, np.ndarray]): + """ + Rather than fitting radius to the AP_CORR CRDS, use the closes + Encircled energy value + + :param filter_string: the filter string + :type filter_string: str + :param encircled_energy: the encircled energy + :type encircled_energy: np.array + :param table_f_name: the table file name + :type table_f_name: str + :param verbose: int for verbose. + :type verbose: int | bool + :return: tuple of ap_corr and radius or ExitFail. + :rtype: tuple of np.array and np.array or int. + :raises: FileNotFoundError when the table_f_name does not exist. + """ + if not table_f_name or not os.path.exists(table_f_name): + raise FileNotFoundError("cannot find table f name") + + tmp: Table = Table.read(table_f_name, format="fits") + + if HeaderTags.FILTER_LOWER in tmp.colnames: + t_ap_corr = tmp[(tmp[HeaderTags.FILTER_LOWER] == filter_string)] + else: + t_ap_corr = tmp + + line: Row = t_ap_corr[(np.abs( + t_ap_corr[TableColumn.EE_FRACTION] - encircled_energy)).argmin()] + if verbose: + printf( + "-> best matching encircled energy %.1f," + " with radius %g pixels\n" % ( + line[TableColumn.EE_FRACTION], line[TableColumn.RADIUS])) + printf( + "-> using aperture correction: %f\n" % + line[TableColumn.AP_CORR]) + + return line[TableColumn.AP_CORR], line[TableColumn.RADIUS] + + @staticmethod + def radius_from_enc_energy( + filter_string: str, ee_frac: float, + table_f_name: str | None) -> float: + """ + """ + if not table_f_name or not os.path.exists(table_f_name): + raise FileNotFoundError("cannot find table f name") + t_ap_corr = Table.read(table_f_name, format="fits") + + if (len({TableColumn.EE_FRACTION, TableColumn.RADIUS} & + set(t_ap_corr.col_names)) != 2): + raise Exception("invalid col_names size.") + + # Crop down table + if HeaderTags.FILTER_LOWER in t_ap_corr.col_names: + t_ap_corr = t_ap_corr[ + (t_ap_corr[HeaderTags.FILTER_LOWER] == filter_string)] + + # Crop down table + if TableColumn.PUPIL in t_ap_corr.col_names: + t_ap_corr = t_ap_corr[t_ap_corr[TableColumn.PUPIL] == Modes.CLEAR] + + return float( + np.interp( + ee_frac, t_ap_corr[TableColumn.EE_FRACTION], + t_ap_corr[TableColumn.RADIUS])) + + def __init__( + self, radius: float, sky_in: float, sky_out: float, + verbose: int | bool = 0) -> None: + """ + Aperture photometry called by starbug + + :param radius: Pixel radius of photometric aperture + :type radius: float + :param sky_in: Pixel radius of inner sky annuli + :type sky_in: float + :param sky_out: Pixel radius of outer sky annuli + :type sky_out: float + :param verbose: Set whether to print verbose output information + :type verbose: bool + """ + if sky_in < radius: + warn("Sky annulus radii must be larger than aperture radius.\n") + sky_in = radius + 1 + + if sky_in >= sky_out: + warn("Sky annulus outer radii must be larger than the inner.\n") + sky_out = sky_in + 1 + + self.radius: float = radius + self.sky_in: float = sky_in + self.sky_out: float = sky_out + self.catalogue: Table = Table(None) + self.verbose: int | bool = verbose + + def __call__( + self, image: np.ndarray, + detections: Table, + error: np.ndarray | None = None, + dq_flags: np.ndarray | None = None, + ap_corr: float = 1.0, + sig_sky: float = 3.0) -> Table: + """ + Forced aperture photometry on a list of detections are an + `astropy.table.Table` with columns x_centroid y_centroid or x_0 y_0 + This will add extra columns into this table ap_flux ap_sky + + :param detections: Table with source positions containing + x_centroid, y_centroid columns + :type detections: astropy.table.Table + :param image: 2D Image data to run photometry on + :type image: numpy.ndarray + :param error: 2D Image array containing photometric error per pixel + :type error: numpy.ndarray + :param dq_flags: 2D Image array containing JWST data quality flags per + pixel + :type dq_flags: numpy.ndarray + :param ap_corr: Aperture correction to be applied to the flux + :type ap_corr: float + :param sig_sky: Sigma threshold above the median to clip from sky + apertures + :type sig_sky: float + :return: Photometry catalogue + :rtype: astropy.table.Table + """ + return self._run(image, detections, error, dq_flags, ap_corr, sig_sky) + + def _run(self, image: np.ndarray, + detections: Table, + error: np.ndarray | None = None, + dq_flags: np.ndarray | None = None, + ap_corr: float = 1.0, + sig_sky: float = 3.0) -> Table: + """ + Forced aperture photometry on a list of detections are an + `astropy.table.Table` with columns x_centroid y_centroid or x_0 y_0 + This will add extra columns into this table ap_flux ap_sky + + :param detections: Table with source positions containing + x_centroid, y_centroid columns + :type detections: astropy.table.Table + :param image: 2D Image data to run photometry on + :type image: numpy.ndarray + :param error: 2D Image array containing photometric error per pixel + :type error: numpy.ndarray + :param dq_flags: 2D Image array containing JWST data quality flags per + pixel + :type dq_flags: numpy.ndarray + :param ap_corr: Aperture correction to be applied to the flux + :type ap_corr: float + :param sig_sky: Sigma threshold above the median to clip from sky + apertures + :type sig_sky: float + :return: Photometry catalogue + :rtype: astropy.table.Table + """ + pos: list[tuple[Table | Row, Table | Row]] + if (len({TableColumn.X_CENTROID, TableColumn.Y_CENTROID} & + set(detections.colnames)) == 2): + pos = [(line[TableColumn.X_CENTROID], + line[TableColumn.Y_CENTROID]) for line in detections] + elif (len({TableColumn.X_0, TableColumn.Y_0} & + set(detections.colnames)) == 2): + pos = [ + (line[TableColumn.X_0], + line[TableColumn.Y_0]) for line in detections] + elif (len({TableColumn.X_INIT, TableColumn.Y_INIT} & + set(detections.colnames)) == 2): + pos = [ + (line[TableColumn.X_INIT], + line[TableColumn.Y_INIT]) for line in detections] + else: + p_error( + "Cannot identify position in detection catalogue (" + "x_0/x_centroid)\n") + raise Exception( + "Cannot identify position in detection catalogue (" + "x_0/x_centroid)\n") + + mask: np.ndarray = np.isnan(image) + if error is None: + error = np.sqrt(image) + + apertures: CircularAperture = CircularAperture(pos, self.radius) + smooth_apertures: CircularAperture = CircularAperture( + pos, min(1.5 * self.radius, self.sky_in)) + annulus_aperture: CircularAnnulus = CircularAnnulus( + pos, r_in=self.sky_in, r_out=self.sky_out) + + self.log( + "-> apertures: %.2g (%.2g - %.2g)\n" % ( + self.radius, self.sky_in, self.sky_out)) + phot: QTable = aperture_photometry( + image, (apertures, smooth_apertures), error=error, mask=mask) + self.catalogue = ( + Table(np.full((len(pos), 4), np.nan), + names=(TableColumn.SMOOTHNESS, TableColumn.FLUX, + TableColumn.E_FLUX, TableColumn.SKY))) + + self.log("-> calculating sky values\n") + masks_raw = annulus_aperture.to_mask(method="center") + + # convert to list + masks: list[ApertureMask] = ( + masks_raw if isinstance(masks_raw, list) else [masks_raw]) + + # generate dat_list. + dat_list: list[np.ndarray | None] = list( + map(lambda a: a.multiply(image), masks)) + dat: np.ndarray + + try: + dat = np.array(dat_list).astype(float) + except (ValueError, TypeError) as e: + # Cases where the array is inhomogeneous + # If annulus reaches the edge of the image, it will create a + # mask the wrong shape. If for whatever reason the point lies + # outside the image, it will have None in the list, this needs + # to be caught too + warn( + f"Ran into issues with the sky annuli. {e}," + f" trying to fix them..\n") + size: int = int( + np.max([np.shape(d) for d in dat_list if d is not None])) + fixed_dat: list[np.ndarray] = [] + for d in dat_list: + if d is None: + fixed_dat.append(np.zeros((size, size))) + elif (shape := np.shape(d)) != (size, size): + padded: np.ndarray = np.zeros((size, size)) + padded[:shape[0], :shape[1]] += d + fixed_dat.append(padded) + else: + fixed_dat.append(d) + dat = np.array(fixed_dat) + + mask = (dat > 0 & np.isfinite(dat)) + dat[~mask] = np.nan + clipped_dat: np.ma.MaskedArray = np.ma.MaskedArray(sigma_clip( + dat.reshape(dat.shape[0], -1), sigma=sig_sky, axis=1)) + self.catalogue[TableColumn.SKY] = ( + np.ma.median(clipped_dat, axis=1).filled(fill_value=0)) + std: np.ndarray = np.ma.std(clipped_dat, axis=1) + + e_poisson: np.ndarray = phot[QTableColNames.SUM_ERR_0] + esky_scatter: np.ndarray = apertures.area * std ** 2 + esky_mean: np.ndarray = ( + (std ** 2 * apertures.area ** 2) / annulus_aperture.area) + + self.catalogue[TableColumn.E_FLUX] = np.sqrt( + e_poisson ** 2 + esky_scatter ** 2 + esky_mean ** 2) + self.catalogue[TableColumn.FLUX] = ( + ap_corr * (phot[QTableColNames.SUM_0] - ( + self.catalogue[TableColumn.SKY] * apertures.area))) + + self.catalogue[TableColumn.FLUX][ + self.catalogue[TableColumn.FLUX] == 0] = np.nan + + ###################### + # Source "smoothness", the gradient of median pixel values within the + # two test apertures + ###################### + + self.catalogue[TableColumn.SMOOTHNESS] = ( + (phot[QTableColNames.SUM_1] / smooth_apertures.area) + / (phot[QTableColNames.SUM_0] / apertures.area)) + + col: Column = Column( + np.full(len(apertures), SourceFlags.SRC_GOOD), + dtype=np.uint16, name=TableColumn.FLAG) + if dq_flags is not None: + self.log("-> flagging unlikely sources\n") + for i, ap_mask in enumerate(apertures.to_mask(method="center")): + i: int + ap_mask: ApertureMask + tmp: np.ndarray = ap_mask.multiply(dq_flags) + if tmp is not None: + dq_dat = np.array(tmp, dtype=np.uint32) + if np.sum(dq_dat & ( + DQFlags.DQ_DO_NOT_USE | DQFlags.DQ_SATURATED)): + col[i] |= SourceFlags.SRC_BAD + if np.sum(dq_dat & DQFlags.DQ_JUMP_DET): + col[i] |= SourceFlags.SRC_JMP + self.catalogue.add_column(col) + return self.catalogue + + def log(self, msg: str) -> None: + """ + log message if in verbose mode + + :param msg: message to log + :return: None + """ + if self.verbose: + printf(msg) + sys.stdout.flush() diff --git a/starbug2/routines/artificial_star_routine.py b/starbug2/routines/artificial_star_routine.py new file mode 100644 index 0000000..b8b63b9 --- /dev/null +++ b/starbug2/routines/artificial_star_routine.py @@ -0,0 +1,191 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import time +import numpy as np +from astropy.table import Column, Table +from photutils.datasets import make_model_image, make_random_models_table +from photutils.detection import StarFinder +from photutils.psf import IterativePSFPhotometry, ImagePSF + +from starbug2.constants import TableColumn +from starbug2.utils import Loading, warn, export_table + + +class ArtificialStarRoutine: + def __init__( + self, + detector: StarFinder, + psf_fitter: IterativePSFPhotometry, + psf: ImagePSF): + """ + :param detector: Detection class that fits the StarFinder base class + :type detector: photutils.detection.StarFinder + :param psf_fitter: PSF fitting class that fits the + IterativePSFPhotometry base class + :type psf_fitter: photutils.psf.IterativePSFPhotometry + :param psf: Empirical Image Point Spread Function model + :type psf: photutils.psf.ImagePSF + """ + self._detector: StarFinder = detector + self._psf_fitter: IterativePSFPhotometry = psf_fitter + self._psf: ImagePSF = psf + + print("WARNING: THIS IS UNDER DEVELOPMENT") + + def run(self, + image: np.ndarray, + n_tests: int = 1000, + sub_image_size: int = 500, + sources: Table | None = None, + full_width_half_max: float = 1.0, + flux_range: tuple[float, float] | list[float] = (0.0, 1e5), + separation_thresh: float = 2.0, + save_progress: bool = True) -> Table: + # noinspection SpellCheckingInspection + """ + Run artificial star testing on an image. + + :param image: the image to run artificial star routine on. + :type image: numpy.ndarray + :param n_tests: Number of tests to conduct + :type n_tests: int + :param sub_image_size: Size of the cropped sub_image + :type sub_image_size: int + :param sources: Precalculated positions to test stars with x_0, y_0, + and flux columns + :type sources: astropy.table.Table or None + :param full_width_half_max: FWHM of the stars to be added (used for + border safety checks). + :type full_width_half_max: float. + :param flux_range: Range of fluxes to test. + :type flux_range: tuple or list. + :param separation_thresh: Number of pixels above which a detection is + considered a failure. + :type separation_thresh: float. + :param save_progress: Periodically save the catalogue during the run. + :type save_progress: bool. + :return: Table containing the injected parameters alongside recoveries. + :rtype: astropy.table.Table. + """ + shape: np.ndarray = np.array(image.shape) + if np.any(sub_image_size > shape): + warn("sub image_size bigger than image dimensions\n") + sub_image_size = int(min(shape)) + sub_image_size = int(sub_image_size) + + if sources is None: + x_range: list[float] = [ + 2.0 * full_width_half_max, + float(shape[0] - (2.0 * full_width_half_max)) + ] + y_range: list[float] = [ + 2.0 * full_width_half_max, + float(shape[1] - (2.0 * full_width_half_max)) + ] + sources = make_random_models_table( + int(n_tests), + {TableColumn.X_0: x_range, TableColumn.Y_0: y_range, + TableColumn.FLUX: flux_range}, + seed=int(time.time())) + + # noinspection SpellCheckingInspection + sources.add_column( + Column(np.zeros(len(sources)), name=TableColumn.OUT_FLUX)) + sources.add_column( + Column(np.zeros(len(sources)), name=TableColumn.X_DET)) + sources.add_column( + Column(np.zeros(len(sources)), name=TableColumn.Y_DET)) + sources.add_column( + Column(np.zeros(len(sources)), name=TableColumn.STATUS)) + + load: Loading = Loading(len(sources), msg="artificial star tests") + load.show() + + active_sources: Table = sources + + # noinspection PyTypeChecker + for n, src in enumerate(iter(active_sources)): + subx: int = 0 + suby: int = 0 + + if sub_image_size > 0: + subx = int(np.random.randint( + max(0, + src[TableColumn.X_0] + + (2 * full_width_half_max) - sub_image_size), + np.min( + shape[0] - sub_image_size, + src[TableColumn.X_0] - (2 * full_width_half_max)))) + suby = int(np.random.randint( + max(0, + src[TableColumn.Y_0] + + (2 * full_width_half_max) - sub_image_size), + np.min(shape[1] - sub_image_size, + src[TableColumn.Y_0] - (2 * full_width_half_max)))) + + # src mod translates the position within the sub-image + src_mod: Table = Table(src) + src_mod[TableColumn.X_0] -= subx + src_mod[TableColumn.Y_0] -= suby + + sky: np.ndarray = image[ + subx: subx + sub_image_size, suby: suby + sub_image_size] + + # Dynamically extract matrix geometry from sky to support + # rectangular crops safely + base: np.ndarray = np.copy(sky) + make_model_image( + shape=sky.shape, model=self._psf, params_table=src_mod + ) + + detections: Table = self._detector(base) + detections.rename_column(TableColumn.X_CENTROID, TableColumn.X_0) + detections.rename_column(TableColumn.Y_CENTROID, TableColumn.Y_0) + + # Check positional matches + separations: np.ndarray = ( + (src_mod[TableColumn.X_0] - detections[TableColumn.X_0]) ** 2 + + (src_mod[TableColumn.Y_0] - detections[TableColumn.Y_0]) ** 2 + ) + best_match: int = int(np.argmin(separations)) + + if np.sqrt(separations[best_match]) <= separation_thresh: + psf_tab: Table = self._psf_fitter( + base, init_params=detections) + index: np.ndarray = np.where( + psf_tab[TableColumn.ID] == + detections[best_match][TableColumn.ID])[0] + + if len(index) > 0: + matched_idx: int = int(index[0]) + sources[n][TableColumn.OUT_FLUX] = ( + psf_tab)[matched_idx][TableColumn.FLUX_FIT] + sources[n][TableColumn.X_DET] = ( + psf_tab[matched_idx][TableColumn.X_0] + subx) + sources[n][TableColumn.Y_DET] = ( + psf_tab[matched_idx][TableColumn.Y_0] + suby) + + if (abs(sources[n][TableColumn.OUT_FLUX] - + sources[n][TableColumn.FLUX]) + < (sources[n][TableColumn.FLUX] / 100.0)): + # star matched + sources[n]["status"] = 1 + load() + load.show() + + if save_progress and not n % 10: + export_table( + sources[0:n], f_name="/tmp/artificial_stars.save") + + return sources diff --git a/starbug2/routines/background_estimate_routine.py b/starbug2/routines/background_estimate_routine.py new file mode 100644 index 0000000..b52c39a --- /dev/null +++ b/starbug2/routines/background_estimate_routine.py @@ -0,0 +1,240 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +from typing import List, Optional, Union + +import numpy as np +from matplotlib.axis import Axis +from photutils.background import Background2D, BackgroundBase +from photutils.aperture import CircularAperture, ApertureMask +from astropy.table import Table +from starbug2.constants import TableColumn +from starbug2.utils import Loading, printf, warn + + +class BackGroundEstimateRoutine(BackgroundBase): + def __init__( + self, source_list: Table | None, + box_size: int = 2, full_width_half_max: float = 2.0, + sig_sky: float = 2.0, bgd_r: float = -1.0, + profile_scale: float = 1.0, profile_slope: float = 0.5, + verbose: bool | int = 0, + bgd: Optional[Background2D] = None) -> None: + """ + Diffuse background emission estimator run by starbug. + + :param source_list: List of sources in the image in a table + containing X_CENTROID Y_CENTROID + :type source_list: astropy.table.Table or None + :param box_size: Size of the kernel to pass over the image in + un-sharp masking + :type box_size: int + :param full_width_half_max: Source full width half maximum in the + image. + :type full_width_half_max: float. + :param sig_sky: Sigma threshold for background clipping. + :type sig_sky: float. + :param bgd_r: Fixed aperture mask radius around each source. + :type bgd_r: float. + :param profile_scale: Scaling factor for the aperture mask radius + profile. + :type profile_scale: float. + :param profile_slope: Slope of the aperture mask radius profile. + :type profile_slope: float. + :param verbose: Set whether to print verbose output information. + :type verbose: bool. + """ + self._source_list: Table | None = source_list + self._box_size: int = box_size + self._full_width_half_max: float = full_width_half_max + self._sig_sky: float = sig_sky + self._bgd_r: float = bgd_r + self._a: float = profile_scale + self._b: float = profile_slope + self._verbose: int | bool = verbose + self._background: Background2D | None = bgd + super().__init__() + + def calc_peaks(self, im: np.ndarray) -> np.ndarray: + """ + Determine peak pixel value for each source in xy + :param im: ?????? + :return: peaks + :rtype: np.array + """ + + assert self._source_list is not None + x: Table = self._source_list[TableColumn.X_CENTROID] + y: Table = self._source_list[TableColumn.Y_CENTROID] + apertures: List[ApertureMask] = CircularAperture( + np.array((x, y)).T, 2).to_mask() + peaks: np.ndarray = np.full(len(x), np.nan) + + i: int + mask: ApertureMask + for i, mask in enumerate(apertures): + peaks[i] = np.nanmax(mask.multiply(im)) + return peaks + + def log(self, msg: str) -> None: + """ + log this message. + :param msg: the message to log + :return: None + """ + if self._verbose: + printf(msg) + + def __call__( + self, data: np.ndarray | None, + axis: Optional[Axis] = None, masked: bool = False, + output: Optional[str] = None) -> Background2D | None: + """ + does background estimation routine. + + :param data: the data to process + :type data: np.ndarray + :param axis: the axis + :type axis: matplotlib.axis.Axis or None + :param masked: if the data is masked + :type masked: bool + :param output: if the data should be outputted + :type output: str + :return: the new background 2D object. + :rtype: Background2D + """ + if self._source_list is None or data is None: + return self._background + _data: np.ndarray = np.copy(data) + + x_grid: np.ndarray + y_grid: np.ndarray + x_grid, y_grid = np.ogrid[:data.shape[1], :data.shape[0]] + + default_r: float = 2 * self._full_width_half_max + + rlist: np.ndarray + if self._bgd_r and self._bgd_r > 0: + self.log( + "-> using BGD_R=%g masking aperture radii\n" % self._bgd_r) + rlist = self._bgd_r * np.ones(len(self._source_list)) + else: + if TableColumn.FLUX in self._source_list.colnames: + self.log("-> calculating source aperture mask radii\n") + sky: Union[np.ndarray, float] = ( + self._source_list["sky"] + if "sky" in self._source_list.colnames else 1.0) + rlist = ( + self._a * self._full_width_half_max * ( + np.log(self._source_list[TableColumn.FLUX] / sky)) + ** self._b) + rlist[np.isnan(rlist)] = default_r + + if output: + with open(output, 'w') as fp: + for i in range(len(rlist)): + radius_val: float = float(rlist[i]) + x_cen: float = float( + self._source_list[i][TableColumn.X_CENTROID]) + y_cen: float = float( + self._source_list[i][TableColumn.Y_CENTROID]) + + fp.write( + "circle %f %f %f #color=green;" % ( + 1 + x_cen, 1 + y_cen, radius_val)) + fp.write( + "annulus %f %f %f %f #color=white;" % ( + 1 + x_cen, 1 + y_cen, 1.5 * radius_val, + 1.5 * radius_val + 1)) + self.log("-> exporting check file \"%s\"\n" % output) + else: + warn("Unable to calculate aperture mask sizes, " + "add '-A' to starbug command.\n") + rlist = default_r * np.ones(len(self._source_list)) + + dimension: int = 50 + load: Loading = Loading( + len(self._source_list), msg="masking sources", res=10) + + r: float + src: Table + for r, src in zip(rlist, self._source_list): # type: ignore + + rin: float = 1.5 * r + rout: float = rin + 1 + + x: int = int(round(src[TableColumn.X_CENTROID])) + y: int = int(round(src[TableColumn.Y_CENTROID])) + _X: np.ndarray = x_grid[ + max(x - dimension, 0): min(x + dimension, data.shape[1])] + _Y: np.ndarray = y_grid[ + :, max(y - dimension, 0): min(y + dimension, data.shape[0])] + + radius: np.ndarray = np.sqrt( + (_X - src[TableColumn.X_CENTROID]) ** 2 + + (_Y - src[TableColumn.Y_CENTROID]) ** 2) + + mask: np.ndarray = (radius < r) + annuli_mask: np.ndarray = ((radius > rin) & (radius < rout)) + + tmp: np.ndarray = _data[_Y, _X] + tmp[mask] = np.median(data[_Y, _X][annuli_mask]) + _data[_Y, _X] = tmp + + load() + + # This will slow the thing down quite a lot + if self._verbose: + load.show() + if self._verbose: + printf("-> estimating bgd2d\n") + self._background = Background2D(_data, self._box_size) + return self._background + + def calc_background( + self, data: np.ndarray, + axis: Optional[int] = None, + masked: Optional[bool] = None) -> np.ndarray: + """ + Calculate the background value. + + Parameters + -------------- + data : array_like or `~numpy.ma.MaskedArray` + The array for which to calculate the background value. + + axis : int or `None`, optional + The array axis along which the background is calculated. If + `None`, then the entire array is used. + + masked : bool, optional + If `True`, then a `~numpy.ma.MaskedArray` is returned. If + `False`, then a `~numpy.ndarray` is returned, where masked + values have a value of NaN. The default is `False`. + + Returns + ----------- + result : float, `~numpy.ndarray`, or `~numpy.ma.MaskedArray` + The calculated background value. If ``masked`` is + `False`, then a `~numpy.ndarray` is returned, otherwise a + `~numpy.ma.MaskedArray` is returned. A scalar result is + always returned as a float. + """ + if self._background is None: + self.__call__(data) + if self._background is None: + raise Exception( + "the background file didnt get created for some reason") + return self._background.background diff --git a/starbug2/routines/detection_routines.py b/starbug2/routines/detection_routines.py new file mode 100644 index 0000000..d0d31bb --- /dev/null +++ b/starbug2/routines/detection_routines.py @@ -0,0 +1,381 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +from typing import Optional +from collections.abc import Callable + +import numpy as np +from scipy.ndimage import convolve +from skimage.feature import match_template + +from astropy.stats import sigma_clipped_stats +from astropy.coordinates import SkyCoord +from astropy.table import Column, Table, vstack +from astropy.convolution import RickerWavelet2DKernel +from astropy.units import Quantity + +from photutils.background import Background2D +from photutils.detection import StarFinderBase, DAOStarFinder, find_peaks + +from starbug2.constants import TableColumn +from starbug2.routines.source_properties import SourceProperties +from starbug2.utils import printf + + +class DetectionRoutine(StarFinderBase): + def __init__( + self, sig_src: float = 5.0, sig_sky: float = 3.0, + full_width_half_max: float = 2.0, sharp_lo: float = 0.2, + sharp_hi: float = 1, round_1_hi: float = 1, round_2_hi: float = 1, + smooth_lo: float = -np.inf, smooth_hi: float = np.inf, + ricker_r: float = 1.0, verbose: int | bool = 0, + clean_src: bool | int = 1, do_bgd_2d: bool | int = 1, + box_size: int = 2, do_con_vl: bool | int = 1) -> None: + # noinspection SpellCheckingInspection + """ + Detection routine + + A standalone detection that runs on a 2D image. + It uses DAOStarFinder as the base for peak detection but run + several times on a series of background subtracted images. + Each run the background subtraction is different, bringing out a + different set of sources + + :param sig_src: The detection flux threshold, this sets the number of + sigma above the image median flux that a source must + be brighter than. Default: sig_src=5 is a "solid" + detection. + :type sig_src: float + :param sig_sky: The number of sigma above the image median flux that + is still considered "sky". Pixels below this will be + cut out during the detection steps. + :type sig_sky: float + :param full_width_half_max: Full width half maximum of a standard + source in the image. + :type full_width_half_max: float + :param sharp_lo: Lowest bound for a source "sharpness". + :type sharp_lo: float + :param sharp_hi: Upper bound for a source "sharpness". + :type sharp_hi: float + :param round_1_hi: Upper bound for a source "roundness1", this + distribution is symmetric and the lower bound is + taken as negative round_1_hi. + :type round_1_hi: float + :param round_2_hi: Upper bound for a source "roundness2", this + distribution is symmetric and the lower bound is + taken as negative round_2_hi. + :type round_2_hi: float + :param smooth_lo: Lower bound for source "smoothness". + :type smooth_lo: float + :param smooth_hi: Upper bound for source "smoothness". + :type smooth_hi: float + :param ricker_r: Pixel radius for the wavelet used in the CONVL + detection step. + :type ricker_r: float + :param verbose: Set whether to print verbose output information. + :type verbose: bool or int + :param clean_src: Set whether to "clean" the catalogue after detection + based on the above source geometric properties. + :type clean_src: bool + :param do_bgd_2d: Set whether to run the BGD2D detection step. + :type do_bgd_2d: bool + :param box_size: Set kernel size for BGD2D background measuring step. + :type box_size: int + :param do_con_vl: Set whether to run the CONVL detection step. + :type do_con_vl: bool + """ + self.sig_src: float = sig_src + self.sig_sky: float = sig_sky + self.full_width_half_max: float = full_width_half_max + self.sharp_hi: float = sharp_hi + self.sharp_lo: float = sharp_lo + self.round_1_hi: float = ( + round_1_hi if round_1_hi is not None else np.inf) + self.round_2_hi: float = ( + round_2_hi if round_2_hi is not None else np.inf) + self.smooth_lo: float = ( + smooth_lo if smooth_lo is not None else -np.inf) + self.smooth_hi: float = ( + smooth_hi if smooth_hi is not None else np.inf) + + self.ricker_r: float = ricker_r + self.clean_src: bool | int = clean_src + + self.catalogue: Table = Table() + self.verbose: bool | int = verbose + + self.do_bgd_2d: bool | int = do_bgd_2d + self.box_size: int = box_size + self.do_con_vl: bool | int = do_con_vl + + def detect(self, data: np.ndarray, + bkg_estimator: Optional[Callable[ + [np.ndarray], np.ndarray]] = None, + xy_coords: Table | None = None, + use_find_peaks: bool = False) -> Table: + """ + The core detection step (DAOStarFinder) + + :param data: Image array to detect on + :type data: numpy.ndarray or array.pyi + :param bkg_estimator: Function to call to generate the background + array the same shape as data array + :type bkg_estimator: callable function + :param xy_coords: Table of initial guesses (x_centroid, y_centroid) + :type xy_coords: `astropy.table.Table` + :param use_find_peaks: if true, use the photutils findpeaks method + else use the DAOStarFinder method + :type use_find_peaks: bool + :return: Source list Table + :rtype: astropy.Table + """ + bkg: np.ndarray = np.zeros(data.shape) + if bkg_estimator: + bkg = bkg_estimator(data) + + median_stat: float + std: float + catalogue: Table | None + _, median_stat, std = sigma_clipped_stats(data, sigma=self.sig_sky) + if use_find_peaks: + catalogue = find_peaks( + data - bkg, median_stat + std * self.sig_src, box_size=11) + + else: + round_hi: float = max((self.round_1_hi, self.round_2_hi)) + find: DAOStarFinder = DAOStarFinder( + threshold=std * self.sig_src, fwhm=self.full_width_half_max, + sharplo=self.sharp_lo, sharphi=self.sharp_hi, + roundlo=-round_hi, roundhi=round_hi, peak_max=np.inf, + xycoords=xy_coords) + catalogue = find.find_stars(data - bkg) + + # if no results, create a table reflecting no results. with expected + # column names. + if catalogue is None or len(catalogue) == 0: + col_names = [ + TableColumn.ID, TableColumn.X_PEAK, TableColumn.Y_PEAK, + TableColumn.X_CENTROID, TableColumn.Y_CENTROID] + col_data = [list(), list(), list(), list(), list()] + catalogue: Table = Table(col_data, names=col_names) + return catalogue + + def bkg2d(self, data: np.ndarray) -> np.ndarray: + """ + Calculates a 2D background array map. + + :param data: the data to apply background 2d to. + :return: background + :rtype: numpy.ndarray + """ + return Background2D(data, self.box_size, filter_size=3).background + + def match(self, base: Table, cat: Table) -> Table: + """ + Internal function to class + Used to match detections from separate background subtracted images + into the main catalogue. This will append a source if its matched + separation is above the threshold = self.full_width_half_max + + :param base: Base catalogue to match to. + :type base: astropy.table.Table + :param cat: Catalogue to be matched + :type cat: astropy.table.Table + :return: The matched catalogue + :rtype: astropy.Table + """ + base_sky: SkyCoord = SkyCoord( + x=base[TableColumn.X_CENTROID], + y=base[TableColumn.Y_CENTROID], + z=np.zeros(len(base)), + representation_type="cartesian") + cat_sky: SkyCoord = SkyCoord( + x=cat[TableColumn.X_CENTROID], + y=cat[TableColumn.Y_CENTROID], + z=np.zeros(len(cat)), + representation_type="cartesian") + + dist: Quantity + _, _, dist = cat_sky.match_to_catalog_3d(base_sky) + mask: np.ndarray = dist.to_value() > self.full_width_half_max + return vstack((base, cat[mask])) + + def _clean_up_data( + self, data: np.ndarray | None, + mask: Optional[np.ndarray] = None) -> np.ndarray | None: + """ + sets up data for detections + :param data: 2D image array to detect on + :type data: numpy.ndarray or None + :param mask: Pixels to mask out on the data array + :type mask: numpy.ndarray + :return: the masked data + """ + if data is None: + return None + if mask is None: + mask = np.where(np.isnan(data)) + + median_stat: float + _, median_stat, _ = sigma_clipped_stats(data, sigma=self.sig_sky) + data[mask] = median_stat + return data + + def _do_plain_detections(self, data: np.ndarray) -> None: + """ + do plain detections. + + :param data: 2D image array to detect on + :type data: numpy.ndarray or None + :return: None + """ + self.catalogue = self.detect(data) + if self.verbose: + printf("-> [PLAIN] pass: %d sources\n" % len(self.catalogue)) + + def _do_background_detection(self, data: np.ndarray) -> None: + """ + executes the background detection. + + :param data: 2D image array to detect on + :type data: numpy.ndarray or None + :return: None + """ + + # execute background search and then feed it to a match with + # plain results. + background_results: Table = self.detect(data, self.bkg2d) + if len(self.catalogue) == 0: + self.catalogue = background_results + else: + self.catalogue = self.match(self.catalogue, background_results) + if self.verbose: + printf("-> [BGD2D] pass: %d sources\n" % len(self.catalogue)) + + def _do_differential_detection(self, data: np.ndarray) -> None: + """ + executes the 2nd differential detection. + + :param data: 2D image array to detect on + :type data: numpy.ndarray or None + :return: None + """ + + kernel: RickerWavelet2DKernel = ( + RickerWavelet2DKernel(self.ricker_r)) + conv: np.ndarray = convolve(data, kernel.array) + corr: np.ndarray = match_template( + conv / np.amax(conv), kernel.array) + detections: Table = self.detect(corr, use_find_peaks=True) + if detections: + detections[TableColumn.X_PEAK] += kernel.shape[0] // 2 + detections[TableColumn.Y_PEAK] += kernel.shape[0] // 2 + detections.rename_columns( + (TableColumn.X_PEAK, TableColumn.Y_PEAK), + (TableColumn.X_CENTROID, TableColumn.Y_CENTROID)) + if len(self.catalogue) == 0: + self.catalogue = detections + else: + self.catalogue = self.match(self.catalogue, detections) + if self.verbose: + # noinspection SpellCheckingInspection + printf("-> [CONVL] pass: %d sources\n" % len(self.catalogue)) + + def _update_mask_for_cleaning_src(self, mask: np.ndarray) -> np.ndarray: + if (TableColumn.SHARPNESS in self.catalogue.colnames and + TableColumn.ROUNDNESS1 in self.catalogue.colnames and + TableColumn.ROUNDNESS2 in self.catalogue.colnames): + mask &= ( + (self.catalogue[TableColumn.SHARPNESS] > self.sharp_lo) + & (self.catalogue[TableColumn.SHARPNESS] < self.sharp_hi) + & (self.catalogue[TableColumn.ROUNDNESS1] > -self.round_1_hi) + & (self.catalogue[TableColumn.ROUNDNESS1] < self.round_1_hi) + & (self.catalogue[TableColumn.ROUNDNESS2] > -self.round_2_hi) + & (self.catalogue[TableColumn.ROUNDNESS2] < self.round_2_hi)) + else: + printf(f"Incorrect columns for cleaning source. not doing so. " + f"Please ensure the catalogue table contains the following" + f" column names: [{TableColumn.SHARPNESS}, " + f"{TableColumn.ROUNDNESS1}, {TableColumn.ROUNDNESS2}]") + return mask + + def _reshape_detections(self, data: np.ndarray) -> None: + """ + reshapes the sharp and round at detected locations. + + :param data: 2D image array to detect on + :type data: numpy.ndarray or None + :return: None + """ + tmp: Table | None = ( + SourceProperties(data, self.catalogue, verbose=self.verbose) + .calculate_geometry(self.full_width_half_max)) + if tmp is not None: + self.catalogue = tmp + + mask: np.ndarray = ( + ~np.isnan(self.catalogue[TableColumn.X_CENTROID]) & + ~np.isnan(self.catalogue[TableColumn.Y_CENTROID])) + + if self.clean_src: + self._update_mask_for_cleaning_src(mask) + + if self.verbose: + printf("-> cleaning %d unlikely point sources\n" % sum(~mask)) + self.catalogue = self.catalogue[mask] + + if self.verbose: + printf("Total: %d sources\n" % len(self.catalogue)) + + self.catalogue.replace_column( + "id", Column(range(1, 1 + len(self.catalogue)))) + + def find_stars( + self, data: np.ndarray | None, + mask: Optional[np.ndarray] = None) -> Table: + """ + This routine runs source detection several times, but on a different + form of the data array each time. Each form has been "skewed" somehow + to brighten the most faint sources and flatten the differential + background. + + 1:Plain detections + 2:Subtract Background estimation + 3:RickerWave convolution + + :param data: 2D image array to detect on + :type data: numpy.ndarray or None + :param mask: Pixels to mask out on the data array + :type mask: numpy.ndarray + :return: the catalogue containing stars. + :rtype: astropy.Table + """ + data: np.ndarray | None = self._clean_up_data(data, mask) + if data is None: + printf("No data, not trying to detect") + return Table() + + self._do_plain_detections(data) + if self.do_bgd_2d: + self._do_background_detection(data) + + # 2nd order differential detection + if self.do_con_vl: + self._do_differential_detection(data) + + # Now with xy-coords DAOStarfinder will refit the sharp and round + # values at the detected locations + self._reshape_detections(data) + return self.catalogue diff --git a/starbug2/routines/psf_phot_routine.py b/starbug2/routines/psf_phot_routine.py new file mode 100644 index 0000000..a339ba4 --- /dev/null +++ b/starbug2/routines/psf_phot_routine.py @@ -0,0 +1,188 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import sys + +import numpy as np +from astropy.table import Column, hstack, Table, QTable +from photutils.aperture import CircularAperture, aperture_photometry +from photutils.psf import PSFPhotometry, SourceGrouper, ImagePSF + +from starbug2.constants import TableColumn +from starbug2.utils import printf, p_error, warn + + +class _Grouper(SourceGrouper): + """ + Overloaded SourceGrouper. This class gives a starbug warning into stderr + if there are more than CRITICAL_VAL source in any given group. Then + returns the original __call__ function results. + + Parameters: + ----------- + min_separation : float > 0 + The minimum distance (in pixels) such that any two sources + separated by less than this distance will be placed in the same + group if the ``min_size`` criteria is also met. + """ + CRITICAL_VAL = 25 + min_separation = 0 + + def __init__(self, min_separation: float) -> None: + super().__init__(min_separation) + + def __call__(self, x: np.ndarray, y: np.ndarray, + return_groups_object: bool = False) -> np.ndarray: + res: np.ndarray = super().__call__(x, y) + n: int + if n := sum(np.bincount(res) > self.CRITICAL_VAL): # noqa + warn("Source grouper has %d groups larger than %d. Consider" + " reducing \"CRIT_SEP=%g\" or fitting might take a long" + " time.\n" % (n, self.CRITICAL_VAL, self.min_separation)) + return res + + +class PSFPhotRoutine(PSFPhotometry): + def __init__( + self, psf_model: ImagePSF, + fit_shape: int | tuple[int, int], + app_hot_r: float = 3.0, + min_separation: float = 8.0, + force_fit: int | bool = False, + background: np.ndarray | None = None, + verbose: int | bool = 1) -> None: + # noinspection SpellCheckingInspection + """ + PSF Photometry routine called by starbug + + :param psf_model: Model PSF to be used in the fitting + :type psf_model: photutils.psf.FittableImageModel + :param fit_shape: Size of PSF to use in pixels. Must be less than or + equal to the size of psf_model + :type fit_shape: int or tuple + :param min_separation: Minimum source separation for source grouper + (pixels) + :type min_separation: float + :param app_hot_r: Aperture radius to be used in initial guess + photometry. + :type app_hot_r: float. + :param force_fit: Conduct forced centroid PSF fitting. + :type force_fit: bool or int (with values 0 or 1). + :param background: 2D array with the same dimensions as the data used + in fitting. + :type background: numpy.ndarray. + :param verbose: Show verbose outputs. + :type verbose: bool or int + """ + self._verbose: int | bool = verbose + self._force_fit: bool | int = force_fit + self._background: np.ndarray | None = background + + grouper: _Grouper = _Grouper(min_separation) + + if force_fit: + psf_model.x_0.fixed = True + psf_model.y_0.fixed = True + + super().__init__( + psf_model=psf_model, fit_shape=fit_shape, finder=None, + progress_bar=verbose, aperture_radius=app_hot_r, grouper=grouper) + + if self._verbose: + printf("-> source group separation: %g\n" % min_separation) + + def __call__( + self, image: np.ndarray, + init_params: Table | None = None, + error: np.ndarray | None = None, + mask: np.ndarray | None = None): + """ + runs the psf phot routine. + :param image: the image to process. + :type image: np.ndarray + :param init_params: the init params. + :type init_params: Table + :param error: the error. + :type error: np.array + :param mask: the mask. + :type mask: np.array + :return: the processed table. + :rtype: astropy.table.Table + """ + return self.do_photometry(image, init_params, error, mask) + + def do_photometry( + self, + image: np.ndarray, + init_params: Table | None = None, + error: np.ndarray | None = None, + mask: np.ndarray | None = None) -> Table | None: + """ + does the photometry + :param image: the image to process. + :type image: np.ndarray + :param init_params: the init params. + :type init_params: Table + :param error: the error. + :type error: np.array + :param mask: the mask. + :type mask: np.array + :return: the processed table. + :rtype: astropy.table.Table + """ + + if (init_params is None or len(init_params) == 0 + or mask is None or error is None): + p_error("Must include source list and a mask\n") + return None + + # Removing completely masked sources + apertures: CircularAperture = CircularAperture( + [(row[TableColumn.X_INIT], + row[TableColumn.Y_INIT]) for row in init_params], + self.aperture_radius) + ap_masks: QTable = aperture_photometry(~mask, apertures) + init_params.remove_rows(ap_masks["aperture_sum"] == 0) + + # bad errors should be big not small + error[error == 0] = sys.maxsize + + if self._background is not None: + image = image - self._background + if self._verbose: + printf("-> fitting %d sources\n" % len(init_params)) + cat: QTable = super().__call__( + image, mask=mask, init_params=init_params, error=error) + + d: np.ndarray = np.sqrt(( + (cat[TableColumn.X_INIT] - cat[TableColumn.X_FIT]) ** 2.0 + + (cat[TableColumn.Y_INIT] - cat[TableColumn.Y_FIT]) ** 2.0)) + + # noinspection SpellCheckingInspection + cat.add_column(Column(d, name=TableColumn.XY_DEV)) + + if TableColumn.FLUX_ERR not in cat.colnames: + cat.add_column( + Column(np.full(len(cat), np.nan), name=TableColumn.E_FLUX)) + warn("Something went wrong with PSF error fitting\n") + else: + cat.rename_column(TableColumn.FLUX_ERR, TableColumn.E_FLUX) + + cat.rename_column(TableColumn.FLUX_FIT, TableColumn.FLUX) + + # noinspection SpellCheckingInspection + keep: list[str] = [ + TableColumn.X_FIT, TableColumn.Y_FIT, TableColumn.FLUX, + TableColumn.E_FLUX, TableColumn.XY_DEV, TableColumn.Q_FIT] + return hstack((init_params, cat[keep])) diff --git a/starbug2/routines/source_properties.py b/starbug2/routines/source_properties.py new file mode 100644 index 0000000..25fdd4e --- /dev/null +++ b/starbug2/routines/source_properties.py @@ -0,0 +1,145 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +from typing import Optional + +import numpy as np +from astropy.table import Table, QTable, hstack +from photutils.detection import DAOStarFinder + +from starbug2.constants import TableColumn +from starbug2.utils import Loading, printf, p_error + + +class SourceProperties: + status: int = 0 + + def __init__( + self, image: Optional[np.ndarray], + source_list: Optional[Table], verbose: int | bool = 1) -> None: + """ + source properties. + + :param image: the image + :type image: numpy.ndarray or None + :param source_list: the source list + :type source_list: astropy.Table or None + :param verbose: int for verbose + :type verbose: int + """ + self._image: Optional[np.ndarray] = image + self._source_list: Optional[Table] = None + self._verbose: int | bool = verbose + + if source_list and type(source_list) in (Table, QTable): + if (len({TableColumn.X_CENTROID, TableColumn.Y_CENTROID} & + set(source_list.colnames)) == 2): + self._source_list = ( + Table(source_list[ + [TableColumn.X_CENTROID, TableColumn.Y_CENTROID]])) + elif (len({TableColumn.X_0, TableColumn.Y_0} & + set(source_list.colnames)) == 2): + self._source_list = ( + Table(source_list[[TableColumn.X_0, TableColumn.Y_0]])) + assert self._source_list is not None + self._source_list.rename_columns( + (TableColumn.X_0, TableColumn.Y_0), + (TableColumn.X_CENTROID, TableColumn.Y_CENTROID)) + else: + p_error("no positional columns in source list\n") + else: + p_error("bad source list type: %s\n" % type(source_list)) + + def __call__( + self, do_crowd: int = 1, n_closest_sources: int = 10, + full_width_half_max: float = 2.0) -> Table: + """ + trigger source properties + + :param do_crowd: int check for doing crowd + :type do_crowd: int + :param n_closest_sources: the number of closest sources. + :type n_closest_sources: int + :param full_width_half_max: the full width half max. + :type full_width_half_max: float + """ + + out: Table = Table() + + # This can be slow + if do_crowd: + out = hstack( + (out, Table([self.calculate_crowding(n_closest_sources)], + names=["crowding"]))) + + out = hstack((out, self.calculate_geometry(full_width_half_max))) + return out + + def calculate_crowding( + self, n_closest_sources: int = 10) -> np.ndarray | None: + """ + Crowding Index: Sum of magnitude of separation of n closest sources + + :param n_closest_sources: the number of closest sources. + :type n_closest_sources: int + """ + if self._source_list is None: + p_error("no source list\n") + return None + + crowd: np.ndarray = np.zeros(len(self._source_list)) + load: Loading = Loading( + len(self._source_list), msg="calculating crowding", res=10) + + for i, src in enumerate([self._source_list]): + i: int + src: Table + dist: np.ndarray = np.sqrt( + (src[TableColumn.X_CENTROID] - + self._source_list[TableColumn.X_CENTROID]) ** 2 + + (src[TableColumn.Y_CENTROID] - + self._source_list[TableColumn.Y_CENTROID]) ** 2) + dist.sort() + crowd[i] = sum(dist[1: n_closest_sources]) + load() + if self._verbose: + load.show() + return crowd + + def calculate_geometry( + self, full_width_half_max: float = 2.0) -> Table | None: + """ + calculate geometry + + :param full_width_half_max: the full width half max. + :type full_width_half_max: float + :return the result of geometry + :rtype Table + """ + if self._source_list is None: + p_error("no source list\n") + return None + if self._verbose: + printf("-> measuring source geometry\n") + xy_coords: np.ndarray = np.array( + (self._source_list[TableColumn.X_CENTROID], + self._source_list[TableColumn.Y_CENTROID])).T + + dao_find: DAOStarFinder = DAOStarFinder( + -np.inf, full_width_half_max, sharplo=-np.inf, sharphi=np.inf, + roundlo=-np.inf, roundhi=np.inf, xycoords=xy_coords, + peak_max=np.inf) + + # ABS protected access. yuck + return dao_find._get_raw_catalog(self._image).to_table() diff --git a/starbug2/star_bug_config.py b/starbug2/star_bug_config.py new file mode 100644 index 0000000..918c3c9 --- /dev/null +++ b/starbug2/star_bug_config.py @@ -0,0 +1,1588 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import getopt +import os +import numpy as np +from astropy import units +from astropy.units import Quantity +from typing import Dict, Tuple, Final, Any +from parse import parse + +from starbug2.constants import ( + SCI, DEFAULT_COLOUR, HeaderTags, AP_FILE, BGD_FILE, PSF_FILE, TableColumn, + STAR_BUG_PARAMS, DEFAULT_PSF_FILE_NAME, PROBLEMATIC_FILTER_ID, + PROBLEMATIC_FILTER_WARNING, DEFAULT_PARAM_TEMPLATE, STARBUG_DATA_DIR) +from starbug2.utils import p_error, get_version, warn + + +class StarBugMainConfig: + # the first characters to exclude + EXCLUDES: Final[str] = "# \t\n //" + + # A single master map linking (Short Flag, Long Flag) to the internal + # property for MAIN + # Format: (short_flag, long_flag, type) -> property_name + # None for short_flag means it only has a long version + # noinspection SpellCheckingInspection + MAIN_FLAG_MAP: Dict[Tuple[str | None, str, Any], str] = { + ('A', 'apphot', bool): 'do_aperture_photometry', + ('B', 'background', bool): 'do_bgd_estimate', + ('D', 'detect', bool): 'do_star_detection', + ('f', 'find', bool): 'find_file', + ('G', 'geom', bool): 'do_source_geometry', + ('h', 'help', bool): 'show_help', + ('M', 'match', bool): 'do_matching', + ('P', 'psf', bool): 'do_photometry_routine', + ('S', 'subbgd', bool): 'do_bgd_subtraction', + ('v', 'verbose', bool): 'verbose_logs', + ('b', 'bgdfile', str): 'background_file', + ('d', 'apfile', str): 'ap_file', + ('n', 'ncores', int): 'n_cores', + ('o', 'output', str): 'output_file', + ('p', 'param', str): 'param_file', + ('s', 'set', str): 'set_parameter', + (None, 'init', bool): 'execute_jwst_initialisation', + (None, 'generate-psf', bool): 'generate_psf', + (None, 'local-param', bool): 'generate_local_param_file', + (None, 'generate-region', bool): 'generate_region', + (None, 'version', bool): 'show_version', + (None, 'generate-run', bool): 'generate_run', + (None, 'update-param', bool): 'update_param', + (None, 'debug', bool): 'debug_mode', + (None, 'dev', bool): 'dev_mode', + } + + # A single master map linking (Short Flag, Long Flag) to the internal + # property for AST + # Format: (short_flag, long_flag, type) -> property_name + # None for short_flag means it only has a long version + # noinspection SpellCheckingInspection + AST_FLAG_MAP: Dict[Tuple[str | None, str, Any], str] = { + ("h", "help", bool): 'show_ast_help', + ('v', 'verbose', bool): 'verbose_logs', + ('n', 'ncores', int): 'n_cores', + ('p', 'param', str): 'param_file', + ('s', 'set', str): 'set_parameter', + ('o', 'output', str): 'output_file', + ('N', 'ntests', int): "artificial_star_tests_count", + ('S', 'nstars', int): "stars_per_artificial_test", + ('R', 'recover', bool): "ast_recover", + (None, 'autosave', int): "ast_auto_save", + (None, 'no-background', bool): "ast_no_background", + (None, 'no-psfphot', bool): "ast_no_psf_phot", + (None, 'save_added_image', bool): "save_added_image", + (None, 'save_added_image_path', str): "save_added_image_path", + (None, 'seed', int): "ast_seed" + } + + # noinspection SpellCheckingInspection + MATCH_FLAG_MAP: Dict[Tuple[str | None, str, Any], str] = { + # Boolean Switches (No arguments) + ('B', 'band', bool): 'do_band_processing', + ('C', 'cascade', bool): 'do_cascade', + ('G', 'generic', bool): 'generic_mode', + ('X', 'exact', bool): 'exact_match', + ('e', 'error', str): 'error_col', + ('f', 'full', bool): 'full_run', + ('h', 'help', bool): 'show_match_help', + ('m', 'mask', str): 'mask_eval', + ('o', 'output', str): 'output_file', + ('p', 'param', str): 'param_file', + ('s', 'set', str): 'set_parameter', + ('v', 'verbose', bool): 'verbose_logs', + (None, 'band-depr', bool): 'band_deprecated', + (None, 'dither', bool): 'use_dither', + } + + # noinspection SpellCheckingInspection + PLOT_FLAG_MAP: Dict[Tuple[str | None, str, Any], str] = { + # Boolean Switches (No arguments) + ('h', 'help', bool): 'show_plot_help', + ('v', 'verbose', bool): 'verbose_logs', + ('X', 'test', bool): 'test_mode', + (None, 'apfile', bool): 'ap_file', + (None, 'dark', bool): 'dark_mode', + + # Options with Arguments (Strings) + ('I', 'inspect', str): 'inspect_parameter', + ('o', 'output', str): 'output_file', + ('d', 'style', str): 'plot_style', + } + + # Comprehensive mapping configuration linking keys to internal + # properties and types + # noinspection SpellCheckingInspection + MAIN_PARAM_FILE_MAP: Dict[str, Tuple[str, type]] = { + # GENERIC + "VERBOSE": ("verbose_logs", bool), + "OUTPUT": ("output_file", str), + "HDUNAME": ("hdu_name", str), + "FILTER": ("custom_filter", str), + # DETECTION + "FWHM": ("full_width_half_max", float), + "SIGSKY": ("sigma_sky", float), + "SIGSRC": ("sigma_source", float), + "DOBGD2D": ("do_bgd_2d", bool), + "DOCONVL": ("do_convolution", bool), + "CLEANSRC": ("clean_sources", bool), + "SHARP_LO": ("sharp_cutoff_low", float), + "SHARP_HI": ("sharp_cutoff_high", float), + "ROUND1_HI": ("round1_cutoff_high", float), + "ROUND2_HI": ("round2_cutoff_high", float), + "SMOOTH_LO": ("smooth_low", float), + "SMOOTH_HI": ("smooth_high", float), + "RICKER_R": ("ricker_wavelet_radius", float), + # APERTURE PHOTOMETRY + "APPHOT_R": ("aperture_phot_radius", float), + "ENCENERGY": ("encircled_energy_fraction", float), + "SKY_RIN": ("sky_annulus_inner_radius", float), + "SKY_ROUT": ("sky_annulus_outer_radius", float), + "APCORR_FILE": ("ap_corr_file_override", str), + # BACKGROUND ESTIMATION + "BGD_R": ("bgd_radius", float), + "PROF_SCALE": ("profile_scaling_factor", float), + "PROF_SLOPE": ("profile_slope", float), + "BOX_SIZE": ("background_box_size", int), + "BGD_CHECKFILE": ("bgd_check_file", str), + # PHOTOMETRY + "AP_FILE": ("ap_file", str), + "BGD_FILE": ("background_file", str), + "PSF_FILE": ("psf_file_override", str), + "USE_WCS": ("use_wcs_values", bool), + "ZP_MAG": ("zero_point_magnitude", float), + "CRIT_SEP": ("critical_separation", float), + "FORCE_POS": ("force_centroid_position", bool), + "MAX_XYDEV": ("max_xy_deviation", str), + "PSF_SIZE": ("psf_fit_size", int), + "GEN_RESIDUAL": ("generate_residual_image", bool), + # SOURCE STATS + "CALC_CROWD": ("calculate_crowding_metric", bool), + # CATALOGUE MATCHING + "MATCH_THRESH": ("match_threshold_arc_sec", str), + "MATCH_COLS": ("extra_match_columns", str), + "NEXP_THRESH": ("exposure_count_threshold", int), + "BRIDGE_COL": ("bridge_band_column", str), + # ARTIFICIAL STAR TESTS + "NTESTS": ("artificial_star_tests_count", int), + "NSTARS": ("stars_per_artificial_test", int), + "SUBIMAGE": ("sub_image_crop_size", int), + "MAX_MAG": ("test_magnitude_bright_limit", int), + "MIN_MAG": ("test_magnitude_faint_limit", int), + "PLOTAST": ("ast_plot_filename", str), + # MISC EXTRAS + "REGION_COL": ("region_colour", str), + "REGION_SCAL": ("region_scale", bool), + "REGION_RAD": ("region_radius", int), + "REGION_XCOL": ("region_x_column_name", str), + "REGION_YCOL": ("region_y_column_name", str), + "REGION_WCS": ("region_uses_wcs", bool), + # generate psf extras + "DET_NAME": ("detector_name", str), + # generation of region tables + "REGION_TAB": ("region_file", str), + "PARAM_TAG": ("param_tag", str), + + # --- NEW PARAM FILE SHORT-CIRCUITS FOR STEPS & FLOW CONTROLS --- + "RUN_APPHOT": ("do_aperture_photometry", bool), + "RUN_BGD_EST": ("do_bgd_estimate", bool), + "RUN_DETECT": ("do_star_detection", bool), + "RUN_GEOM": ("do_source_geometry", bool), + "RUN_MATCH": ("do_matching", bool), + "RUN_PSFPHOT": ("do_photometry_routine", bool), + "RUN_BGDSUB": ("do_bgd_subtraction", bool), + "NCORES": ("n_cores", int), + "FIND_FILE": ("find_file", bool), + "INIT_JWST": ("execute_jwst_initialisation", bool), + "GEN_PSF": ("generate_psf", bool), + "GEN_RUN": ("generate_run", bool), + "GEN_REGION": ("generate_region", bool), + "AST_RECOVER": ("ast_recover", bool), + "AST_AUTOSAVE": ("ast_auto_save", int), + "AST_NO_BGD": ("ast_no_background", bool), + "AST_NO_PSF": ("ast_no_psf_phot", bool), + "MATCH_BAND": ("do_band_processing", bool), + "MATCH_CASCADE": ("do_cascade", bool), + "MATCH_DITHER": ("use_dither", bool), + "MATCH_EXACT": ("exact_match", bool), + "MATCH_FULL": ("full_run", bool), + "MATCH_GENERIC": ("generic_mode", bool), + "MATCH_ERR_COL": ("error_col", str), + "MATCH_MASK_EVAL": ("mask_eval", str), + "PLOT_TEST": ("test_mode", bool), + "PLOT_DARK": ("dark_mode", bool), + "PLOT_INSPECT": ("inspect_parameter", str), + "PLOT_STYLE": ("plot_style", str), + "PARAM": ("param_tag", str), + } + + def __init__(self) -> None: + self._frozen: bool = False + + # high level stuff + self._show_help: bool = False + self._show_ast_help: bool = False + self._verbose_logs: bool = False + self._show_version: bool = False + + # main actions + self._do_aperture_photometry: bool = False + self._do_bgd_estimate: bool = False + self._do_star_detection: bool = False + self._do_source_geometry: bool = False + self._do_matching: bool = False + self._do_photometry_routine: bool = False + self._do_bgd_subtraction: bool = False + + # other actions + self._generate_psf: bool = False + self._generate_run: bool = False + self._generate_region: bool = False + self._generate_local_param_file: bool = False + self._execute_jwst_initialisation = False + + # updates an old version param file. + self._update_param: bool = False + + # file parameters + self._param_file: str | None = None + self._ap_file: str | None = None + self._background_file: str | None = None + self._find_file: bool = True + self._region_file: str | None = None + + # multiprocessing params (assumes to use 1 core to begin with) + self._n_cores: int = 1 + + # artificial stars params + self._ast_recover: bool = False + self._ast_auto_save: int = 100 + self._ast_no_background: bool = False + self._ast_no_psf_phot: bool = False + self._save_added_image: bool = False + self._save_added_image_path: str = "" + self._ast_seed: int | None = None + + # matching params + self._do_band_processing: bool = False + self._do_cascade: bool = False + self._use_dither: bool = False + self._exact_match: bool = False + self._full_run: bool = False + self._generic_mode: bool = False + self._show_match_help: bool = False + self._band_deprecated: bool = False + self._error_col: str = TableColumn.E_FLUX + self._mask_eval: str | None = None + + # plot params + self._show_plot_help: bool = False + self._test_mode: bool = False + self._dark_frame_correction = False + self._inspect_parameter: str | None = None + self._plot_style: str | None = None + + # param file defaults. These constants do not have justifications yet. + self._output_file: str | None = os.getenv(STARBUG_DATA_DIR) + self._hdu_name: str = SCI + self._filter: str | None = None + self._full_width_half_max: float = -1.0 + self._sigma_sky: float = 2.0 + self._sigma_source: float = 5.0 + self._do_bgd_2d: bool = True + self._do_convolution: bool = True + self._clean_sources: bool = True + self._sharp_cutoff_low: float = 0.4 + self._sharp_cutoff_high: float = 0.9 + self._round1_cutoff_high: float = 1.0 + self._round2_cutoff_high: float = 1.0 + self._smooth_low: float = 0.0 + self._smooth_high: float = 1.0 + self._ricker_wavelet_radius: float = 1.0 + self._aperture_phot_radius: float = 1.5 + self._encircled_energy_fraction: float = -1.0 + self._sky_annulus_inner_radius: float = 3.0 + self._sky_annulus_outer_radius: float = 4.5 + self._ap_corr_file_override: str | None = None + self._bgd_radius: float = 0.0 + self._profile_scaling_factor: float = 1.0 + self._profile_slope: float = 0.5 + self._background_box_size: int = 2 + self._bgd_check_file: str | None = None + self._psf_file_override: str = DEFAULT_PSF_FILE_NAME + self._use_wcs_values: bool = True + self._zero_point_magnitude: float = 8.9 + self._critical_separation: float = 1.0 + self._force_centroid_position: bool = False + self._centroid_delta_threshold: float = -1.0 + self._max_xy_deviation: str = '3.0' + self._psf_fit_size: int = -1 + self._generate_residual_image: bool = False + self._calculate_crowding_metric: bool = True + self._match_threshold_arc_sec: str = "0.1" + self._extra_match_columns: str | None = None + self._exposure_count_threshold: int = -1 + self._signal_to_noise_threshold: float = -1.0 + self._bridge_band_column: str | None = None + self._artificial_star_tests_count: int = 100 + self._stars_per_artificial_test: int = 10 + self._sub_image_crop_size: int = 500 + self._test_magnitude_bright_limit: int = 18 + self._test_magnitude_faint_limit: int = 28 + self._ast_plot_filename: str | None = None + self._region_colour: str = DEFAULT_COLOUR + self._region_scale: bool = True + self._region_radius: int = 3 + self._region_x_column_name: str = TableColumn.RA + self._region_y_column_name: str = TableColumn.DEC + self._region_uses_wcs: bool = True + self._param_tag: str = STAR_BUG_PARAMS + + # generate psf variables + self._detector_name: str | None = None + + # target images + self._fits_images: list[str] = [] + + @classmethod + def _generate_get_opt_definitions(cls, param_map) -> Tuple[str, list[str]]: + # noinspection SpellCheckingInspection + """ + Natively inspects the configuration structure to construct perfectly + formatted short-option strings and long-option arrays for getopt + dynamically. + + :return: the inputs to gnu_getopt. + """ + short_opts_compiled = "" + long_opts_compiled = [] + + for (short_flag, long_flag, data_type) in param_map.keys(): + suffix = "" if data_type is bool else "=" + long_opts_compiled.append(f"{long_flag}{suffix}") + + if short_flag: + short_suffix = "" if data_type is bool else ":" + short_opts_compiled += f"{short_flag}{short_suffix}" + + return short_opts_compiled, long_opts_compiled + + @classmethod + def generate_main_get_opt_definitions(cls) -> Tuple[str, list[str]]: + # noinspection SpellCheckingInspection + """ + Natively inspects the configuration structure to construct perfectly + formatted short-option strings and long-option arrays for getopt + dynamically for main. + + :return: the inputs to gnu_getopt. + """ + return cls._generate_get_opt_definitions(cls.MAIN_FLAG_MAP) + + @classmethod + def generate_ast_get_opt_definitions(cls) -> Tuple[str, list[str]]: + # noinspection SpellCheckingInspection + """ + Natively inspects the configuration structure to construct perfectly + formatted short-option strings and long-option arrays for getopt + dynamically for artificial stars. + + :return: the inputs to gnu_getopt. + """ + return cls._generate_get_opt_definitions(cls.AST_FLAG_MAP) + + @classmethod + def generate_match_get_opt_definitions(cls) -> Tuple[str, list[str]]: + # noinspection SpellCheckingInspection + """ + Natively inspects the configuration structure to construct perfectly + formatted short-option strings and long-option arrays for getopt + dynamically for match. + + :return: the inputs to gnu_getopt. + """ + return cls._generate_get_opt_definitions(cls.MATCH_FLAG_MAP) + + @classmethod + def generate_plot_get_opt_definitions(cls) -> Tuple[str, list[str]]: + # noinspection SpellCheckingInspection + """ + Natively inspects the configuration structure to construct perfectly + formatted short-option strings and long-option arrays for getopt + dynamically for plot. + + :return: the inputs to gnu_getopt. + """ + return cls._generate_get_opt_definitions(cls.PLOT_FLAG_MAP) + + @staticmethod + def parse_param(line: str) -> Dict[str, int | float | str]: + """ + Parse a parameter line + :param line: the line to parse + :type line: str + :return: the parsed params from the line + :rtype: Dict[str, int | float | str] + """ + param: Dict[str, int | float | str] = {} + + # Guard against empty lines or comment lines + if line and line[0] not in StarBugMainConfig.EXCLUDES: + key_str: str = "" + val_str: str = "" + + # Explicitly parse out raw string substrings first + if "//" in line and line[0] != "/": + parsed = parse("{}={}//{}", line) + if parsed: + key_str, val_str, _ = parsed + else: + parsed = parse("{}={}", line) + if parsed: + key_str, val_str = parsed + + # Clean up tracking whitespaces while they are guaranteed to be + # strings + key = key_str.strip() + raw_value = val_str.strip() + + # Default fallback type is the cleaned string itself + value: int | float | str = raw_value + + # Attempt numeric type conversions safely + try: + if '.' in raw_value: + value = float(raw_value) + else: + value = int(raw_value) + except (ValueError, AttributeError, TypeError): + # If conversion fails, value remains a string + pass + + # Special case environmental variables expansions for paths + if (key in (HeaderTags.OUTPUT, AP_FILE, BGD_FILE, PSF_FILE) + and isinstance(value, str)): + value = os.path.expandvars(value) + + if value == "False": + raise Exception("") + param[key] = value + + return param + + @staticmethod + def load_params(f_name) -> 'StarBugMainConfig': + """ + Convert a parameter file into a dictionary of options + + :param f_name: path/to/file.param + :type f_name: str or None + :return: dictionary of options + :rtype: dict of string, string + """ + config: StarBugMainConfig = StarBugMainConfig() + if f_name is None: + return config + + if os.path.exists(f_name): + with open(f_name, "r") as fp: + for line in fp.readlines(): + config.update(StarBugMainConfig.parse_param(line)) + else: + p_error("config file \"%s\" does not exist\n. Using " + "default config instead" % f_name) + return config + + def populate_params( + self, argv: list[str], short_definition: str, + long_definition: list[str], + param_map: dict[tuple[str | None, str, Any], str]) -> None: + """ + populates the config from command line requests + :param argv: the command line + :param short_definition: the short lists of command lines + :param long_definition: the large list of command lines + :param param_map: mapping between command line and property. + :return: None + """ + opts: list[tuple[str, str]] + args: list[str] + opts, args = getopt.gnu_getopt( + argv, short_definition, long_definition) + + for opt, opt_arg in opts: + # strip down to raw option label text + clean_opt = opt.lstrip('-') + + for (short_flag, long_flag, data_type), property_name in ( + param_map.items()): + if clean_opt in (short_flag, long_flag): + if data_type is bool: + setattr(self, property_name, True) + else: + # Casts string inputs to explicit types for + # variables + setattr(self, property_name, data_type(opt_arg)) + break + + # add the training args as target image files + self.fits_images = args + + def got_valid_psf_generation_params(self) -> bool: + """ + returns if the config has parameters set correctly to execute psf + generation + :return: bool if the params are set away from invalid defaults. + :rtype: bool + """ + return (self._filter != "" and self._detector_name != "" + and self._psf_fit_size != -1) + + def use_main_one_time_runs(self) -> bool: + """ + check for any of the one-off runs. + :return: bool if there is one time runs to run. + :rtype: bool + """ + return ( + self._show_help or self._update_param or + self._execute_jwst_initialisation or self._generate_psf or + self._generate_run or self._generate_region or + self._generate_local_param_file or self._show_version) + + def use_ast_one_time_runs(self) -> bool: + """ + check for any of the one-off runs. + :return: bool if there is one time runs to run. + :rtype: bool + """ + return self._show_ast_help or self._ast_recover + + def generate_default_param_file_text(self, version_str: str) -> str: + """ + Dynamically constructs the entire default configuration string template + using the active internal variable defaults directly via the named + constant template. + """ + format_dictionary: dict[str, str] = {} + for key, (prop, target_type) in self.MAIN_PARAM_FILE_MAP.items(): + val = getattr(self, prop) + if target_type is bool: + format_dictionary[key] = "1" if val else "0" + else: + format_dictionary[key] = "" if val is None else str(val) + + return DEFAULT_PARAM_TEMPLATE.format( + version_str=version_str, **format_dictionary) + + def do_generate_local_param_file(self) -> None: + """ + writes a local param file based off the state of this config. + :return: None + """ + with open("starbug.param", "w") as fp: + fp.write(self.generate_default_param_file_text(get_version())) + + def update(self, update_values: dict[str, str | int | float]) -> None: + """ + updates the config with new values extracted from a param file. + :param update_values: the updated values + :return: None + """ + for key, raw_value in update_values.items(): + if key not in self.MAIN_PARAM_FILE_MAP.keys(): + raise TypeError( + f"Param {key} no longer works within Starbug2. Please " + f"execute starbug2 --update-param") + property_name, target_type = self.MAIN_PARAM_FILE_MAP[key] + + # process raw value + if raw_value == "" or raw_value is None: + setattr(self, property_name, None) + continue + + if target_type is bool: + # Converts parameter flags like 0 or 1 integers to standard + # Booleans + setattr(self, property_name, bool(int(raw_value))) + else: + setattr(self, property_name, target_type(raw_value)) + + def _normalize_threshold( + self, threshold: float | int | np.ndarray | list | Quantity) -> ( + None | np.ndarray | Quantity): + """ + Normalises threshold inputs to ensure they possess the 'arcsec' unit. + - Unitless Quantities -> scaled to arcsec + - Floats/Ints -> converted to arcsec Quantity + - Lists/Arrays of floats -> converted to an object array of arcsec + Quantities + """ + if threshold is None: + return None + + # Handle standard Lists or NumPy arrays + if isinstance(threshold, (list, np.ndarray)): + # Recursively normalise each element, keeping them as individual + # object array slots + return np.array( + [self._normalize_threshold(t) for t in threshold], + dtype=object) + + # Handle Astropy Quantity instances + if isinstance(threshold, Quantity): + # Check if the Quantity has no physical units (dimensionless) + if threshold.unit == units.dimensionless_unscaled: + return threshold.value * units.arcsec + return threshold + + # Handle raw primitive Python numeric scalars (ints, floats) + if isinstance(threshold, (int, float, np.number)): + return threshold * units.arcsec + return None + + def freeze(self) -> None: + """ + locks the class from being editable + :return: None + """ + self._frozen = True + + def unfreeze(self) -> None: + """ + unlocks the class to become editable. + NOTE: if frozen previously. this really shouldn't happen. + :return: None + """ + self._frozen = False + + # ========================================== + # these 2 methods are here to ensure the access to threshold is in + # a quantity and an array of quantity as needed + # ========================================== + @property + def match_threshold_arc_sec_as_an_arc_sec(self) -> units.Quantity: + """ + builds threshold as an arc second Quantity. + :return: the threshold as a quantity + :rtype: units.Quantity + """ + threshold: float = float(self._match_threshold_arc_sec) + normalised_threshold: None | np.ndarray | Quantity = ( + self._normalize_threshold(threshold)) + assert isinstance(normalised_threshold, Quantity) + return normalised_threshold + + @property + def match_threshold_arc_sec_as_an_array(self) -> np.ndarray: + """ + builds threshold as an array of quantity + :return: the threshold as an array of quantity + :rtype: ndarray[Quantity] + """ + threshold_array: np.ndarray = np.array( + self._match_threshold_arc_sec.split(','), float) + normalised_threshold: None | np.ndarray | Quantity = ( + self._normalize_threshold(threshold_array)) + assert isinstance(normalised_threshold, np.ndarray) + return normalised_threshold + + # ========================================== + # BELOW HERE ARE GETTERS AND SETTERS FOR EVERYTHING + # ========================================== + + # ========================================== + # HIGH LEVEL STUFF + # ========================================== + + @property + def show_help(self) -> bool: + return self._show_help + + @show_help.setter + def show_help(self, value: bool) -> None: + self._show_help = value + + @property + def verbose_logs(self) -> bool: + return self._verbose_logs + + @verbose_logs.setter + def verbose_logs(self, value: bool) -> None: + self._verbose_logs = value + + @property + def show_version(self) -> bool: + return self._show_version + + @show_version.setter + def show_version(self, value: bool) -> None: + self._show_version = value + + # ========================================== + # MAIN ACTIONS + # ========================================== + + @property + def do_aperture_photometry(self) -> bool: + return self._do_aperture_photometry + + @do_aperture_photometry.setter + def do_aperture_photometry(self, value: bool) -> None: + self._do_aperture_photometry = value + + @property + def do_bgd_estimate(self) -> bool: + return self._do_bgd_estimate + + @do_bgd_estimate.setter + def do_bgd_estimate(self, value: bool) -> None: + self._do_bgd_estimate = value + + @property + def do_star_detection(self) -> bool: + return self._do_star_detection + + @do_star_detection.setter + def do_star_detection(self, value: bool) -> None: + self._do_star_detection = value + + @property + def do_source_geometry(self) -> bool: + return self._do_source_geometry + + @do_source_geometry.setter + def do_source_geometry(self, value: bool) -> None: + self._do_source_geometry = value + + @property + def do_matching(self) -> bool: + return self._do_matching + + @do_matching.setter + def do_matching(self, value: bool) -> None: + self._do_matching = value + + @property + def do_photometry_routine(self) -> bool: + return self._do_photometry_routine + + @do_photometry_routine.setter + def do_photometry_routine(self, value: bool) -> None: + self._do_photometry_routine = value + + @property + def do_bgd_subtraction(self) -> bool: + return self._do_bgd_subtraction + + @do_bgd_subtraction.setter + def do_bgd_subtraction(self, value: bool) -> None: + self._do_bgd_subtraction = value + + # ========================================== + # OTHER ACTIONS + # ========================================== + + @property + def generate_psf(self) -> bool: + return self._generate_psf + + @generate_psf.setter + def generate_psf(self, value: bool) -> None: + self._generate_psf = value + + @property + def generate_run(self) -> bool: + return self._generate_run + + @generate_run.setter + def generate_run(self, value: bool) -> None: + self._generate_run = value + + @property + def generate_region(self) -> bool: + return self._generate_region + + @generate_region.setter + def generate_region(self, value: bool) -> None: + self._generate_region = value + + @property + def generate_local_param_file(self) -> bool: + return self._generate_local_param_file + + @generate_local_param_file.setter + def generate_local_param_file(self, value: bool) -> None: + self._generate_local_param_file = value + + @property + def update_param(self) -> bool: + return self._update_param + + @update_param.setter + def update_param(self, value: bool) -> None: + self._update_param = value + + # ========================================== + # PARAMETERS + # ========================================== + + @property + def param_file(self) -> str | None: + return self._param_file + + @param_file.setter + def param_file(self, value: str | None) -> None: + self._param_file = value + + @property + def ap_file(self) -> str | None: + return self._ap_file + + @ap_file.setter + def ap_file(self, value: str | None) -> None: + if value is None or os.path.exists(value): + self._ap_file = value + else: + p_error("AP_FILE \"%s\" does not exist\n" % value) + + @property + def background_file(self) -> str | None: + return self._background_file + + @background_file.setter + def background_file(self, value: str | None) -> None: + if value is None or os.path.exists(value): + self._background_file = value + else: + p_error("BGD_FILE \"%s\" does not exist\n" % value) + + @property + def find_file(self) -> bool: + return self._find_file + + @find_file.setter + def find_file(self, value: bool) -> None: + self._find_file = value + + @property + def n_cores(self) -> int: + return self._n_cores + + @n_cores.setter + def n_cores(self, value: int) -> None: + self._n_cores = value + + # ========================================== + # DYNAMIC PARAM FILE PROPERTIES (MONSTER EXTRAS) + # ========================================== + + @property + def output_file(self) -> str | None: + return self._output_file + + @output_file.setter + def output_file(self, value: str) -> None: + self._output_file = value + + @property + def hdu_name(self) -> str: + return self._hdu_name + + @hdu_name.setter + def hdu_name(self, value: str) -> None: + self._hdu_name = value + + @property + def custom_filter(self) -> str | None: + return self._filter + + @custom_filter.setter + def custom_filter(self, value: str) -> None: + # added warning if we're planning on using F150W2 filter, as currently + # issue arises that we've had to 1/2 the resolution to allow it to + # pass init. see https://github.com/alan-stokes/starbug2/issues/2 + # for more details. + self._filter = value + + if self._filter == PROBLEMATIC_FILTER_ID: + warn(PROBLEMATIC_FILTER_WARNING) + + @property + def full_width_half_max(self) -> float: + return self._full_width_half_max + + @full_width_half_max.setter + def full_width_half_max(self, value: float) -> None: + self._full_width_half_max = value + + @property + def sigma_sky(self) -> float: + return self._sigma_sky + + @sigma_sky.setter + def sigma_sky(self, value: float) -> None: + self._sigma_sky = value + + @property + def sigma_source(self) -> float: + return self._sigma_source + + @sigma_source.setter + def sigma_source(self, value: float) -> None: + self._sigma_source = value + + @property + def do_bgd_2d(self) -> bool: + return self._do_bgd_2d + + @do_bgd_2d.setter + def do_bgd_2d(self, value: bool) -> None: + self._do_bgd_2d = value + + @property + def do_convolution(self) -> bool: + return self._do_convolution + + @do_convolution.setter + def do_convolution(self, value: bool) -> None: + self._do_convolution = value + + @property + def clean_sources(self) -> bool: + return self._clean_sources + + @clean_sources.setter + def clean_sources(self, value: bool) -> None: + self._clean_sources = value + + @property + def sharp_cutoff_low(self) -> float: + return self._sharp_cutoff_low + + @sharp_cutoff_low.setter + def sharp_cutoff_low(self, value: float) -> None: + self._sharp_cutoff_low = value + + @property + def sharp_cutoff_high(self) -> float: + return self._sharp_cutoff_high + + @sharp_cutoff_high.setter + def sharp_cutoff_high(self, value: float) -> None: + self._sharp_cutoff_high = value + + @property + def round1_cutoff_high(self) -> float: + return self._round1_cutoff_high + + @round1_cutoff_high.setter + def round1_cutoff_high(self, value: float) -> None: + self._round1_cutoff_high = value + + @property + def round2_cutoff_high(self) -> float: + return self._round2_cutoff_high + + @round2_cutoff_high.setter + def round2_cutoff_high(self, value: float) -> None: + self._round2_cutoff_high = value + + @property + def smooth_low(self) -> float: + return self._smooth_low + + @smooth_low.setter + def smooth_low(self, value: float) -> None: + self._smooth_low = value + + @property + def smooth_high(self) -> float: + return self._smooth_high + + @smooth_high.setter + def smooth_high(self, value: float) -> None: + self._smooth_high = value + + @property + def ricker_wavelet_radius(self) -> float: + return self._ricker_wavelet_radius + + @ricker_wavelet_radius.setter + def ricker_wavelet_radius(self, value: float) -> None: + self._ricker_wavelet_radius = value + + @property + def aperture_phot_radius(self) -> float: + return self._aperture_phot_radius + + @aperture_phot_radius.setter + def aperture_phot_radius(self, value: float) -> None: + self._aperture_phot_radius = value + + @property + def encircled_energy_fraction(self) -> float: + return self._encircled_energy_fraction + + @encircled_energy_fraction.setter + def encircled_energy_fraction(self, value: float) -> None: + self._encircled_energy_fraction = value + + @property + def sky_annulus_inner_radius(self) -> float: + return self._sky_annulus_inner_radius + + @sky_annulus_inner_radius.setter + def sky_annulus_inner_radius(self, value: float) -> None: + self._sky_annulus_inner_radius = value + + @property + def sky_annulus_outer_radius(self) -> float: + return self._sky_annulus_outer_radius + + @sky_annulus_outer_radius.setter + def sky_annulus_outer_radius(self, value: float) -> None: + self._sky_annulus_outer_radius = value + + @property + def ap_corr_file_override(self) -> str | None: + return self._ap_corr_file_override + + @ap_corr_file_override.setter + def ap_corr_file_override(self, value: str) -> None: + self._ap_corr_file_override = value + + @property + def bgd_radius(self) -> float: + return self._bgd_radius + + @bgd_radius.setter + def bgd_radius(self, value: float) -> None: + self._bgd_radius = value + + @property + def profile_scaling_factor(self) -> float: + return self._profile_scaling_factor + + @profile_scaling_factor.setter + def profile_scaling_factor(self, value: float) -> None: + self._profile_scaling_factor = value + + @property + def profile_slope(self) -> float: + return self._profile_slope + + @profile_slope.setter + def profile_slope(self, value: float) -> None: + self._profile_slope = value + + @property + def background_box_size(self) -> int: + return self._background_box_size + + @background_box_size.setter + def background_box_size(self, value: int) -> None: + self._background_box_size = value + + @property + def bgd_check_file(self) -> str | None: + return self._bgd_check_file + + @bgd_check_file.setter + def bgd_check_file(self, value: str) -> None: + self._bgd_check_file = value + + @property + def psf_file_override(self) -> str: + return self._psf_file_override + + @psf_file_override.setter + def psf_file_override(self, value: str) -> None: + self._psf_file_override = value + + @property + def use_wcs_values(self) -> bool: + return self._use_wcs_values + + @use_wcs_values.setter + def use_wcs_values(self, value: bool) -> None: + self._use_wcs_values = value + + @property + def zero_point_magnitude(self) -> float: + return self._zero_point_magnitude + + @zero_point_magnitude.setter + def zero_point_magnitude(self, value: float) -> None: + self._zero_point_magnitude = value + + @property + def critical_separation(self) -> float: + return self._critical_separation + + @critical_separation.setter + def critical_separation(self, value: float) -> None: + self._critical_separation = value + + @property + def force_centroid_position(self) -> bool: + return self._force_centroid_position + + @force_centroid_position.setter + def force_centroid_position(self, value: bool) -> None: + self._force_centroid_position = value + + @property + def max_xy_deviation(self) -> str: + return self._max_xy_deviation + + @max_xy_deviation.setter + def max_xy_deviation(self, value: str) -> None: + self._max_xy_deviation = value + + @property + def psf_fit_size(self) -> int: + return self._psf_fit_size + + @psf_fit_size.setter + def psf_fit_size(self, value: int) -> None: + self._psf_fit_size = value + + @property + def generate_residual_image(self) -> bool: + return self._generate_residual_image + + @generate_residual_image.setter + def generate_residual_image(self, value: bool) -> None: + self._generate_residual_image = value + + @property + def calculate_crowding_metric(self) -> bool: + return self._calculate_crowding_metric + + @calculate_crowding_metric.setter + def calculate_crowding_metric(self, value: bool) -> None: + self._calculate_crowding_metric = value + + @property + def match_threshold_arc_sec(self) -> str: + return self._match_threshold_arc_sec + + @match_threshold_arc_sec.setter + def match_threshold_arc_sec(self, value: str) -> None: + self._match_threshold_arc_sec = value + + @property + def extra_match_columns(self) -> str: + if self._extra_match_columns is None: + return "" + return self._extra_match_columns + + @extra_match_columns.setter + def extra_match_columns(self, value: str) -> None: + self._extra_match_columns = value + + @property + def exposure_count_threshold(self) -> int: + return self._exposure_count_threshold + + @exposure_count_threshold.setter + def exposure_count_threshold(self, value: int) -> None: + self._exposure_count_threshold = value + + @property + def bridge_band_column(self) -> str: + if self._bridge_band_column is None: + return "" + return self._bridge_band_column + + @bridge_band_column.setter + def bridge_band_column(self, value: str) -> None: + self._bridge_band_column = value + + @property + def artificial_star_tests_count(self) -> int: + return self._artificial_star_tests_count + + @artificial_star_tests_count.setter + def artificial_star_tests_count(self, value: int) -> None: + self._artificial_star_tests_count = value + + @property + def stars_per_artificial_test(self) -> int: + return self._stars_per_artificial_test + + @stars_per_artificial_test.setter + def stars_per_artificial_test(self, value: int) -> None: + self._stars_per_artificial_test = value + + @property + def sub_image_crop_size(self) -> int: + return self._sub_image_crop_size + + @sub_image_crop_size.setter + def sub_image_crop_size(self, value: int) -> None: + self._sub_image_crop_size = value + + @property + def test_magnitude_bright_limit(self) -> int: + return self._test_magnitude_bright_limit + + @test_magnitude_bright_limit.setter + def test_magnitude_bright_limit(self, value: int) -> None: + self._test_magnitude_bright_limit = value + + @property + def test_magnitude_faint_limit(self) -> int: + return self._test_magnitude_faint_limit + + @test_magnitude_faint_limit.setter + def test_magnitude_faint_limit(self, value: int) -> None: + self._test_magnitude_faint_limit = value + + @property + def ast_plot_filename(self) -> str | None: + return self._ast_plot_filename + + @ast_plot_filename.setter + def ast_plot_filename(self, value: str) -> None: + self._ast_plot_filename = value + + @property + def region_colour(self) -> str: + return self._region_colour + + @region_colour.setter + def region_colour(self, value: str) -> None: + self._region_colour = value + + @property + def region_scale(self) -> bool: + return self._region_scale + + @region_scale.setter + def region_scale(self, value: bool) -> None: + self._region_scale = value + + @property + def region_radius(self) -> int: + return self._region_radius + + @region_radius.setter + def region_radius(self, value: int) -> None: + self._region_radius = value + + @property + def region_x_column_name(self) -> str: + return self._region_x_column_name + + @region_x_column_name.setter + def region_x_column_name(self, value: str) -> None: + self._region_x_column_name = value + + @property + def region_y_column_name(self) -> str: + return self._region_y_column_name + + @region_y_column_name.setter + def region_y_column_name(self, value: str) -> None: + self._region_y_column_name = value + + @property + def region_uses_wcs(self) -> bool: + return self._region_uses_wcs + + @region_uses_wcs.setter + def region_uses_wcs(self, value: bool) -> None: + self._region_uses_wcs = value + + @property + def execute_jwst_initialisation(self) -> bool: + return self._execute_jwst_initialisation + + @execute_jwst_initialisation.setter + def execute_jwst_initialisation(self, value: bool) -> None: + self._execute_jwst_initialisation = value + + @property + def detector_name(self) -> str | None: + return self._detector_name + + @detector_name.setter + def detector_name(self, value: str) -> None: + self._detector_name = value + + @property + def region_file(self) -> str | None: + return self._region_file + + @region_file.setter + def region_file(self, value: str) -> None: + self._region_file = value + + @property + def fits_images(self) -> list[str]: + return self._fits_images + + @fits_images.setter + def fits_images(self, values: list[str]) -> None: + self._fits_images = values + + # when in match, it is no longer image data. but table data. so utilise + # this method for clarity of user readability. + @property + def fits_table(self) -> list[str]: + return self._fits_images + + @fits_table.setter + def fits_table(self, values: list[str]) -> None: + self._fits_images = values + + @property + def param_tag(self) -> str: + return self._param_tag + + @param_tag.setter + def param_tag(self, value) -> None: + self._param_tag = value + + # =============================== + # AST properties + # =============================== + + @property + def ast_seed(self) -> int | None: + return self._ast_seed + + @ast_seed.setter + def ast_seed(self, value: int | None) -> None: + self._ast_seed = value + + @property + def save_added_image_path(self) -> str: + return self._save_added_image_path + + @save_added_image_path.setter + def save_added_image_path(self, value: str) -> None: + self._save_added_image_path = value + + @property + def save_added_image(self) -> bool: + return self._save_added_image + + @save_added_image.setter + def save_added_image(self, value: bool) -> None: + self._save_added_image = value + + @property + def show_ast_help(self) -> bool: + return self._show_ast_help + + @show_ast_help.setter + def show_ast_help(self, value: bool) -> None: + self._show_ast_help = value + + @property + def ast_recover(self) -> bool: + return self._ast_recover + + @ast_recover.setter + def ast_recover(self, value: bool) -> None: + self._ast_recover = value + + @property + def ast_auto_save(self) -> int: + return self._ast_auto_save + + @ast_auto_save.setter + def ast_auto_save(self, value: int) -> None: + self._ast_auto_save = value + + @property + def ast_no_background(self) -> bool: + return self._ast_no_background + + @ast_no_background.setter + def ast_no_background(self, value: bool) -> None: + self._ast_no_background = value + + @property + def ast_no_psf_phot(self) -> bool: + return self._ast_no_psf_phot + + @ast_no_psf_phot.setter + def ast_no_psf_phot(self, value: bool) -> None: + self._ast_no_psf_phot = value + + # ======================================= + # matching properties + # ======================================= + + @property + def do_band_processing(self) -> bool: + return self._do_band_processing + + @do_band_processing.setter + def do_band_processing(self, value: bool) -> None: + self._do_band_processing = value + + @property + def do_cascade(self) -> bool: + return self._do_cascade + + @do_cascade.setter + def do_cascade(self, value: bool) -> None: + self._do_cascade = value + + @property + def use_dither(self) -> bool: + return self._use_dither + + @use_dither.setter + def use_dither(self, value: bool) -> None: + self._use_dither = value + + @property + def exact_match(self) -> bool: + return self._exact_match + + @exact_match.setter + def exact_match(self, value: bool) -> None: + self._exact_match = value + + @property + def full_run(self) -> bool: + return self._full_run + + @full_run.setter + def full_run(self, value: bool) -> None: + self._full_run = value + + @property + def generic_mode(self) -> bool: + return self._generic_mode + + @generic_mode.setter + def generic_mode(self, value: bool) -> None: + self._generic_mode = value + + @property + def show_match_help(self) -> bool: + return self._show_match_help + + @show_match_help.setter + def show_match_help(self, value: bool) -> None: + self._show_match_help = value + + @property + def band_deprecated(self) -> bool: + return self._band_deprecated + + @band_deprecated.setter + def band_deprecated(self, value: bool) -> None: + self._band_deprecated = value + + @property + def error_col(self) -> str: + return self._error_col + + @error_col.setter + def error_col(self, value: str) -> None: + self._error_col = value + + @property + def mask_eval(self) -> str | None: + return self._mask_eval + + @mask_eval.setter + def mask_eval(self, value: str | None) -> None: + self._mask_eval = value + + # ============================== + # plot getters and setters + # ============================== + + @property + def show_plot_help(self) -> bool: + return self._show_plot_help + + @show_plot_help.setter + def show_plot_help(self, value: bool) -> None: + self._show_plot_help = value + + @property + def test_mode(self) -> bool: + return self._test_mode + + @test_mode.setter + def test_mode(self, value: bool) -> None: + self._test_mode = value + + @property + def dark_mode(self) -> bool: + return self._dark_frame_correction + + @dark_mode.setter + def dark_mode(self, value: bool) -> None: + self._dark_frame_correction = value + + @property + def inspect_parameter(self) -> str | None: + return self._inspect_parameter + + @inspect_parameter.setter + def inspect_parameter(self, value: str | None) -> None: + self._inspect_parameter = value + + @property + def plot_style(self) -> str | None: + return self._plot_style + + @plot_style.setter + def plot_style(self, value: str | None) -> None: + self._plot_style = value + + def __setattr__(self, key: str, value: Any) -> None: + # Check if the class is frozen, allowing the internal '_frozen' + # flag itself to be set + if getattr(self, '_frozen', False) and key != '_frozen': + raise RuntimeError(f"Cannot modify property '{key}': " + f"Configuration is frozen for workers!") + + # handle -s or --set + if key == "set_parameter": + if value and '=' in value: + # Split into parameter key and value string + raw_key, raw_val = value.split('=', 1) + param_key = raw_key.strip() + val_str = raw_val.strip() + + if param_key not in self.MAIN_PARAM_FILE_MAP: + raise ValueError( + f"Param '{param_key}' passed via -s/--set is not a " + f"valid Starbug2 parameter. Please check spelling") + + property_name, target_type = ( + self.MAIN_PARAM_FILE_MAP[param_key]) + + # Convert empty arguments or strings to standard configurations + if val_str == "" or val_str.lower() == "none": + super().__setattr__(property_name, None) + elif target_type is bool: + super().__setattr__(property_name, bool(int(val_str))) + else: + super().__setattr__(property_name, target_type(val_str)) + return + + super().__setattr__(key, value) diff --git a/starbug2/star_bug_interface.py b/starbug2/star_bug_interface.py new file mode 100644 index 0000000..57b21ab --- /dev/null +++ b/starbug2/star_bug_interface.py @@ -0,0 +1,262 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +from abc import ABC, abstractmethod +from astropy.table import Table +from astropy.io.fits import PrimaryHDU, ImageHDU, HDUList, Header + +import numpy as np + + +class StarBugInterface(ABC): + + @abstractmethod + def log(self, msg: str) -> None: + """ + Print message if in verbose mode + + :param msg: Message to print out + :type msg: str + :return: None + """ + pass + + @abstractmethod + def load_image(self, f_name: str) -> None: + """ + Given f_name, load the image into starbug to be worked on. + + :param f_name: Filename of fits image (with any number of extensions). + If using a non-standard HDU index, set the name or index of the + extension with "HDU_NAME=XXX" in the parameter file. + :type f_name: str + :return: None + """ + pass + + @abstractmethod + def load_ap_file(self, f_name: str | None = None) -> None: + """ + Load an AP_FILE to be used during photometry + + :param f_name: Filename for fits table containing source coordinates. + These coordinates can be x-centroid / y-centroid, x_init / y_init, + x_0, y_0 or RA/DEC. The latter is used if starbug gets "USE_WCS=1" + in the parameter file. + :type f_name: str or None + :return: None + """ + pass + + @abstractmethod + def load_bgd_file(self, f_name: str | None = None) -> None: + """ + Load a BGD_FILE to be used during photometry + + :param f_name: Filename of fits image the same dimensions as the + main image + :type f_name: str or None + :return: None + """ + pass + + @abstractmethod + def load_psf(self, f_name: str | None = None) -> int: + """ + Load a PSF_FILE to be used during photometry + + :param f_name: Filename of a PSF fits image + :type f_name: str or None + :return: The execution status (0 for success, non-zero for failure) + :rtype: int + """ + pass + + @abstractmethod + def prepare_image_arrays(self) -> tuple[ + np.ndarray, np.ndarray, np.ndarray | None, np.ndarray]: + """ + Make a copy of the original image, and prepare the other image arrays + + :return: A tuple containing (image, error, bgd, mask) arrays + :rtype: tuple of (np.ndarray, np.ndarray, np.ndarray or None, + np.ndarray) + """ + pass + + @abstractmethod + def detect(self) -> int: + """ + Full source detection routine. Saves the result as a table + self._detections + + :return: The execution status (0 for success, non-zero for failure) + :rtype: int + """ + pass + + @abstractmethod + def aperture_photometry(self) -> int: + """ + Executes aperture photometry + + :return: 0 for success, 1 for failure + :rtype: int + """ + pass + + @abstractmethod + def bgd_estimate(self) -> int: + """ + Estimate the background of the active image + Saves the result as an ImageHDU self._background + + :return: The execution status (0 for success, non-zero for failure) + :rtype: int + """ + pass + + @abstractmethod + def bgd_subtraction(self) -> int: + """ + Internally subtract a background array from an image array + + :return: 0 for success, 1 otherwise + :rtype: int + """ + pass + + @abstractmethod + def photometry_routine(self) -> int: + """ + Full photometry routine + Saves the result as a table self._psf_catalogue, + Additionally it appends a residual Image onto the + self._residuals HDUList + + :return: 0 for success, 1 otherwise + :rtype: int + """ + pass + + @abstractmethod + def source_geometry(self) -> None: + """ + Calculate source geometry stats for a given image and source list + + :return: None + """ + pass + + @abstractmethod + def verify(self) -> int: + """ + This simple function verifies that everything necessary has been + loaded properly + + :return: 0 on success, 1 on failure + :rtype: int + """ + pass + + @property + @abstractmethod + def header(self) -> Header: + """ + Construct relevant base header information for routine products + + :return: Header file containing a series of relevant information + :rtype: Header + """ + pass + + @property + @abstractmethod + def info(self) -> dict[str, str]: + """ + Get some useful information from the image header file. + + :return: Extracted keys and elements from the image header. + :rtype: dict of str to str + """ + pass + + @property + @abstractmethod + def main_image(self) -> ImageHDU | PrimaryHDU: + # noinspection SpellCheckingInspection + """ + Automagically find the main image array to use + Order of importance is: + > self._nHDU (if set) + > param[ HDUNAME ] + > SCI, BGD, RES + > first ImageHDU + > image[0] + + :return: The main image array extension + :rtype: ImageHDU or PrimaryHDU + """ + pass + + @property + @abstractmethod + def filter(self) -> str | None: + pass + + @property + @abstractmethod + def n_hdu(self) -> int: + pass + + @property + @abstractmethod + def image(self) -> HDUList | None: + pass + + @image.setter + @abstractmethod + def image(self, new_image: HDUList) -> None: + pass + + @property + @abstractmethod + def psf_catalogue(self) -> Table | None: + pass + + @property + @abstractmethod + def psf(self) -> np.ndarray | None: + pass + + @property + @abstractmethod + def f_name(self) -> str | None: + pass + + @property + @abstractmethod + def detections(self) -> Table | None: + pass + + @detections.setter + @abstractmethod + def detections(self, new_detections: Table) -> None: + pass + + @property + @abstractmethod + def out_dir(self) -> str | None: + pass diff --git a/starbug2/starbug.py b/starbug2/starbug.py index a65415e..480f286 100644 --- a/starbug2/starbug.py +++ b/starbug2/starbug.py @@ -1,935 +1,1312 @@ -from astropy.wcs import WCS -from astropy.table import hstack, vstack -from photutils.psf import FittableImageModel +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +import os +import sys +from os import getenv +from typing import Final, Tuple, Dict, List, cast, Any + +from astropy.wcs import ( + WCS, NoConvergence, SingularMatrixError, InconsistentAxisTypesError, + InvalidTransformError) +import numpy as np +from astropy.io.fits import ( + PrimaryHDU, ImageHDU, HDUList, Header, open, BinTableHDU) +from astropy.table import hstack, Column, vstack, Table +from astropy.stats import sigma_clipped_stats from photutils.datasets import make_model_image - -import starbug2 -from starbug2.param import load_params, load_default_params -from starbug2.utils import * -from starbug2.misc import * -from starbug2.routines import * - - - -class StarbugBase(object): +from photutils.psf import ImagePSF +from starbug2.constants import ( + HeaderTags, ImageHeaderTags, SCI, BGD, RES, VERBOSE_TAG, AP_FILE, BGD_FILE, + FITS_EXTENSION, DQ, AREA, WHT, NIRCAM, SourceFlags, DQFlags, + DetectorLengths, Units, ERR, ExitStates, NIRCAM_STRING, STARBUG_DATA_DIR, + DEFAULT_FULL_WIDTH_HALF_MAX, TableColumn, MIRI_STRING, MIRI_IMAGE) +from starbug2.filters import STAR_BUG_FILTERS, FilterStruct +from starbug2.routines.app_hot_routine import APPhotRoutine +from starbug2.routines.background_estimate_routine import ( + BackGroundEstimateRoutine) +from starbug2.routines.detection_routines import DetectionRoutine +from starbug2.routines.psf_phot_routine import PSFPhotRoutine +from starbug2.routines.source_properties import SourceProperties +from starbug2.star_bug_config import StarBugMainConfig +from starbug2.star_bug_interface import StarBugInterface +from starbug2.utils import ( + collapse_header, parse_unit, get_version, ext_names, printf, + split_file_name, p_error, warn, import_table, get_mj_ysr2jy_scale_factor, + flux2mag, reindex, export_table) + + +class StarbugBase(StarBugInterface): """ - StarbugBase is the overall container for the photometry package. It holds the active image, - the parameter file and the output images/tables. - It is self contained enough to simply run "photometry" and everything should just take care - of itself from there on. + StarbugBase is the overall container for the photometry package. It holds + the active image, the parameter file and the output images/tables. + It is self-contained enough to simply run "photometry" and everything + should just take care of itself from there on. """ - filter=None - stage=0 - outdir="" - fname=None - bname="" - - detections=None - psfcatalogue=None - residuals=None - background=None - source_stats=None - psf=None - - _image=None - _nHDU=-1 - _unit=None - wcs=None - def __init__(self, fname, pfile=None, options={}): - """ - fname : FITS image file name - pfile : parameter file name - options : extra options to load into starbug - """ - if not pfile: - if os.path.exists("starbug.param"): pfile="starbug.param" - else: pfile=None - self.options=load_params(pfile) - self.options.update(options) - self.load_image(fname) ## Load the fits image - - if self.options["AP_FILE"]: self.load_apfile() ## Load the source list if given - if self.options["BGD_FILE"]: self.load_bgdfile() - - #_=self.image ## Force self._nHDU - @property - def header(self): - """ - Construct relevant base header information for routine products + MIN_MAG: Final[int] = 27 + MAX_MAG: Final[int] = 18 - Returns - ------- - `fits.Header` : Header file containing a series of relevvant information + @staticmethod + def get_data_path() -> str: """ - head={} - head["STARBUG"]=get_version() - head["CALIBLEVEL"]=self.stage - if self.filter: head["FILTER"]=self.filter - head.update(self.options) - head.update(self.info) - return utils.collapse_header(head) + Returns the data path. + :return: The data path + :rtype: str + """ + env_path: str | None = getenv(STARBUG_DATA_DIR) + return (env_path if env_path else + "%s/.local/share/starbug" % (getenv("HOME"))) - @property - def info(self): + @staticmethod + def sort_output_names( + f_name: str, param_output: str | None = None + ) -> Tuple[str, str, str]: """ - Get some useful information from the image header file + This is a useful function that looks at both an input file and a set + output and figures out how to name output files. If param_output looks + like a directory then the output will be set to that directory with + the basename of f_name. If param_output looks like a file, then the + output basename will take that form. + + :param f_name: Filename to use as the core of the output + :type f_name: str + :param param_output: This is the OUTPUT parameter in the parameter + file. It can be an output directory or output filename. If None + (default) then it will be ignored + :type param_output: str + :return: A tuple of (The output directory, The output file basename, + The file extension split from the inputs) + :rtype: tuple of (str, str, str) """ - out={} - keys=("FILTER","DETECTOR","TELESCOP","INSTRUME", - "BUNIT","PIXAR_A2", "PIXAR_SR") - if self._image: - for hdu in self._image: - out.update( { (key,hdu.header[key]) for key in keys if key in hdu.header}) - - return out - - @property - def image(self): + out_dir: str = "" + b_name: str = "" + extension: str = "" + if f_name: + out_dir, b_name, extension = split_file_name(f_name) + if (tmp_out_name := param_output) and tmp_out_name != '.': + inner_out_dir, inner_b_name, _ = split_file_name(tmp_out_name) + if os.path.exists(out_dir) and os.path.isdir(out_dir): + out_dir = inner_out_dir + else: + p_error("unable to locate output directory \"%s\"\n" % + inner_out_dir) + if inner_b_name: + b_name = inner_b_name + return out_dir, b_name, extension + + def __init__( + self, f_name: str, config: StarBugMainConfig, + ap_file: str | None, bkg_file: str | None, verbose: Any + ) -> None: """ - automagically find the main image array to use - Order of importance is: - > self._nHDU (if set) - > param[ HDUNAME ] - > SCI, BGD, RES - > first ImageHDU - > image[0] + Star bug initialisation. + + :param f_name: FITS image file name + :type f_name: str + :param config: The starbug configuration object + :type config: StarBugMainConfig + :param ap_file: Optional aperture coordinates file path + :type ap_file: str or None + :param bkg_file: Optional background reference file path + :type bkg_file: str or None + :param verbose: Verbosity configuration/flag + :type verbose: Any """ - if self._nHDU >=0: return self._image[self._nHDU] - enames=extnames(self._image) - - ## HDUNAME in param file - n=self.options["HDUNAME"] - if n and n in enames: - self._nHDU=enames.index(n) - return self._image[n] - - ##index? - if type(n) in (int,float): - self._nHDU=int(n) - return self._image[self._nHDU] - - ## SCI, BGD, RES (common names) - for n in ("SCI","BGD","RES"): - if n in enames: - self._nHDU=enames.index(n) - return self._image[n] - - ## First ImageHDU - for n,hdu in enumerate(self._image): - if type[hdu]==fits.ImageHDU: - self._nHDU=enames.index(n) - return hdu - - self._nHDU=0 - return self._image[0] - - def log(self, msg): + # Defaults + self._config = config + self._f_name: str | None = None + self._out_dir: str | None = None + self._b_name: str | None = None + self._image: HDUList | None = None + self._filter: str | None = None + self._header: Header | None = None + self._wcs: WCS | None = None + self._stage: float = 0.0 + self._detections: Table | None = None + self._n_hdu: int = -1 + self._unit: str | None = None + self._background: ImageHDU | PrimaryHDU | None = None + self._residuals: np.ndarray | None = None + self._psf_catalogue: Table | None = None + self._source_stats: np.ndarray | None = None + self._psf: np.ndarray | None = None + + # Overridden configs + self._ap_file = ap_file + self._background_file = bkg_file + self._verbose = verbose + self._full_width_half_max = config.full_width_half_max + + # Process options + # Load the fits image + self.load_image(f_name) + + if ap_file is not None: + # Load the source list if given + self.load_ap_file(ap_file) + if bkg_file is not None: + self.load_bgd_file(bkg_file) + + def log(self, msg: str) -> None: """ - Print message if in verbose mode + Print message if in verbose mode. - Parameters: - ----------- - msg : str - Message to print out + :param msg: Message to print out + :type msg: str + :return: None """ - if self.options["VERBOSE"]: + if self._config.verbose_logs: printf(msg) sys.stdout.flush() + def load_image(self, f_name: str | None) -> None: + """ + Given f_name, load the image into starbug to be worked on. - @staticmethod - def sort_output_names(fname, param_output=None): - """ - This is a useful function that looks at both an input file and a set output - and figures out how to name output files. If param_output looks like a directory - then the output will be set to that directory with the basename of fname. If - param_output looks like a file, then the output basename will take that form - - Parameters - ---------- - fname : str - Filename to use as the core of the output - - param_output : str - This is the OUTPUT parameter in the parameter file. It can be an output - directory or output filename. If None (default) then it will be ignored - - Returns - ------- - outdir : str - The output directory - - bname : str - The output file basename (e.g. path/to/output.txt -> "output") - - extension : str - The file extension split from the inputs - """ - outdir="" - bname="" - extension="" - if fname: - outdir,bname,extension=split_fname(fname) - if (tmp_outname:=param_output) and tmp_outname !='.': - _outdir,_bname,_=split_fname(tmp_outname) - if os.path.exists(outdir) and os.path.isdir(outdir): outdir=_outdir - else: perror("unable to locate output directory \"%s\"\n"%_outdir) - if _bname: bname=_bname - - return outdir,bname,extension - - def load_image(self, fname): - """ - Given fname, load the image into starbug to be worked on. - - Parameters - ---------- - fname : str - Filename of fits image (with any number of extensions. If using - a non standard HDU index, set the name or index of the extension - with "HDUNAME=XXX" in the parameter file - """ - self.fname=fname - if fname: - ######################################### + :param f_name: Filename of fits image (with any number of extensions). + If using a non-standard HDU index, set the name or index of the + extension with "HDU_NAME=XXX" in the parameter file. + :type f_name: str | None + :return: None + """ + self._f_name = f_name + if f_name: # Sorting out the file names and what not - ######################################### - self.outdir,self.bname, extension = self.sort_output_names(fname,self.options.get("OUTPUT")) - #self.outdir,self.bname,extension=split_fname(fname) - #if (tmp_outname:=self.options.get("OUTPUT")) and tmp_outname !='.': - # outdir,bname,_=split_fname(tmp_outname) - # if os.path.exists(outdir) and os.path.isdir(outdir): self.outdir=outdir - # else: perror("unable to locate output directory \"%s\"\n"%outdir) - # if bname: self.bname=bname - - if extension==".fits": - if os.path.exists(fname): - self.log("loaded: \"%s\"\n"%fname) - self._image=fits.open(fname) - _=self.image ## Force assigning _nHDU - self.log("-> using image HDU: %d (%s)\n"%(self._nHDU,self.image.name)) - if self.image.data is None: - warn("Image seems to be empty.\n") - - if (val:=self.header.get("TELESCOP")) is None or (val.find("JWST")<0): - warn("Telescope not JWST, there may be undefined behaviour.\n") + extension: str + self._out_dir, self._b_name, extension = self.sort_output_names( + f_name, self._config.output_file) - self.filter=self.options.get("FILTER") - if ("FILTER" in self.header) and (self.header["FILTER"] in starbug2.filters.keys()): - self.filter=self.header["FILTER"] - if self.options["FWHM"]<0: self.options["FWHM"]=starbug2.filters[self.filter].pFWHM - if self.filter: - self.log("-> photometric band: %s\n"%self.filter) - else: - warn("Unable to determine image filter\n") + if extension == FITS_EXTENSION: + if os.path.exists(f_name): + self.log("loaded: \"%s\"\n" % f_name) + self._image = open(f_name) - if "DETECTOR" in self.info.keys(): - self.log("-> detector module: %s\n"%self.info["DETECTOR"]) - else: warn("Unable to determine Telescope DETECTOR.\n") + # Force assigning _nHDU + main_image: ImageHDU | PrimaryHDU = self.main_image + self._header = main_image.header - if "BUNIT" in self.image.header: - self._unit=self.image.header["BUNIT"] - else: warn("Unable to determine image BUNIT.\n") + self.log( + "-> using image HDU: %d (%s)\n" % ( + self._n_hdu, main_image.name)) - self.wcs=WCS(self.image.header) + if main_image.data is None: + warn("Image seems to be empty.\n") - ## I NEED TO DETERMINE BETTER WHAT STAGE IT IS IN - exts=extnames(self._image) - if "DQ" in exts: - if "AREA" in exts: self.stage=2 - else: self.stage=2.5 - elif "WHT" in exts: self.stage=3 - elif "CALIBLEVEL" in self.image.header: self.stage=self.image.header["CALIBLEVEL"] + if ((val := main_image.header.get( + ImageHeaderTags.TELESCOPE)) is None + or (val.find(ImageHeaderTags.JWST) < 0)): + warn("Telescope not JWST, " + "there may be undefined behaviour.\n") + + self._filter = self._config.custom_filter + assert self._filter is not None + if ((HeaderTags.FILTER in main_image.header) and + (main_image.header[HeaderTags.FILTER] in + STAR_BUG_FILTERS.keys())): + self._filter = main_image.header[HeaderTags.FILTER] + assert self._filter is not None + if self._full_width_half_max < 0: + self._full_width_half_max = ( + STAR_BUG_FILTERS[ + self._filter].full_width_half_max) + if self._filter: + self.log("-> photometric band: %s\n" % self._filter) else: - warn("Unable to determine calibration level, assuming stage 3\n") - self.stage=3 + warn("Unable to determine image filter\n") + if ImageHeaderTags.DETECTOR in self.info.keys(): + self.log( + "-> detector module: %s\n" % + self.info[ImageHeaderTags.DETECTOR]) + else: + warn("Unable to determine Telescope DETECTOR.\n") - #self.log("loaded: \"%s\"\n"%fname) - self.log("-> pipeline stage: %d\n"%self.stage) + if ImageHeaderTags.BUN_IT in main_image.header: + self._unit = main_image.header[ImageHeaderTags.BUN_IT] + else: + warn("Unable to determine image BUNIT.\n") + + self._wcs = WCS(self.main_image.header) + + # Determine calculation stage level + extension_names: List[str] = ext_names(self._image) + if DQ in extension_names: + if AREA in extension_names: + self._stage = 2.0 + else: + self._stage = 2.5 + elif WHT in extension_names: + self._stage = 3.0 + elif HeaderTags.CALIBRATION_LV in self.main_image.header: + self._stage = ( + self.main_image.header[HeaderTags.CALIBRATION_LV]) + else: + warn("Unable to determine calibration level, " + "assuming stage 3\n") + self._stage = 3.0 + self.log("-> pipeline stage: %d\n" % self._stage) - else: warn("fits file \"%s\" does not exist\n"%fname) - else: warn("included file must be FITS format\n") + else: + warn("fits file \"%s\" does not exist\n" % f_name) + else: + warn("included file must be FITS format\n") - def load_apfile(self,fname=None): + def load_ap_file(self, f_name: str | None = None) -> None: """ - Load a AP_FILE to be used during photometry - - Parameters - ---------- - fname : str - Filename for fits table containg source coordinates. These coorindates can be - xcentroid/ycentroid, x_init/y_init, x_0,y_0 or RA/DEC. The latter is used if - starbug gets "USE_WCS=1" in the parameter file. + Load an AP_FILE to be used during photometry. + + :param f_name: Filename for fits table containing source coordinates. + These coordinates can be x-centroid / y-centroid, x_init / y_init, + x_0, y_0 or RA/DEC. The latter is used if starbug gets "USE_WCS=1" + in the parameter file. + :type f_name: str + :return: None """ - if not fname: fname=self.options["AP_FILE"] - if os.path.exists(fname): - self.detections=import_table(fname) - cn=set(self.detections.colnames) + if not f_name: + f_name = self._ap_file + if f_name and os.path.exists(f_name): + self._detections = import_table(f_name) + if self._detections is None or self._wcs is None: + raise Exception("could not read the ap file") + + column_names: set[str] = set(self._detections.colnames) - self.log("loaded AP_FILE='%s'\n"%fname) + self.log("loaded AP_FILE='%s'\n" % f_name) - if self.options.get("USE_WCS"): - if len(cn & set(("RA","DEC")))==2: - self.log("-> using RADEC coordinates\n") + if self._config.use_wcs_values: + if len(column_names & {TableColumn.RA, TableColumn.DEC}) == 2: + self.log("-> using RA-DEC coordinates\n") try: - xy=self.wcs.all_world2pix(self.detections["RA"], self.detections["DEC"],0) - except: - warn("Something went wrong converting WCS to pixels, trying wcs_world2pix next.\n") - xy=self.wcs.wcs_world2pix(self.detections["RA"], self.detections["DEC"],0) - if "xcentroid" in cn: self.detections.remove_column("xcentroid") - if "ycentroid" in cn: self.detections.remove_column("ycentroid") - self.detections.add_columns(xy,names=("xcentroid","ycentroid"),indexes=[0,0]) + xy: Any = self._wcs.all_world2pix( + self._detections[TableColumn.RA], + self._detections[TableColumn.DEC], 0) + except (NoConvergence, MemoryError, SingularMatrixError, + InconsistentAxisTypesError, ValueError, + InvalidTransformError) as e: + warn(f"Something went wrong converting WCS to pixels " + f"({e}), trying wcs_world2pix next.\n") + xy = self._wcs.wcs_world2pix( + self._detections[TableColumn.RA], + self._detections[TableColumn.DEC], 0) + if TableColumn.X_CENTROID in column_names: + self._detections.remove_column(TableColumn.X_CENTROID) + if TableColumn.Y_CENTROID in column_names: + self._detections.remove_column(TableColumn.Y_CENTROID) + self._detections.add_columns( + xy, + names=[TableColumn.X_CENTROID, TableColumn.Y_CENTROID], + indexes=[0, 0]) else: warn("No 'RA' or 'DEC' found in AP_FILE\n") - #self.options["USE_WCS"]=0 - elif len( set(("x_0","y_0"))&cn)==2: - self.detections.rename_columns(("x_0","y_0"),("xcentroid","ycentroid")) - elif len( set(("x_init","y_init"))&cn)==2: - self.detections.rename_columns(("x_init","y_init"),("xcentroid","ycentroid")) + elif len({TableColumn.X_0, TableColumn.Y_0} & column_names) == 2: + self._detections.rename_columns( + (TableColumn.X_0, TableColumn.Y_0), + (TableColumn.X_CENTROID, TableColumn.Y_CENTROID)) + elif (len({TableColumn.X_INIT, TableColumn.Y_INIT} + & column_names) == 2): + self._detections.rename_columns( + (TableColumn.X_INIT, TableColumn.Y_INIT), + (TableColumn.X_CENTROID, TableColumn.Y_CENTROID)) + + if len({TableColumn.X_CENTROID, TableColumn.Y_CENTROID} & + set(self._detections.colnames)) == 2: + mask: np.ndarray = ( + (self._detections[TableColumn.X_CENTROID] >= 0) + & (self._detections[TableColumn.X_CENTROID] < + self.main_image.shape[1]) + & (self._detections[TableColumn.Y_CENTROID] >= 0) + & (self._detections[TableColumn.Y_CENTROID] < + self.main_image.shape[0]) + ) + + # cant figure how to resolve this typing + self._detections.remove_rows(~mask) # noqa + self.log( + "-> loaded %d sources from AP_FILE\n" % + len(self._detections)) + else: + warn("Unable to determine physical coordinates" + " from detections table\n") + else: + p_error("AP_FILE='%s' does not exist\n" % f_name) + + def load_bgd_file(self, f_name: str | None = None) -> None: + """ + Load a BGD_FILE to be used during photometry. + + :param f_name: Filename of fits image the same dimensions as the + main image + :type f_name: str + :return: None + """ + if f_name is None: + f_name = self._config.background_file + if f_name is None: + return + if os.path.exists(f_name): + self._background = open(f_name)[1] + self.log("loaded BGD_FILE='%s'\n" % f_name) + else: + p_error("BGD_FILE='%s' does not exist\n" % f_name) + + # noinspection SpellCheckingInspection + def load_psf(self, f_name: str | None = None) -> ExitStates: + """ + Load a PSF_FILE to be used during photometry. - if len( set(("xcentroid","ycentroid"))&set(self.detections.colnames))==2: - mask=(self.detections["xcentroid"]>=0) & (self.detections["xcentroid"]=0) & (self.detections["ycentroid"] loaded %d sources from AP_FILE\n"%len(self.detections)) + :param f_name: Filename of a PSF fits image + :type f_name: str + :return: The exit status state + :rtype: ExitStates + """ + status: ExitStates = ExitStates.EXIT_SUCCESS + assert self._filter is not None + if not f_name: + filter_struct: FilterStruct | None = ( + STAR_BUG_FILTERS.get(self._filter)) + if filter_struct: + dt_name: str = self.info[ImageHeaderTags.DETECTOR] + if dt_name == "NRCALONG": + dt_name = "NRCA5" + if dt_name == "NRCBLONG": + dt_name = "NRCB5" + if dt_name == "MULTIPLE": + if (filter_struct.instr == NIRCAM + and filter_struct.length == DetectorLengths.SHORT): + dt_name = "NRCA1" + elif (filter_struct.instr == NIRCAM and + filter_struct.length == DetectorLengths.LONG): + dt_name = "NRCA5" + elif filter_struct.instr == MIRI_STRING: + dt_name = "" + if dt_name == MIRI_IMAGE: + dt_name = "" + f_name = "%s/%s%s.fits" % ( + StarbugBase.get_data_path(), self._filter, dt_name) else: - warn("Unable to determine physical coordinates from detections table\n") - else: perror("AP_FILE='%s' does not exists\n"%fname) - - def load_bgdfile(self,fname=None): - """ - Load a BGD_FILE to be used during photometry - - Parameters - ---------- - fname : str - Filename of fits image the same dimensions as the main image - """ - if not fname: fname=self.options["BGD_FILE"] - if os.path.exists(fname): - self.background=fits.open(fname)[1] - self.log("loaded BGD_FILE='%s'\n"%fname) - else: perror("BGD_FILE='%s' does not exist\n"%fname) - - def load_psf(self,fname=None): - """ - Load a PSF_FILE to be used during photometry - - Parameters - ---------- - fname : str - Filename of a PSF fits image - """ - status=0 - if not fname: - fltr=starbug2.filters.get(self.filter) - if fltr: - dtname=self.info["DETECTOR"] - if dtname=="NRCALONG": dtname="NRCA5" - if dtname=="NRCBLONG": dtname="NRCB5" - if dtname=="MULTIPLE": - if fltr.instr==starbug2.NIRCAM and fltr.length==starbug2.SHORT: dtname="NRCA1" - elif fltr.instr==starbug2.NIRCAM and fltr.length==starbug2.LONG: dtname="NRCA5" - elif fltr.instr==starbug2.MIRI: dtname="" - if dtname=="MIRIMAGE": dtname="" - fname="%s/%s%s.fits"%(starbug2.DATDIR,self.filter,dtname) - else: status=1 - if os.path.exists(fname): - fp=fits.open(fname) - - if fp[0].data is None: - perror("There is a version mismatch between starbug and webbpsf. Please reinitialise with: starbug2 --init.\n") - quit("Fatal error, quitting\n") + status = ExitStates.EXIT_FAIL + + if f_name is not None and os.path.exists(f_name): + fp: HDUList = open(f_name) + if fp[0].data is None: + p_error( + "There is a version mismatch between starbug and " + "webbpsf. Please reinitialise with: starbug2 --init.\n") + quit("Fatal error, quitting\n") - self.psf=fp[0].data ####hmm + self._psf = fp[0].data fp.close() - self.log("loaded PSF_FILE='%s'\n"%(fname)) + self.log("loaded PSF_FILE='%s'\n" % f_name) else: - perror("PSF_FILE='%s' does not exist\n"%fname) - status=1 + p_error("PSF_FILE='%s' does not exist\n" % f_name) + status = ExitStates.EXIT_FAIL return status - def prepare_image_arrays(self): + def prepare_image_arrays(self) -> ( + Tuple[np.ndarray, np.ndarray, np.ndarray | None, np.ndarray]): """ - Make a copy of the original image, and prepare the other image arrays + Make a copy of the original image, and prepare the other image arrays. - Returns - ------- - image : - A copy of the image array, scale into Jy if appropriate + :return: Tuple of image, error, background, and validation mask arrays + :rtype: tuple of (np.ndarray, np.ndarray, np.ndarray or None, + np.ndarray) + """ + # Collect scale factor + scale_factor: int | float + if self.header.get(ImageHeaderTags.BUN_IT) == "MJy/sr": + scale_factor = get_mj_ysr2jy_scale_factor(self.main_image) + self.log( + "-> converting unit from MJy/sr to Jr with factor: %e\n" + % scale_factor) + else: + scale_factor = 1 - error : np.array - An error array in the same unit as image + image: np.ndarray = self.main_image.data.copy() * scale_factor - bgd : np.array or None - A copy of the loaded background image in the same units as the image - - mask : ? - """ + # Scale by area + extension_names: list[str] = ext_names(self._image) + assert self._image is not None + if AREA in extension_names: + # AREA distortion correction + image *= self._image[AREA].data - # Collect scale factor - if self.header.get("BUNIT")=="MJy/sr": - scalefactor=get_MJysr2Jy_scalefactor(self.image) - self.log("-> converting unit from MJy/sr to Jr with factor: %e\n"%scalefactor) - else: scalefactor=1 - - image=self.image.data.copy() * scalefactor - - # scale by area - if "AREA" in extnames(self._image): - image*= self._image["AREA"].data ## AREA distortion correction - - # collect and scale error - if "ERR" in extnames(self._image) and np.shape(self._image["ERR"]): - error=self._image["ERR"].data.copy() * scalefactor - else: error=np.sqrt(np.abs(image)) - - # create mask - if "DQ" in extnames(self._image): - mask=self._image["DQ"].data & (DQ_DO_NOT_USE|DQ_SATURATED) #|DQ_JUMP_DET) - mask=mask.astype(bool) - else:mask=(np.isnan(image) | np.isnan(error)) - #fits.PrimaryHDU(data=mask.astype(int)).writeto("/tmp/out.fits",overwrite=True) - - # collect and scale background array - if self.background is not None: - bgd=self.background.data.copy() * scalefactor - else: bgd=None + # Collect and scale error + error: np.ndarray + if ERR in extension_names and np.shape(self._image[ERR]): + error = self._image[ERR].data.copy() * scale_factor + else: + error = np.sqrt(np.abs(image)) + + # Create mask + mask: np.ndarray + if DQ in extension_names: + mask = ( + self._image[DQ].data + & (DQFlags.DQ_DO_NOT_USE | DQFlags.DQ_SATURATED)) + mask = mask.astype(bool) + else: + mask = (np.isnan(image) | np.isnan(error)) + + # Collect and scale background array + bgd: np.ndarray | None + if self._background is not None: + bgd = self._background.data.copy() * scale_factor + else: + bgd = None return image, error, bgd, mask - def detect(self): + def detect(self) -> ExitStates: """ - Full source detection routine - Saves the result as a table self.detections + Full source detection routine. Saves the result as a table. + self._detections + + :return: Status + :rtype: ExitStates """ self.log("Detecting Sources\n") - status=0 - if self.image:# and self.filter: - _f=starbug2.filters.get(self.filter) - if self.options["FWHM"]>0: FWHM=self.options["FWHM"] - elif _f: FWHM=_f.pFWHM - else: FWHM=2 - #FWHM=_f.pFWHM if _f else self.options["FWHM"] - #FWHM=starbug2.filters.get(self.filter).pFWHM - - detector=Detection_Routine( sig_src=self.options["SIGSRC"], - sig_sky=self.options["SIGSKY"], - fwhm=FWHM, - sharplo=self.options["SHARP_LO"], - sharphi=self.options["SHARP_HI"], - round1hi=self.options["ROUND1_HI"], - round2hi=self.options["ROUND2_HI"], - smoothlo=self.options["SMOOTH_LO"], - smoothhi=self.options["SMOOTH_HI"], - ricker_r=self.options["RICKER_R"], - dobgd2d=self.options["DOBGD2D"], - doconvl=self.options["DOCONVL"], - boxsize=int(self.options["BOX_SIZE"]), - cleansrc=self.options["CLEANSRC"], - verbose=self.options["VERBOSE"]) - - self.detections=detector(self.image.data.copy())["xcentroid","ycentroid","sharpness","roundness1","roundness2"] - - ra,dec=self.wcs.all_pix2world(self.detections["xcentroid"], self.detections["ycentroid"],0) - self.detections.add_column( Column(ra, name="RA"), index=2) - self.detections.add_column( Column(dec, name="DEC"), index=3) - self.detections.meta=dict(self.header.items()) - self.detections.meta.update({"ROUNTINE":"DETECT"}) + status: ExitStates = ExitStates.EXIT_SUCCESS + assert self._filter is not None + if self.main_image: + filter_struct: FilterStruct | None = ( + STAR_BUG_FILTERS.get(self._filter)) + + full_width_half_max: float + if self._full_width_half_max > 0: + full_width_half_max = self._full_width_half_max + elif filter_struct: + full_width_half_max = filter_struct.full_width_half_max + else: + full_width_half_max = DEFAULT_FULL_WIDTH_HALF_MAX + + # noinspection SpellCheckingInspection + detector: DetectionRoutine = DetectionRoutine( + sig_src=self._config.sigma_source, + sig_sky=self._config.sigma_sky, + full_width_half_max=full_width_half_max, + sharp_lo=self._config.sharp_cutoff_low, + sharp_hi=self._config.sharp_cutoff_high, + round_1_hi=self._config.round1_cutoff_high, + round_2_hi=self._config.round2_cutoff_high, + smooth_lo=self._config.smooth_low, + smooth_hi=self._config.smooth_high, + ricker_r=self._config.ricker_wavelet_radius, + do_bgd_2d=self._config.do_bgd_2d, + do_con_vl=self._config.do_convolution, + box_size=self._config.background_box_size, + clean_src=self._config.clean_sources, + verbose=self._verbose) + + self._detections = detector(self.main_image.data.copy()) + assert self._detections is not None + + # check we have columns we need + if not (TableColumn.X_CENTROID in self._detections.colnames and + TableColumn.Y_CENTROID in self._detections.colnames and + TableColumn.SHARPNESS in self._detections.colnames and + TableColumn.ROUNDNESS1 in self._detections.colnames and + TableColumn.ROUNDNESS2 in self._detections.colnames): + printf( + f"dont have the pre-requisite columns. Please ensure " + f"that the detections table has columns named the " + f"following: {TableColumn.X_CENTROID}, " + f"{TableColumn.Y_CENTROID}, {TableColumn.SHARPNESS}, " + f"{TableColumn.ROUNDNESS1}, {TableColumn.ROUNDNESS2}.") + return ExitStates.EXIT_FAIL + + # filter to just the fields we need + self._detections = self._detections[ + TableColumn.X_CENTROID, TableColumn.Y_CENTROID, + TableColumn.SHARPNESS, TableColumn.ROUNDNESS1, + TableColumn.ROUNDNESS2] + + # Check for insane states + if self._detections is None or self._wcs is None: + return ExitStates.EXIT_FAIL + + ra: np.ndarray + dec: np.ndarray + ra, dec = self._wcs.all_pix2world( + self._detections[TableColumn.X_CENTROID], + self._detections[TableColumn.Y_CENTROID], 0) + self._detections.add_column( + Column(ra, name=TableColumn.RA), index=2) + self._detections.add_column( + Column(dec, name=TableColumn.DEC), index=3) + self._detections.meta = dict(self.header.items()) + + # noinspection SpellCheckingInspection + self._detections.meta.update({"ROUNTINE": "DETECT"}) self.aperture_photometry() - else: - perror("Something went wrong.\n") - status=1 + p_error("Something went wrong.\n") + status = ExitStates.EXIT_FAIL return status + # noinspection SpellCheckingInspection + def aperture_photometry(self) -> ExitStates: + """ + Executes aperture photometry processing steps. - - def aperture_photometry(self): - - if self.detections is None: - perror("No detection source file loaded (-d file-ap.fits)\n") - return 1 - if len(set(("x_0","y_0","x_init","y_init","xcentroid","ycentroid")) & set(self.detections.colnames))<2: - perror("No pixel coordinates in source file\n") - return 1 - - new_columns=("smoothness","flux","eflux","sky", "flag", self.filter,"e%s"%self.filter) - self.detections.remove_columns( set(new_columns)&set(self.detections.colnames) ) - - - ####################### - # APERTURE PHOTOMETRY # - ####################### + :return: Success or failure exit state identifier + :rtype: ExitStates + """ + if self._detections is None: + p_error("No detection source file loaded (-d file-ap.fits)\n") + return ExitStates.EXIT_FAIL + if self._image is None: + p_error("No image provided") + return ExitStates.EXIT_FAIL + if len({TableColumn.X_0, TableColumn.Y_0, TableColumn.X_INIT, + TableColumn.Y_INIT, TableColumn.X_CENTROID, + TableColumn.Y_CENTROID} & set(self._detections.colnames)) < 2: + p_error("No pixel coordinates in source file\n") + return ExitStates.EXIT_FAIL + if self._filter is None: + p_error("no filter name") + return ExitStates.EXIT_FAIL + + new_columns: tuple[str, str, str, str, str, str | None, str] = ( + TableColumn.SMOOTHNESS, TableColumn.FLUX, TableColumn.E_FLUX, + TableColumn.SKY, TableColumn.FLAG, self._filter, + "e%s" % self._filter) + self._detections.remove_columns( + set(new_columns) & set(self._detections.colnames)) + + # APERTURE PHOTOMETRY self.log("\nRunning Aperture Photometry\n") + image: np.ndarray + error: np.ndarray + mask: np.ndarray image, error, _, mask = self.prepare_image_arrays() - ####################### - # Aperture Correction # - ####################### - apcorr=1 - apcorr_fname=None - if (_apcorr_fname:=self.options.get("APCORR_FILE")): apcorr_fname=_apcorr_fname - elif self.info.get("INSTRUME")=="NIRCAM": apcorr_fname="%s/apcorr_nircam.fits"%starbug2.DATDIR - elif self.info.get("INSTRUME")=="MIRI": apcorr_fname="%s/apcorr_miri.fits"%starbug2.DATDIR - - if apcorr_fname: self.log("-> apcorr file: %s\n"%apcorr_fname) - else: + # Aperture Correction + ap_corr_f_name: str | None = None + if _ap_corr_f_name := self._config.ap_corr_file_override: + ap_corr_f_name = _ap_corr_f_name + elif self.info.get(ImageHeaderTags.INSTRUMENT) == NIRCAM_STRING: + ap_corr_f_name = ( + "%s/apcorr_nircam.fits" % StarbugBase.get_data_path()) + elif self.info.get(ImageHeaderTags.INSTRUMENT) == MIRI_STRING: + ap_corr_f_name = ( + "%s/apcorr_miri.fits" % StarbugBase.get_data_path()) + + if ap_corr_f_name: + self.log("-> apcorr file: %s\n" % ap_corr_f_name) + else: warn("No apcorr file available for instrument\n") - radius=self.options["APPHOT_R"] - eefrac=self.options["ENCENERGY"] - skyin= self.options["SKY_RIN"] - skyout=self.options["SKY_ROUT"] - - if eefrac >=0: - radius=APPhot_Routine.radius_from_encenrgy(self.filter, eefrac, apcorr_fname) - if radius >0: self.log("-> calculating aperture radius from encirlced energy\n") - - if radius <=0: - if (radius:=self.options["FWHM"])>0: - self.log("-> using FWHM as aprture radius\n") + radius: float = float(self._config.aperture_phot_radius) + ee_frac: float = float(self._config.encircled_energy_fraction) + sky_in: float = float(self._config.sky_annulus_inner_radius) + sky_out: float = float(self._config.sky_annulus_outer_radius) + + if ee_frac >= 0: + radius = APPhotRoutine.radius_from_enc_energy( + self._filter, ee_frac, ap_corr_f_name) + if radius > 0: + self.log( + "-> calculating aperture radius from encircled energy\n") + + if radius <= 0: + if (radius := self._full_width_half_max) > 0: + self.log("-> using FWHM as aperture radius\n") else: - radius=2 - - apcorr=APPhot_Routine.calc_apcorr(self.filter, radius, table_fname=apcorr_fname, verbose=self.options["VERBOSE"]) - - ################## - # Run Photometry # - ################## - apphot=APPhot_Routine( radius, skyin, skyout, verbose=self.options["VERBOSE"]) - - if "DQ" in extnames(self._image): - dqflags=self._image["DQ"].data.copy() - else: dqflags=None - ap_cat=apphot(image, self.detections, error=error, dqflags=dqflags, apcorr=apcorr, sig_sky=self.options["SIGSKY"]) - - - fltr=self.filter if self.filter else "mag" - mag,magerr=flux2mag( ap_cat["flux"], ap_cat["eflux"]) - ap_cat.add_column(Column(mag+self.options.get("ZP_MAG"),fltr)) - ap_cat.add_column(Column(magerr,"e%s"%fltr)) - self.detections=hstack((self.detections,ap_cat)) - - if self.options.get("CLEANSRC"): - N=len(self.detections) - if (smoothlo:=self.options.get("SMOOTH_LO")) != "": - self.detections.remove_rows( self.detections["smoothness"]smoothhi) - if len(self.detections)!=N: - self.log("-> removing %d sources outside SMOOTH range\n"%(N-len(self.detections))) - - reindex(self.detections) - self.detections.meta["FILTER"]=self.filter - - if not self.options.get("QUIETMODE"): - _fname="%s/%s-ap.fits"%(self.outdir, self.bname) - self.log("--> %s\n"%_fname) - export_table(self.detections, _fname, header=self.header) - - return 0 - - - def bgd_estimate(self): + self.log( + "No valid aperture radius was detected. defaulting " + "to default value of 2") + radius = 2.0 + + ap_corr: float = APPhotRoutine.calc_ap_corr( + self._filter, radius, table_f_name=ap_corr_f_name, + verbose=self._verbose) + + # Run Photometry + app_hot: APPhotRoutine = APPhotRoutine( + radius, sky_in, sky_out, verbose=bool(self._verbose)) + + dq_flags: np.ndarray | None + if DQ in ext_names(self._image): + dq_flags = self._image[DQ].data.copy() + else: + dq_flags = None + ap_cat: Table = app_hot( + image, self._detections, error=error, dq_flags=dq_flags, + ap_corr=ap_corr, sig_sky=self._config.sigma_sky) + + filter_string: str = self._filter if self._filter else "mag" + + # Extract magnitudes + mag: float + mag_err: float + mag, mag_err = flux2mag( + ap_cat[TableColumn.FLUX], ap_cat[TableColumn.E_FLUX]) + + # Add columns to the catalogue + ap_cat.add_column(Column( + mag + self._config.zero_point_magnitude, filter_string)) + ap_cat.add_column(Column( + mag_err, "e%s" % filter_string)) + + # Update detections + self._detections = hstack((self._detections, ap_cat)) + + # Check for insanity + if self._detections is None: + return ExitStates.EXIT_FAIL + + if self._config.clean_sources: + detections_length = len(self._detections) + if (smooth_lo := self._config.smooth_low) != "": + self._detections.remove_rows( + self._detections[TableColumn.SMOOTHNESS] < smooth_lo) + if (smooth_hi := self._config.smooth_high) != "": + self._detections.remove_rows( + self._detections[TableColumn.SMOOTHNESS] > smooth_hi) + if len(self._detections) != detections_length: + self.log("-> removing %d sources outside SMOOTH range\n" + % (detections_length - len(self._detections))) + + reindex(self._detections) + self._detections.meta[HeaderTags.FILTER] = self._filter + + f_name = "%s/%s-ap.fits" % (self._out_dir, self._b_name) + printf(f"going to save ap file at {f_name}") + self.log("--> %s\n" % f_name) + export_table(self._detections, f_name, header=self.header) + + return ExitStates.EXIT_SUCCESS + + def bgd_estimate(self) -> ExitStates: """ - Estimate the background of the active image - Saves the result as an ImageHDU self.background + Estimate the background of the active image. + Saves the result as an ImageHDU self._background + + :return: The status execution code + :rtype: ExitStates """ self.log("\nEstimating Diffuse Background\n") - status=1 - if self.detections: - sourcelist=self.detections.copy() - - _f=starbug2.filters.get(self.filter) - if self.options["FWHM"]>0: FWHM=self.options["FWHM"] - elif _f: FWHM=_f.pFWHM - else: FWHM=2 - - if "x_init" in sourcelist.colnames: sourcelist.rename_column("x_init", "xcentroid") - if "y_init" in sourcelist.colnames: sourcelist.rename_column("y_init", "ycentroid") - if "x_det" in sourcelist.colnames: sourcelist.rename_column("x_det", "xcentroid") - if "y_det" in sourcelist.colnames: sourcelist.rename_column("y_det", "ycentroid") - if "flux_det" in sourcelist.colnames: sourcelist.rename_column("flux_det", "flux") - mask=~(np.isnan(sourcelist["xcentroid"])|np.isnan(sourcelist["ycentroid"])) - - - bgd=BackGround_Estimate_Routine(sourcelist[mask], - boxsize=int(self.options["BOX_SIZE"]), - fwhm=FWHM, - sigsky=self.options["SIGSKY"], - bgd_r=self.options["BGD_R"], - profile_scale=self.options["PROF_SCALE"], - profile_slope=self.options["PROF_SLOPE"], - verbose=self.options["VERBOSE"]) - header=self.header - header.update(self.wcs.to_header()) - self.background=fits.ImageHDU(data=bgd(self.image.data.copy(), output=self.options.get("BGD_CHECKFILE")), header=header) - if not self.options.get("QUIETMODE"): - _fname="%s/%s-bgd.fits"%(self.outdir, self.bname) - self.log("--> %s\n"%_fname) - self.background.writeto(_fname,overwrite=True) + status: ExitStates = ExitStates.EXIT_SUCCESS + assert self._filter is not None + if self._detections: + source_list: Table = self._detections.copy() + + filter_struct: FilterStruct | None = ( + STAR_BUG_FILTERS.get(self._filter)) + full_width_half_max: float + if self._full_width_half_max > 0: + full_width_half_max = self._config.full_width_half_max + elif filter_struct: + full_width_half_max = filter_struct.full_width_half_max + else: + full_width_half_max = 2.0 + + # noinspection DuplicatedCode + if TableColumn.X_INIT in source_list.colnames: + source_list.rename_column( + TableColumn.X_INIT, TableColumn.X_CENTROID) + if TableColumn.Y_INIT in source_list.colnames: + source_list.rename_column( + TableColumn.Y_INIT, TableColumn.Y_CENTROID) + if TableColumn.X_DET in source_list.colnames: + source_list.rename_column( + TableColumn.X_DET, TableColumn.X_CENTROID) + if TableColumn.Y_DET in source_list.colnames: + source_list.rename_column( + TableColumn.Y_DET, TableColumn.Y_CENTROID) + if TableColumn.FLUX_DET in source_list.colnames: + source_list.rename_column( + TableColumn.FLUX_DET, TableColumn.FLUX) + mask: np.ndarray = ~( + np.isnan(source_list[TableColumn.X_CENTROID]) + | np.isnan(source_list[TableColumn.Y_CENTROID])) + + bgd: BackGroundEstimateRoutine = BackGroundEstimateRoutine( + source_list[mask], + box_size=self._config.background_box_size, + full_width_half_max=full_width_half_max, + sig_sky=self._config.sigma_sky, + bgd_r=self._config.bgd_radius, + profile_scale=self._config.profile_scaling_factor, + profile_slope=self._config.profile_slope, + verbose=self._verbose) + header: Header = self.header + + # Check for insanity + if self._wcs is None: + return ExitStates.EXIT_FAIL + + header.update(self._wcs.to_header()) + + # Get image data + image_data = bgd( + self.main_image.data.copy(), + output=self._config.bgd_check_file) + assert image_data is not None + + self._background = ImageHDU( + data=image_data.background, + header=header) + + # Check for insanity + if self._background is None: + return ExitStates.EXIT_FAIL + + f_name = "%s/%s-bgd.fits" % (self._out_dir, self._b_name) + self.log("--> %s\n" % f_name) + self._background.writeto(f_name, overwrite=True) else: - perror("unable to estimate background, no source list loaded\n") - status=1 + p_error("unable to estimate background, no source list loaded\n") + status = ExitStates.EXIT_FAIL return status - - - def bgd_subtraction(self): + def bgd_subtraction(self) -> ExitStates: """ - Internally subtract a background array from an image array + Internally subtract a background array from an image array. + + :return: Success or failure exit state + :rtype: ExitStates """ self.log("Subtracting Background\n") - if self.background is None: - perror("No background array loaded (-b file-bgd.fits)\n") - return 1 - array= self.image.data - self.background.data - self.residuals = array - self._image[self._nHDU].data=array - header=self.header - header.update(self.wcs.to_header()) - fits.ImageHDU(data=self.residuals, name="RES", header=header).writeto("%s/%s-res.fits"%(self.outdir,self.bname), overwrite=True) - return 0 + if self._background is None or self._wcs is None: + p_error("No background array loaded (-b file-bgd.fits)\n") + return ExitStates.EXIT_FAIL + + array: np.ndarray = self.main_image.data - self._background.data + self._residuals = array + + assert self._image is not None + self._image[self._n_hdu].data = array + header: Header = self.header + header.update(self._wcs.to_header()) + + # Wrap array as Any to map properly to ImageHDU writer expectations + hdu_output = ImageHDU(data=cast(Any, array), header=header, name="RES") + f_name = "%s/%s-res.fits" % (self._out_dir, self._b_name) - def photometry(self): + try: + hdu_output.writeto(f_name, overwrite=True) + self.log( + "--> Background subtracted image written to %s\n" % f_name) + except Exception as e: + p_error( + "Failed to write background-subtracted file: %s\n" % str(e)) + return ExitStates.EXIT_FAIL + + return ExitStates.EXIT_SUCCESS + + # noinspection SpellCheckingInspection + def photometry_routine(self) -> int: """ Full photometry routine - Saves the result as a table self.psfcatalogue - // Additionally it appends a residual Image onto the self.residuals HDUList + Saves the result as a table self._psf_catalogue, + Additionally it appends a residual Image onto the + self._residuals HDUList + + :return: 0 for success, 1 otherwise + :rtype int """ - if self.image: + if self._filter is None or self._wcs is None: + return ExitStates.EXIT_FAIL + + if self.main_image: self.log("\nRunning PSF Photometry\n") + # lock the types. + image: np.ndarray + error: np.ndarray + bgd: np.ndarray | None + mask: np.ndarray image, error, bgd, mask = self.prepare_image_arrays() if bgd is None: - _,median,_=sigma_clipped_stats(image,sigma=self.options["SIGSKY"]) - bgd=np.ones(self.image.shape)*median - self.log("-> no background file loaded, measuring sigma clipped median\n") - - ################################### - # Collect relevent files and data # - ################################### - #image=self.image.data.copy() - - if self.detections is None: - perror("unable to run photometry: no source list loaded\n") - return 1 - - if self.psf is None and self.load_psf(os.path.expandvars(self.options["PSF_FILE"])): - perror("unable to run photometry: no PSF loaded\n") - return 1 - - psfmask= ~np.isfinite(self.psf) - if psfmask.sum(): - self.psf[psfmask]=0 + clipped_median: float + _, clipped_median, _ = ( + sigma_clipped_stats(image, sigma=self._config.sigma_sky)) + bgd = np.ones(self.main_image.shape) * clipped_median + self.log( + "-> no background file loaded, measuring sigma " + "clipped median\n") + assert bgd is not None + + # Collect relevant files and data + if self._detections is None: + p_error("unable to run photometry: no source list loaded\n") + return ExitStates.EXIT_FAIL + + if (self._psf is None + and self.load_psf( + os.path.expandvars(self._config.psf_file_override))): + p_error("unable to run photometry: no PSF loaded\n") + return ExitStates.EXIT_FAIL + + psf_mask: np.ndarray = ~np.isfinite(self._psf) + if psf_mask.sum(): + assert self._psf is not None + self._psf[psf_mask] = 0 self.log("-> masking INF pixels in PSF_FILE\n") - psf_model=FittableImageModel(self.psf) - if self.options["PSF_SIZE"]>0: size=int(self.options["PSF_SIZE"]) - else: size=psf_model.shape[0] - if not size%2: size-=1 - self.log("-> psf size: %d\n"%size) - - ######################### - # Sort out Init guesses # - ######################### - apphot_r=self.options.get("APPHOT_R") - if not apphot_r or apphot_r <=0: apphot_r=3 - - init_guesses=self.detections.copy() - #print(init_guesses, end="") - if "xcentroid" in init_guesses.colnames: init_guesses.rename_column("xcentroid", "x_init") - if "ycentroid" in init_guesses.colnames: init_guesses.rename_column("ycentroid", "y_init") - if "x_det" in init_guesses.colnames: init_guesses.rename_column("x_det", "x_init") - if "y_det" in init_guesses.colnames: init_guesses.rename_column("y_det", "y_init") - - init_guesses=init_guesses[ init_guesses["x_init"]>=0 ] - init_guesses=init_guesses[ init_guesses["y_init"]>=0 ] - init_guesses=init_guesses[ init_guesses["x_init"]0] - #init_guesses.remove_column("flux_0") - - - ########### - # Run Fit # - ########### - - min_separation=self.options.get("CRIT_SEP") + psf_model: ImagePSF = ImagePSF(data=self._psf) + size: int + if self._config.psf_fit_size > 0: + size = self._config.psf_fit_size + else: + size = psf_model.shape[0] + if not size % 2: + size -= 1 + self.log("-> psf size: %d\n" % size) + + # Sort out Init guesses + app_hot_r: float = float(self._config.aperture_phot_radius) + if not app_hot_r or app_hot_r <= 0: + app_hot_r = 3.0 + + init_guesses: Table = self._detections.copy() + + # noinspection DuplicatedCode + if TableColumn.X_CENTROID in init_guesses.colnames: + init_guesses.rename_column( + TableColumn.X_CENTROID, TableColumn.X_INIT) + if TableColumn.Y_CENTROID in init_guesses.colnames: + init_guesses.rename_column( + TableColumn.Y_CENTROID, TableColumn.Y_INIT) + if TableColumn.X_DET in init_guesses.colnames: + init_guesses.rename_column( + TableColumn.X_DET, TableColumn.X_INIT) + if TableColumn.Y_DET in init_guesses.colnames: + init_guesses.rename_column( + TableColumn.Y_DET, TableColumn.Y_INIT) + + init_guesses = init_guesses[init_guesses[TableColumn.X_INIT] >= 0] + init_guesses = init_guesses[init_guesses[TableColumn.Y_INIT] >= 0] + init_guesses = init_guesses[ + init_guesses[TableColumn.X_INIT] + < self.main_image.header[HeaderTags.NAXIS1]] + init_guesses = init_guesses[ + init_guesses[TableColumn.Y_INIT] + < self.main_image.header[HeaderTags.NAXIS2]] + + # Allow tables that don't have the correct columns through + required: List[str] = [ + TableColumn.X_INIT, TableColumn.Y_INIT, TableColumn.FLUX, + self._filter, TableColumn.FLAG] + for notfound in set(required) - set(init_guesses.colnames): + dtype = np.uint16 if notfound == TableColumn.FLAG else float + init_guesses.add_column( + Column(np.zeros(len(init_guesses)), + name=notfound, dtype=dtype)) + + init_guesses = init_guesses[required] + init_guesses.remove_column(TableColumn.FLUX) + init_guesses.rename_column(self._filter, "ap_%s" % self._filter) + + # Run Fit + min_separation: float = self._config.critical_separation if not min_separation: - min_separation = min(5, 2.5*self.options.get("FWHM")) - - if self.options["FORCE_POS"]: - phot=PSFPhot_Routine(psf_model, size, min_separation=min_separation, apphot_r=apphot_r, background=bgd, force_fit=1, verbose=self.options["VERBOSE"]) - psf_cat=phot(image,init_params=init_guesses, error=error, mask=mask) - psf_cat["flag"] |= starbug2.SRC_FIX + min_separation = min(5.0, 2.5 * self._full_width_half_max) + + if self._config.force_centroid_position: + phot: PSFPhotRoutine = PSFPhotRoutine( + psf_model, size, min_separation=min_separation, + app_hot_r=app_hot_r, background=bgd, force_fit=1, + verbose=self._verbose) + psf_cat: Table = phot( + image, init_params=init_guesses, error=error, + mask=mask) + psf_cat[TableColumn.FLAG] |= SourceFlags.SRC_FIX else: - phot=PSFPhot_Routine(psf_model, size, min_separation=min_separation, apphot_r=apphot_r, background=bgd, force_fit=0, verbose=self.options["VERBOSE"]) - psf_cat=phot(image,init_params=init_guesses, error=error, mask=mask) - - if not psf_cat: return 1 - - - ################################## - # Setting position max variation # - ################################## - maxydev,unit=utils.parse_unit(self.options["MAX_XYDEV"]) + phot = PSFPhotRoutine( + psf_model, size, min_separation=min_separation, + app_hot_r=app_hot_r, background=bgd, force_fit=0, + verbose=self._verbose) + psf_cat = phot( + image, init_params=init_guesses, error=error, + mask=mask) + + if not psf_cat: + return ExitStates.EXIT_FAIL + + # Setting position max variation + max_y_dev: float + unit: int + max_y_dev, unit = parse_unit(self._config.max_xy_deviation) if unit is not None: - if unit==starbug2.DEG: - maxydev*=60 - unit=starbug2.ARCMIN - if unit==starbug2.ARCMIN: - maxydev*=60 - unit=starbug2.ARCSEC - if unit==starbug2.ARCSEC: - if not self.header.get("PIXAR_A2"): - warn("MAX_XYDEV is units arcseconds, but starbug cannot locate a pixel scale in the header. Please use syntax MAX_XYDEV=%sp to set change to pixels\n"%maxydev) - else: maxydev /= np.sqrt(self.header.get("PIXAR_A2")) - - if maxydev>0: - self.log("-> position fit threshold: %.2gpix\n"%maxydev) - phot=PSFPhot_Routine(psf_model, size, min_separation=min_separation, apphot_r=apphot_r, background=bgd, force_fit=1, verbose=self.options["VERBOSE"]) - ii=np.where( psf_cat["xydev"]>maxydev) - fixed_centres= psf_cat[ii][["x_init","y_init","ap_%s"%self.filter,"flag"]] + if unit == Units.DEG: + max_y_dev *= 60 + unit = Units.ARCMIN + if unit == Units.ARCMIN: + max_y_dev *= 60 + unit = Units.ARCSEC + if unit == Units.ARCSEC: + if not self.header.get(ImageHeaderTags.PIXAR_A2): + warn( + "MAX_XYDEV is units arcseconds, but starbug " + "cannot locate a pixel scale in the header." + " Please use syntax MAX_XYDEV=%sp to set " + "change to pixels\n" % max_y_dev) + else: + max_y_dev /= np.sqrt( + self.header.get(ImageHeaderTags.PIXAR_A2)) + + if max_y_dev > 0: + self.log( + "-> position fit threshold: %.2gpix\n" % max_y_dev) + phot = PSFPhotRoutine( + psf_model, size, min_separation=min_separation, + app_hot_r=app_hot_r, background=bgd, force_fit=1, + verbose=self._verbose) + ii: np.ndarray = psf_cat[TableColumn.XY_DEV] > max_y_dev + fixed_centres: Table = psf_cat[ii][ + [TableColumn.X_INIT, TableColumn.Y_INIT, + "ap_%s" % self._filter, TableColumn.FLAG]] if len(fixed_centres): self.log("-> forcing positions for deviant sources\n") - fixed_cat=phot(image, init_params=fixed_centres, error=error, mask=mask) - fixed_cat["flag"]|=starbug2.SRC_FIX - psf_cat.remove_rows(ii) - psf_cat=vstack((psf_cat, fixed_cat)) - else: self.log("-> no deviant sources\n") - - ra,dec=self.wcs.all_pix2world(psf_cat["x_fit"], psf_cat["y_fit"],0) - psf_cat.add_column( Column(ra, name="RA"), index=2) - psf_cat.add_column( Column(dec, name="DEC"), index=3) - - #psf_cat.rename_column("flux_fit","flux") - mag,magerr=flux2mag(psf_cat["flux"],psf_cat["eflux"]) - - fltr= self.filter if self.filter else "mag" - psf_cat.add_column(mag+self.options.get("ZP_MAG"),name=fltr) - psf_cat.add_column(magerr,name="e%s"%fltr) - self.psfcatalogue=psf_cat - self.psfcatalogue.meta=dict(self.header.items()) - self.psfcatalogue.meta["AP_FILE"]=self.options["AP_FILE"] - self.psfcatalogue.meta["BGD_FILE"]=self.options["BGD_FILE"] - - reindex(self.psfcatalogue) - if not self.options.get("QUIETMODE"): - _fname="%s/%s-psf.fits"%(self.outdir, self.bname) - self.log("--> %s\n"%_fname) - fits.BinTableHDU(data=self.psfcatalogue, header=self.header).writeto(_fname,overwrite=True) - - ################## - # Residual Image # - ################## - - if self.options["GEN_RESIDUAL"]: + fixed_cat: Table = phot( + image, init_params=fixed_centres, + error=error, mask=mask) + # ABS. why are we using such an aggressive type check + # here? + fixed_cat[TableColumn.FLAG] |= ( + np.uint16(SourceFlags.SRC_FIX)) + psf_cat.remove_rows(ii.tolist()) + psf_cat = vstack((psf_cat, fixed_cat)) + else: + self.log("-> no deviant sources\n") + ra: np.ndarray + dec: np.ndarray + ra, dec = self._wcs.all_pix2world( + psf_cat[TableColumn.X_FIT], psf_cat[TableColumn.Y_FIT], 0) + psf_cat.add_column(Column(ra, name=TableColumn.RA), index=2) + psf_cat.add_column(Column(dec, name=TableColumn.DEC), index=3) + + mag: float + mag_err: float + mag, mag_err = flux2mag( + psf_cat[TableColumn.FLUX], psf_cat[TableColumn.E_FLUX]) + + filter_string: str = ( + self._filter if self._filter else TableColumn.MAG) + psf_cat.add_column( + mag + self._config.zero_point_magnitude, name=filter_string) + psf_cat.add_column(mag_err, name="e%s" % filter_string) + self._psf_catalogue = psf_cat + + # verify catalogue isnt none + assert self._psf_catalogue is not None + + self._psf_catalogue.meta = dict(self.header.items()) + self._psf_catalogue.meta[AP_FILE] = self._ap_file + self._psf_catalogue.meta[BGD_FILE] = self._background_file + + reindex(self._psf_catalogue) + + file_name: str = ( + "%s/%s-psf.fits" % (self._out_dir, self._b_name)) + self.log("--> %s\n" % file_name) + BinTableHDU( + data=self._psf_catalogue, + header=self.header).writeto(file_name, overwrite=True) + + # Residual Image + if self._config.generate_residual_image: self.log("-> generating residual\n") - _tmp=psf_cat["x_fit","y_fit","flux"].copy() - _tmp.rename_columns( ("x_fit","y_fit"), ("x_0","y_0")) - stars=make_model_image(image.shape, psf_model, _tmp, model_shape=(size,size)) - residual=image-(bgd+stars) - self.residuals=residual/get_MJysr2Jy_scalefactor(self.image) - header=self.header - header.update(self.wcs.to_header()) - fits.ImageHDU(data=self.residuals, name="RES", header=header).writeto("%s/%s-res.fits"%(self.outdir,self.bname), overwrite=True) - - return 0 + _tmp: Table = psf_cat[ + TableColumn.X_FIT, TableColumn.Y_FIT, + TableColumn.FLUX].copy() + _tmp.rename_columns( + (TableColumn.X_FIT, TableColumn.Y_FIT), + (TableColumn.X_0, TableColumn.Y_0)) + stars: np.ndarray = make_model_image( + image.shape, psf_model, _tmp, model_shape=(size, size)) + residual: np.ndarray = image - (bgd + stars) + self._residuals = ( + residual / get_mj_ysr2jy_scale_factor(self.main_image)) + header: Header = self.header + header.update(self._wcs.to_header()) + ImageHDU( + data=cast(Any, self._residuals), + name="RES", header=header).writeto( + "%s/%s-res.fits" % (self._out_dir, self._b_name), + overwrite=True) + return ExitStates.EXIT_SUCCESS + return ExitStates.EXIT_FAIL + + def source_geometry(self) -> None: + """ + Calculate source geometry stats for a given image and source list + :return: None + """ + if self._detections is None: + p_error("No source file loaded\n") + return + + if self._filter is None: + p_error("no filter string provided\n") + return + + self.log("Running Source Geometry\n") + slist: Table = self._filter_detections() + + sp: SourceProperties = SourceProperties( + self.main_image.data, slist, + verbose=self._verbose) + stat: Table = sp( + full_width_half_max=STAR_BUG_FILTERS[ + self._filter].full_width_half_max, + do_crowd=self._config.calculate_crowding_metric) + + self._source_stats = hstack((slist, stat)) + f_name: str = "%s/%s-stat.fits" % (self._out_dir, self._b_name) + self.log("--> %s\n" % f_name) + reindex(Table(self._source_stats)) + BinTableHDU( + data=self._source_stats, header=self.header).writeto( + f_name, overwrite=True) + + # noinspection SpellCheckingInspection + def verify(self) -> ExitStates: + """ + This simple function verifies that everything necessary has been + loaded properly - #def artificial_stars(self): - # """ - # #Run artificial star testing + :return: int where 0 on success, 1 on fail + :rtype ExitStates + """ - # #>>> This needs to get the background loaded into it somewhere!! - # #>>> Need to make sure all the appropriate parameters are being passed into the functions - # """ - # status=0 - # self.log("\nArtificial Star Testing (n=%d)\n"%(self.options["NTESTS"])) - # - # ################################ - # # Collect files and sort units # - # ################################ - # image=self.image.data.copy() - # bgd=None + status: ExitStates = ExitStates.EXIT_SUCCESS - # if self.background is not None: - # bgd=self.background.data.copy() + self.log("Checking internal systems..\n") - # if self.header.get("BUNIT")=="MJy/sr-1": - # scalefactor=get_MJysr2Jy_scalefactor(self.image) - # image/=scalefactor - # if bgd is not None: bgd/=scalefactor + if not self._filter: + warn("No FILTER set, please set in parameter file or " + "use \"-s FILTER=XXX\"\n") + status = ExitStates.EXIT_FAIL - # self.load_psf(self.options.get("PSF_FILE")) - # psf_model=FittableImageModel(self.psf) + d_name: str = os.path.expandvars(StarbugBase.get_data_path()) + if not os.path.exists(d_name): + warn("Unable to locate STARBUG_DATDIR='%s'\n" % d_name) - # ############################# - # # Build the Routine Classes # - # ############################# + if self._out_dir is not None and not os.path.exists(self._out_dir): + warn("Unable to locate OUTPUT='%s'\n" % self._out_dir) + status = ExitStates.EXIT_FAIL - # detector=Detection_Routine( sig_src=self.options["SIGSRC"], - # sig_sky=self.options["SIGSKY"], - # fwhm=starbug2.filters[self.filter].pFWHM, - # sharplo=self.options["SHARP_LO"], - # sharphi=self.options["SHARP_HI"], - # round1hi=self.options["ROUND1_HI"], - # verbose=0) - # phot=APPhot_Routine ( self.options["APPHOT_R"], - # self.options["SKY_RIN"], - # self.options["SKY_ROUT"]) - # - # phot=PSFPhot_Routine( psf_model, psf_model.shape, - # apphot_r=self.options["APPHOT_R"], force_fit=False, - # background=bgd, verbose=0) - # export_table(phot(image,detector(image)),fname="/tmp/out.fits") + if self._image is None or self.main_image.data is None: + warn("Image did not load correctly\n") + status = ExitStates.EXIT_FAIL + if self._ap_file and self._detections is not None: + test = self._filter_detections() + if not len(test): + warn("Detection file empty or no sources overlap the image.\n") + status = ExitStates.EXIT_FAIL + return status - # art=Artificial_Stars(detector=detector, photometry=phot, psf=psf_model) + def _filter_detections(self) -> Table: + """ + filters the detections based on some fixed constraints. + :return: the filtered detections + """ + assert self._detections is not None + detections: Table = self._detections[ + [TableColumn.X_CENTROID, TableColumn.Y_CENTROID]].copy() + detections = detections[detections[TableColumn.X_CENTROID] >= 0] + detections = detections[detections[TableColumn.Y_CENTROID] >= 0] + detections = detections[ + detections[TableColumn.X_CENTROID] < + self.main_image.header[HeaderTags.NAXIS1]] + return detections[ + detections[TableColumn.Y_CENTROID] < + self.main_image.header[HeaderTags.NAXIS2]] + + def __getstate__(self) -> dict[str, Any]: + """ + extracts the inner state of this class. deleting image or/and + background if it's there. + :return: the internal state with those bits filtered away + """ + assert self._image is not None + self._image.close() + state: dict[str, Any] = self.__dict__.copy() + if "_image" in state: + # Sorry but we cant have that + del state["_image"] + # This currently doesnt get reloaded + if "_background" in state: + del state["_background"] - # ########### - # # Execute # - # ########### + return state - # MINMAG=27 - # MAXMAG=18 + def __setstate__(self, state) -> None: + self.__dict__.update(state) + v: int = int(self._verbose) + self._verbose = 0 + self.load_image(self._f_name) + self._verbose = v - # ZP = self.options.get("ZP_MAG") if self.options.get("ZP_MAG") else 0 - # min_flux=np.exp( (np.log(10)/2.5)*(ZP-MINMAG) ) - # max_flux=np.exp( (np.log(10)/2.5)*(ZP-MAXMAG) ) + @property + def header(self) -> Header: + """ + Construct relevant base header information for routine products - # #print("calc", min_flux, max_flux, MINMAG, MAXMAG) - # #print("cat", np.nanmin( self.detections["flux"]), np.nanmax( self.detections["flux"]), np.nanmax(self.detections["F444W"]), np.nanmin(self.detections["F444W"])) + :return: Header file containing a series of relevant information + :rtype: Header + """ + head: Dict[str, str | float | None] = { + HeaderTags.STAR_BUG: get_version(), + HeaderTags.CALIBRATION_LV: self._stage + } + + if self._filter: + head[HeaderTags.FILTER] = self._filter + + # add the basic params + for fits_key, (property_name, _) in ( + StarBugMainConfig.MAIN_PARAM_FILE_MAP.items()): + value = getattr(self._config, property_name) + if value is None: + head[fits_key] = "" + else: + head[fits_key] = value + # add the changed ones + head[AP_FILE] = self._ap_file + head[BGD_FILE] = self._background_file + head[VERBOSE_TAG] = self._verbose - # result= art.run_auto( image, background=bgd, ntests=self.options.get("NTESTS"), stars_per_test=self.options.get("NSTARS"), - # subimage_size=self.options.get("SUBIMAGE"), flux_range=( min_flux, max_flux)) + # add info + head.update(self.info) + return collapse_header(head) - # _fname="%s/%s-afs.fits"%(self.outdir, self.bname) - # export_table(result, fname=_fname) + @property + def info(self) -> dict[str, str]: + """ + Get some useful information from the image header file. - # return status + :return: extracted keys and elements from the image header. + :rtype: dict of str, to str. + """ + out: dict[str, str] = {} + keys: list[str] = [ + ImageHeaderTags.FILTER, ImageHeaderTags.DETECTOR, + ImageHeaderTags.TELESCOPE, ImageHeaderTags.INSTRUMENT, + ImageHeaderTags.BUN_IT, ImageHeaderTags.PIXAR_A2, + ImageHeaderTags.PIXAR_SR] + if self._image: + for hdu in self._image: + out.update( + {(key, hdu.header[key]) for key in keys + if key in hdu.header}) + return out + @property + def main_image(self) -> ImageHDU | PrimaryHDU: + # noinspection SpellCheckingInspection + """ + automagically find the main image array to use + Order of importance is: + > self._nHDU (if set) + > param[ HDUNAME ] + > SCI, BGD, RES + > first ImageHDU + > first ImageHDU + > image[0] + :return: the main image array. + :rtype: HDUList + """ + assert self._image is not None + if self._n_hdu >= 0: + return self._image[self._n_hdu] + e_names: list[str] = ext_names(self._image) + + # HDU_NAME in param file + n: str = str(self._config.hdu_name) + if n and n in e_names: + self._n_hdu = e_names.index(n) + return self._image[n] + # index? + if isinstance(n, (int, float, np.number)): + self._n_hdu = int(n) + return self._image[self._n_hdu] + + # SCI, BGD, RES (common names) + for name in (SCI, BGD, RES): + name: str + if name in e_names: + self._n_hdu = e_names.index(name) + return self._image[name] + + # First ImageHDU + # ABS ARE WE SURE WE WANT TO LOOK FOR A INDEX WITH A ENUMERATE INDEX? + assert self._image is not None + for index, hdu in enumerate(self._image): + index: int + hdu: ImageHDU | PrimaryHDU | BinTableHDU + if isinstance(hdu, ImageHDU): + self._n_hdu = index + return hdu + self._n_hdu = 0 + return self._image[0] - def source_geometry(self): - """ - Calculate source geometry stats for a given image and source list - """ - if self.detections is None: perror("No source file loaded\n") - else: - self.log("Running Source Geometry\n") - slist=self.detections[["xcentroid","ycentroid"]].copy() - slist=slist[ slist["xcentroid"]>=0 ] - slist=slist[ slist["ycentroid"]>=0 ] - slist=slist[ slist["xcentroid"] str | None: + return self._filter - sp=SourceProperties(self.image.data, slist, verbose=self.options["VERBOSE"]) - stat=sp(fwhm=starbug2.filters[self.filter].pFWHM, do_crowd=self.options["CALC_CROWD"]) - #geom=sp.calculate_geometry(fwhm=starbug2.filters[self.filter].pFWHM) - - self.source_stats=hstack((slist,stat)) - _fname="%s/%s-stat.fits"%(self.outdir, self.bname) - self.log("--> %s\n"%_fname) - reindex(self.source_stats) - fits.BinTableHDU(data=self.source_stats,header=self.header).writeto(_fname, overwrite=True) - - def calculate_psf(self): - """ - """ - pass + @property + def n_hdu(self) -> int: + return self._n_hdu + @property + def image(self) -> HDUList | None: + return self._image + @image.setter + def image(self, new_image: HDUList) -> None: + self._image = new_image - def verify(self): - """ - This simple function verifies that everything necessary has been loaded properly + @property + def psf_catalogue(self) -> Table | None: + return self._psf_catalogue - RETURN - ------ - 0 : on success - 1 : on fail - """ - status=0 - #warn=lambda :perror(sbold("WARNING: ")) - - self.log("Checking internal systems..\n") + @property + def psf(self) -> np.ndarray | None: + return self._psf - if not self.filter: - warn("No FILTER set, please set in parameter file or use \"-s FILTER=XXX\"\n") - status=1 - - dname = os.path.expandvars(starbug2.DATDIR) - if not os.path.exists(dname): - warn("Unable to locate STARBUG_DATDIR='%s'\n"%dname) - #status=1 - - if not os.path.exists(self.outdir): - warn("Unable to locate OUTPUT='%s'\n"%self.outdir) - status=1 - - tmp=load_default_params() - if set(tmp.keys()) - set(self.options.keys()): - warn("Parameter file version mismatch. Run starbug2 --update-param to update\n") - status=1 - - if self._image is None or self.image.data is None: - warn("Image did not load correctly\n") - status=1 - - if self.options["AP_FILE"] and self.detections is not None: - test=self.detections[["xcentroid","ycentroid"]] - test=test[ test["xcentroid"]>=0 ] - test=test[ test["ycentroid"]>=0 ] - test=test[ test["xcentroid"] str | None: + return self._f_name - return status + @property + def detections(self) -> Table | None: + return self._detections - def __getstate__(self): - self._image.close() - #if self.background: self.background.close() - state=self.__dict__.copy() - if "_image" in state: - del state["_image"] ##Sorry but we cant have that - if "background" in state: ## This currently doesnt get reloaded - del state["background"] - - return state + @detections.setter + def detections(self, new_detections: Table) -> None: + self._detections = new_detections - def __setstate__(self, state): - self.__dict__.update(state) - v=self.options["VERBOSE"] - self.options["VERBOSE"]=0 - self.load_image(self.fname) - self.options["VERBOSE"]=v + @property + def out_dir(self) -> str | None: + return self._out_dir diff --git a/starbug2/utils.py b/starbug2/utils.py index 3e380d2..da42f3d 100644 --- a/starbug2/utils.py +++ b/starbug2/utils.py @@ -1,144 +1,291 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +import os +import sys import time -import os, sys, numpy as np -from parse import parse -import pkg_resources -from astropy.table import Table,hstack,Column,MaskedColumn,vstack +from importlib import metadata +from importlib.metadata import PackageNotFoundError +from typing import Any, Dict, List, Tuple + from astropy.io import fits +from astropy.table import Column, MaskedColumn, Table, hstack, vstack from astropy.wcs import WCS -import starbug2 +import numpy as np import requests -printf=sys.stdout.write -perror=sys.stderr.write -puts=lambda s:printf("%s\n"%s) -sbold=lambda s:"\x1b[1m%s\x1b[0m"%s -warn=lambda s:perror("%s%s"%(sbold("Warning: "), s)) +from starbug2.constants import ( + DEFAULT_COLOUR, + TMP_OUT, + TMP_FITS, + TableColumn, + HeaderTags, + FITS_EXTENSION, + N_MIS_MATCHES, + ExitStates, + REST_SUCCESS_CODE, + Units, + ImageHeaderTags, +) +from starbug2.filters import STAR_BUG_FILTERS -def strnktn(s,n,c): - for _ in range(n): s+=c - return s -def nputch(n,c): printf(strnktn("",n,c)) - -def split_fname(fname): - dname,bname=os.path.split(fname) - base,ext=os.path.splitext(bname) - if(not dname): dname='.' - return dname,base,ext - -class loading(object): - bar=40 - n=0 - N=1 - msg="" - - def __init__(self, N, msg="", res=1): - self.setlen(N) - self.msg=msg - self.startime=time.time() - self.res=int(res) - - def setlen(self,n): - self.N=abs(n) - - def __call__(self): - self.n+=1 - return self.n<=self.N - - def show(self): - dec=self.n/self.N - if (dec==1) or (not self.n%self.res): ## only show once per self.res loads - out="%s|"%self.msg - for i in range(self.bar+0): - out+= ('=' if (i<(self.bar*dec)) else ' ') - out+="|%.0f%%"%(100*dec) - - if self.n: - etc=(time.time()-self.startime)*(self.N-self.n)/self.n - nhrs=etc//3600 - nmins= (etc-(nhrs*3600))//60 - nsecs= (etc-(nhrs*3600)-(nmins*60)) - stime="" - if nhrs: stime+="%dh"%int(nhrs) - if nmins: stime+="%dm"%int(nmins) - - stime+="%ds"%int(nsecs) - out+= " ETC:%s"%stime - - printf("\x1b[2K%s\r"%out) - sys.stdout.flush() - if(dec==1): printf("\n") +# different print methods (why are we not using loggers?) +def printf(s: str) -> int: + return sys.stdout.write(s) + + +def p_error(s: str) -> int: + return sys.stderr.write(s) + + +def puts(s: str) -> int: + return printf("%s\n" % s) + + +def s_bold(s: str) -> str: + return "\x1b[1m%s\x1b[0m" % s + + +def warn(s: str) -> int: + return p_error("%s%s" % (s_bold("Warning: "), s)) -def tabppend(base, tab): + +def append_chars(s: str, n: int, c: str) -> str: + """Append n characters to s. + + :param s: the base string + :type s: str + :param n: the number of times to add the character + :type n: int + :param c: the characters to add. + :type c: str + :return: the adjusted string. + :rtype: str """ - Is this the same as vstack? + for _ in range(n): + s += c + return s + + +def repeat_print(n: int, c: str) -> None: + """Prints out a repeated string. + + NOTE: this seems unused. + + :param n: the number of times to repeat + :type n: int + :param c: the string to repeat. + :type c: str + :return: None """ - if(not base): return tab#base=tab - else: - #for line in tab: base.add_row(line) - return vstack([base,tab]) + printf(append_chars("", n, c)) -def export_region(tab, colour="green", scale_radius=1, region_radius=3, xcol="RA", ycol="DEC", wcs=1, fname="/tmp/out.reg"): + +def split_file_name(file_path: str | None) -> Tuple[str, str, str]: + """Breaks apart a path into folder, filename and extension. + + :param file_path: the path to split + :return: (folder, file name, extension) + :rtype: tuple of str, str, str """ - A handy function to convert the detections in a DS9 region file + if file_path is None: + raise Exception("failed as path is None") - Parameters - ---------- - tab : table - Source list table with some kind of positional columns + folder, file = os.path.split(file_path) + file_name, ext = os.path.splitext(file) + if not folder: + folder = "." + return folder, file_name, ext - colour : str - Region colour - scale_region : int - Scale region radius with flux ? true/false +class Loading(object): + # how long the bar is + bar = 40 - region_radius : int - Otherwise, use this region radius in pixels + # current length + n = 0 - xcol/ycol : str - XY column names to use + # no idea + length = 1 - wcs : int - Boolean if the xycols use WCS system + # loading bar message + msg = "" - fname : str - Filename to output to - """ - if xcol not in tab.colnames: - xcols= list(filter(lambda s: 'x'==s[0],tab.colnames)) - if xcols: - xcol=xcols[0] - printf("Using '%s' as x position column\n"%sbold(xcol)) - wcs=0 - - if ycol not in tab.colnames: - ycols= list(filter(lambda s: 'y'==s[0],tab.colnames)) - if ycols: - ycol=ycols[0] - printf("Using '%s' as y position column\n"%sbold(ycol)) - wcs=0 - - if "flux" in tab.colnames and scale_radius: - r= (-40.0/np.log10(tab["flux"])) - r[r None: + self.set_len(length) + self.msg = msg + self.start_time = time.time() + self.res = res + + def set_len(self, length: int) -> None: + self.length = abs(length) + + def __call__(self) -> bool: + self.n += 1 + return self.n <= self.length + + def show(self) -> None: + dec: int = int(self.n / self.length) + # only show once per self.res loads + if (dec == 1) or (not self.n % self.res): + out: str = "%s|" % self.msg + for i in range(self.bar + 0): + out += "=" if (i < (self.bar * dec)) else " " + out += "|%.0f%%" % (100 * dec) + + if self.n: + etc: float = ( + (time.time() - self.start_time) + * (self.length - self.n) + / self.n + ) + n_hrs: float = etc // 3600 + n_minutes: float = (etc - (n_hrs * 3600)) // 60 + n_secs: float = etc - (n_hrs * 3600) - (n_minutes * 60) + stime: str = "" + if n_hrs: + stime += "%dh" % int(n_hrs) + if n_minutes: + stime += "%dm" % int(n_minutes) + + stime += "%ds" % int(n_secs) + out += " ETC:%s" % stime + + printf("\x1b[2K%s\r" % out) + sys.stdout.flush() + if dec == 1: + printf("\n") + + +def combine_tables(base: Table | None, tab: Table | None) -> Table | None: + """Is this the same as vstack?""" + if not base: + return tab + else: + return vstack([base, tab]) + + +def export_region( + tab: Table, + colour: str = DEFAULT_COLOUR, + scale_radius: int = 1, + region_radius: int = 3, + x_col: str = TableColumn.RA, + y_col: str = TableColumn.DEC, + wcs: int = 1, + f_name: str = TMP_OUT, +) -> None: + """A handy function to convert the detections in a DS9 region file. + + :param tab: Source list table with some kind of positional columns + :type tab: Table + :param colour: Region Colour + :type colour: str + :param scale_radius: Scale region radius with flux ? true/false + :type scale_radius: int + :param region_radius: Otherwise, use this region radius in pixels + :type region_radius: int + :param x_col: X column name to use + :type x_col: str + :param y_col: Y column name to use + :type y_col: str. + :param wcs: Boolean which is true if the x or y_cols use the WCS system. + :type wcs: int. + :param f_name: Filename to output to + :type f_name: str + :return: + """ + if x_col not in tab.colnames: + x_cols = list(filter(lambda s: "x" == s[0], tab.colnames)) + if x_cols: + x_col = x_cols[0] + printf("Using '%s' as x position column\n" % s_bold(x_col)) + wcs = 0 + + if y_col not in tab.colnames: + y_cols = list(filter(lambda s: "y" == s[0], tab.colnames)) + if y_cols: + y_col = y_cols[0] + printf("Using '%s' as y position column\n" % s_bold(y_col)) + wcs = 0 + + r: np.ndarray + if TableColumn.FLUX in tab.colnames and scale_radius: + r = -40.0 / np.log10(tab[TableColumn.FLUX]) + r[r < region_radius] = region_radius + r[np.isnan(r)] = region_radius + else: + r = np.ones(len(tab)) * region_radius + + prefix: str = "fk5;" if wcs else "" + + with open(f_name, "w") as fp: + fp.write("global color=%s width=2\n" % colour) if tab: - for src, ri in zip(tab,r[r>0]): - #fp.write("circle %f %f %f;"%(1+src[xcol], 1+src[ycol], ri)) - fp.write("%scircle %f %f %fi\n"%(prefix,src[xcol], src[ycol], ri)) + for src, ri in zip([tab], r[r > 0]): + fp.write( + "%scircle %f %f %fi\n" % ( + prefix, src[x_col], src[y_col], ri) + ) + else: + p_error("unable to open %f\n" % f_name) + + +def translate_param_float( + opt: str, + opt_arg: str, + set_opt: Dict[str, float], + options: int, + kill_option: int, +) -> Tuple[int, Dict[str, float]]: + """Converts an opt param into a float. + + :param opt: the opt string + :type opt: str + :param opt_arg: the opt_arg string to convert + :type opt_arg: str + :param set_opt: the set opt dictionary + :type set_opt: dict of strings + :param options: the options integer + :type options: int + :param kill_option: the kill bit mask + :type kill_option: int + :return: tuple of options int and set_opt dict + :rtype int, dict + """ + if opt in ("-s", "--set"): + if "=" in opt_arg: + key: str + val: float + key, val = opt_arg.split("=") + try: + val = float(val) + except ValueError: + pass + set_opt[key] = val else: - perror("unable to open %f\n"%fname) + p_error("unable to set parameter, use syntax -s KEY=VALUE\n") + options |= kill_option + return options, set_opt + + +def parse_unit(raw: str) -> Tuple[float | None, int | None]: + """Take a value with the ability to be cast into several units and + parse it. -def parse_unit(raw): - """ - Take a value with the ability to be cast into several units and parse it i.e. 123p -> 123 'pixels' Recognised units are: @@ -147,526 +294,560 @@ def parse_unit(raw): m : arcmin d : degree - Parameters - ---------- - raw : str - Raw input string to operate on - - Returns - ------- - value : float - Numerical value of unit - - unit : int - Unit type (p,s,m,d) - """ - recognised={'p':starbug2.PIX, 's':starbug2.ARCSEC, 'm':starbug2.ARCMIN, 'd':starbug2.DEG} - value=None - unit=None + :param raw: Raw input string to operate on + :type raw: str + :return: Numerical value of unit + :rtype float + """ + recognised: Dict[str, int] = { + "p": Units.PIX, + "s": Units.ARCSEC, + "m": Units.ARCMIN, + "d": Units.DEG, + } + value: float | None = None + unit: int | None = None if raw: - try: - value=float(raw) - unit=None - except: + try: + value = float(raw) + unit = None + except ValueError: try: - value=float(raw[:-1]) - unit=recognised.get(raw[-1]) - except: - perror("unable to parse '%s'\n"%raw) - return value,unit + value = float(raw[:-1]) + unit = recognised.get(raw[-1]) + except ValueError: + p_error("unable to parse '%s'\n" % raw) + return value, unit -def tab2array(tab,colnames=None): - """ - Returns the contents of the table as a notmal 2D numpy array - NB: this is different from Table.asarray(), which returns an array of numpy.voids - if colnames not None, return the subset of the table corresponding to this list +def tab2array(tab: Table, col_names: List[str] | None = None) -> np.ndarray: + """Returns the contents of the table as a normal 2D numpy array. - Parameters - ---------- - tab : table - Table to operate on + NB: this is different from Table.asarray(), which returns an array of + numpy.voids - colnames : list - Column names in table to include in the array + if col_names not None, return the subset of the table corresponding to + this list - Returns - ------- - array : numpy.ndarray - Array from the table + :param tab: Table to operate on + :type tab: astropy.Table + :param col_names: Column names in table to include in the array + :type col_names: list of str + :return: Array from the table + :rtype: numpy.ndarray """ - if not colnames: colnames=tab.colnames - else: colnames=rmduplicates(colnames)#list( set(colnames)&set(tab.colnames) ) ####BBAAAAD - return np.array( tab[colnames].as_array().tolist() ) + if not col_names: + col_names = tab.colnames + else: + col_names = remove_duplicates(col_names) + return np.array(tab[col_names].as_array().tolist()) + + +def collapse_header(header) -> fits.Header: + """Convert a dictionary to a Header. -def collapse_header(header): - """ - Convert a dictionary to a Header. Parameters in PARAMFILES have keys longer than 8 chars which can cause issues in the fits format. This function turns those to comment cards. - Parameters - ---------- - header : dict , `fits.Header` - Header or dictionary to convert to collapse header - - Returns - ------- - result : `fits.Header` - Collapsed Header - """ - out=fits.Header() - for key,value in header.items(): - if len(key)>8: - out["comment"]=":".join([key,str(value)]) - else: out[key]=value + :param header: Header or dictionary to convert to collapse header + :type header: dict, fits.Header + :return: Collapsed Header + :rtype fits.Header + """ + out: fits.Header = fits.Header() + key: str + value: float + for key, value in header.items(): + if len(key) > 8: + out["comment"] = ":".join([key, str(value)]) + else: + out[key] = value return out -def export_table(table, fname=None, header=None): - """ - Export table with correct dtypes - - Parameters - ---------- - table : - Table to export - - fname : str - Filename to export to - - header : dict,Header - Optional header file to include in fits table - """ - dtypes=[] - if "Catalogue_Number" not in table.colnames: table=reindex(table) +def export_table( + table: Table, + f_name: str | None = None, + header: fits.Header | None = None, +) -> None: + """Export table with correct dtypes. + + :param table: Table to export. + :type table: astropy.Table + :param f_name: Filename to export to. + :type f_name: str + :param header: Optional header file to include in fits table + :type header: dict, fits.Header + :return: None + """ + dtypes: List[Any] = [] + if TableColumn.CAT_NUM not in table.colnames: + table = reindex(table) for name in table.colnames: - if name=="Catalogue_Number": dtypes.append(str) - elif name=="flag": dtypes.append(np.uint16) - else: dtypes.append(table[name].dtype) - table=fill_nan(Table(table,dtype=dtypes)) - - if not fname: fname="/tmp/starbug.fits" - btab=fits.BinTableHDU(data=table, header=header).writeto(fname, overwrite=True, output_verify="fix") - -def import_table(fname, verbose=0): - """ - Slight tweak to `astropy.table.Table.read`. This makes sure that the - proper column dtypes are maintained - - Parameters - ---------- - fname : str - Path to binary fits table file - - verbose : bool - Display verbose information - - Returns - ------- - table : - Loading table - """ - tab=None - if os.path.exists(fname): - if os.path.splitext(fname)[1]==".fits": - tab=fill_nan(Table.read(fname,format="fits")) - if not tab.meta.get("FILTER"): - if (fltr:=find_filter(tab)): - tab.meta["FILTER"]=fltr - if verbose: printf("-> loaded %s (%s:%d)\n"%(fname,tab.meta.get("FILTER"), len(tab))) - - else: perror("Table must fits format\n") - else: perror("Unable to locate \"%s\"\n"%fname) + if name == TableColumn.CAT_NUM: + dtypes.append(str) + elif name == "flag": + dtypes.append(np.uint16) + else: + dtypes.append(table[name].dtype) + table = fill_nan(Table(table, dtype=dtypes)) + + if not f_name: + f_name = TMP_FITS + + fits_header = header if header is not None else fits.Header() + + fits.BinTableHDU(data=table, header=fits_header).writeto( + f_name, overwrite=True, output_verify="fix" + ) + + +def import_table(f_name: str, verbose: bool | int = 0) -> Table | None: + """Slight tweak to `astropy.table.Table.read`. + + This makes sure that the proper column dtypes are maintained + + :param f_name: Path to binary fits table file + :type f_name: str + :param verbose: Display verbose information + :type verbose: boolean or int + :return: Loading table + :rtype: atrophy.Table | None + """ + printf(f"trying to load file {f_name}\n") + printf(f" file exists in correct location {os.path.exists(f_name)}\n") + new_f_name = os.path.join( + str(os.environ.get("STARBUG_DATDIR")), os.path.basename(f_name)) + printf(f"new location path is {new_f_name}\n") + printf(f"the file exists in datadir instead { + os.path.exists(new_f_name)}\n") + + tab: Table | None = None + if os.path.exists(f_name): + if os.path.splitext(f_name)[1] == FITS_EXTENSION: + tab = fill_nan(Table.read(f_name, format="fits")) + if tab is None: + printf(f"table at {f_name} failed to read") + return None + if not tab.meta.get(HeaderTags.FILTER): + if filter_string := find_filter(tab): + tab.meta[HeaderTags.FILTER] = filter_string + if verbose: + printf( + "-> loaded %s (%s:%d)\n" + % (f_name, tab.meta.get(HeaderTags.FILTER), len(tab)) + ) + else: + p_error("Table must fits format\n") + else: + p_error("Unable to locate \"%s\"\n" % f_name) return tab -def fill_nan(table): - """ - Fill empty values in table with nans - This is useful for tables that have columns that - dont support nans (e.g. starbug flag). These will be set to zero instead - - Parameters - ---------- - table : - table to operate on - - Returns - ------- - table : - Input table will masked vales filled in as nan - """ - for i,name in enumerate(table.colnames): - match(table.dtype[i].kind): - case 'f': fill_val=np.nan - case 'i'|'u': fill_val=0 - case _: fill_val=np.nan - if type(table[name])==MaskedColumn: table[name]=table[name].filled(fill_val) +def fill_nan(table: Table) -> Table: + """Fill empty values in table with nans. + + This is useful for tables that have columns that don't support nans (e.g. + starbug flag). These will be set to zero instead + + :param table: table to operate on + :type table: atrophy.table + :return: Input table with masked vales filled in as nan + :rtype: atrophy.table + """ + name: str + fill_val: int | float + for _, name in enumerate(table.colnames): + match table[name].dtype.kind: + case "f": + fill_val = np.nan + case "i" | "u": + fill_val = 0 + case _: + fill_val = np.nan + if isinstance(table[name], MaskedColumn): + table[name] = table[name].filled(fill_val) return table -def find_colnames(tab, basename): - """ - Find substring (basename) within the table colnames. - Searches for substring at the beginning of the word - I.E search for "flux" in ("flux_out","flux_err","dflux") - returns as ("flux_out","flux_err") - - Parameters - ---------- - tab : table - Table to operate on - - basename : str - String basename to search - - Returns - ------- - result : list - List of all matching column names - """ - return [colname for colname in tab.colnames if colname[:len(basename)]==basename] -def combine_fnames(fnames, ntrys=10): - """ - when matching catalogues, combines the file names into an appropriate combination - of all the inputs - - Parameters - ---------- - fnames : list of file names - - ntrys : int - The number of mismatched characters it will allow - - Returns - ------- - fname : str - Combined filenames - """ - trys=0 - fname="" - dname,_,ext=split_fname(fnames[0]) - fnames= [split_fname(name)[1] for name in fnames] - - for i in range(len(fnames[0])): - chars= [name[i] for name in fnames if len(name)>i] - if len(set(chars))==1: fname+=chars[0] - else: - fname+="(%s)"%"".join(sorted(set(chars))) - trys+=1 - if trys>ntrys: return None - while ")(" in fname: fname=fname.replace(")(","") - return "%s/%s%s"%(dname,fname,ext) - - -def hcascade(tables, colnames=None): - """ - Similar use as hstack - Except rather than adding a full new column, the inserted value - is placed into the leftmost empty column - - Parameters - ---------- - tables: list of Tables - Table to hcascade - - colnames: list of str - List of column names to include in the stacking. - If colnames=None, use all possible columns - - Returns - ------- - result : table - Single combined table +def find_col_names(tab: Table, basename: str) -> List[str]: + """Find substring (basename) within the table colnames. + + Searches for substring at the beginning of the word I.E search for "flux" + in ("flux_out","flux_err","d_flux") returns as ("flux_out","flux_err") + + :param tab: Table to operate on + :type tab: atrophy.table + :param basename: String basename to search + :type basename: str + :return: List of all matching column names + :rtype: list of str """ - tab=fill_nan(hstack(tables)) + return [ + col_name + for col_name in tab.colnames + if col_name[: len(basename)] == basename + ] - if not colnames: colnames=tables[0].colnames - for name in colnames: - cols=find_colnames(tab,name) - if not cols: continue - move=1 - while move: - move=0 - for n in range(len(cols)-1,0,-1): - currmask= np.invert( np.isnan( tab[cols[n]] ) ) ##everything that has a value - leftmask= np.isnan(tab[cols[n-1]]) ##everything empty in left neighbouring column - mask=np.logical_and(currmask,leftmask) ##cur has value and left is empty - tab[cols[n]]=MaskedColumn(tab[cols[n]]) - tab[cols[n-1]][mask] = tab[cols[n]][mask] - tab[cols[n]][mask]=tab[cols[n]].info.mask_val - #try: tab[cols[n]][mask]*=np.nan - #except: tab[cols[n]][mask]*=0 - if sum(mask): move=1 - #tab[cols[n]]=MaskedColumn(np.ma.array(tab[cols[n]], mask=mask), mask=mask) +def combine_file_names( + f_names: List[str | None], n_mismatch: int = N_MIS_MATCHES +) -> str | None: + """When matching catalogues, combines the file names. - #print(tab[cols[n]].info) + Combines file names into an appropriate combination of all the inputs. - #if name != "flag": ## this is a bodge because it was removing the column if all the stars were good - #if tab[cols].dtype.kind=='f': # I suspect this could be done with masked bad_vals + :param f_names: list of file names + :type f_names: list of str | None + :param n_mismatch: The number of mismatched characters it will allow + :type n_mismatch: int + :return: Combined filenames + :rtype: str + """ + trys: int = 0 + f_name: str = "" + d_name: str + ext: str - #empty= ( np.nansum( tab2array( tab[cols] ),axis=0 ) ==0) - #if any(empty): tab.remove_columns(np.array(cols)[empty]) + if f_names is None: + return None - #for col in cols: - # if sum(np.isnan(tab[col]))==len(col): - # tab.remove_columns(col) + d_name, _, ext = split_file_name(f_names[0]) + f_names_split: List[str] = [split_file_name(name)[1] for name in f_names] - cols=find_colnames(tab,name)#[ colname for colname in tab.colnames if name in colname] - if cols: tab.rename_columns(cols, ["%s_%d"%(name,i+1) for i in range(len(cols))]) + for i in range(len(f_names_split[0])): + chars: List[str] = [ + name[i] for name in f_names_split if len(name) > i + ] + if len(set(chars)) == 1: + f_name += chars[0] + else: + f_name += "(%s)" % "".join(sorted(set(chars))) + trys += 1 + if trys > n_mismatch: + return None + while ")(" in f_name: + f_name = f_name.replace(")(", "") + return "%s/%s%s" % (d_name, f_name, ext) + + +def h_cascade( + tables: List[Table], col_names: List[str] | None = None +) -> Table: + """Similar use as hstack. + + Except rather than adding a full new column, the inserted value is placed + into the leftmost empty column + + :param tables: Table to h_cascade. + :type tables: list of atrophy.Table + :param col_names: List of column names to include in the stacking. + If col_names=None, use all possible columns + :type col_names: list of str + :return: Single combined table + :rtype: atrophy.Table + """ + tab: Table = fill_nan(hstack(tables)) + + if not col_names: + col_names = tables[0].colnames + for name in col_names: + cols: List[str] = find_col_names(tab, name) + if not cols: + continue + move: int = 1 + while move: + move = 0 + for n in range(len(cols) - 1, 0, -1): + curr_mask: np.ndarray = np.invert(np.isnan(tab[cols[n]])) + left_mask: np.ndarray = np.isnan(tab[cols[n - 1]]) + mask: np.ndarray = np.logical_and(curr_mask, left_mask) + + tab[cols[n]] = MaskedColumn(tab[cols[n]]) + tab[cols[n - 1]][mask] = tab[cols[n]][mask] + tab[cols[n]][mask] = tab[cols[n]].info.mask_val + if sum(mask): + move = 1 + cols = find_col_names(tab, name) + if cols: + tab.rename_columns( + cols, ["%s_%d" % (name, i + 1) for i in range(len(cols))] + ) for name in tab.colnames: - col=tab[name] - try: - if col.info.n_bad==col.info.length: - tab.remove_column(col) - except: pass + col: Table = tab[name] + n_bad: int = getattr(col.info, "n_bad", 0) + if n_bad == len(col): + tab.remove_column(name) return tab -def extnames(hdulist): - """ - Return list of HDU extension names - - Parameters - ---------- - hdulist : HDUList - fits hdulist to operate on - Returns - ------- - result : list - List of extension names - """ - return list(ext.name for ext in hdulist) +def ext_names(hdu_list: fits.HDUList | None) -> List[str]: + """Return list of HDU extension names. -def flux2mag(flux,fluxerr=None, zp=1): + :param hdu_list: fits hdu_list to operate on + :type hdu_list: fits.HDUList + :return: List of extension names + :rtype: list of str """ - Convert flux to magnitude in an arbitrary system + if hdu_list is None: + return [] - Parameters - ---------- - flux : list (float) - List of source flux values + return [ext.name for ext in hdu_list] - fluxerr : list (flost) - List of known flux uncertainties - zp : float - Zero point flux value +def flux2mag( + raw_flux: np.ndarray | float, + flux_err: Column | None | np.ndarray | int | float = None, + zp: float = 1.0, +) -> Tuple[np.ndarray, np.ndarray]: + """Convert flux to magnitude in an arbitrary system. - Returns - ------- - mag : float - Source magnitudes + Uses the Pogsons relation. - magerr : float - Magnitude errors + :param raw_flux: List of source flux values + :type raw_flux: list of floats or float or None or ndarray + :param flux_err: List of known flux uncertainties + :type flux_err: list of floats or float or None or ndarray + :param zp: Zero point flux value + :type zp: float. + :return: tuple of (Source magnitudes, Magnitude errors ). + :rtype: tuple (ndarray, ndarray). """ + flux: np.ndarray = np.atleast_1d(raw_flux).astype(float) + if flux_err is None: + flux_err_arr: np.ndarray = np.zeros_like(flux) + else: + flux_err_arr = np.atleast_1d(np.asarray(flux_err)).astype(float) + mag: np.ndarray = np.full(len(flux), np.nan) + mag_err: np.ndarray = np.full(len(flux), np.nan) + with np.errstate(invalid="ignore"): + mask_flux: np.ndarray = (flux > 0) & np.isfinite(flux) + mask_f_err: np.ndarray = flux_err_arr >= 0 - ## sort any type issues in FLUX - if type(flux)!=np.array: flux=np.array(flux) - if not flux.shape: flux=np.array([flux]) - - # sort type issues in FLUXERR - if fluxerr is None: fluxerr=np.zeros(len(flux)) - if type(fluxerr)!=np.array: fluxerr=np.array(fluxerr) - if not fluxerr.shape: fluxerr=np.array([fluxerr]) - - mag=np.full( len(flux), np.nan ) - magerr=np.full( len(flux), np.nan ) + mask: np.ndarray = mask_flux & mask_f_err - maskflux = (flux>0) - maskferr = (fluxerr>=0) - mask= np.logical_and( maskflux, maskferr) + mag[mask_flux] = -2.5 * np.log10(flux[mask_flux] / zp) + mag_err[mask] = 2.5 * np.log10(1.0 + (flux_err_arr[mask] / flux[mask])) + mag[flux == np.inf] = -np.inf - mag[maskflux]= -2.5*np.log10(flux[maskflux]/zp) - magerr[mask] = 2.5*np.log10( 1.0+( fluxerr[mask]/flux[mask]) ) + return mag, mag_err - return mag,magerr +def flux_2_ab_mag( + flux: float, flux_err: Column | None = None +) -> Tuple[np.ndarray, np.ndarray]: + """Convert flux to AB magnitudes. -def flux2ABmag(flux,fluxerr=None): + :param flux: Source flux values. + :type flux: float + :param flux_err: Source flux error values if known. + :type flux_err: float + :return: Magnitude in AB system + :rtype: tuple[ndarray, ndarray] """ - Convert flux to AB magnitudes - - Parameters - ---------- - flux : float - Source flux values + return flux2mag(flux, flux_err, zp=3631.0) - fluxerr : float - Soure flux error values if known - - Returns - ------- - result : float - Magnitude in AB system - """ - return flux2mag( flux, fluxerr, zp=3631.0) +def wget(address: str, f_name: str | None = None) -> ExitStates: + """A really simple "implementation" of wget. -def wget(address, fname=None): + :param address: URL to download + :type address: str + :param f_name: Filename to save output to + :type f_name: str + :return: 0 on success, 1 on failure + :rtype ExitStates """ - A really simple "implementation" of wget - - Parameters - ---------- - address : str - URL to download - - fname : str - Filename to save output to - - Returns - ------- - status : int - 0 on success, 1 on failure - """ - r=requests.get(address) - if r.status_code==200: - fname=fname if fname else os.path.basename(address) - with open(fname,"wb") as fp: + r: requests.Response = requests.get(address) + if r.status_code == REST_SUCCESS_CODE: + f_name = f_name if f_name else os.path.basename(address) + with open(f_name, "wb") as fp: for chunk in r.iter_content(chunk_size=128): fp.write(chunk) - return 0 + return ExitStates.EXIT_SUCCESS else: - perror("Unable to download \"%s\"\n"%address) - return 1 + p_error("Unable to download \"%s\"\n" % address) + return ExitStates.EXIT_FAIL -def reindex(table): - """ - Add indexes into a table +def reindex(table: Table) -> Table: + """Add indexes into a table. + + :param table: the table to reindex + :type table: atrophy.Table + :return: the reindex-ed table + :rtype: atrophy.Table """ - if "Catalogue_Number" in table.colnames: table.remove_column("Catalogue_Number") - column=Column(["CN%d"%i for i in range(len(table))], name="Catalogue_Number") - table.add_column(column,index=0) + if TableColumn.CAT_NUM in table.colnames: + table.remove_column(TableColumn.CAT_NUM) + column = Column( + ["CN%d" % i for i in range(len(table))], name=TableColumn.CAT_NUM + ) + table.add_column(column, index=0) return table -def colour_index(table,keys): - """ - Allow table indexing with A-B + +def colour_index(table: Table, keys: List[str]) -> Table: + """Allow table indexing with A-B. + + :param table: table to colour index. + :type table: atrophy.Table + :param keys: column names to index. + :return: A table which has only columns defined in keys. + :rtype: atrophy.Table """ - out=Table() + out: Table = Table() + key: str for key in keys: - if key in table.colnames: out.add_column(table[key]) - elif '-' in key: - a,b=key.split('-') - out.add_column(table[a]-table[b],name=key) + if key in table.colnames: + out.add_column(table[key]) + elif "-" in key: + a: str + b: str + a, b = key.split("-") + out.add_column(table[a] - table[b], name=key) return out -def get_MJysr2Jy_scalefactor(ext): - """ - Find the unit scale factor to convert an image from MJy/sr to Jy + +def get_mj_ysr2jy_scale_factor( + ext: fits.PrimaryHDU | fits.ImageHDU | fits.BinTableHDU, +) -> float: + """Find the unit scale factor to convert an image from MJy/sr to Jy. + Header file must contain the keyword "PIXAR_SR" - - Parameters - ---------- - ext : PrimaryHDU,ImageHDU,BinaryTableHDU - Fits extension with header file - - Returns - ------- - scalefactor : float - Value of scaling factor from the header - """ - scalefactor=1 - if ext.header.get("BUNIT")=="MJy/sr": - if "PIXAR_SR" in ext.header: - scalefactor=1e6*float(ext.header["PIXAR_SR"]) - return scalefactor -def find_filter(table): + :param ext: Fits extension with header file + :type ext: PrimaryHDU, ImageHDU, BinTableHDU + :return: Value of scaling factor from the header + :rtype float """ - Attempt to identify filter for a table from the meta data or column names + scale_factor: float = 1.0 + if ext.header.get(ImageHeaderTags.BUN_IT) == "MJy/sr": + if ImageHeaderTags.PIXAR_SR in ext.header: + scale_factor = 1e6 * float(ext.header[ImageHeaderTags.PIXAR_SR]) + return scale_factor - Parameters - ---------- - table : `astropy.table.Table` - Table to work on - Returns - ------- - filter : str - Identified filter value, otherwise None - """ - fltr=None - if not (fltr:=table.meta.get("FILTER")): - lst=(set(table.colnames)&set(starbug2.filters.keys())) - if lst: fltr= lst.pop() - return fltr +def find_filter(table: Table) -> str: + """Attempt to identify filter for a table from metadata or columns. -def get_version(): + :param table: Table to work on. + :type table: astropy.table.Table + :return: Identified filter value, otherwise None. + :rtype: str """ - Try to determine the installed starbug version on the system + filter_string: str + if filter_string := table.meta.get(HeaderTags.FILTER): + return filter_string + + matching_filters: set[str] = ( + set(table.colnames) & set(STAR_BUG_FILTERS.keys()) + ) + if matching_filters: + return matching_filters.pop() + + return "" + - Returns - ------- - version : str - Starbug2 installed version +def get_version() -> str: + """Try to determine the installed starbug version on the system. + + :return: the StarBugII version string + :rtype str """ - try: version=pkg_resources.get_distribution("starbug2").version - except: version="UNKNOWN" ## Github pytest work around for now + version: str + try: + version = metadata.version("starbug2") + except (AttributeError, TypeError, PackageNotFoundError): + version = "UNKNOWN" return version -def rmduplicates(seq): - """ - Take a sequence and rm its duplicates while preserving the order - of the input - - Parameters - ---------- - seq : list - Input list to work on - - Returns - ------- - result : list - A copy of the list with the duplicate elements removed - """ - seen = set() - return [x for x in seq if not (x in seen or seen.add(x))] -def cropHDU(hdu, xlim=None, ylim=None): +def remove_duplicates[T](seq: List[T]) -> List[T]: + """Take a sequence and rm its duplicates while preserving order. + + :param seq: Input list to work on + :type seq: list of + :return: A copy of the list with the duplicate elements removed + :rtype list of + """ + seen: set[T] = set() + seen.update(seq) + to_return: list[T] = [] + value: T + for value in seq: + if value in seen: + to_return.append(value) + seen.remove(value) + return to_return + + +def crop_hdu( + hdu: fits.HDUList, + x_limit: Tuple[int, int] | None = None, + y_limit: Tuple[int, int] | None = None, +) -> fits.HDUList | None: + """Crop an image with multiple extensions. Retaining the extensions. + + :param hdu: A multi frame fits HDUList + :type hdu: fits.HDUList + :param x_limit: Pixel X bounds to crop image between + :type x_limit: list of ????? + :param y_limit: Pixel Y bounds to crop image + :type y_limit: list of ???? + :return: The full HDUList that has been spatially cropped + :rtype fits.HDUList + """ + if x_limit is None or y_limit is None: + return None + + ext: fits.PrimaryHDU | fits.ImageHDU | fits.BinTableHDU | fits.GroupsHDU + for ext in hdu: + if not isinstance(ext, (fits.PrimaryHDU, fits.ImageHDU)): + continue + if not ext.header[HeaderTags.NAXIS]: + continue + + ctype: str = ext.header.get(HeaderTags.C_TYPE) + ext.header[HeaderTags.C_TYPE] = "%s-SIP" % ctype + + w: WCS = WCS(ext.header, relax=False) + ext.data = ext.data[x_limit[0]: x_limit[1], y_limit[0]: y_limit[1]] + ext.header.update( + w[x_limit[0]: x_limit[1], y_limit[0]: y_limit[1]].to_header() + ) + return hdu + + +def usage(docstring: str | None, verbose: bool | int = 0) -> int: + """Outputs the usage layout string. + + :param docstring: the doc string to output + :param verbose: if to do so in verbose mode + :return: 1 when complete. """ - Crop an image with multiple extensions. Retaining the extensions + if docstring is None: + return 1 - Parameters - ---------- - hdu : fits.HDUList - A multi frame fits HDUList + if verbose: + p_error(docstring) + else: + p_error("%s\n" % docstring.split("\n")[1]) + return 1 - xlim : list - Pixel X bounds to crop image between - ylim : list - Pixel Y bounds to crop image between +def parse_cmd(args: List[str]) -> Tuple[str, List[str]]: + """Parses an args command. - Returns - ------- - hdu : fits.HDUList - The full HDUList that has been spatially cropped + :param args: the args array. + :return: tuple of the command and the rest of the args array. + :rtype: (str, array[str]) """ - if xlim is None or ylim is None: return None - - for ext in hdu: - if type(ext) not in (fits.PrimaryHDU, fits.ImageHDU): continue - if not ext.header["NAXIS"]: continue - - ctype=ext.header.get("CTYPE") - #if ctype and "-SIP" in ctype: - ext.header["CTYPE"]="%s-SIP"%ctype - #ext.header["CTYPE"]=ctype.replace("-SIP","") - - w=WCS(ext.header,relax=False) - ext.data=ext.data[ xlim[0]:xlim[1], ylim[0]:ylim[1] ] - ext.header.update( w[ xlim[0]:xlim[1], ylim[0]:ylim[1]].to_header()) - return hdu - + cmd = os.path.basename(args[0]) + return cmd, args[1:] if __name__ == "__main__": @@ -676,5 +857,3 @@ def cropHDU(hdu, xlim=None, ylim=None): print(parse_unit("10 D")) print(parse_unit("10")) print(parse_unit("p10")) - - diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..06d2fb1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,14 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" diff --git a/tests/dat/image.fits b/tests/dat/image.fits deleted file mode 100644 index 9c27703..0000000 --- a/tests/dat/image.fits +++ /dev/null @@ -1,1879 +0,0 @@ -SIMPLE = T / conforms to FITS standard BITPIX = 8 / array data type NAXIS = 0 / number of array dimensions EXTEND = T DATE = '2023-02-22T19:12:00.333' / UTC date file created ORIGIN = 'STSCI ' / Organization responsible for creating file TIMESYS = 'UTC ' / principal time system for time-related keywordsTIMEUNIT= 's ' / Default unit applicable to all time values FILENAME= 'jw01227002001_02105_00001_nrcalong_destrip_tweakregstep.fits' / Name SDP_VER = '2022_2a ' / Data processing software version number PRD_VER = 'PRDOPSSOC-055' / S&OC PRD version number used in data processingOSS_VER = '8.4.11 ' / Observatory Scheduling Software (OSS) version nCAL_VER = '1.9.4 ' / Calibration software version number CAL_VCS = 'RELEASE ' / Calibration software version control sys numberDATAMODL= 'ImageModel' / Type of data model TELESCOP= 'JWST ' / Telescope used to acquire the data HGA_MOVE= F / High Gain Antenna moved during data collection PWFSEET = 59775.46035327546 / Previous WFS exposure end time NWFSEST = 0.0 / Next WFS exposure start time COMPRESS= F / On-board data compression was used (T/F) Association information ASNPOOL = 'none ' / Name of the ASN pool ASNTABLE= 'asn_level2b.json' / Name of the ASN table Program information TITLE = 'NGC 346: Star Formation at Low Metallicity in the Small Magellanic &'CONTINUE 'Cloud&' CONTINUE '' / Proposal title PI_NAME = 'Meixner, Margaret' / Principal investigator name CATEGORY= 'GTO ' / Program category SCICAT = 'Stellar Populations' / Science category assigned during TAC process Observation identifiers DATE-OBS= '2022-07-16' / [yyyy-mm-dd] UTC date at start of exposure TIME-OBS= '09:40:04.216' / [hh:mm:ss.sss] UTC time at start of exposure DATE-BEG= '2022-07-16T09:40:04.216' / Date-time start of exposure DATE-END= '2022-07-16T09:40:57.900' / Date-time end of exposure OBS_ID = 'V01227002001P0000000002105' / Programmatic observation identifier VISIT_ID= '01227002001' / Visit identifier PROGRAM = '01227 ' / Program number OBSERVTN= '002 ' / Observation number VISIT = '001 ' / Visit number VISITGRP= '02 ' / Visit group identifier SEQ_ID = '1 ' / Parallel sequence identifier ACT_ID = '05 ' / Activity identifier EXPOSURE= '1 ' / Exposure request number BKGDTARG= F / Background target TEMPLATE= 'NIRCam Imaging' / Observation template used OBSLABEL= 'NIRcam imaging' / Proposer label for the observation OBSFOLDR= 'NIRCam Imaging - Observation Epoch 1' / Name of the APT observation f Visit information ENG_QUAL= 'OK ' / Engineering data quality indicator from EngDB ENGQLPTG= 'CALCULATED_TRACK_TR_202111' / Quality of pointing information from EnVISITYPE= 'PRIME_TARGETED_FIXED' / Visit type VSTSTART= '2022-07-16 09:06:02.0300000' / UTC visit start time VISITSTA= 'SUCCESSFUL' / Status of a visit NEXPOSUR= 12 / Total number of planned exposures in visit INTARGET= F / At least one exposure in visit is internal TARGOOPP= F / Visit scheduled as target of opportunity TSOVISIT= F / Time Series Observation visit indicator EXP_ONLY= F / Special commanding without SI configuration CROWDFLD= F / Are the FGSes in a crowded field? Target information TARGPROP= 'NGC-346 ' / Proposer's name for the target TARGNAME= 'NGC 346 ' / Standard astronomical catalog name for target TARGTYPE= 'FIXED ' / Type of target (fixed, moving, generic) TARG_RA = 14.77060458333333 / Target RA at mid time of exposure TARG_DEC= -72.16920833333336 / Target Dec at mid time of exposure TARGURA = 0.1 / Target RA uncertainty TARGUDEC= 0.1 / Target Dec uncertainty MU_RA = 0.0 / Target proper motion in RA MU_DEC = 0.0 / Target proper motion in Dec PROP_RA = 14.77060458333333 / Proposer's target RA PROP_DEC= -72.16920833333334 / Proposer's target Dec Instrument configuration information INSTRUME= 'NIRCAM ' / Instrument used to acquire the data DETECTOR= 'NRCALONG' / Name of detector used to acquire the data MODULE = 'A ' / NIRCam module: A, B, or MULTIPLE CHANNEL = 'LONG ' / Instrument channel FILTER = 'F444W ' / Name of the filter element used PUPIL = 'CLEAR ' / Name of the pupil element used PILIN = F / Pupil imaging lens in the optical path? OPMODE = 'NONE ' / Lamp operating mode Exposure parameters EXPCOUNT= 9 / Running count of exposures in visit EXPRIPAR= 'PRIME ' / Prime or parallel exposure EXP_TYPE= 'NRC_IMAGE' / Type of data in the exposure EXPSTART= 59776.40282657581 / [d] exposure start time in MJD EXPMID = 59776.40313724624 / [d] exposure mid time in MJD EXPEND = 59776.40344791667 / [d] exposure end time in MJD OSF_FILE= '2022197T124606499_002_osf.xml' / Observatory Status File name coverinREADPATT= 'BRIGHT2 ' / Readout pattern NOUTPUTS= 4 / Number of detector outputs used NINTS = 1 / Number of integrations in exposure NGROUPS = 2 / Number of groups in integration NFRAMES = 2 / Number of frames per group FRMDIVSR= 2 / Divisor applied to frame-averaged groups GROUPGAP= 0 / Number of frames dropped between groups DRPFRMS1= 0 / Frames dropped prior to first integration DRPFRMS3= 0 / Frames dropped between integrations NSAMPLES= 1 / Number of A/D samples per pixel TSAMPLE = 10.0 / [us] Time between samples TFRAME = 10.73677 / [s] Time between frames TGROUP = 21.47354 / [s] Time between groups EFFINTTM= 42.94708 / [s] Effective integration time EFFEXPTM= 42.947 / [s] Effective exposure time DURATION= 42.947 / [s] Total duration of exposure NRSTSTRT= 1 / Number of resets at start of exposure NRESETS = 1 / Number of resets between integrations ZEROFRAM= T / Zero frame was downlinked separately DATAPROB= F / Science telemetry indicated a problem SCA_NUM = 485 / Sensor Chip Assembly number DATAMODE= 31 / post-processing method used in FPAP SCTARATE= 0.0 / [ms/s] Spacecraft Clock Time Adjust RATE Subarray parameters SUBARRAY= 'FULL ' / Subarray used SUBSTRT1= 1 / Starting pixel in axis 1 direction SUBSTRT2= 1 / Starting pixel in axis 2 direction SUBSIZE1= 2048 / Number of pixels in axis 1 direction SUBSIZE2= 2048 / Number of pixels in axis 2 direction FASTAXIS= -1 / Fast readout axis direction SLOWAXIS= 2 / Slow readout axis direction Dither information PATTTYPE= 'NONE ' / Primary dither pattern type PRIDTYPE= '1 ' / Primary dither points and packing PRIDTPTS= 1 / Number of points in primary dither pattern PATT_NUM= 1 / Position number within dither pattern NUMDTHPT= 4 / Total number of points in pattern SUBPXPTS= 4 / Number of points in subpixel dither pattern SUBPXPAT= 'STANDARD' / Subpixel dither pattern type XOFFSET = -60.80311063470378 / x offset from pattern starting position YOFFSET = -63.25322769560025 / y offset from pattern starting position Aperture information APERNAME= 'NRCA5_FULL' / PRD science aperture used PPS_APER= 'NRCALL_FULL' / original AperName supplied by PPS Velocity aberration correction information VA_RA = 14.71874755259901 / [deg] Velocity aberrated apparent reference RA VA_DEC = -72.15458726830636 / [deg] Velocity aberrated apparent reference Dec Time information BARTDELT= 289.8608800023794 / Barycentric time correction BSTRTIME= 59776.40618144711 / [d] Barycentric exposure start time in MJD BENDTIME= 59776.40680276135 / [d] Barycentric exposure end time in MJD BMIDTIME= 59776.40649210423 / [d] Barycentric exposure mid time in MJD HELIDELT= 289.3635997083038 / Heliocentric time correction HSTRTIME= 59776.40617569155 / [d] Heliocentric exposure start time in MJD HENDTIME= 59776.40679700577 / [d] Heliocentric exposure end time in MJD HMIDTIME= 59776.40648634866 / [d] Heliocentric exposure mid time in MJD NIRCam Focus Adjust Mechanism parameters FAM_LA1 = 0 / position in steps of linear actuator 1 FASTEP1 = 0 / requested focus actuator 1 starting steps FAUNIT1 = 0.0 / requested focus actuator 1 starting units FAPHASE1= 'NULL ' / requested focus actuator 1 starting phase FA1VALUE= 0 / requested focus actuator 1 position value FAM_LA2 = 0 / position in steps of linear actuator 2 FASTEP2 = 0 / requested focus actuator 2 starting steps FAUNIT2 = 0.0 / requested focus actuator 2 starting units FAPHASE2= 'NULL ' / requested focus actuator 2 starting phase FA2VALUE= 0 / requested focus actuator 2 position value FAM_LA3 = 0 / position in steps of linear actuator 3 FASTEP3 = 0 / requested focus actuator 3 starting steps FAUNIT3 = 0.0 / requested focus actuator 3 starting units FAPHASE3= 'NULL ' / requested focus actuator 3 starting phase FA3VALUE= 0 / requested focus actuator 3 position value Guide star information GS_ORDER= 1 / index of guide star within list of selected guiGSSTRTTM= '2022-07-16 09:11:50.1890000' / UTC time when guide star activity starGSENDTIM= '2022-07-16 09:13:18.2530000' / UTC time when guide star activity compGDSTARID= 'S0W8273581' / guide star identifier GS_RA = 14.87304998699964 / guide star right ascension GS_DEC = -72.19487491390106 / guide star declination GS_URA = 0.000309062073595355 / guide star right ascension uncertainty GS_UDEC = 0.000161019298902668 / guide star declination uncertainty GS_MAG = 16.53066253662109 / guide star magnitude in FGS detector GS_UMAG = 0.07830334454774857 / guide star magnitude uncertainty GS_V3_PA= 275.1600184488465 / V3 Position Angle of guide star for science PCS_MODE= 'FINEGUIDE' / Pointing Control System mode VISITEND= '2022-07-16 09:48:38.9550000' / Observatory UTC time when the visit co Reference file information CRDS parameters CRDS_VER= '11.16.19' / Version of CRDS file selection software used CRDS_CTX= 'jwst_1046.pmap' / CRDS context (.pmap) used to select ref files Pixel area reference file information R_AREA = 'crds://jwst_nircam_area_0015.fits' / Pixel area reference file name Nirspec Camera reference file information R_CAMERA= 'N/A ' / Nirspec Camera reference file name Nirspec Collimator reference file information R_COLLIM= 'N/A ' / Nirspec Collimator reference file name Dark reference file information R_DARK = 'crds://jwst_nircam_dark_0378.fits' / Dark reference file name Disperser reference file information R_DISPER= 'N/A ' / Disperser reference file name Distortion reference file information R_DISTOR= 'crds://jwst_nircam_distortion_0141.asdf' / Distortion reference file Filter Offset reference file information R_FILOFF= 'crds://jwst_nircam_filteroffset_0007.asdf' / Filter Offset reference Flat reference file information R_FLAT = 'crds://jwst_nircam_flat_0574.fits' / Flat reference file name DFlat reference file information R_DFLAT = 'N/A ' / DFlat reference file name FFlat reference file information R_FFLAT = 'N/A ' / FFlat reference file name SFlat reference file information R_SFLAT = 'N/A ' / SFlat reference file name Nirspec FORE Model reference file information R_FORE = 'N/A ' / Nirspec FORE Model reference file name Nirspec FPA Model reference file information R_FPA = 'N/A ' / Nirspec FPA Model reference file name Gain reference file information R_GAIN = 'crds://jwst_nircam_gain_0097.fits' / Gain reference file name IFU fore reference file information R_IFUFOR= 'N/A ' / ifufore reference file name IFU post reference file information R_IFUPOS= 'N/A ' / ifupost reference file name IFU slicer reference file information R_IFUSLI= 'N/A ' / ifuslicer reference file name IPC reference file information R_IPC = 'crds://jwst_nircam_ipc_0028.fits' / IPC reference file name Linearity reference file information R_LINEAR= 'crds://jwst_nircam_linearity_0052.fits' / Linearity reference file na Mask reference file information R_MASK = 'crds://jwst_nircam_mask_0063.fits' / Mask reference file name Nirspec MSA Model reference file information R_MSA = 'N/A ' / Nirspec MSA Model reference file name Nirspec OTE Model reference file information R_OTE = 'N/A ' / Nirspec OTE Model reference file name Persistence saturation reference file information R_PERSAT= 'crds://jwst_nircam_persat_0021.fits' / Persistence saturation referen Photometric reference file information R_PHOTOM= 'crds://jwst_nircam_photom_0111.fits' / Photometric reference file nam Read noise reference file information R_READNO= 'crds://jwst_nircam_readnoise_0184.fits' / Read noise reference file n Regions reference file information R_REGION= 'N/A ' / Regions reference file name Saturation reference file information R_SATURA= 'crds://jwst_nircam_saturation_0097.fits' / Saturation reference file Spectral distortion reference file information R_SPCWCS= 'N/A ' / Spectral distortion reference file name Superbias reference file information R_SUPERB= 'crds://jwst_nircam_superbias_0152.fits' / Superbias reference file na Trap density reference file information R_TRPDEN= 'crds://jwst_nircam_trapdensity_0004.fits' / Trap density reference fi Trap parameters reference file information R_TRPPAR= 'crds://jwst_nircam_trappars_0002.fits' / Trap parameters reference fi Wavelength Range reference file information R_WAVRAN= 'N/A ' / Wavelength Range reference file name Calibration step information S_WCS = 'COMPLETE' / Assign World Coordinate System S_DARK = 'COMPLETE' / Dark Subtraction S_DQINIT= 'COMPLETE' / Data Quality Initialization S_FLAT = 'COMPLETE' / Flat Field Correction S_GANSCL= 'SKIPPED ' / Gain Scale Correction S_GRPSCL= 'SKIPPED ' / Group Scale Correction S_IPC = 'COMPLETE' / Interpixel Capacitance Correction S_JUMP = 'SKIPPED ' / Jump Detection S_LINEAR= 'COMPLETE' / Linearity Correction S_PERSIS= 'COMPLETE' / Persistence Correction S_PHOTOM= 'COMPLETE' / Photometric Calibration S_RAMP = 'COMPLETE' / Ramp Fitting S_REFPIX= 'COMPLETE' / Reference Pixel Correction S_SATURA= 'COMPLETE' / Saturation Checking S_SUPERB= 'COMPLETE' / Superbias Subtraction S_TWKREG= 'COMPLETE' / Image Alignment via Astronomical Sources END XTENSION= 'IMAGE ' / Image extension BITPIX = -32 / array data type NAXIS = 2 / number of array dimensions NAXIS1 = 100 NAXIS2 = 100 PCOUNT = 0 / number of parameters GCOUNT = 1 / number of groups EXTNAME = 'SCI ' / extension name MJD-BEG = 59776.402826576 / [d] MJD at start of observation MJD-AVG = 59776.403137246 / [d] MJD at midpoint of observation MJD-END = 59776.403447917 / [d] MJD at end of observation TDB-BEG = 59776.40618144711 / [d] TDB time of exposure start in MJD TDB-MID = 59776.40649210423 / [d] TDB time of exposure mid-point in MJD TDB-END = 59776.40680276134 / [d] TDB time of exposure end in MJD XPOSURE = 42.947 / [s] Exposure (integration) time TELAPSE = 42.947 / [s] Elapsed time (start to stop) JWST ephemeris information REFFRAME= 'EME2000 ' / Ephemeris reference frame EPH_TYPE= 'Predicted' / Definitive or Predicted EPH_TIME= 59776.40277777778 / [d] MJD time of position and velocity vectors JWST_X = 59870440.18206555 / [km] barycentric JWST X coordinate at MJD_AVG JWST_Y = -128971809.4217552 / [km] barycentric JWST Y coordinate at MJD_AVG JWST_Z = -56385875.05851612 / [km] barycentric JWST Z coordinate at MJD_AVG OBSGEO-X= 464745027.91907 / [m] observatory X-coordinate OBSGEO-Y= -1298530102.5876 / [m] observatory Y-coordinate OBSGEO-Z= -1072634133.7156 / [m] observatory Z-coordinate JWST_DX = 26.87043559784812 / [km/s] barycentric JWST X velocity at MJD_AVG JWST_DY = 10.80664264909184 / [km/s] barycentric JWST Y velocity at MJD_AVG JWST_DZ = 4.720441084687461 / [km/s] barycentric JWST Z velocity at MJD_AVG OBSGEODX= 57.2915811629463 / [m/s] geocentric JWST X velocity at MJD_AVG OBSGEODY= 12.556224610402 / [m/s] geocentric JWST Y velocity at MJD_AVG OBSGEODZ= 41.4072552632994 / [m/s] geocentric JWST Z velocity at MJD_AVG PA_APER = 275.2528706117782 / [deg] Position angle of aperture used VA_SCALE= 1.000014379756885 / Velocity aberration scale factor BUNIT = 'MJy/sr ' / physical units of the array values Photometry information PHOTMJSR= 0.3925000131130219 / Flux density (MJy/steradian) producing 1 cps PHOTUJA2= 9.225489294810032 / Flux density (uJy/arcsec2) producing 1 cps PIXAR_SR= 9.31E-14 / Nominal pixel area in steradians PIXAR_A2= 0.00396 / Nominal pixel area in arcsec^2 Information about the coordinates in the file RADESYS = 'ICRS ' / Equatorial coordinate system Spacecraft pointing information RA_V1 = 14.26338813102824 / [deg] RA of telescope V1 axis DEC_V1 = -72.1705913196639 / [deg] Dec of telescope V1 axis PA_V3 = 275.7585278550421 / [deg] Position angle of telescope V3 axis WCS parameters WCSAXES = 2 / Number of coordinate axes CRPIX1 = 624.5 / Pixel coordinate of reference point CRPIX2 = 924.5 / Pixel coordinate of reference point CRVAL1 = 14.716521866995 / [deg] Coordinate value at reference point CRVAL2 = -72.160098164536 / [deg] Coordinate value at reference point CTYPE1 = 'RA---TAN-SIP' / TAN (gnomonic) projection + SIP distortions CTYPE2 = 'DEC--TAN-SIP' / TAN (gnomonic) projection + SIP distortions CUNIT1 = 'deg ' / Units of coordinate increment and value CUNIT2 = 'deg ' / Units of coordinate increment and value CD1_1 = -1.5679886011701E-06 / Coordinate transformation matrix element CD1_2 = -1.7443799916272E-05 / Coordinate transformation matrix element CD2_1 = -1.7361872546864E-05 / Coordinate transformation matrix element CD2_2 = 1.6034455684617E-06 / Coordinate transformation matrix element S_REGION= 'POLYGON ICRS 14.780090014 -72.143669890 14.663478433 &' CONTINUE '-72.140800818 14.652507372 -72.176045532 14.769949792 &' CONTINUE '-72.179796115&' CONTINUE '' / spatial extent of the observation V2_REF = 85.87763 / [arcsec] Telescope V2 coord of reference point V3_REF = -493.586939 / [arcsec] Telescope V3 coord of reference point VPARITY = -1 / Relative sense of rotation between Ideal xy andV3I_YANG= -0.07436147999999999 / [deg] Angle from V3 axis to Ideal y axis RA_REF = 14.71648616672556 / [deg] Right ascension of the reference point DEC_REF = -72.16009119964826 / [deg] Declination of the reference point ROLL_REF= 275.3272320917782 / [deg] V3 roll angle at the ref point (N over E)VELOSYS = -4305.99 / [m/s] Velocity towards source A_ORDER = 3 B_ORDER = 3 AP_ORDER= 3 BP_ORDER= 3 A_0_2 = -1.5453677386174E-06 A_0_3 = 6.10893686062316E-12 A_1_1 = -1.1649381936847E-05 A_1_2 = 1.78332574931013E-09 A_2_0 = 1.87885281901476E-06 A_2_1 = -5.0099206886796E-11 A_3_0 = 1.64856852203092E-09 B_0_2 = -6.7504613529845E-06 B_0_3 = 1.71073956685069E-09 B_1_1 = 3.58426378071039E-06 B_1_2 = -5.7238452771782E-11 B_2_0 = 4.98073276467236E-06 B_2_1 = 1.67195993816551E-09 B_3_0 = -9.5765664048086E-13 AP_0_2 = 1.53957978408835E-06 AP_0_3 = 3.22017865502708E-11 AP_1_1 = 1.15456871125972E-05 AP_1_2 = -1.5679643330086E-09 AP_2_0 = -1.8634478615112E-06 AP_2_1 = -7.2205270904496E-11 AP_3_0 = -1.6823215681226E-09 BP_0_2 = 6.69452422953254E-06 BP_0_3 = -1.6091983662367E-09 BP_1_1 = -3.5522427659597E-06 BP_1_2 = -7.2065880007778E-11 BP_2_0 = -4.9625482499047E-06 BP_2_1 = -1.8152447808414E-09 BP_3_0 = 3.66258285098479E-11 EXTVER = 1 / extension value WCSNAME = 'world ' / Coordinate system title LONPOLE = 180.0 / [deg] Native longitude of celestial pole LATPOLE = -72.160098164536 / [deg] Native latitude of celestial pole MJDREF = 0.0 / [d] MJD of fiducial time SIPMXERR= 0.1444597423782575 / Max diff from GWCS (equiv pix). AP_0_1 = 2.33908017363842E-07 AP_1_0 = -7.6757204098130E-06 BP_0_1 = -7.3778228131908E-06 BP_1_0 = 4.56565516473742E-07 SIPIVERR= 0.1054954578048884 / Max diff for inverse (pixels) PC1_1 = -1.5679886011701E-06 / Coordinate transformation matrix element PC1_2 = -1.7443799916272E-05 / Coordinate transformation matrix element PC2_1 = -1.7361872546864E-05 / Coordinate transformation matrix element PC2_2 = 1.6034455684617E-06 / Coordinate transformation matrix element CDELT1 = 1.0 / [deg] Coordinate increment at reference point CDELT2 = 1.0 / [deg] Coordinate increment at reference point DATE-BEG= '2022-07-16T09:40:04.216' / ISO-8601 time at start of observation DATE-AVG= '2022-07-16T09:40:31.058' / ISO-8601 time at midpoint of observation DATE-END= '2022-07-16T09:40:57.900' / ISO-8601 time at end of observation END >p? B? "?a:?N,>? ?ʖ? ?5'>>=d>Ζ>(? #?2>q?w:?"}?{$?A?2M>??+?'?>UY>?>y?͉>?$? k>ݯ>j/?>➮>>!>-j>~> ?K5>]?>츊?0? sr?Y>>>\>? Mv?H6>}%Q>M>T?ʦ?;6>S>rv>,?)? UB>Ԥ? >Q>|>Y?L>Ƒ(>>>K>(M>ߵI>>?Q?? ?0?X7\?O?U?Z?IwJ?p3-?w?)@RA]r@۩L@%DG?A`?P>Ϧ>u?a???[? q?4S?X?8?9 ?3h>??1,?6?u?$d#?T.?ir?44?? ??"?d6 ?+?Q{>n>?m>q??)> ?[?!>@> ?1> -? 2>|h>> *>>?3?b?G#?8g#??Ga?.H?>v>Ӿ?=???`>Q?F)?6Ab?m>*>+'>n?6>$?>/?A?>??>>>R~? -<>?!;Q>>Q?;??"J? V>>g>$?>C?5~?b?8 ?]*?f?D.?g9?@:+@$?8?E?8?b?q`>jx>~e>>>>꿛>?#?L@?RJ?Ԟ?/p?0>*T??xw?h:?z}??m??@g?H?7z?,>*>F>?{>rO>?}>?> A?y>5?(cl>ځ? ->v~?4=>pK?4>$?)?Y??7Hv??>>>?>?&y?">?">?>]>i>?F>}?-%>>/^>Ɵk? ?>OG>7>>g>=G?=?-?-'&?;f?{?>M3? L?Ad?f?&K?^?"??ӛ?^?Lz??‹?W??(>[?t0>!>`>$>D? -;z?:?3M?L?Z?$.0>?.ȹ?;?j?С??Έ??.E?-H?EA> >>̧n?e?B?.?#?)?=>!?%>?E?/!>s>?A?$? ?(f#>m>?9?B?$g?p> ->׿9?t?)>d?,>Ҕw?FC?#>y?> ?M*?#N?ZP?,M>Y? ?<?'>K?B?>><>0?5?:Dd?%j?]Q?#?7t?F?Pg?̢?>%?&=?K>f>z7??UU?]?Վ?Z?Ⱦ??BQK?)?2{>Rp?GY?"?>J ?>> ^?8}?N5?J>5???I3?}?Q?&??a?$/?Mh?ޠu??>?#c?N>>?{j>*? ?#M> >͏? -?-~?U>? a.>Y6? \>j>?@?;7>>>`?"?>:>M|? ? N? />®>ΌC?aO>2>e?>A=? "?$L>;>ͺQ>>F>?M? XR>>F?>Ӝ?U_>E??3>T>T> ">>?? ?#L??FG?]x?Xb?rA?K?KF>+?L;8?grl?˧?k?`m?hO?>h>1> #>0R>+$?>т>%t>[?k?z?)'?o?=?<%>:?O=>!?! ? ->{? B?S?"6H?e>6>:>H>F>t5>F>/>?/W?0>˾>Ӗ:>U?Q??1?>,?>h>\>?[>.y>>wu? [?H>>m?S>Y>z>?"Ɋ>??0Y?" ?:Ռ?6?gF>+3?N?ˮ?l`>;(?!o?@ML??|*?@"?Pw?w?Z?^?E?K,?D??;?O!?|?y?`?W? -h >O>m>?>}H>h?.O'?*?qn? -8?i?O.>?RE?i?Cl>٩Z?'?"I>?>/j?^???D>@>>A>Ȫ>>n>ĺr>h*>7>? +>T>>>>JX>?D>ӑ>? +4?0>>??? #>??>y`>Ű>? ?54>&>?x? k?G)]?zuC?_\>?$?:j?5?=[=?~?0?1o?i?H2!>cJ>s>Q>)?|>ʝ? -z?.'?Et?}?U?@/??j?C?_??>~e?uv?#>>R>Fn>?8i?^J?'$"?? $?!? -7?pe? ?r >8? >D?CZz?*c`?0w>5E>*? 4 -?k?&>-\>I>>i>?>H5>\>g>>O>L>>L>?~@?x>1!>??>6>;?>M>:?>d>>k>7>׈>j?>C?*l>z?r?>Bnb?Q?S~>ޓ>C?s?D}? >>i?>>??q&?$P?BgA*?a,?>/>>p>ۺA?W?@j? X??@=@!@DY?N>f$?)nk?>`?>Q>ə?C3>ځ? q? -N>ӏ>n>f?ť?]?K??G?iai>ꭓ?X?+f?Dr ?b>l">Y?V:?, 8?WV??\>_? -?<>>Z?>>۱? C>?<>? ->ޫ>/?"->ۅ?-d>m??7>?<&>>?.g?0 ?>?@?2>? -??@>2>}H?,>o>{A?&> ->/?Ni?I??;??<1?5(?-h?wz?&?B3TAK?{w>/!?k?Z?z?-"?@@Q=&@V@3X@/`@%g@H%>?b>1?#LI?W?+? _?7?#?:>2>S>:>Ϫ -?_?ù4??hG?9r? ,>?9l?5>);?p? ?L?U?BC?9??K?=N?5??-U? N?Px?^>k$>G>*>X&?Lp>(?ء?t>?>>G?*ȋ?G;>? y>ʅu>>> j?>?u? A>ʹ>FH>>1>_>:>ꄻ?*?\?Ws?;,#??̏?m>P>I? {A?:g?(Z?op?]?G;?SB?1?&`>?QH?cz?? ??"@V@!@C@AAuOAͼ@])?f&?M<? ש?x?,?=ү?!U>? ?w>?6>g? ?)a??x?6i>f? #>?7Wm?>?.T?g?yʒ?G?b?9?I??7e>(??k>a>>}4>L>l?-,?\q?&d>>xP?b>2Ѿ?G>Nj?/D+>@O??%>>ߋ>ڗV?y>>.?q?.l?R>uj>c>ɼ1>W>~7? -^>8?-Ǽ?{ڮ?eB?Ru? ~H?W?>>b{>ܝ?E?/S4??e#(?r? A?7?22?ށ>ꍑ?30l?e?uq4>׵?EV?%@]@jMt@YBwBMB3BRA'?W??'*>>{>w>)>|>E>8h?f5?;PH>`?;?2D?Q ?+d?e>㸣?>>?<0?/i?)?Ol?cT.??+|?> >}>Й? ? ?(h+?9 G?D?90>˹c?? P?E?g>B?f>?% ? _??b?>F>?O>c/?QK)?I?/?Ba?">?\?$F?T?>m?RvP?v?X?A?P@?@P=7@W?A7WBiPB0BqA?*X??U>H1? Q>W?#>O>?i? ->W]>+? ->>Ƈ>0>}`?J>z?>aƠ?E:W>?tp?">??9>2>R>&>릳?%>\?>ܶ>X>Һ#?#F>˻?:0> -? B?j?1P?w!?df? m>>V>(>>I?&>z.>?3ֱ?`>?>5 > >p>>>3?Dy>3>?F?8f??Պ?xF?d?#_?B!?3?U&?<†?5h?::?Z{?N?tJ?S]?*??L@y@MA -BBvB*KFA E?@?G??>֋>?>ϯ>>3.?x>?d?/xK>x?0>%? V'>H?bm>?>?T?\?Tr?!? ?&}?,60? &?e?9?+A? 6>L?!?]?-?"ڑ?-@>?Q>eE???V?'>?G>->Ձ?U?!_>?*?L?;'?2?(?V(?>cF>IN?"?f??i>>?"??H -?5=?6L?ڑd@6@|?N?-?F![?>d? &?p ?D?+h?W?])P??G?Y?=c@oÿ@@`^A AyAbA3^>_?/>>8>?"??2{'?,>w >O>X>I>>H>AV>>>c??> ?p>9>??)S? 5?*7? 3t? -9>jP>ѿ[> ?>Y?.?!c>ϗ?9o?O?3?/0?S???,?8L?%g>>>R?? S>?0?>2? ?F?&?:O?^<>?/?< ?)>U?`>D9>>0?Qwh?F?V\?@??D>? ?n>?9 ?e?6m?4ѧ>?N(?/[?K'b?DR?"S@ -[@V@@AlAVB AN>?&>t>$>ʅ>[> -v>@>$F?J?>?3y>9>> }?X>i>f >p>|?>ɾ>m>?.?>*?6?'y>??a??/??*?!>?(N1?L??S?nD?]?sR? ?QeW?,z>?9>??Q?:?/?G~?1~>?‡?*t?e ~?@?G>J>>?W>?%?-?j?'3?TP?v)?@*?Ln?dj???s?Ŕ>؂?Sm?2N??5?*>6?O`?#t?k?+4?Л?Nv@@f@YANUA'A+\?(? >O?1>??U>X???3>R?r? >cv>m> ?>?Q>8??h? P>hH>#>b=>z??\? - ?%v>?? .>I?L?I? ?iE?)Y?H?P)?'0??}~?SV>|>A?<ٕ>L5?? fm>^? O>?88?~?JL?B?Ayy?XØ?>̃? ?,g?L8? A?$fY?[Y?Ug?C'>?}f?s?A?Z,? }? ??0?n?>>>?)a??%H?6 -?'f?? >W? ?E`?0-?@}@@ @NA4@h?#?v>7??&N?>> >,u>d?>X>>沮>#>G)>ݺ>=>?@>{>!>E? ?T??h?Y>q>>?P?m?^U?o|?v`?i>?h?>>X >*?-?_A1?X?}I??;p?o>?Yԟ>?2?._?&?;>澪?K?G.?E|??xr?`>f?m7>???9?AC?;!?Qs? dX?7?];?X?F?n? d ? 8%??"q[> -?Ҟ??? ? ??>5?%??M?YD?n?J?,?"??Xw? nn?>/?|?"?>Q>1? ?>*?\>3;?>׺? -!>$>f??́>>??D#W?;?+?ת>M-?-#>>=>?kE?@?A?Ń@??? -M? l>Ωm?7>?i?D|?R?~~u?jV?j)>?0C?a?G?vC?2?mc??39V?%?^t?F?P>??>?)ӕ?5@?z ??+3>g??-#?E_?]?65?,D>Ճ?6P??j>ƪ>˂?K>>F? ,{>ߙ>r?9?-?9q?Lo?#k?t*?{?mu1???0>>>;>0?)>=J?%>:>>ń>>W>>n?>=?-?!3?$`q> ? M?M?4S?n?&{?.?>q>oK?D??>s?{?hJ?>?7{??&Kr?4? V?,>"<>7?Dg?1??~?O??:>z?_?8?o??~?˦>M??> M>+? T>Oo>a>>a?6?K/?;f?6?}?H>f!? Y>>v?Kj?U>ݠ?>>>ל>B?Z>>f-?E?&??4>8-?Gy?CU?R?"?4?u>ʱ>>6>UV??&>̻>S?>Ł>8>?>ҿ>K>>? 9"? >lS>%)?+?9?>d??e?0Q>;9?!-??`>c?W?WN?r ,?aH(?>ٹ>t>!?>ڳ>?Q?ک?fY4?R?u7?…??Ĵ?+h?W?^???p~? Y?3"?'?8Pc>fJ?)?>*?)߉?N?W?E'?L???.6?0?P?]? -?[?K?q? @E?,?KW>%?>ʦ? I>łf??I?8?H#8? E?-rk?nK?t>6?.!?8? (>[>³?K>?W> >>R?$?H>U>M->#>Q:>>> >L?<>_\? -?? r? ?2f>܍?s >Q?>? R>Ñ>\?o?g?2!>}?C>m1? P>.>Z? > - -?$"%?_q?ß?Y?}???g?Ch?g??$e?uHY?uP?i?"n?y,?0X?L?C> >J??K>#B>F?5?8M?Nb>_?9,?+q?R08?6?"?5I?Ag??7\?,v?>㡉>H]>>W8? >׃?/T??@?K6??IC?T>X?/>?t>>">4!?8>h>3N>Z?8>peW>^>K5>|>Qu>d>[*>#>%>>:-5>l?>??0K?D>>h>>%>?_? >Mc>(>:>>?>????&?h?.>V?"B?p?t=?ދ?Ȍ4???P?Ȝ?;? ? ???Oq;? ;>?6? ?x?{a?+>&+?X{?Iӹ?fos>S?"4>@?p?Q???0u?f?h?+!?(x?0p>>`>g>??2?#? ue??.K?3?DN?'?$??FG>>;>?%?>$>Z>M>l>X!>>`?n}>>w>?>c>ޭJ?>O>>i?[>>'>Pצ???$O>?F->_?C'S>p>x>>>?7_?>A??3#>Đ?%:F>?#]?P,z?T?i@i@b@7@cB??[?b?LI?Nq?!>x?I>?3Ll>>>? ?n>dW?nZN?@Pw?ξ?"@? ?0d??s?*J?sD?Skt>O?E?7A?BB?.l> v? ->wc>:{?m,?#> ??Ն>2p? -o?9G>D? B>/?>?>V7?Dl?)qk>mC?>t?>?w?<nD?>I?u>6? > >>P>3>??'?21?Fס>?>t^?">խ?ػ?,vQ?9>O? σ>?6O?;?X?7?k?(?vZ>y?@N?w@A@x.@@'@FA@v@]Z@y]@c??G?RE?"? ?>ڜ? -e?Z?=??L?u?TI?cu? ?cz?>F????Z ?']:?x*?NU??t?r?!?/Z?(?H,?3??0>?۝?6^?Z ??(&?'?>f?t>r?y?Y?Kc?gR>>??@?4g??9?O?$Ls?>!H???;>H>Ӷ>`>p?!E?'? >"I>H>?#^>?.v?vE??Qa?\??L?&y?@A??J?b?0j? j?.V?8/??v? |??I?Bw>x?4?> ?J?>?U -?Q?#?C?BV>u>?Ϭ>d?.G ->l>`?? -D>?*>݈?+R?1Y? y?Rh?C?ل> -?>6g>>?>|?6?;\?X???? ?'a?B??S?|?^@AI@A-BPP#BB0AA*A@9?0?D?1?Mn?xU?;$?"P[?:h??R??? ??,L>d9?OL?@_???r?13>1w>??DX>s?);? ?P?? "??+_?#?$>V>? ?$w ?>'?>=.?1>b>??6bE?t??H -l?9,?,u>$>?+8??i>#&>Sh? -L>`?Y?m?I?v>>U?R??$9X?x>C?!e?/g?£?-? >ƍ? -3>#%?>V?U0x?NN?T?q;?U?g ?w?,?@?V@@ң}@HA4cC -eCIڮB}A@3Aj@"?l?D?ll???`HV?g?[?>sj?06>tP?$?? -N??+a?g?(?b?#9>H?:$?1t>຋?[?!W>?/c%>F?>?1> -?i(??(P-??:Y?!>>?$?Z??/?:B?'>Ƚu>?2C>0?$v>? K{>E? -o? u>>ץ?>CD?6?!_?&>>B>>y??'?9>~>>>i"b? ;??ͤ?*Yi?W?v?Eu ?K?'^??_;? F@$@/@KBC CGsB A@îx@G@8??R?^?@?z?)?[?ݗ?#p>?e?5A?=.?~???T#?7K>jX?/?I)? 2?O>>?-fL?4?%-T>W?z?Q)?bZ?9?p?0K? -?)>r>?6I??W?>U>.T> ? ?+Z??;?? -_|??!>>٬??" ->৴>T?"8?>?U??>M?9>/?F >???4m>U >$?8E?$X?7? -85?Z[?A?1?rz%?ʓH??n }?J3m@F{AkA@3A>-eBTBB*DA -!Ay@ᇾ@$?sf??pQ?B??t?6??M?'?2Q?8>??oR?x$?O?5*?4?6p>g?6g?$D?f"? ? 7?4>7>X?D?> -??,?4?J?A?Y?8nq?&Kk>u?!?7?Q>.]>?N? -?`4?N>A> I>#>î?>PR>ڼ?%h?">N?4R?f?D>ϫ >e>>$c>>.?>>ْ>??1q?!t>H>L?W?"UK?B/]? [j?+?a?E$>?!S?]?NM?_?U@@@@@{A*@Ƙ@A@Y?餆?)?+@}Q?;?[T?:?;v~?C@>?/y?=]>>K>? -6??u?2?J?. -+?.?9?*?'kM?LA ?+>c/>> ->G}>?/O?>?9Y ???V>??? /~>%s?/`?}'? >M>>~J?O>P>ׄ<> ?G? ->>>@>h? -l>?v>0q>?>e0?7>i>z??MT>[>V/&?%i?#>V?f?E?>?lW>#9>2>?&>&D?6+S>)2?@H?? T>?-F?^?@[)'@@@Յ@@@d@ |??_8@_U@J@]?]?#?Y1a??$C?8q-?B:9?p?Xn?$?!%>?5k4? ??1?X ?D}?+??>>??=0?*9?s8??SL?_?+3? ?A?\>ӑc>F? ?#S?Rˬ?Vy?(>Y>Ǖ?"->m>D?26>u?$>j>`>m>?$H? -Q?'??=M>?%x>??>?*s>?%D?#[?)?Ve?/i>v>k> ?(?-R?,>Y?#?)a??]J???'>;?-?0>ܛ??V$?,1?9?4#?*x?&\?3O?-G?!?I>DN??"?>> ->?Z?)2?0c?>ߚ>7[?A>A>>Q?[?>C?K>׆9?>a ?>p? Y>?#?.X?>ސ>>?4i?>>3?ʉ>EG>D~>>cJ?3!>t>U??|?"? >vS??!@>\??OX? ??$?&D?(?Q?T?Qk?×?u~??•?4Q?C?%g?j[?3!? *?f?W>W>> d>Y>`?@?<>Z+?$>N?"i"?]? ??+? L? a>ų??>?X>I*>?">%>>??0>B? ?"?)_>?=>? C ?>>>>B?&H>r}?Ȗ>o?x?0I>4> ?P*?H<\?Vt>u?>?7?>鍧>>Ώ?ר>J>>Uc>⣦?g?+?>? -r? ???X ???v????E??d>U>&??"*?]? 6???z?*-?H|?3?$A?9a? p??I?FP?09?>Ա>? -E>?ԅ???_e ?~>7>\?8 ? - ->M??9>}?(>V?,>^>?+~>>O>C>?3{i?4?N{>s2?m>T?24R?:?tB6?T"p?%B>h?*XJ?>n=?=>?B? _i> ?~?}?P>؃> ?k>-J?)L>짠>g>uY?Fs?6`>>X>N?c?IN?-&?5h???>t?M?l?H'K?fW?A?K?T?i;?@|?2>?=`??DZ?Q2?:??H?b>?^W>Ѱ?'.???a??U`??|_?DI> -?,>>>;??Z?v??X?8?v,?j?-|? ??0>w>E>Ό>8>?F? >\x>??E3? G8?X'l? -d>>,? ?CN?{L?` ?gM?P>>>>%1?0+L?]=?=? -??.j?/>>>?T>??/W>|?K?"->?d?>>? F?>Y^>:?0)?0>a>Ѵ?S>,> ??%[?>????ai?K%> ? ?:?Ïn?k}?.?T?b?L+?!?P?!"?F>j?>P??Ӟ?s?w>1g?D`?B>C? ?>ˣ?`t?"E?J A>+?@Ē?6\? -$?&>ғ>?'>1?-?2>υ?2]>>Md?T?94>>V?->Fw>z>>>&?&??yc?/>]R?y>>Q ?f??a?l?5~? ?#J>ۭ>? 0?Lv?u>- ?>6>>>x?R>ҹ>]?!?1K\?? b??Q?E>o>Jn?1/?4?'5?2̟?I? t?x?*?`> ?,?)c8?~?@??%??)>?^?J*?@o? -mM>?k??}`N??HbC?u>G? -8?%|?'k?*%>>>>0>H?$b>d?(M>>>g?D>k9>/>>q>t:>>O>(3?+Pd? r>d>ަ?q>>>??Y?. %??B[?e>l>1> h?FP?`B?c6>X>S?+?)?,^?#S!?1?'>͜?:?,&>S? -d?U> -N?O?E?3>?)\?6I ?U?g?l?Cƚ?>j?B6>?a%V?RQ?U?|ty?D?2 ?&`?.I?M>??>?U?!?rz?)c ?? 5?*>?6k?9jC>?,?D?-*?9(>G?'?IP? ?i?%t?.>>Y?8> ?\> >=a>?)>3? L>?9>#N>?O>>J?V>sC>w?]??x>7]>͛ >L?;l?/G?!? '?GI? %>>̻? -Ih??.6D>?&o>?TL?/,??@>|>+>~?Z0?u?>?ba????ӷ?Auj?K?CE6?T?i?F6?C?| ?B?0H?/=u?5&>>j>0T?/??s??'v?$>V?3=?-?) -Y??.NH?Km?M>?=(:?\3?*??? n->1?dWs?4I?1U~>k:?#>` ->c>`J? -?"o>?ހ>X?? ??5>ڙ?%D? j??;>>>~>?k?M|? >%t>&??,,?3y>?+x?F*?(?5%?Kh?m ?7:?6,??>v?p?+??ķ>?k???"?+G>]?#H?PW? z?vl?ek?J@ -@*??m=?s??f=?@ ?a0? ?/> >Ե?&̳>>j>>Ļ>u?6!??5>?? 8?a? ު?!?/? -#?">> ??ݐ?=:? _>/?u&V>#}>rR? >o?e?$>?r>N?ɒ>|>b>?"`6?K?)0>?!rt? v?e?$>4K>ܼ>ͅ>>W>>A? H>(? -?|?6? e?*?־>Q?@?*y?A?bh-?y?)?q?@/? ߋ?]о? ?({??>?N?h>v>_?.ܤ?(? ,?0 ?:R?:@z@֮@aw?O?&K?\?n?}? A,?E?CH%?ZI?>l?>>0?p?*?o -?y?jl?&>Z ?,A?;I???TN? k? -n?8 ?:g?\?>?.?$(?7?M>?Rp>bb> >2?1>?&>)>tp?2 ->^&>!]>8>u?>oV>b?0>0?O?>j>!>>旬>b>?>5? n>_>>>?!ց?s>Oٳ?)n?"7 >G.?o'??? -? >_?`?&]>U>oR>.?QT>6u>?Na ??fD?m?[> ? F?0?0?;>?C?wg> ?I>?Vr?D?'a>)?IW? 5?ƅ>5?>>H?);? -?"i? k?&/>> >>ȶ[>]?#>C? >>4>>ҳ>ȗ>K?:>>`#?[?l>>_K?>t>~-? 6?*?0{?%??!A>]^?>o?|?@B?m+???S?N???[?{8? I?+?'~??A?%?8>??7>?S?s?MP(?<@!@^L??u]?W$?x?+6*?$Z?d?5^>`? >L??*Z?g?أ? ^?$N?*?+H?!>&W>L>>?E>:?Z?? -.?5q? J>M??5?8!m?-?*?7?p?EJX>N>?9? ->?73>>"> ?c?4>2>ړ%><>H4?h>?ɉ>$>+?E>3>,$?)>??I}? M?9?? -#,?UI?{?V??z?u ??6@eIP@L@*?s@ -?,??J[?.?,/?Xf>,?%?=?i^?0T?/?1Q?k?Z?Z??V?r?_GZ?[J?5Զ?(s?=>?cS??+?A?H?K0b?T*?,x? -?/m?*?v?K??~?$??J?^?H5>?%r>HK>iT>?:??*oJ?@??y?7D?Q?%H??;BB?)F>Q>4>>[>TI>? )>>>d>r?P>¢>qI>~'q? >q? 5? >F?>>?I >=? >5?%?t?l? U?O^?E?ͨ ?dl@AݴAA+-{@@?۸?e?qd? א? 2? O? a?.a?0 ??Ö?x,? '? *??-?^U??Fc?֎`?w??|?*-?d?^a?D>ׯ -?@Ν?Iw>?.?k>?o>S? -? ?4>Ц?!?X?Ĩ? > ->Ӽ>? P> ? %>l>?"w?>S?mi? ?h#?'5?,P?0>>d[??>K??_M?>p>>o>>>>O? ->6>x?I>aP>>>?*&?i?e??$?R9? [?d?Q?"w?"_?›?i@A֙BnA@F??"?8?,W?.r? -|(>㵝?#z?g?A??0?%0t?'L?L?|?b*??x*?A4??"?ԋn??[!!??gƂ?V?*?m?T_p?Y??8??$?6?" q?1L> >?O>x>+?&?.W>Cܒ?A?)>ܐ[?]F?xd?I^?q?2)?p#?Q.>y>0>Æ ?>1?"ȸ>3>=>|>ǘ>Ւ?%s>?)9>j>u>m>#/?[/??V?n>I>> p? ov>г?? -S>sd? 5?7?[Q?v?l?5?;?ea_?F?.@A{tAA$B@ ?F?t(?A@?.F?7І?+A>V?%?)?w?q]?x?S>>7?b?Q??@L@i'@77?.??2M?D@=?N?u?3K?=Y?\?1i?*/?BW?%>V>>';? ?v>)>e?@2>?I?APD>>u? ?b/? >??qu?Y?\Y?6>? >9?$.?[>/?U?ߥ>zH? $?@C? >{>>s>1>E>???A?8QC>:>+>A>??I? -,??Cv?Dn??f.?K?oVK?O??@`M@D"@ Ɛ?i?-o?? ?$??12L>F?7?0>f?'1s>?w? ?`}?I?K@uAA0A/???\_q?@v?I?M ?C?H@>k?_#?'#>¹6?>阢?U?%??>nO>Ҭ>K?:=]?aH>䕎>3>dt? $>kR>+>7?۟>v? ->k>Yj?V?0>.>>f>o?4>)>? w@>?J%>??sC>3?/? r? =>> ?j>>@:?d>ʾ>?.>BD?&?#?W8>:?T[?^e?Yy?t?0?v??i?ك??K89?T~?e?>??E?2?)?Pi?AĎ?> ??Yx?-?my}?C?,@݈AAA0?? -?.?qc?&>??O2?H???E?0>WZ>Υ??>_? ><?+? v?33?1r??#_y?"7?)8%?$W{?I>?K?0>ӷ? ~?eN?+?*?]@?:>*>? ?:?5>?y>>lj?8?'a>\X?ʵ??>ʁ>ڤ!>:?>[M>>]>>0l>m>m>]?V?$g? -?*F)? ?)Q?ZV?6x?mG?KAb?eX?"?Ё?7?an?X%?y?-B>ְ?)7?W>>B?8?*?P=?< ?^>?a$?ď?Q}@Q/A hA?_@f?B?S ??X;?m?b??us?h??R?>r>y?-w?v?9?F>??,??x?7s?>?5>>6>5? ???? }?W>?J?$)?>w?>x?(&>-*?إ?oa?"L?3>v?s?18?>k`?&>6>*x?/?+??i>ްV?@>>Ā>>?+? ?E4?K?5?-Z -?o?ΊC?}?*0>?y?x?Y?K>Ē3?u>?4x>dq?9>կ?( ->JB? >ph?->?0?>_??d!?Ǡ?)?p+?#@?b7?V&?τ?˦??@.8@@j??h@9??f@?1&?:s?1&??;P ?o ?2?7V?(?5?E=?,b?5}>n[? >L>ߗ>핪? -?V.? x?' N>Ȋ?$?VD? >^ ?<>v>Ŷ>>w>7??>Z?2?;_?=)>h?!n?6 -?%? N>|?#c? -?G>]?L?!1?BE??'?T>U? ? ?\?%ڦ? ?6>q!?J? -u?(ک?Q?}?(V?=?n>?j?c>0? ?l>'?h??B???kg?y s?R?Ž-?ĿATAwAj@?89?@?Q?/?r?%w?J2>̧>Z?d?B&?/?mg?VH??o?}:??h? ??>L ?'>?%2?5>?>±?Lv?r9?҇>W;?F,>WJ>׆>H>z9>?>^?L>P>,?,k? ?"%>h><>?1O?!m??70?qq?(0? ?JW?EJR?G5K??s ?n?U ?4>f?>.? ?N?Ug?J?6i?&G??>"?-?? -*>ٹ?D>?>u>>q?P.?/?*? ?Ri?O?^*$?.?4?A: AAA??y?_Y>h? z&?7>? X??t?L>ɾB? H>МD>?,?KPI?=Ht?h?i9?+?`?#Y>? >! ?# ?[?3>?j >??Cj?>>~?3?>>dw>"??mc?G?l? ? u?? ;?l'?Y?4?g>E7>??M?5E0>߸?1?N>?b3>B??K? >? >ںp>?>0? >R?0>?=•>f?>f?{?Si?P>?">? 7G?C/>ך>ǭq? #?1Q>2>l?>>/>v?Q? ̲??Vl?/r?%>>) ?J>}5>(> D?{>T?'?vE?]?&>?1#?>?>.?3A>z?bU?Q>!?G?A?W> ?7j>#?L g?9VP?bS?1rB?/?#?#D?A?}?(?/g(?[?9N? KC>҇>C>Ѽ??`\>Ј\?A'?:?=?<8?Ev?ntI?6H?$?h?^?ʪ??@>F@q??|k??rv>p? _>??/k>>%7?LQ?0s??WI?>?pN>9>U?/*?_C? C??Cq?]?,]???:? C?"q?jh?2)?F ? -?%\s?5>D>5o>>ْ/??!>?06>9g>q;?x?w?q? 9s?&3???1 >5?V?Y>*> ->ä??+>?q? ->/?>y?{>? U>S>D>?)Mz?3\?>?3?L?E?Gd?? ?_?B ?;n?!?F{??"˯>؝?;?mu#?t?Moc??_?z?=?l\?QJ?7?6>>-&?))>>?@ؗ?}]>>ӫ3>9?&A??>d>?'> ?e?e@MB;BPo??2?1?B>nq?&>? /?:? Z?8>-s?5y>??IƊ?2<?>>Bh??7@?}?*>>6x?0?"]?[?{>Z>g?̫>?"s?"0??Ir??Y?5>WV?$0>qR? > >[j>έ%??+g?? ?2r?Kc?MV'?2?7?F>w??^?Hs?2.?>%?K?(?ǎ?P>>Y?=#>>v?>K?>E>? \? >?1?;u>zX?Oz?> > :>?k@8A;WA? ?&Z?z?'A?L]>?0e>?"M>? R??>I?@m?TН?7>??c?)i?8V0?BI?0? *>e??m? ,?'~?y>p>b?>CD?M>v>A>H?T,>?1-?Ɖ>?Q>:>}>+?t??3+>O? Q?Dn?"^>©?;?B??f?"?g?02?$?FW?S?J!?*s? ?.?}?D?6?t޲?i?o?*?Ho? U[?)H? -?2o? >E<>?2? W?? ?%>M>ȴ?C?(>?)V>A? =? ?Y -?*?L]?$???*f?Pz? Aw? ?>&~?|??>>>[ -?Lau???FBL>? ?#>w>-J>>5#?<`?w?*1`?6??A?[?M?kT?U>f?t?C?0m?ĥ?.>?-?>g[?*0?>|m>ߜ? j4?3R?>G>>v?7/&>ͯ3?5?S??E?$>=O?IN>?pK?0?'ei????6?D0?h}?f?A?R1?>>#?? ļ????-Ts?%>~?>T>_2?U?+=?/?lQ?m>?D7?/?;@?G2??B?*??6>|? ? B>?G֖?3W?X?(Wn??\??#r?:U??Vb>f>()?%:?$?71>EW>I>f?-4>C?#?1?l???AT>傪?>"S? 4#?+>T? ?>PV>\>}? l?$>vL>c?'?*??$?q~K^?~?k?:>u?e>?Hj?wI?<9? ? >?!'?#>d>^>ɽ >}?#>E> >?RA( B(@o'?w?@?!G>??u?=Q?C> ?? :V?%~s?#l?IU?F+??up>c^>Þ/>%>z>HY?3F?S?l~?,s?I0?^>&>>NW>ݓ?*??: -$>c?>t? >?>?y?>>B???9,?Ϊ??.?>_?$d>n?T? ? C)>V>l$>k? ??&Eq>2>֨>ދ>~H?Ct?B7??ρ?Bc'>.3>Q>?!]?*?Br?<6u?}?+?0GZ?? &?A*? 9G?1>?/~2??Pe>t?$q?dI?'?NAAV\@ݗ?bQ ??E?2u?z$?Y?B ?)9 ?==>8? -::>ŏ?>9?&u? -Ri?%? ?5E?0>}?~?3)?E?=*?-߽??*>?#O>P?>[>Q?)>>ِ>h3?D?j?-l}?_?I>ni?!_?2???+l?,? >F>d3?? >TP>{?4Z?>{? ?8?>>>">P^>f> >>'?:?d>>?3u>?3>Rs?$@t?F j?-??_>n???*،?Jߓ>'?2z?!aj> Q>>ش"?@??,?]@F?x?I+N>?p{?"?P?AtH? ?# >>I?u>J>5>዗?T>46>> -? ? %}?>Õ>if?[->ܘ>>tf>?2>e?S>!?+_>?*?Cr)>4?б?x?cr?Ei?p>>a>>? Y?o? ?$?<;>>Ǡ?e?k1?$?? +?&-?o?I>(?$? -z>0?tz?,>~>?<?u??'#??#?!?>=?>?7q? N*?g>}?C?4l?M,??>$>&>>>>}x?'>?c???n"?We?C?)?%?PwU?&E?NR;?g?}?x>>E?&[z?4>1??H>a?}?e4??)K?t? v? >{ -?4DY>E>?Tz?,?j{?:u?^C? ;?(a>i> h??21??4?2ވ?IA?8?6?M?!?*?%E>‚>g>o?`?,>ɧ?"?$?~>\?!%>x>F? >_>t? >>?}>?>?>>:>ʷ?W?b¤?LM?=? %?'>ގz??Y?J?Im>r\? -UN?L>?s>Ǻ?!'?4/?0A5?<@!8@$?[9$?Fl??8ԅ>? ?I?E???`sG?JLk?-B9??=m?>V>?#r?%?Y? -??8?D?CM>_?/?U>YM?AF??*(?)4?_>U>>j>B?˶?('?Yw?1{?=π>o#?>>I? ͒?=>tl>z>p>m ?D>>M?,^>x>?ҩ>L>ɵ>">˘>o^>>?>T>"n>>ˊ?+=> Q>? -G?v? ?qɓ?tcw?L>>EJ>U>,?N?C?HM>Ś? -O?7;?->>_>?+/?>و?>-?ۑ@*O@0?[?0^?Q>/? -}?9U?K? 4?i? -,U?~>Z?J>?H> >>i>9?(D?U4???\?>WF>>(??W >>>u>?>?;.>>p??j(>M? .?n>|>ܠ?o`>I>Lq? -2>:>4`>[?#2>Ɉ??+?1 ?,??;?L!>D>e?0?)l>? ?]>5>>?& ?B?&?_??Ie?]?$?L?h?&W?K >?J?թ?U?,0>>V>EQ? .?DO?|? -л?4;?.PA>F?E?5l?v??=?+~>c?+?I??>`>` ?? ??>q>@>g?l?T??6?M>ڥ+?ξ>s??A?>G>>?>">>R?r>Ex>|%?>=>E?$c?Q>=??=?F*>M?#>>?({>˛?@S*>??./?3"?Go>v4>h?$?)s?D?&x>?`??-m?f?>ȷ?<.??I"?<,?-5>' ??>l!??1 ?*]?F2? -? >H? -m?"O>9??!>> E?$??*> ?3?U?>O?H?_?E?{?Dd?003?>*>׹>ne?1>h>b>=>ᑜ>?=5?&N>A?C`>>5?>$O> >ڑ>><>*u>⎕>.>z>->Y>E'>W?w>p P>v><>[>>>>h>69>+>@>j>P?6Q >? s?(!>>>#>@>> -?3|>nM>?h? s?6">ٹe>Ӡ?yY>_?@q>c>׏>Dm?3>$>p=>Y`>?^?>_(?fo~? '?@?g^?PP?>?L>ήn>u?(?)ڀ>U.?R??Q_?t?V>?I?dP?ϔ>>1>?P>~>`>?%~?>ܵ??=>f>p? {>?b?o>њ?2>>ǻ?C>e:>E>B??Y? -r?>>#? I? J>>?\>y#>>)?DF>H>>>c?;?"?!#O=??Vb>Y8>ت[>i>1>??6Ί?ap?6?[u??xH??$;p? >?y?'>>@e>?:k%>9? +s>>l?x?cO5?? p> l>l>&?9 ?G*?F?g?zp?f?>\+?.i?L? >+? ؈>o>>f?qi?? -%?,?%Ŕ>۵>>>4>9? ?#?>U>>>b>V?Q>74?!>> >>u>KR>F>Ps?&?>S? ?>#8>6?M?<>>>w>Nt?$>>p>4>d>m/>_>? ->$>ސL>o??xj?-;?">??~)???1$?*u ?'܅>U>۔>ć?E\?`k>Ş>B>W?ޢ?((?'H ?K ?4;?}F?TD?p?}&?LC?~?Ӡ?X?Nq>> ->?!c?Ï??R_@5<@W@B??߽?yX?* ->Z?Z>n?b?Iz ?1?3aF?>D>J>>H>_>5>v>6n>\)?0>A%>>ԥ>fI>%I?A>Y>~?C>랼?C?? ?>.? >x>®>φ>?;y>Љ>3R?? >ޤ>x> |>[̃?":>k>W>H?S:>ُ>=y>?4?aZ?k?'@?j?zr?lE??.?@? 1>8?&>H>K>l0? [4>>`?+/u?*;?|?b#?V?'@2?[?}%?}a??&…?U? 3C?r?}?]@e1AA<5g@?t???2t?;??.???6-?T>rj>Y>ᡢ>V>$(>j?:?e>m>>ΛG>>^z>EI>_> >>d>r??I?)d%>r>Ũ>>j=?B>)>.w+>J? }? X??;?@?V>v>S>ߩ>? >?y>'? ?tl?4d>c???H6@AOܚAW@r?&?!?5'? >?C P> '>ž[>ݔ?6`>(?~?#2?)K?BJ?w?Xz@5pMA'A'v@ T?n?`?ni'??*>?'?r?r@pA)UAPp`@ݪ?ҹO?t=?V?$H>ӣ>??Y?xZ?fd>N>;!>:>tn>.? R>D ->>-&>?\>B?֐>M?ƺ>>y> >>_>> ??I?"N>>>x>? q>ۣ>j>^>?0A?>>>e>(?2>u>">? ? >D?)S??@ ZAU{+AęA @??I? >?J?8?$1>>>} ?B,? P?e??*>!?H?Cmj?j9?S@eA]AWA_?g0?X c?=? ] ?7">z?D{?C?u?@@ө)@n)F?h/?? ?ݜ?'B>>.> ?F?u>H>V?#?$?>n>>>hx? -b>ᵇ?2?'4>??"V?ug?. >辥?v>f>>T^??>?W?>B>*?%l>1>n>>?>*?D?6>)? S?? @A8A"AR@n??`?x??7?0?? -D?>غ?0Λ?>~8>R?5?;e?a@U8AaA,#@R?˟?v?Q?1<>Ů?;?Y+?T?hu?Y?I?`C?lG?SV}??u? ?>t?0?G>G>E?M>>???xi>ڮ=>Vg?T??n?(O?/? PN?2W? ؿ>w?0>5?bO??>%?7?9? ->Ԍ??+>>ܩ>a?l>L>#b>ɛ>? H?#O9? 0>?">y?8)?!tm?{=>2Y???c?o?@26@y@pp?@??? h> >䖻?%>g>Չ>̃>>Q?F? q>?`??g?y@@1?V?{I??K?J>o >'o>cF???!d?0?9?]4~?I\ ?D? &?,h?S?c>n?1?+? ??,>>R?V?P&K>w0?>1? >2,>,>$]?M?jK?'\Q?>$?5?A>^?!U??>D?&A>>D?!i>v>9{>c?>>ɞ>^?N,>m>E>>d -? b>k? c>v?z? 9?J?T>? ,?;?.??|'7?wXd??nR??xvk>5?l>t? ?&*>>? -<>P?>׼Q>\?'?'!?^?=?(k?;?l??0h??G?6>?]? ? >c?"{>$>:B?p>2>`>O? -n>]nf>1X>݉>>lR? w?+>˺?($1?L5@*c@%A??>6?#۰>g>[> >ǂ>͜K>q>v?? V?Q>K>L>q?m+? ?Ak? o>D>ۆ?T>a>b>r>W??C?!Vu>@?^'?1.?x?,h?i?s?>>{?O??>?7?S%>`<>>f>?>?D??> ?K?2?c1?1?HO?AЪ>U?&p?Q?+?-?c>k>a?J>??U>?" >X?N???"WO?>u>Z>d? ?,}>}>>O>Q>?>??<>cO??Y? >l ?7? @6@'?n>%>>ʿQ?,>a[>? H>C>>$?P?>|?+p>_?b>>u>d>b>>->>@??&>op>(!>?-?\?&^>??E?l?9? -u? ?SF>?Ar`?=Q? -He>>o?? -~>ݳ>s>5>x\>ݑ??#o?R?E??>˹'?!?G?S9 ?^?'?h>9>C?3j?G?rX?a?\h??7a?@?J? x>ί?S>/%?6>z'>:>??m>> ?p>/>b)>?6jk? >)g?Tն?9?6>d>\?/?Fh>T? ? L>> -J>6 ->M>GQ>>E>7>v>e t>"> ?؛?(/>>~\>þ6>GR>?>r?R? z?:2>,d?n>`? >]?0N9?%K?9-?? Z?_?^?E+?[@?GP?3?>)>й> -?6?Y>>>m??EG?)B? ?~?1?)?Vq??i>M?o?Lr>Ѱ>&?P>i??H?>n??j ?I.? ^? -y>ݸ>i?AB>^o>>̪!>N>>!?>`5>>٩>?$>6> -?f?|> >.? ?>>x!>}>Rۘ>&>>K=>a>?>2=nD>Tw>m>ֱ>>O>9j>>u>2>bz>F>ʉ>=d???R?+c?L}?.>U>>’0>n?!;>+?4y?B>簂?(%2?? >>V>?>vv?+/?:?=,?Fv? ?#T? 8?0>W?*?s?Oy? ?>?f?^+? >V??>?4<>wH? ? R>Zz>ۖ?>>>>v>->> >>>A>>>)>&>>>T?<?*NX?2 -? ۩?$2?'2>[E>EL>ˤg>x{>>>Àe?>>v!I>ҕ>0r? >mM??XE>k>%>>d>E?T?>ض?P?>? -͕>À>}B>T>D?>?#?b?*?ŕ?*ga>p?A?%^? %?>ڣ?Ee4?;>i?J{??Y?FF?dA?*1?6?R4??9v??If?>??5ß>?@?yQ>+?*?sY?T-r?* -\?@>F?Ѐ>? ??;>PM>Y?"8>\+>>R>u>>ڋ>>I? u>X#>*??-X? ?Nd>?8L>٘>Y;>ɋ?$ve?I??E>>)?L>>Z>r>'>ʥ*>~?>?#>c?>&?y>ٍ??a?e?!m>ռ>9 ?n5?=>_2>>?,>1? ^>솵>=>>Q>?%>a>??n?F?7?w?H?>?&>P7>}]>U?&y??2>?Sc?=>&>?Y?_?i??>>3>?Oۚ? ->>=>!a>L>t>H?6>Q?V>5l?(;B? T>>>_>>I,>P>87>c>,?4,?5q?bb\>0?>? X?>u> ?$ -??3b? >?i?M>V?>d >>FJ?<?E^?+T>t?8?C&?%s??Z?`u?J?߻?1?M? <[?:N?k>>C>9?3k?1?#>N>k>C>>Mc? w??v?R"?"?=?.>?J?2? j?CkB?)?1ۖ>wF>*9>?_?3 ->#?9w?e{j?ZN? }?S8>?›?&>~>ב>r>>?.. >>"? ,>A>Ӫ>>R>=P>2?%?z>>L>*>Ѣ?: ?<">h >>%>>\>֗>>??"u>5?-!?k?+N>q?>S1???2?5? >>@?UH?W)?C>҈???>W? ->r? ?&H?4=>]?!L?>#>\>>`l>Q>I?>? ~?? ?E?aO>??0W+>P?O??Q?????$?G{? ܈?>l?>Xh>`?#݇?<?C?'>L?Ļ?b?>?>!>H>>$>۰?4:?]>>>?J?>d??JT? H>?U? D[?#81? >>>u? ?N?t?|?c?9?LQ>O??X>?Wd? ?$ !?!?-cH>>c?(#?]?sנ?P??C>?19>m>S? %?.t>~?o?_>>i?2? >T?. ?>>?&ͬ?B>?)>?>%?@h>`?>ޜ??>M??6?Nw?0??'Az?X#>,Q?"? -,?>k?P?>g?%yn?J[>?Ci?s? )?NN>^?>TUt>7>v>S?)> a>g?> ? >>_A>? ;]>?{?r>Ņ}?,X?E*>2>=A>Z?(? NS??ٮ>?=j?"!>}>ρ?R>K?d>]#?'>i$>5?a >ӗ>P?q??? ?Cg>%>>t>->B?#s>%?!>6>>?2>@=H(? ??(>>چl?߂>;>+?D>#?v>+X>q?V?"?n*???/g>Z>J?<?j?G?Cm7?SP?X>Yf?"4?5?dh?$>>6>?#>>>>N>Q> >>b>@_>h/x>ɪ>v>씮? ->?i>8k>d?> ?4>K>x>>ݱ>>Q>?6 ->Ԓ?>Ӆ?>r?>? W>ў? mP>>:>y>?:s?(C?3^>P6? 2>q>!?D?>n>l>Յ? >I>>R>c>>ڨ>>&>U>g>?)W>ܿ?d>h>;?4? =?I>?9]?)y?/s? >M ?. ?Q>??j?[?g>d>A?\??u?2&?(d>>1>> q>X>E?'>>_P>2=l?\Z>N>? >?>>? OO>>p>> -d>'>&?!?e?El@>>/>>o[?"??V>p3>>>>,>FB?_!?B?{>>>s>=>S>>Sc>f)>ui>}q>>2>H_> >;>5>|>T>>:>>ƒb>?(@?:;>̭G>$?^?>1?N>eP>(8>B>0>ȇ?\? >0T>n`>>a>>l??’??3?T?">>x?C???3??>T3>Tq+>H>>>>S>GU?/'6>>k>G^>'>n\?>/?,w5>?(ʚh?-/&? 9?+o7?j?>ښ)??? ?`??&>R?2=>>lj0?.?d3> N?O>n ?<>䙪?5l?5&?;?ud?>C>>?#>X>vT?5Y ?y>>4?1>s?vI>TH? >?<?!0?>n>?4$?)? AG? ->i?i>y>>?u|??+^%?D?@Z? V>k>%?5le?>?>>36>?F ?W?>_?A-N?%#>p?>5m>Z>Ĵ|>`U>H%? -?H>!(>? ?MY?\!>⯚>j=G>?$s?6->~o?ǚ>@U>>T??ʡ?!?Kl->>9? >2>錝?[?:"T?WS?>>h>6?* ?)?(u>d?"?S>귚>\u>>/*>+e>>=>??>E >G>Q->#>Ye? q?AM>?A??_?D??$@ ?>ȼ?b?i?L?">,>[?L?>]?&?#\?-c>,?)? e>;>έ8>>2>e? >>&?Ľ? M? -.>>>lJ>s>?9?U?3?x? ? >ؼJ>> ?m^?c?0)?? l? - ?-?>7$?{?5?#? ?*˟?IN>>z>^mM> ->>,? >?->1>)>୪>$>/>4>D>hF?T? >j>z>S?:??>_>'?}>>0? >}? ? q>>?/&>|?G?>Ə>־>n>>E ?">#r??{?}=?>?9a>o[?Y\?4W?XQ??R> >,?>g">x?>?Nd?5Ո?O,?sG>3>>>?%/?W?p>>?%wJ>>??&uE?K? -W>?_?t???/v?>>??dsX??_w>=?)L>?!?V>>bK?Q>u>?8 >ޡ>a?5?>yV? >˸??N>)?=H?L>n? d?>5?Q??g>Ao>?>n?#?7 -?Y!r?-DW>?~?=2?@_x?0?2Ճ>ַ?< ??b?zi?$?;? -?X,??'??g?G>/??Y>A>??,F?">^?&?l(>̦?)? @?e$?/>?*@A?T>6? -T??}??'>i> >I>0>Ύ>IA> >4>>wY?=?jc?'? |?"J???|?)S>ϛ>g?v$? `='>&>?2?{?'>>>>> >I?[? Ѻ>9>!>'r?2>>ƽ?+?h:?6? -&N>:>o>N*>w?> ?S#?-?+??(?j,?1?V? fb?;??F?y:?>q?'D?j?>?>&? K>u?)A??2֗>Ӧ+>r??,=?!>>2??X?&p?h?-N;? k>K[>ϥ?(#?zv?> ->?>? -p?':X>{\>y?? >ڀc>>?>M?2b?xz?v?;>W??? >?4? 8)?r?&?:?!>\>!O? >?9??? -;?[>˜>?'3?>ɀ[?7܆>4>o??>\8? S >>CS>C>)q?^n???,>"?-M?}[?(o?0p?S?N??Rc?P>$>h?x>??j? -?T>`'?P>Up?>[?"?8?(ڤ?:X? ->c ?^?W?u?\?r?&޲>Έ>?t>T"?dž>>?J>ᚾ??0>aw0? ?L?\I?$?8k!?}l?+?5??HV?'5?!? >:>Y? g>?4>>>e??:A?4M?>5?>[>a>Y? M?>?+8>>?5]?`,>>Kw>">?'>&?!?+>x?#G?F?+Q7?8o??o>>|?&?-x??A?&5?)?<Ց?>??#?lS=?=HS?J0??25??~?4{?9j?]P(?/?_h>Mj>Q>E? u^?{? '??X?=?]A>K?T>>>?>k6>>;>T >>??9?[? -T>*>ڨ?fB??? -C?"??? ]>7?>? ? -[?;=? -??o[?$>V?&>{?65?'?)~> ?R4>?{>> Q>?9s>ư?\>hj>>F>->?!,?1Xj?t~>ZL?B>tC>? >K?(>ݨ?Z?? -B? >?I>?,?8>D;>dP??,?)/?Hg>?!L??6@;?6?)WM?+|>ŷ?.? -f?[P??t>$?.,C>???1?.>y?1?/:>=?6bX?{??Y>e>? ->Z>>=? n?$>>c? >` >׺?.o>8?)?2{?!? ,>? -s?G>?;=>LqS?"??h??x??&?=>t>4?5?M>k>n?+m?H`?>??E{>ɹ?@?J -?>4A>">[? -n?G? -?k>x>E?3Lz? >T?0a??5?FC?6>̨> -??G??\? >*?}0>>k>>9?('? <>?`?y?GT?c?>ɨ>Dd(?8?J>s(>џ>0>f>%>i>?|>>E>>,?>Q>?]?>? >a>o >"?3'?BJ??V>q>>Q?@>>>l>'>o>>`?>+B?5?5? ?{?^?\(>>?ē?KR:?Ed?t?8> >n>?qX>???N>ש>vr?,?1??8 ?->¤W?(?;?/|?N&>> -?|?=-? -ϵ? t>:>L?8>b*??.?8>>N?"v ?J>]>3?->>ݭ>C>v>5>t>>Ӊ?;? -??9?JL>E.?3? V>>_>>xx>B>>5J>{N>oD>Ī>_?!>;>(>24>}M>>f>8 >>7?{#>>M?'?m+??R?U>.>X>[?(?vd?d@|?,?h>?)?>>߫?o>f> -?${>җ?? -t?>?X?[>ـN?HE@?F?*S?#F? -C>?Y?j?5Z>?n? a>%c? ->s ->e0?$H>o#?"?m?#j?o?XTENSION= 'IMAGE ' / Image extension BITPIX = -32 / array data type NAXIS = 2 / number of array dimensions NAXIS1 = 100 NAXIS2 = 100 PCOUNT = 0 / number of parameters GCOUNT = 1 / number of groups EXTNAME = 'ERR ' / extension name BUNIT = 'MJy/sr ' / physical units of the array values EXTVER = 1 / extension value WCSAXES = 2 / Number of coordinate axes CRPIX1 = -400.0 / Pixel coordinate of reference point CRPIX2 = -100.0 / Pixel coordinate of reference point CDELT1 = 1.0 / Coordinate increment at reference point CDELT2 = 1.0 / Coordinate increment at reference point CRVAL1 = 0.0 / Coordinate value at reference point CRVAL2 = 0.0 / Coordinate value at reference point LATPOLE = 90.0 / [deg] Native latitude of celestial pole WCSNAME = 'DEFAULTS' / Coordinate system title MJDREF = 0.0 / [d] MJD of fiducial time END =∸>a= ->>t=q-= =d=>> -=*m=o=!d>v> =#(==> -Y=:>=㏂=C=I==j==d2===P====zE==bi=\G==f=~>f=v>+-/=J=)=X=!c==P=+=f=K%==L==^=Y{=Ⴙ> =|Q==S=#C>Nw=M=?>=> -/=>e=\=j>#6=s>==쐯>3>>=Y>>?> M>> O2> > >>@>M3>?>>F>>n=a=#>0i=,=t+>=>p=(D=>t><=q>=m=S=g>X> -2p=.>"+`>~>E=f> -)>r>Oe==/=fg=C=Tm=yv=id=>6=k===m>=S>E=J==B>>>R=Cl>=Q>f=I,=D=&=-=Z4=4=e>>C>x==o>=<(=׼=,O=&l=n=9=w=%== >/>M>C>p(>d4>>"Z==I=>l>;> <> >>>=>>Y>Aߑ>=$>4>K>>=vc=몐=y=e=X===>/b>(>=z>=0?==z>L> >> 6> hu>>4@>=*== =Կ?=[>8>===_=]='==+=>I==q=H====b==Q>.o>-z>4=Q=t=z=g=;p=V=Uk=.=$ =R>t{=5=1===>=>@"> w=O>z=;=ݦ=Y=VK=.=#=p=>NX>>>Z=->=A5>>>zC>_L>u7>$>> > O>m>c>" c>0>e=E=9=? ==;P==y==X>>O>T>5=%=}7>>S> B`>-l>\S>0 >>>>A_?5=̭==%=M=>=y= ->Y=D>S=P>%+> X=f=b=24>V=f>ͭ=H=>|> @=u0====>#=S>=^>==J[===Z>r=4>Ǵ>#8=#>=퀠=>>{=^P=:=ݭ> -"> >) >qv>:u> > >V>>'d>>F>=2q> K>?I>>5>/>>A>> =g>>q@=!=*>=_=*>Q>w8>#= =R=>ʉ> >|> >ay>e>=˰>=(>%=lB>E=>M> ==ڇ=4>:>Đ>=-=ʴ>==>ޡ=]==9=~=VD=>=ݽ=o=6=qi=H=֡==>su==\ =!=Z=-==xi=m>A>I9=n={==ۼ>/2==!=!>=k>y>+=s>?=84= ==Ҿ= ==>H>f>P>>>>>>e>3>=>L>t,==*===X=Yi==_<=-=_=> -TD>J >> -:>Lh>l===A=>ja> :==> -22=>=c=A.=ߵ%=F>7v=I=߹>P=z=b=j==>6=5a===!=n=%-=Gd=>==#==Ə>y=n=]=V`>1==w==Q}=8=0==Q>>f=>>M>+,=|>3K==!z>>M=Ի==g=﷭>->><==>%>F>> -_>7> P>> >8p>>>> &=ٲ=Q=(==>u=I>==>(>V> >m>>=">-v> -D_=={=2=>r=j\=Fl=>b]>o=볆=i=C=g===I=I=L====m=64>G=?6>=A=Gv==>d==e==>!=>>|]==֬==58=J=l>K="==K>>> v> -Z=Ta=>>+> -= =u=%p> A>>9=N=_b=|X= '=m>xj>S>> S>p>'G>:3>/,>>kl>e=&==F>[==Z==M===U==xR=8>'9O>Y<> > ͦ=]>˪=@>&> C=s=x=uq==>M{>==*=uZ=ж=5=P=Ҕ==D>*2=㛲==r=ރ=m=1S= =}y=t=== ->$=>="=v=y==p=|=>vq=tG==4=> w%>A="==>j=>=kT====V> -U> >U`?mU?Z>TH=%====>==?>z>'>Ge'>[>T>0=6=/=j===Ty=y==9=*==桰=GQ=>>>>>!\> o=3>I=m>> - ==/"=+^>&4> ->8=+=>8=i= >=B=M.===0=+T>z=R=ߜl>=ܢ=P=>y=d==B=@>ı==(=,X=d=p=ퟚ=j>&==S>q>=*=Ű=Z=1='>>F>= >#!=7>4>@>9}>".?39?5T>d>u=?=">> -d>>5\>M|>UZk>F>Bi>D{K>T=y==>^===5&>Rc>EF>=1=n=ϙ=I> >4>#F> x>A=H@=^o> -:== > -j=~=l>>> x>G>u3>*=m=';=T=>]==5=K=><==b==o=H=<=&>q>=w=Sc=M7=u=VQ=i=_"====[=>Y>;=eu=%=1r=A> > %>w>v&>R=[==U>=> m4>Je>>=M===>>)>~/=ks=~?>6>K>k>H>a>p> >V> ->=-> -==9>?8=w=>A=ߙ=L== >12>>e>===h>/==f>R> - *>Q> >>z>]Z=>G= W=>=K=I=T=G=>L=n===H=n=9=>=\=>a={=!~==!=C=}f=^==Z==T=)=G=`=k=>> ->>V>Y>^> =|==>0a=q> v>>=/=r==O><>{>C>A>>f>RQ>U6>?$[j?a_?2 P>> g>c==Y==H=a=_L=3G=>> -!=C=0U> >:= == -[= 7=w>QP> (>@> i>> k>> >>===hH=R=`>=='>=>*=?>;>=MZ=`>ds=߈ =8=h=Y=g==a1=.=A==a=j=)(> >> >\>Y>>'(>=$>s>>yM>=c=R> -U=E>?^=8=Z==}>n'=={==w>> > +>^> ->u>KY>Q>?N?1?jt> >'>L>S=V==8====\h==`T="=T=i[=縚=sp=~=Sl=Դ\>>9> 8=D>=={=!>(=,=4=M=M==>)f==l=쯫=5=3=>>6 >={>ԥ>R= =_=_=6=X===S>=U=NW=> =[==蝺=w==i=====><=s=U=4>2 >5>#O> '>=>=3>-==<=j> >W> >|v>e>5>W>Y>g>׺?~?Z?/>> <==S==== = -=}=.m>==+==>=> -===B=#=|>> >=K==>> -*>l=D>S>\=?===2>D==B=J>`=A>l>>v> %O= c=\=U==vh=>9=g=-= >r=[>gJ>G=nk=5 -===>N==>==kE=(>=2=!>&/V>A@><~>l=ۃ>J*=v[=O=>D2=e=1 -=]>> Z1>> @O>>`>t>SF>>?>l=&=m>=;=6T=w=?==e=>h==$=Ň=&=ۯ='==F=x=#===X=t===o=1> 20> ">=+=e=7=a=8>ab===腸>>>m>q> ]>El>}=d=.>P==PP>#=bq=G>=(=>=C==>>R=<=1>=-=*==҉t==f>$ =-> -V">RF>(r>+N:>=S==#=敤=5=>U=p=kb>=g>\ ->;> ;>->Q >D>? t:?'Ve>jA=Π>m==޾=V=`=9==>=>=R=%|=ޛ>=뺼>l=LJ=>_c==t=c=@> -u=>j>>>l>=%=e;=r> ==Yg=4>>fn> ->>> >%>rV=Y=ޭ>(=;=H%>X>u>N>=n={>$>͍=`>=u==U>=}>6e=v=H=z> [>?>> -> -?>Wm> ->p> "=q>>= -=}^=>=|L>k=Rb=O==> >->.>j??P>9="^=[=W= ==ꄮ=n=_====}>L=~s==Ğ=">==;=y=Ǐ==T=j8=dB==> 7>a>=K==<>>I>;>H= =U=f=t>'>I>s>>g>=/=>=====a=>c-> )> > h4>U> T=ɉ== -=>>k=*>=|$>=r#> ss> 1=>&={>E=~==1=?=븧=m&>r>>5>==H=>>$>,x>-.>0Z>:>`>>u%f=R=m=>> =0====$#====?=U=7=[=>=s>==~=q>&q=O =L==*=p>4>=Z=M(>V4> G>>2=XN>?===埛>n>>̠>J>@>ӥ>B=-==>=> ^>4H>> ->s>[{>8=0=5=c>=/=2B>= >=ު> >v9=%> 4'==4 ====#E==#=b=.=#=.>´=ƞ> a=:$><>-z>>g>,>>==9==n==0P==+=P=G==H>5===M=->G==6=5>g> -8>%>1=>=q>="U>jP>=%>Q>>X>- >g=> ~=D=愪>n=2>> B&> > -> T>V=h=xg=>> >VO>+c>=^=B=> =c=(== >>> x===$=/>==Z>=V=F=>Y==*=-=]=cU> #= =N> 3>T> =>]y=/1>> >q>>(K>"=]= T=輩=gf=Se====zH==~=Y=d=(=\=> ɣ>{>>%e=>=>=>=> =:=П>n=>,>!l>&>> >r>>>u=L =T;>>w> -R>>$)>>== >>c->*> ->e=f>=C>9=*>Bh==Ÿ=>'KE>>E>= >+,=98= -==:>>L=.n=jp===J= !> =䪛>?>\> > }>>N>>> ">hX>[>L>K==+o="=X{=g@=B]==8=ܤ=/==8>>=[`=甾==謰=UQ>3==>=7=>=S=W3>G=9>>=L=vK>>B>ݡ>4==!k> =;===+>2>X{>7>>>&'>K> g>x=f0> >)>D> > =x==v>] =>8=t==>(c>qn>8=>G>ɦ>>p>U=6>>=~=3==R=&==>=a> =s>[>> WP=G> -'>w>`7>J>h=$=F=;=c=| ==5=N$=$>>jQ==鿑=MM=Ё= ={===w=={>=>p =ꜗ==f=-=.=>=h==>l>==>LB>1=y>W>>ԭ>g>%>r_>>yZ>>gJ>">$>]W>> +>vu>U>.9> 6[>?>Va>=GY=r="=8=F=.>Y>=S=n=1>i>>_>'1>>s=>Ex=/&=?====y=L=4X= =\=>X=>K> -.=v=T=@x=!=| =>]=:=Uv=1=jD==}(=G===c}=A~=SA=}=>=>=F=}>mX=H==n===a==;=>M>=K\>D> -S== !=v> -=z=5=>> gY>m> U>,>" - ->(j>'W>*N>A>.> > ->c>o=g=|> )=>9>d=_=Z=0> >< =t=`=je>$> \>N=n=@=ņ>>tU> M=u=%i==O==>S>== >V>C>֬>\=>E=>:M== =n>O%=>2=)> u====B=F==|=q>=)G=] >Vb=d=n=C> j=><=5==-> -===?> ׍== =Y== >Rs==f)="w=N#==Q> >->d>Mr.>qh>>^>({k>N>#0> >=k<="==9>o=-=O=>?=o>>'H=t== =6 >>0>> -'\>l=>;z>0>U=K-=>==;=-==f= z=O =={==Q=2=5=T=q=@=_|>===)c=J>p==>==05=U,>x|=>^=~=O===>>J>ޞ==Ϋ=I=t>=o`>^/> -n=={q=>>><> -=s=> -=f=O>A1==Z>1>b>=>l>>>m>Z>39>@j>ѽ=G>P=ׯ>=(>==>>$>1l>=? =a>N>á>>)>Ο>=/>a>^y> ]> ~=>xB>->8=> ->=R> ==d=1>E====/=|==b9=\<> >_>'===1>R==t"=#A>Bc> >=ݤ=>>> -FC=IH>='>=>e=t=_=|e>=j >i> B> -C> >r=)>v=M=0>7V=>>"j<>='>$>2>g>V>_>N> >T>>Z><>5e>/> > > z`>|==]=1i>>>==R`=}> -\=E=[>G>?> al>>M>=F!=Q>R=== > '>|>W> oy=;===h> ="=r=S=z6=>D.>z=.>>B>>>,===!=R>e==2=H=)=o>=sc> &L>=C>aF=>>==ܨ=> +p=J=r>#>b>C=*=]==>>Bk>#>%Ʀ>#=c>5!>d>Z>F?BT?x/W?1x>X>8>`|>JO>n> -u>>32>c>> c>I>bo={>Q=?>qe>-x0> >=”=>=옪==?=*=N>GA>K=5V>ѧ>>ڷ=7>==l==tN=>=y$==k=>Љ=.Q=>m> -> P> > >4==K=Y">s=M==>X= N=>[>>G>===s=6==>ת=O=4?=6R=l>>=r)=%>Y>t>L> z>> ;>z>+.> Q>">.>>}W??p?0j?]*>>2 ->>@f>>s>!|r>U>$> W>>N>*&=´===>f>e=> =ZL>==m>>==,==خ>D= ===\s=9>==T=⺠====ꚽ=v=n="==ޔ= >> =7y=W>Xy=]==Ʒ>=9=5==7=-=&> 0>s=}==A>>[=\>jy>>I=ȥ==N=:=8=>>g> ->!>0>.>ř> -!>>7P>8L>y? ??1? ->%>>.>F>0A>#B>->>D>>>>8>m=>8J=)>z5> d>$!>l> =K >y>\=V>`=>lr=r> V>Z=>/D>J=X> = 5>j= = ==;=|===L=U="=넽> ==> ==!>h/=9~>=0=K~==F>%>5==x>>x>RV=I=i==ȣ=G=+>>>f@=k=y=e>ӭ>=> -ge>>'>! >7>U(> !>* >HT>7 >>?Ch6?x[?/>:B>0M>|>> >>"ϙ>)7>>=> ʨ>9>E>> y>> ӻ=>>!> U=C>B>ML>_=>N> -g>}> =U>l=_d>> -%==X=ܶ>=D>^>1E!>>~=h=k>=Z=T=]>,=4=>== ="=:>y==.(>d=?=>&>ܬ>>=_== =A=8-=/=@P=#=C>=h=7= >>> -L=҄=gi=4=>==2>+>9=G>![>:>

#>{ >>F>>T>*>O>$Y>p>"_[>. >Q> >]>^> m>>2 >=-=7=];=a>>|>xZ>]=3=^>> N>ڇ>N=f=%=M=c=;>=j>:===q=p=i=].=X=W=2,=<=蚏= >X==ߪ5=I%>=j=h=+=,=h>==X=%=>O=>=i> 1>3x>I=)=>=N=Q=&=x==A8=)==C=W=33== =>~===Ԥ>_>>')@>MIm>>q>G>}>m>P>2;/>>&>+;>J>4 > x=H>= ->~>Q*>F> >3=5=:=aC>1====9>= >>=_=*x=W=E>F>>RY>`>= =T>3===rc==O>= ->FK==> `==>>Rl=>B==y= >G=G>?=,>7=*>"=N>"=>@=xv=/>Lo> I>'>]=.>n,=W9==> {=>X>2K=N>8>)=~=L=X>.>N>[>>%$>fտ>X>S>)b> :>>&`>W>1+>S>437>o=Ϟ=> M>%=>i>m>k/>6>;=O!==>l>=>U>l=X>GR=M===w[>J>>=舁>t> =>#<>c>,>7>>=>> ===m=>E==6=/>'8=w=h=]=&==慥='=>'=I=>(=F=A{=$=yZ=o=k===.Z==%=WV=$=<='==~+=mH===Ž=a==2>!4= ->> > > >>u>0> L> `[>d> >>q>>#>I>>=`5= ->}>X,=>=P=~=A=L=k>Pe=_>yC>J>r=d>'b= =8=k=/~=N>= =9z=>[=3>==)>=-w>}I>Bc>^=v==}o==o[=9=i =0 =]=n=Q=p=ը=?>==/> j> ->(=R===sU=ہ=T=]= ==i==憟>u]=>fp>=J=>9T=J==4=O> Ò> >>C> M> !>>g>@==>>>Ͱ>->-> '=6=H> =&=o=?>D>cL==.==9==x==@*=3=$> > %{=4=\=1=^K=g=3> ==r=?=\>Pm=v=z =sa=T=U=n> ]>> ->&+==SW=H>F>A> -E=4=f.>3===H=^=m==>>>%N=l=>Z>==~P=_=ԙ=U&={=}=8=b=J>v5=8==&==G=N>=>q(>r> -E>>>F#>>N=M>>w>#"'>>c>4>O>i\>> i =1=n>IU>(>P>==z>g=DS==霹=R>W> > ->w=F8>r> 4=M > -f=6>>˔=N=C==>'V=t==8=J=0v=i>=.>=="==> > ^>=>=M=(=>>>%=-L=J(> ;>=i=J>Yc>>E>> j=n==׀=`>==>=;>>|==>v=Į==#=~4==k$> <>B>n>#Bl>z>O> >>%N>>Yx>>V> 0==9> d>'%=>>&۠>> ==>(>>O=+=_=Gy>c=i>B4=>'>@3=6=2=>k>=Ν=&l>.P>>ma=Ti>=l4>}=Д=ڢ|=C>t==%X=.=D}>>>`>== t> :=e>;>=3)>>0>=<=qZ==> i>'=|>d]===#p=>*W==>q#=ο>h=Ν===쪺>`=e=>'>D>W>9?>> -> s>X=d>>> ->>f>#!>g=>}>3#>Z=u= ->c>PL> ;>==pc>#ON=>s===1T==m===m=c>=愉= -> 6D> =i==X>He==>=7=t6=.=t>@I>!= =s=7=4`=k=z>5>yB=7=|=Q=> -> -h> *=:=>—==t=p>T=>=黏= ==P=Z.=(>L=y>r >#=>>>>A>> =[>T >1q=>>im> >>=T>k">=v>Ұ>]=>W>>>t=b=\=M==>8g=9S>>z>%`=x=܀=ln=?=Z>==>1==n=I>(>.> "O=>X=+=[u=>5=T>=0=>}=ґ==nw=헲===">+=Ѫ4=;z==->h>>=,=?>->;=y>:>> vI> ==B====>e> |===/=^=<==RA=> ly=Tv>>(>>mb=A>>n>>m> V>>>p>$<=W>2%>> =|2=S=k> -m>K>t>>Ɋ=:>>> -kp=>H> ݤ=Vo>>E>}=\m=)=>L=>U>hL>9=={>X=t=,>->a> ==P=͓>B>> -,>f/>E> u=:>+=v>>n>Yz= J=>0չ> $>N>\y=e >e>> >>Wj>?">> >>=p>?>X>>i=N=>===L4>C=px=O> ¯>B> '>M> -7>5I>C+>>P>R>> -uP>/=>>2= =m>>=r= =0>>> >= =>l!=f=E=>B>===l>>.(=P=^>j=k> ====˦=@>=D><==D=o>(>zD>7= >>>=>>>==1=ӓ==f=Dl=Ma=Z1> N=D=v>zh=th> =0=> >Eu>F>l> >j> 8> >8>?>====pA=D==8>=q->5>)_+>x>1>>V1> > -> => =[V=Z>=>$>==== => ~> 9T> > -i> =.>[>==.><>H=>=7>:=I=>$e>f>>+!==>===S=6M=>'=8=I=,=Xv=1=Q=﹡=dJ==2=$=꺙>p> ?=====B=d=E=p===Φ=ψ>nG>=>(q==[>> 2q>!>+>&Æ=L> >#> d>>j=!y=奭=p=^==> -u > > a=@>1(>}>>W> n>z> >>J=!i=ץ=[=ii>@==J===ɂ==)={*==-=|>tj=(>=o>>6l>Q='>fK=/=o=Q=@ -= +=iy==>o> -==3=GB==&O> =l>%=n==k=Wh=3=5 >=====W=B=>=v=୑=?=L>+N=#==Z=>>K>>V>7>!>p>?>x^>7>b>6>*d>U>>'0q>j=1=>u>A> > > -> -p> >9>Rln>&d>>>>«=>&=x=W=)=;=%=>r=u=9>:>>i=J=={Y=׵>=>O>>===^>>7>qJ> .>=> -y>[z==y==P=vz=B>;}=R=>r==-== >=+>D>G-= > +===Y1>H\=˂=@>֛>g>>`=.>ͷ>*o>5=C>}>>>+ym>[>yV>? >>/2>#O>>II> $>>9>>> ª>f>P=>i/>>3!> Z> >*>b> d!>>I> > - >>=A=> [>s>X><=[ >j>&==7==t>.> -}== H>mZ>>=*=f3> -#>C>> >d>d>* >@O=H>q==u==b=G`=8==~=X>-> =B?> O=栊=~H=י"=3==%==>= =띙>=>F=?y>?z> >ۘ==>4>H>h?>xD>Y>_>>3z\>%t>*>N=o=~=9d>>> 8>e>#>=B==j> -%>G>~>.>#+>$>>>*> ->W> "=Wa>P>n=n>#g>>4=vu=9=Y>> 8>I==x=GZ=C==en>i=+=P===>>Z>`> *>> eC=w5=`B=> > h>>>/>=z=s= -=)>#= =<==D=>@u=W=j===K=U}==^>Q>{P>>>>=#>>%>? e?=7>n>K>W>%>& ===M[=u>6> r>߮> >=J>3>[?>>Z>3 -> -4>@> >> >>X>/w>a>=~=M]>=9=r=>==&>n>N=`==xr=u=蓺=>1=L8><=#>> =y>> >a>Ŝ>b=%==D=r]=U{> +== >#===>"g=4==V*==x> -.>r> -=2=[=#j=i=F>=S> 0>,<>>&>>F>M> ->& >>\M>M>h>>0V>5>>eX=>1>_=ڶ=>l> >>h>>Ø=>>F2>>4{>6>{>B,>g>/>> 1a>M> 'p> >4> >>A Y> -i~>P >MQ=ZS>5+=r=WW>=E=&B> 3=[>O>>=5=ڎ=%==q> -S>>>L>u==T=~=߆>3> -=k>g=>^D====b=0=>=@=->>)>m>,a=w=^==V=3=;>3=>>>> )>>> >&">!L>#W>d >`k$>0-Q>l>*L4>>==A>c=LD=]==1q>/M==C=>>>O>>> S>`D>#a>?)>S> *+>N>> \{>$=)>>=(=ˉ=t=%8=lu=*>$> E>,=?=h>=`/=m=#=f=>> ==1=2=%{>> => <>v>>]s=F>=l>j=MM=\=BS=h> ->>M=G=ڙ=>:=9=|a=W=g{=>-=W>4>={=e===>;> 4>>1>>+5>>eZ>> [>B>K۶> ҡ>Ϲ>>=-d>g>y> :> Ӑ=R>=>>> C>!%>EI>NZ?MK>j>/>M> l,>i>f>:>z#>%G>"l> >Հ>u=d>=Vc===M>o=ҝ>> 2>(=4=>>D)>> -> -=L=I>n=A=>%>*>>p>>-==~>b=/=댲>˃>H>c=>P9>x -=j=)=9B=i=Dg==h>>=1====D==8>2=>ݤ> 5>Uz>^>[>B>>"<>b>6>>ۦ>n==> ==L>>9|>>> *>>H>>85>N[>N>>u2D>?>k>>Ŷ>>>>> >#}G>>;=">U*>N>|>>į=1=G=> >a>2>bq===>=>PI=c>*>=s>p=R>=>A> _=>H>C=T>f==}n>>=b==K={=f>==#=$===)=hO==n=껡>#_> b=-=>d> 0U>>/>x>6>> !==v=e> a=r====>=/>=|> -dy=H> > 0>>f>j>/k>,:> > $> x>>+>/>@>>>!d>-?>:>3)l=r===R=W_==~==>,> ->>i1=}>I>=q5==;3='=m=A=#">D> ]K=q=> -N>->m>0:=;I= =ޚ=?=>="=\=}=h=졕>}>>r=?>C>W>=̄=/K>X=Z=>>P> _>> >)>/>j`=n=7>)F>R`>H=z+=>>1m=g>>6=n=>'=Y=>#Y=`==5>T> >>Y> @}>e>> O> m`>j>Bw>B/>8>9>p> ><>>>>>K=b`==t> > -c>">2>C>> V>P5>d=$>$>Z=R>X=蓄> S>=N=X=;>>˚>.x>>T===C====͉>$=d= =/X> .==!K=G>CJ>>]=O> > +>Ɯ=H>Vk>>>> l>m>b>>s>'"==~g=c>f>>>=*z>|>p>NN==K=_$=>>7'>N>!4>>>4>>> $> +>>**> ? >T>q/>"q>%7>=0=>ݶ=>G>>>v>2\=>=!>> H$>Y> ʑ> ->v]=mK=+=Y>o=/5=l=>IM=ߖ= 3>s>ԭ>>W=S=;=>=j(>=>=I==1====Jl=Z=s=>=d=>^{>> > -@==M=x===Z=M>]=>a==-=s >e="6=K===> >=$=2==_u>?:=}{>=zO>+>/>i>[<> z=>>>ŏ>#>K>b|>x>\>n>9>(X]> >NS> ca> hs> d= >!}= 8>ۆ> n==l> bt>=>=>8K=(=$=}/=>+= y=="=> @>R#>56>K=r>}=I=J>=`q=X>p&>=c==+v=&==_>*y=L=6=?=Э>>Yu> #>]==%===>&=#e>a>`k> >2=e>v=.V>R=W=&R>p=%>=Vr====a=|=K >7^=D>>^>8>mm>P>$=J>>!%>@>7*>F@>54*>m">>$p_> >>=.>G>==> > o>8>_>]=K> G>I&=h>>l>G=G> F>'>Ո>+>`>=i>|> >8>@=`>>=u= -=Dg=g== !=K==^=>sn>(ԏ==NW==)====m=¦=0==k>1=2==|=Y===İ==.&===~==ez>[==~;>7>+b>>O=_>AZ>oE=>}=#>>=u=>>>N>>8> > ->=> <> >> -==;>7==X>#E>=>K=>2>=~> ==p==O>(I?9X?C>%ڈ>>=hk=BP>p> ==e= =6==;>=N=}>=1=2=O_>i>}>]>0===l=6={=Y==x -= > -E= ->g>=V>>>T=X=>=re=>==W>W=z=>]=@=>>\=?=>==>>f>F>>=> (=K>;> >>@ >>">9>Wz==8V>>A=ZC==&>=O=B=f=h^=10>58>F=<>7=+=T>6=o(>P>C>c{>>>> ==I=hS=op> =,F==؆=L=?u>9==h> {>=E=FI==/>W>ۜ> >^=>O=R5=U=@&>4=>==W==㉥===u==-=>Jz=W>P|=.=Y)=+===qE===9>`==И>Q>>EV=>]>#>>=b^>/>l">>j=/=> >>?> -g=f>]> 2>^=Ni> ȏ>n>cu=@==pb=>>@>k>`>=!=B=>>>=>>"]=>u=>=U=(> 3==%==o>\O=r=$==j=>8> >>y=D2=`==ډ>'=3>j><>39>ݎ> Ռ=> -6> 3>9}=a=W= ==8>Pq=c=z=ׄ="==ih===#={>{>=\#>k>,]G>҅>>==,u>=ϵ=`> =O>>#> >=/I===> >= =====y=>.a=c>>47>E>Ɛ> ,=X> -=e>u>=*~=z>۪=rv>L>P==c=`>$>)>5=I2>=W>c>,==)=1>>>}> H>C>m=FX=Œ=!>n>2=9= =>i=I>t[>#>hX>!N5>'>|=ܴ=|=`>:==ϖ=P==[=u=\=е> =Z=>>+>x>?>>>>y>==z>P\=>=> [> > -1>w?> =9>=aW=1y=>=݀=>Kb=r>>>(3>ҝ?~>Z>-=A=[>p>F]> D>  > -> -yH= =Z=|> > -T>{=->=[e=4>:=3>>!>>>DR>R=Y=}=V==>>'>g==랜==w=(>I==;?==w=:>4<>!>>s>-=X=n~=y===z>Ϳ=,==6=>B>=,S==w=h9>f=@=&>=K ====}(=3N>===@>8B>W>a>>=q=<>=ハ=J>N|>>6>">D>5>9>Ή=o>> w>> ->:> T==Z=H=`=;=Z=h=s=>==>7> ->>=>>j>O=Ng=&=ᤵ=.D=$=>=O>m=q>5>b=> =ϕ=7>=kB==;> -=wl= => -<:===up=Ŭ>\==]P=>=~I=z==U=$=l>L=>{=@=y=>7=^><>=V>> >q> b=j0=^> ->]>R=9====>>nI=%> j >>0>>=K=I>k>>=>==]>t=='=4>&>"ci=Ld=RJ=y=>t`=o=o>lH=]= -=h#=B\>a==%>'>z=}A=> -!= ->> W>>> /)==)5=Y=Jn=~ =Z=,>FQ>]=f=7==U>GT= -=I=<%>e"=_B=D>=|=5=S= z==>b===L5>B>A>ڎ=u=u> N>=yG={> > > U>A=o=<>>====S>4>:>G>>>> _=u>>?>>?>+== >mF>Ɏ==F=Z>= > ==>>\=jo>>>cs=>==>h=>!>X===R>,=> =%=L9=ϔ===(=>-==V==࢝=n=ﹿ=C>ci==Qv=D>=D=R===>==V#=y==k>!. ==%=o=B{>>>W>#F!=7>>;<>>,j> !=@===R>H> -l=䣚>i> ->Ѐ>5c>9md>=4>->?>f>"E=T=.>Z>9>Q>2>>> =>=n=!==>->>=aD>>P>.H>ج>==B>s3===o>ߍ=$=V==zO=ђ=> @>0>*t=S>ZZ=a9>e6> >>{C=t=x= ======R=D=s==f={=k=ʅ>X==|> 2=W=>T==jt> > >41>=`=s>+>> -Œ> 8=z=>> R>X6=h >=>l>> gT>0>A:a>7r>>6> > =>5>>t>ʫ>>' >l=Y>> 79>F=U=>(>H> ->S=t{>>>=]x> X>v=S==>=>a= =L=j=㺬>*T>n>#?5>73>=T2=Ϣ=y{>>m> q > >n=9=>1={[==3F=/><>ݖ==M=W=߼>_t=-=m=ꉌ={z=>ě=ζ>A===2>>>\> -W>>=ln==D==C==(===>C==*=_=xg> ^> >>}>"=Cn>~> =>h8>>>>>>dk=> z>$> I>F>h=!>R=> =>9=C=i>1=O>==H=>MC=U===W===(>]> >i6>=====^=Ζ=Pw=O&=C=7_=f>:=Z=p=$=t>.===A=o=vg=i>S> $=>>5>J==lo>M='>4(=fk>>e>Z==ް=0=5 =)=4> D>"==>==>=Zh>G7>1>L=[=6>c>k>=>H> =E=&>C=>)>(>0$> ->==*=YN>\=> q>=B=]=\> T7>>Q=>N== }===zS=f==ٳ=j> -=>У===>y==I=l=ړ=U=CM===a==H=B4=y=W=I= ==푦=o==-=κ=== =F=e)=#C=j>@=&>=y>>E:==.=R=,5=>Gi> =H=k="=>/= =>=㸯>/=u==E>?=8=[=%=V=R==w>U>C>>N> >T>==c>$=>Ȏ==$=$=5=>>[>%>  >>=轰=v>{>=0=t==K=ŝ=====엓=^=<=='>=$=>xj>r==\=^==)=m=4=>==>R> ٴ>7u=g=[=&==;=ʼn=/l===փ== >= >=E=G=dG=:=p>>u>x=ݤ>>u>Q>=ڲ==_X=>G$=>f >l*>=h=>A= > -s>>>n$>>3>> -|>;=#=V=X=!=J>H>~> >> -4>.V>>>d=>(=> =5=w=}=z=u=>.===%h=><= =@==銥=]===ꉐ=Q>o=;=z=D=f=tD>;E=d==6.>X=]=!=|=t=Q=V>B=7=8=3m=I=[=== =)y=b%=(=i=e==/=>>5>e> y>Y>0> >>2>H>!=ݯ=Z>>= ==1> -T>>vo>5>u>5>m>у> I>V> >z=7>=>>_>>'bK> >?e>X>?>ݜ>+I>h=P=r=X>8>H>s>6>w=@=b=F=h == =q9>=M=>= z>=!=>6=#=:=h>U=?> 5>y=Gb>e> >S -=4>=>p>P==?=U,=[=*Y====U>=0='y==*A=׈>=>c> > >A>D>"Xj> @>&7>>t=0=p>=>=i>>@r=0=>=fh>>7>u>*9>Bk>\2> 9 > >&>>w>6> -> $[>H>72>>>_)>)'>&!>-e>d>>>&>&>> == =I=R=d==ݢ>Cy=F=$=Ez=z>= =;>==Z=U{=DI>>>U-=="=F>|> >@W=^==>=T=S>B,>g>P=鷪=#&> -3=@>=钐>B=F=s={>"=>4> ->IJ>g>*>8>2&> -&D>> -L=J>>>>>#t >=r=>$= >M>r>d>F>>>8/>nM> <>!>>~=m>#=h>U4>r>$$>z a>>Y>>1?> >>=v=H>~9><>(>gq=)]=銹>i=\=L=>=f.= O=B5=\=[>L>">~====o=>=d>=w>>ؼ>m>ź>\=sy=1=>=E=\=~>Y?>JZ>==hP=)=#>>===fR=*d==.>->>B>ɉw?d>n>n>F>%> >%>Sq>"~>>j>'=_>)>=$=>}E>>;>x}>U>6>)B>>>a> u=J> ->"> f>8 L>>>p>>V8> >|T=G>> >> ==8=W> ==X=A=>>=x>==>>Y>Ma>E=Y=>==r3=5e>KL> >V> g=J=}>==>=n=w=N2=>l>w====V=Q=ٕ>=:=h=p>>ۚ>0M>F0>n#>>ZI>> >>G>> >m>z>>ly>;=;A==R=>3> r>U><>X>8>7>!5>,v>=~> -"> tV> ->Vw>#>?> L>m>6>x> 8=l==|y>==\=Ma>)=(>{=7=ۣ=}>>=>=^h=>t>=>P==L>%==,2> >jz> -H>'>J>==d==='==5=c=y4=4=o> z>d>=,=~='>F==">I>&>>ڈ>)>@”>z>W>4> ->j>|.=x=d=}=@i>>=M=7 >k> -=;>5>>>7>;>?Xt>&*>8e>>>`O>==J>x><> >>vP>JE>> C> #~> -x>(@>=2g>q==V>==x>s->@p#=>%[> >*> O>!=斞=KW>;Q>0g>(>5>k?=7>Gh>H>p>;>,=9=>.>t=8+>#3j=l>d=D+===5=۵<==Q>>6=='D=>$09={>G_>v= s=+> > -h>aD> > 2>>/>> -=z>б=t><>M =}=+=?=כ>u>?+=~>C->>JW=c6=>eN>>>=>>>>8>=~=T> >Q=v==nJ>=>->U> -=4=\=]g>!>@=r=lq> -db==>>D>D{S>X> -}=8>{> m=J>==P=/h=nB>=>;=1>$/=Ε=>>>|==>+,>\=x=P=>>\=>&X=ا> > >n>[><> -=_M= -==>X]>C==> I=n=Y> -E`>L>e>.=_=j>τ>R>$>tO>^>0 -=\%>5=K>====>|>=a><>=>X=!> 3j=>>=2=,=4=f=؄===P=];=>f=>f>8=^=*= ==H>>>GG>EW>Y=k=y=:"> =.?=3=+=~=aR=Q=x==L>)==0=D=D==;=R5=L=ʙ==+W> s=Ȩ=2=d=n> E>&=}@=f>4>h==y=]>=xY>cl>=M=K@=P==->}=؅=k>)K=& =z==W.>J>=,7>X==><>-=H>F>0==e>i=> >>C==h> -¿>4u=>==`=='(==n=E4>-{==0=>>ku=}M=+>}=I=?j>>7>"=O=Z>=0=v===>/> =ϻ=)=@>=NH=xa==L:=R>ZW=a=~=௉=ݾs> -=\>2}>>=j=!^=5=G=&=U=E=x>@Q> =e==>e>O>=> -X>V==A=ễ= == M=ܱr=vO=:=P>=n=l>B=L>S>$],>==9>==ht>=n=>%> =ߧk=I>>^==O1=y=D==D!=b=(=߉$=ǟ=>=- = =>=U====5=uv=T=.=r=S=$=i=5=Ы={>o=i=y=⪍=&@==?===3K=m4=ݑ>*=^=~=Y==T==zY=x=(=>=c>==Q==T==V==>=7>R=>@u====K=Y>> ͑=Z> *Z>1>=c==5>>>8=-= ====k=">$>>O=@2>u\=A=b==>u1==Y=W==Ɗ=xu=#t==\(= ,==묫===:=>Y=> > ->)^> ->&U=Ә=:=Q=t=i>$>]=\>!=$:==l==w=Q=|==ꊽ===.=> =L=n>=}> >7X==M=>b=#4=냉=v==4n=x>o=m=x>_>7>>H=>RK> =x?=5>=c%>Z >S>>C>O=>>>g>> ۚ>=t=_=>.t>{>,> я> -=d>= -==j=~G=v=:=v!>Z>*>=_=p=n> .===$i=l=#R=W===r>]'>=/==Mw=u==qF>P> ?p=h=V>)=&=ؿ=ꅅ={= "=ۍ> ==n>8=`==V$=3=<=,> > -===>=={=>/>vL=#=;+> ==u)==J2=>=ce= k>ߤ>%s>Ne>͋> =Y==wY=_=-=L==U>~>>C> >=]=>>1>S>=> ^====J=======>==+=j=餜==,= =>=]^>>U>=>=>(=> =$C=%v=10>>$>(> ==\= =+=*>=*>^>>2==c>>U==>R~>>} =.>= <=0 >P=r,= =#=K>=yH=1=fZ>O>==JW=S=W6>d>\> => =+=|>\>4 >R=>&>]>>>[=>Y>=㜕>)> ->H=ڛ>=b=c=-E===H=Dd>t+= W>=3=-$==~%> 5=6=:>>=_> c=!=1=o==/>}=qp=j==J="=4&==>\>}>,>AN>=ǃ=f=U"=>2=^~>>=L==0>L>f>;=C===O>*>3=x=w>D=Z>*=:=e=ɾ>==C=&!> j=T>>:f=>= H=5h=h=0=h> ->\>N=Wa>v=X>P> -(> ;>إ==?=)>> _=P/>L==/]>j=hJ===t=R===#> ==q= ]>E]>gq>==o= > =D=|<== "=K====R=[,=W>$=G>&>>=H=><>x=/ =~>I>2== >>qZ=g> >}>2U>R=d> 9K=J>P=U>.=΋>B>==B==U>=d== ->w4===EI>e>}=Q=2=A>}=^=́>$><>4[>.>=>Զ> =K(=ɢ==R=>>(=>(n> ='=b> oi=.>=4==:==L=I=>+>Zc>>{>ٖ=ʣ==f>"=>=>G=i==H>q>.=$>= ='> Ў>> ->>DI=g>1=R\==(=x= >do=9=v0=%==>'>> hh===T=>>>b>E>#=G=Q>>t=>=>7(>>l?>==.=S=}O==M==j> ->'k> >'6>T=d=_==]>>*>1> 1=̦>#>d> {=&> =F=>=#= =>,=᠖=<=R=t6=1=7==F>f=====mm=ᨍ>K=y=&>.c==o=J<===b">=:=a>}>l=w=_=ע=o==ڞ==<=IM===F=޷2>K=d==>S4=>[!>>[==>\> =ؙ=K=>dW==솺>>|>>ž=W=G=݇=t='E=>>z> >=P>>>D=>>f==`=f>@>> >>O=>~G==7>==@>\> =؜==>ޘ=0==4==>p_=+}=߻=֑=f>l=`S=쎫=T==)==J=]{>V>/>޸=i4=%='t>$>)>> =>Q=]===ė>"r>Td=Y>=ޞ=Y==p=X==-== v>=숧==o=ʹ=[==,=#=޴= d=n=9z>=>$1> @ >..=0==_t=J=U==M=D>\>8>_== == =ls=#0>=ή>>k=6==>B>L>#==E=> > ==L>fQ>,=@> =)==y7= =,=뾊>i====>>$==7==$ =>=>ڳ> /=6=O> ) -> W>`>$>c=L=q>m=1d=\=>=>i="=3=P==P=o=V>> -'=a<>=봰=$>=6=o==%==}>>=>{H> >)_> >5=@=.>> H=n=g==>>>4>>=;=>\:> =h=쏐==A>1=)>>s=K3>>:>>91>>'H>6>[+==>N>=Ga>Q> -E> e=5> ==ޘ>0>A>=Z ==ZV=[e>>t >y>I>p6==D> ==>*>B=5=6=Й=O=C=>q>X=`>=}=>dw==7={=v= -=<>==f===>>=o>L>U> >H>a> >ڒ> >#=>u>;=䖈==D=8>>`=6>I=?=O=P4=ǰ== ==C=Œ===q>=ja>=[+=_=L=Y>GF>>g=>>z=/W=X=>>f>=X=>^=x== A=H>{Y>zB> > >=h=0=w ={+=f> >=od>3=԰v>=l= ="==>=+==>=ym=;=9==]L=v=-=V>=>"F=Q>%n>|=z"=y>\> >> > H>4>> -R==:>Qq>z,> )>>==}>=>=>P>R=N=;=v==3j==g=+>{=&w=I=b>ߣ=v =/=+>>1>v>=J==g=즡=p>d>=P==|=J=>=g>P>=>>)>/>=>K=^>@=jv=E>>>="=6n==LJ=-L=d==?=/"=> -*l>>K=\k>={>i=k>y>=:==d>I>  =V> C/> -ů> @>@>><>> >h{=2>9>%>$4>9,>i)>7=>8>>k=l> :3>,=o<=g>Y=]=H>x=}=f=>|===ݒ= =_=z>d>=IG>U>/r>p==L=?==d=c=gY==m=ާ==H>>Y>=:>>> >>=k==|==ߺl>=2 =+==yZ=5=wG=]=ܘ=K =O=v= Q=}=>#x>>=?r=>w=M>=*=D= =X>8P=Q=7>E>&>+>=>6>y#>>>O=|=`>KR=^>x> 0>>-F>=i=> b+>>>f>`>`>=iA>>?=>=> 6==z=>>>=>>ys=2q="Y= =)=>U=݅=ʯ=[===>>Y>>~> ='> h>G]= -==U=wt>(=> -AM> .> ,==?> >^>K4=1v=j=k=v> -=w=>IV=A=o,=/>!=X=> -Xn==>n=>\> 3==>> >2=vb=*>)>> >=>> ՠ>P+= >B> -q==>=>E =W=>=> -I>>7> -B>3_>e> >L=m>=d=)q=6= -7=K=%>C> >^>>=C=?> ===풼=>.>˾>lW>j> !=I>>>>>=/>=j=kH>>=+=="=Ѩ=o=h>2>=h=;=BY>2J>Ғ==}=p===D===I>>;>6>v>=/,>|1==Ho=ѷ=^> U=> j>=E>.=l>$>b=˼>d>h>0=#X>>Q=7P>t>>+>> DN>> >>ϙ>{> -> >"=x.==f<>N>`>ώ=_== T>O7>j>4>NG===X= a=+=I> &>=>V> => =3>,q/=>!=u=>>=m> f>X=\>v==9!==8=%=|*>>=>j=6=~>Q=b=l=I=e=W>oN==JF=)>>==ڜ>B>ځ=C = ==)_>n>Ğ>=B>0M=T>=>>a>\=> =A=>{=>`h> g>">c> 3>P>>=Wo>CaV>> 3>>u>|>=c>o>>>>>=ϳ>f> n> i= >;j==>">>>~>>=(> :>/=%H>qq>===T=!*==Y=0=m=p==[>>0]==>>?> 8>= =@=%>x/==C===H= =Ze>I> c>=.Y>>y=b=`>S;>=/=>V=x>='>3>b(=q=> =>E=#b=W====O>> -+k=>$>4> >ƞ==>`> ->؈>=>4=\>Ԟ=>>=P=+G==>=>.2=)E=ۄ$=ּ=4*>> -|> -B==e=|+==~=r>U=Yh>Q=lT=V=|=U8>>#=@E=9K==>{>gx> 3 =E==h==I>P=w=]k=M2==H=V=:e= ='>J> ?s>}>&N4>' > ==Ww=ѣ>>%U>^>>==J==m=L==S>>E>=z#=> ="i>X,> >h>E>h>>I=J==_>Ϣ>>/=> =>#=4=R'>>3P=> -L]> ?=y==Z>==K>==M=Δ> U=V> -@}>h>>J>U=>>>=0 =M)=A>>y=>_=*=9> eJ=a>C>8>B=!=f> +=?=NB=x4=P===f=î=t5=yL=>c$>@>> G:>Aڼ>E>x >Z== -> 3>:>J>==&%=9a>G>f=Q^=c=v>>O=o!>E>J&>{==> >>S> Y>u> > d>==<=> >4>A}=W>U>H>e>>=|> m>> >0ŭ>cXTENSION= 'IMAGE ' / Image extension BITPIX = 32 / array data type NAXIS = 2 / number of array dimensions NAXIS1 = 100 NAXIS2 = 100 PCOUNT = 0 / number of parameters GCOUNT = 1 / number of groups BSCALE = 1 BZERO = 2147483648 EXTNAME = 'DQ ' / extension name EXTVER = 1 / extension value WCSAXES = 2 / Number of coordinate axes CRPIX1 = -400.0 / Pixel coordinate of reference point CRPIX2 = -100.0 / Pixel coordinate of reference point CDELT1 = 1.0 / Coordinate increment at reference point CDELT2 = 1.0 / Coordinate increment at reference point CRVAL1 = 0.0 / Coordinate value at reference point CRVAL2 = 0.0 / Coordinate value at reference point LATPOLE = 90.0 / [deg] Native latitude of celestial pole WCSNAME = 'DEFAULTS' / Coordinate system title MJDREF = 0.0 / [d] MJD of fiducial time END @@@@@@@@@@@@@@@@T@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@XTENSION= 'IMAGE ' / Image extension BITPIX = -32 / array data type NAXIS = 2 / number of array dimensions NAXIS1 = 100 NAXIS2 = 100 PCOUNT = 0 / number of parameters GCOUNT = 1 / number of groups EXTNAME = 'AREA ' / extension name EXTVER = 1 / extension value WCSAXES = 2 / Number of coordinate axes CRPIX1 = -400.0 / Pixel coordinate of reference point CRPIX2 = -100.0 / Pixel coordinate of reference point CDELT1 = 1.0 / Coordinate increment at reference point CDELT2 = 1.0 / Coordinate increment at reference point CRVAL1 = 0.0 / Coordinate value at reference point CRVAL2 = 0.0 / Coordinate value at reference point LATPOLE = 90.0 / [deg] Native latitude of celestial pole WCSNAME = 'DEFAULTS' / Coordinate system title MJDREF = 0.0 / [d] MJD of fiducial time END ??? -?????????????????????z?t?o?j?e?`?[?V?R?M?I?E?A?=?:?6?3?/?,?)?&?#?!?????????? ? ? ? -? ??????????? ? ? -? ? ???????????!?$?'?*?-?0?3?7?:?>?B?F?J?N?S???????????{?t?m?f?`?Y?R?L?F?@?:?4?.?)?#????? -???????????????????????????????????????????????????????????????????????y?q?h?`?X?P?H?A?9?2?+?#???????????????????????????????????~?{?y?w?u?s?q?p?n?m?l?k?j?i?i?h?h?g?g?g?g?h?h?h?i?j?k?l?m?n?p?q?s?u?w?y?{?}?????????????????(? ?????~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~}?~x?~s?~o?~j?~e?~a?~\?~X?~T?~P?~L?~I?~E?~B?~??~;?~8?~6?~3?~0?~.?~+?~)?~'?~%?~#?~"?~ ?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~?~ ?~"?~#?~%?~'?~)?~+?~.?~0?~3?~6?~8?~;?~??~B?~E?~I?~L?~P?~T?~X?~\?~`?~e?}?}?}?}?}?}?}?}?}?}?}?}?}|?}u?}n?}h?}a?}[?}U?}O?}I?}C?}=?}8?}3?}-?}(?}#?}?}?}?}?} ?}?}?}?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?|?}?}?} ?} ?}?}?|?|?|w?|o?|g?|_?|W?|O?|H?|A?|9?|2?|+?|%?|?|?|?| ?|?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{~?{}?{|?{{?{z?{z?{y?{y?{y?{y?{y?{y?{y?{z?{z?{{?{|?{}?{~?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{?{7?{/?{'?{?{?{?{?z?z?z?z?z?z?z?z?z?z?z?z?z?z?z?z?z?z?z?z?z?z?zz?zv?zq?zm?zi?ze?za?z]?zZ?zV?zS?zP?zL?zI?zG?zD?zA?z??z=?z:?z8?z7?z5?z3?z2?z0?z/?z.?z-?z,?z+?z+?z*?z*?z*?z*?z*?z*?z*?z+?z+?z,?z-?z.?z/?z0?z1?z3?z4?z6?z8?z:?z?zA?zC?zF?zI?zL?zO?zR?zU?zX?z\?z`?zc?zg?zk?zp?zt?zx?y?y?y?y?y?y?y?y?y?y?y?y?y?y?y~?yx?yq?yk?ye?y_?yY?yS?yN?yH?yC?y>?y9?y4?y/?y+?y&?y"?y?y?y?y?y?y -?y?y?y?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?x?y?y?y?y -?y ?y?y?y?y?y!?y%?y*?x?x?x?x?xw?xo?xg?x`?xX?xQ?xJ?xC?x?v>?v=?v=?v=?v=?v=?v>?v>?v??v@?vA?vB?vC?vD?vE?vG?vH?vJ?vL?vN?vP?vS?vU?vX?vZ?v]?v`?vc?vf?vj?vm?vq?vu?vx?v|?v?v?v?v?u?u?u?u?u?u?u?u?u?u?u?u?u?u?u?u?u?u}?uw?uq?uk?ue?u`?u[?uU?uP?uK?uF?uB?u=?u8?u4?u0?u,?u(?u$?u ?u?u?u?u?u?u ?u -?u?u?u?u?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?t?u?u?u?u?u -?u ?u?u?u?u?u?u?u#?u&?u*?u.?u2?u7?u;?u@?t?t?t?t?t?t?ty?tq?tj?tc?t\?tU?tN?tG?tA?t:?t4?t.?t(?t"?t?t?t?t ?t?t?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?s?sY?sQ?sI?sA?s9?s1?s*?s"?s?s?s ?s?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r?r|?ry?rv?rs?rp?rm?rk?rh?rf?rd?rb?r`?r^?r\?r[?rY?rX?rW?rV?rU?rT?rS?rS?rR?rR?rR?rR?rR?rS?rS?rS?rT?rU?rV?rW?rX?rY?r[?r\?r^?r`?rb?rd?rf?ri?rk?rn?rp?rs?rv?ry?r}?r?r?r?r?r?r?r?r?r?r?r -?r?q?q?q?q?q?q?q?q?q?q?q?q?q?q?q?q?q?q?q~?qy?qs?qn?qi?qd?q_?qZ?qU?qQ?qL?qH?qD?q@?q?mA?mD?mG?mK?mN?mR?mV?mZ?m^?mb?mf?mk?mo?l?l?l?l?l?l?l?l?l?l?l?l}?lv?lp?li?lc?l]?lW?lQ?lK?lE?l@?l:?l5?l0?l+?l&?l!?l?l?l?l?l ?l?l?l?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?k?l?l?l ?l ?l?l?l?l?l"?k?ky?kq?ki?kb?kZ?kS?kK?kD?k=?k6?k/?k(?k"?k?k?k?k ?k?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j?j4?j+?j#?j?j?j ?j?i?i?i?i?i?i?i?i?i?i?i?i?i?i?i?i?i?i?i?i?i?i?i}?iy?it?ip?il?ii?ie?ia?i^?i[?iX?iU?iR?iO?iL?iJ?iH?iE?iC?iA?i??i>?i?i??iA?iC?iE?iG?iJ?iL?iO?iQ?iT?iW?iZ?i]?ia?id?ih?il?io?is?ix?i|?i?i?i?h?h?h?h?h?h?h?h?h?h?h?h?h?h?h?hz?hs?hm?hg?hb?h\?hW?hQ?hL?hG?hB?h=?h8?h4?h0?h+?h'?h#?h?h?h?h?h?h?h -?h?h?h?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?g?h?h?h?h ?h?h?h?h?h?h?h#?h'?h+?h/?h4?h8?h=?g?g?g?g?gx?gq?gi?gb?g[?gT?gM?gF?g??g9?g2?g,?g&?g ?g?g?g?g ?g?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?f?fJ?fB?f:?f2?f+?f#?f?f?f ?f?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e?e~?ez?ew?et?ep?em?ek?eh?ee?ec?ea?e^?e\?e[?eY?eW?eV?eT?eS?eR?eQ?eP?eO?eO?eN?eN?eN?eM?eM?eN?eN?eN?eO?eP?eP?eQ?eR?eT?eU?eV?eX?eZ?e\?e^?e`?eb?ed?eg?ei?el?eo?er?eu?ex?e|?e?e?e?e?e?e?e?e?e?e?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?dz?dt?do?dj?de?d`?d[?dV?dQ?dM?dH?dD?d@?d?`?\=?\?\??\@?\A?\C?\D?\F?\H?\J?\L?\N?\Q?\S?\V?\X?\[?\^?\a?\e?\h?\k?\o?\s?\w?\{?\?\?\?\?\?\?[?[?[?[?[?[?[?[?[?[?[?[?[?[?[?[z?[t?[n?[i?[c?[^?[X?[S?[N?[I?[D?[@?[;?[7?[3?[.?[*?['?[#?[?[?[?[?[?[?[ ?[ -?[?[?[?[?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?Z?[?[?[?[?[ ?[ ?[?[?[?[?[?[!?[$?[(?[,?[0?[4?[8?[=?[A?[F?[K?Z?Z?Z?Z?Zy?Zq?Zj?Zc?Z\?ZU?ZN?ZH?ZA?Z;?Z4?Z.?Z(?Z#?Z?Z?Z?Z ?Z?Z?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Y?Z?YL?YD?Y?S@?SB?SE?SG?SJ?SL?SO?SR?SU?SY?S\?S_?Sc?Sg?Sk?So?Ss?Sw?S{?S?S?S?S?R?R?R?R?R?R?R?R?R?R?R?R?R}?Rw?Rq?Rk?Re?R_?RZ?RT?RO?RJ?RE?R@?R;?R6?R2?R-?R)?R%?R!?R?R?R?R?R?R ?R?R?R?R?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?Q?R?R?R?R ?R ?R?R?R?R?R?R!?R%?R)?R-?R2?R6?R;?R??RD?Q?Q?Qx?Qq?Qi?Qb?Q[?QT?QM?QF?Q??Q9?Q3?Q,?Q&?Q ?Q?Q?Q?Q -?Q?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P?P=?P5?P-?P&?P?P?P?P ?P?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O?O}?Oz?Ow?Ot?Oq?On?Ok?Oi?Of?Od?Ob?O`?O^?O\?O[?OY?OX?OV?OU?OT?OS?OS?OR?OR?OQ?OQ?OQ?OQ?OQ?OQ?OR?OR?OS?OT?OU?OV?OW?OY?OZ?O\?O]?O_?Oa?Oc?Oe?Oh?Oj?Om?Op?Or?Ou?Oy?O|?O?O?O?O?O?O?O?O?O?O?O?O?O?N?N?N?N?N?N?N?N?N?N?N?N?N?N?N?N?N?N?Nz?Nu?Np?Nj?Ne?Na?N\?NW?NS?NN?NJ?NF?NB?N>?N:?N7?N3?N0?N-?N*?N'?N$?N!?N?N?N?N?N?N?N?N?N?N?N ?N ?N ?N -?N ?N?N?N?N?N?N?N?N?N?N ?N -?N -?N ?N ?N?N?N?N?N?N?N?N?N?N?N!?N$?N&?N)?N,?N/?N3?N6?N9?N=?NA?NE?NI?NM?NQ?NV?NZ?N_?Nd?Ni?M?M?M?M?M?M?M{?Mt?Mm?Mg?M`?MZ?MS?MM?MG?MA?M;?M6?M0?M+?M&?M ?M?M?M?M ?M ?M?M?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?L?M?M?M?M ?M?M?M?M ?L^?LV?LN?LG?L??L8?L1?L*?L#?L?L?L?L ?L?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K~?K|?K{?Kz?Ky?Kx?Kw?Kv?Kv?Ku?Ku?Ku?Ku?Ku?Ku?Ku?Kv?Kv?Kw?Kx?Ky?Kz?K{?K}?K~?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K?K ?K?J?J?J?J?J?J?J?J?J?J?J?J?J?J?J?J?J?J?J?J?J?J~?Jz?Ju?Jq?Jm?Ji?Je?Ja?J]?JZ?JV?JS?JP?JM?JJ?JG?JE?JB?J@?J=?J;?J9?J8?J6?J4?J3?J2?J0?J/?J.?J.?J-?J,?J,?J,?J+?J+?J,?J,?J,?J-?J-?J.?J/?J0?J1?J2?J4?J5?J7?J9?J:?J?I9?I5?I0?I,?I'?I#?I?I?I?I?I?I ?I -?I?I?I?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?H?I?I?I?I ?I ?I?I?I?I?I?I"?I&?I*?I.?I3?I7?I?D9?D3?D.?D(?D#?D?D?D?D?D ?D?D?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?C?D?D?D -?D?D?D?D?D!?D&?CZ?CR?CK?CD?C?A?A@?AC?AE?AG?AJ?AL?AO?AR?AU?AX?A[?A_?Ab?Af?Aj?An?Ar?Av?Az?A?A?A?A?A?A?@?@?@?@?@?@?@?@?@?@?@?@|?@v?@p?@j?@d?@_?@Y?@T?@N?@I?@D?@@?@;?@6?@2?@.?@)?@%?@!?@?@?@?@?@?@ ?@ -?@?@?@?????????????????????????????????????????????????????????????????????????????@?@?@?@?@ -?@?@?@?@?@?@?@"?@&?@*?@/?@3?@7?@?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?>?????>7?>0?>(?>!?>?>?> ?>?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=}?=z?=w?=t?=q?=o?=m?=j?=h?=f?=e?=c?=a?=`?=_?=]?=\?=[?=[?=Z?=Y?=Y?=Y?=Y?=Y?=Y?=Y?=Y?=Z?=[?=[?=\?=]?=^?=`?=a?=c?=d?=f?=h?=j?=l?=n?=q?=s?=v?=y?=|?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=?=??<;?<8?<5?<2???;9?;3?;.?;)?;$?;?;?;?;?;?; ?;?;?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?:?;?;?; -?;?;?;?;?; ?;%?;*?;/?;4?:_?:W?:P?:I?:A?::?:4?:-?:&?: ?:?:?: ?:?:?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?9?8?8?8?8?8?8?8?8?8?8?8?8?8?8?8?8?8?8?8?8?8?8?8?8z?8w?8s?8o?8k?8h?8e?8b?8^?8\?8Y?8V?8T?8Q?8O?8M?8K?8I?8G?8E?8D?8C?8A?8@?8??8>?8>?8=?8=?8?8>?8??8@?8A?8B?8D?8E?8G?8H?8J?8L?8N?8Q?8S?8U?8X?8[?8^?8a?8d?8g?8j?8n?8r?8u?8y?8}?8?8?8?8?8?8?8?8?8?7?7?7?7?7?7?7?7?7?7?7?7?7~?7x?7r?7m?7g?7b?7]?7X?7S?7N?7I?7D?7@?7?.:?.7?.3?.0?.-?.*?.'?.%?."?. ?.?.?.?.?.?.?.?.?.?.?.?. ?. ?. ?. ?. ?. -?. -?. -?. ?. ?. ?. ?. ?. ?.?.?.?.?.?.?.?.?.?.?.?.!?.$?.&?.)?.,?./?.2?.5?.8?.?*=?*=?*?*??*@?*A?*B?*D?*E?*G?*I?*K?*M?*O?*R?*T?*W?*Z?*]?*`?*c?*f?*j?*m?*q?*u?*x?*|?*?*?*?*?*?*?*?*?*?*?)?)?)?)?)?)?)?)?)?)?)?)|?)v?)q?)k?)f?)`?)[?)V?)Q?)L?)H?)C?)??):?)6?)2?).?)*?)'?)#?) ?)?)?)?)?)?)?) ?) -?)?)?)?)?)?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?(?)?)?)?)?)?) ?) ?)?)?)?)?)?)?)!?)%?)(?),?)0?)4?)8?)?$C?$H?$M?$R?$W?#j?#c?#\?#U?#N?#G?#A?#:?#4?#.?#(?#"?#?#?#?# ?#?#?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?"?#?# ?#?#?"%?"?"?"?" ?"?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!}?!z?!w?!t?!r?!o?!m?!k?!i?!g?!e?!d?!b?!a?!`?!_?!^?!]?!\?!\?![?![?![?![?![?![?![?!\?!\?!]?!^?!_?!`?!a?!b?!d?!e?!g?!i?!k?!m?!o?!r?!t?!w?!y?!|?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? }? x? s? n? j? e? a? \? X? T? P? L? I? E? B? ?? ;? 8? 5? 3? 0? .? +? )? '? %? #? !? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? "? #? %? '? )? ,? .? 0? 3? 6? 9? ?9?4?/?*?%?!????? ??????????????????????????????????????????????????????????? ? ?????"?&?+?0?5?:???D?J?W?P?I?B??9?3?.?)?$????? ? ????????????????????????????????????????????????????????????????? ????? ?$?)?/?4?9???G?@?9?2?,?%???? ???????????????????????????????????????????????????????????????????????????????????????????????????????????????????~?z?v?r?o?k?h?e?b?_?\?Z?W?U?R?P?N?L?K?I?H?F?E?D?C?B?A?A?@?@?@?@?@?@?@?A?A?B?C?D?E?F?G?I?J?L?N?P?R?T?V?Y?[?^?a?d?g?j?n?q?u?x?|?????????????????????????z?t?n?i?d?_?Z?U?P?L?G?C???;?7?3?/?,?(?%?"???????? ? ? -???????????????????????????? ? ? ????????"?%?(?+?/?2?6?:?>?B?F?K?O?T?Y?^?c?h?m?r?x?}?u?o?h?a?[?T?N?H?B??@?A?C?E?G?I?K?N?P?S?V?Y?\?_?b?e?i?m?p?t?x?}?????????????????????y?s?n?h?c?^?X?S?N?J?E?A?? ??@?C?F?H?K?N?R?U?X?\?`?c?g?k?p?t?x?}?????????XTENSION= 'IMAGE ' / Image extension BITPIX = -32 / array data type NAXIS = 2 / number of array dimensions NAXIS1 = 100 NAXIS2 = 100 PCOUNT = 0 / number of parameters GCOUNT = 1 / number of groups EXTNAME = 'VAR_POISSON' / extension name EXTVER = 1 / extension value WCSAXES = 2 / Number of coordinate axes CRPIX1 = -400.0 / Pixel coordinate of reference point CRPIX2 = -100.0 / Pixel coordinate of reference point CDELT1 = 1.0 / Coordinate increment at reference point CDELT2 = 1.0 / Coordinate increment at reference point CRVAL1 = 0.0 / Coordinate value at reference point CRVAL2 = 0.0 / Coordinate value at reference point LATPOLE = 90.0 / [deg] Native latitude of celestial pole WCSNAME = 'DEFAULTS' / Coordinate system title MJDREF = 0.0 / [d] MJD of fiducial time END ;D[';;3H<D%<B -;"a;N;΍;N;w ;l; ;jM;;;G^;i;Pe;os<;_;q;i;\;;ˠ;aO;;{;@;gg;-3;; ;W; ;*;b;;;A^:+;H;c;;Xo;;;;#[;.;7;|G;jgU;LN;U ;.;"L;};k;;n;;Iݙ;&;*;-;Y;=A;Z;r;;DD;5;;;;T;V;2;x;{;;;T;<g<?;< -&;<><;5S<7f<=v=0<W;<4;;;b-;.;D<%5;i;ޭ;4 -;(;_;;Ż;;V;[;< r<!k;<9Z;;?;O;6;ƚ;;ԭy;;#;w ;9#;;3;L\;8;;;u;w;r6;{;s;;g;;ƒ;l);wV;D;A;/$;E;/8;P;Q_;;;i;m;4;g;k; -< c;<6<,<.<m;`:;tF;xW;t;E;ނ;xW;;Fg;s;0;U;< ;xn;;֙;i<d<-(<^OEݺ;;O;;;; ;5;;{;;*;/Ǚ;R;c;X;];k;"|;;M;;m;H<"G;[;#;R`;;K;;;;;[;;;n;sb8;;;y;ۦc;UO;;;Q;;7;h9;u;fX;B;!;;Ԅq;F;:q;<;ʗ;E";$;־_;([;;($n< < -E<=<*6;H;^;ȣ;ywq;};tP;~Yb;;x);;~;;e;"|;<uN;6;h;/%;?n;t;DN;;=;k<<%<4R?24>͒<z;;>C;xx;w;;u;.Ek>g3<%o:e$;;;<;;37<; < @;o;i; ;d;;t R; ;X;< k<o;;f;; ;K;(\;;4<<7V;;;H;;I<%<<*;?;{<%<:=0<+=f >b== a< <;ǡ<x;Z;o;t;|@K;[q;;&;CV;Gz;;#z;T;}c;'W;q;G -i? >G=Ƽ<~ -2h;;T9;;;g_r;U;T;<;Ox;;;a;{>;d;L ;"8-;;"a;; z;n;PQ< ;;;O;g;L ;J;e';:;I(;X;F;(;Y4.;zH;ô;tJ;;Ã;x;a;);;u;(;;W;b˩;0;"; -&;D;;;6?;$;;D;; ;8(;Ap;Op;)v;l;^;4;;J; ;9<&<I??>N=8<>;2;;8q;;;3;#;_; \;k;K;;׽7;Fm;;;T`;ˏ;;Ĕ;e;;C<F<;D;=;ч<;(;;Y;;ВQ;;3;o;q ;Ռ;Z;$;r<;<(<3^<I/;,d;j~;;֩;;;;r;lr;o;;;A;ʧ8<;;);d;;;;;|;U;E;;Q;^f;{<dQx>k=i;;;e;U;7MT;|;BP;.;Y;M:;<;K;D;3E;b;vœ;W&;{ ;cA;;P7;;i;(l;QF ;Zr;;ǁq;w};T;;\;W;{{;x3;;[/;/; ;g;yl;% ;7;N;Т>1u>S;se;Z;*~l;5;e;;v|;{;+;ZY;;$;يi;M;';";;<;;;;J;n;v;p;ʶ4;N;O,;؝{;);P;~;U;^;|J;h;Ձx;U';Fp;;E<:.<k>:>&.p;|i; ;;q;;-;H;uL;C;С;C;7q;8;;;0Ī;;;?;Z<;?;G{;m5;ݵ;EǪ;);<;#;;;~;Ő;;o;٨;;f;'$;;/;3;<&)x;;‘;E<x;;];;F; ;`;t1;];;c;ә<b;;;|]< Ⱦ;S;[.;;";S;>;ʗ;U;;ޜ<&n<A;jZ;;q;n0;;Ƞ; ;?w;ʬ;c;;_ ;ҩ;ʨ;;)-;)`;;ь<T<E;;];~;rU;;q; -;R;B)b;a;1;@;;h;Mt;A;U;x;;S;>;<0<;];;|);E|p;D;j;Aq<"o;諳<_W<(<_<;V;,;G;;};;ir< -<#D;";;;.;Ϳ;0;;*;";;;x>;S&;;p;;׊;G8;r;.;;WD<><;壞<<-g;g4;OE7;;W;@ ;3;a;`;ޟ;; -;W;yR;;d\;];I<AZ;< 1<;Z; ;;u;<;;";<%ԙ<<;S.;.;΀z<-;y>;ѝ;";?;qa;.;u ;3;qD|;@;"N3;Y;;Y1;C;epH;{_;5 ;;Jl;;u9;];,;xq;,;{;U ;Ϗ;;.8;WS;^;Į;R;j;͆;ē< >_< B<š;;;Zax;^5;<; ;;Hi;N;f;; 0;Ya;;Ĝ;F~;-;pf;;V'~;`;^;s;;-;Q;dV;;;T;J;*;t{;*;;;8;c ;ƥ;܆;;1;;jX;a;rp;m.;T;Ђ;m;;;d;;);k;;j<<`'V;D<(7;]< -<;W;;. ;D;;=O;z+;;B;;F -;;;];:;;;D;ٙ;;;jx<8;/;u;^<;N;"6;;l;c;L;;5%;;m;";;p:;2b;c;!;;^;;C;;.;ĉy;Jb;̶X<< -U; -;33;ra;O;1;݄;;l;؈;f;~@;G_;c;h;@;y>YG?=3>6=5=+=,<<I<$@?R?i_?>TW|=1=n<˯???>E-=h=y;q?<@> -=5"==j&1<)<2L;<< << ;'0;n';e;3;a?; \;;<<+;";x;П#;;};eH;|;=;;Ī;;]q;I;Yr;dP;w;v;xx;;^;;M;;;;;ȤS;pu<v;T;cH;`;-;m};t_;/(;@S;!;;^t ;p<E@;~z;)_;@<y^;8;l;@;b;#SY;;V;AC;;Ih;(;M;;έ;,;_;ј;C;>g;&;<y<s?<` <9;lmF;t;hY;qΪ;;Q;;'<3;q;R;\h;<$@;ʮ ;UIa;h@7;~mo;2;T;Ip;#&;cz;r4;Y;{;IFI;=;-;r9g;O(;$;1;fHW;T;|;i;5;#n;u4;L;;a';;;j;f<;|;;*;7;2 ;ʙ ;v;; ;W;c;;;N;Z;d[<j;;;<p;< ;3;L[; -K;j< -<F;Y;hH<$&^;';&;ݘ;V;;N;;;ƒ<=R<&$|;_;͏x;Y:;};;ɥ;@;Ό4;D; P;3;뱒<2;gg;;;3;|<6;g;$.;u;B;;c;U;a;;P;n;;>;;;Ej;;v;;@;ޮz;;s';5;A;+;ȴ;;;ϼ;;p;S;;8H;;;;;x;ء(;;;ȅZ;;lR;z;?; X ;*;py;v; ;n:a;Yy;W;~:0;%;;̈<۸;L;|;d;^;x;Ս;E;N;e;;Y};{;ZZ;tP;;m;;I;F;;P;5;a;֡<3s; ;W< $;ʻ;=;k;;a;غ;:1;g48;hYf;#;O;);;w;m;M;>;FX;3;D;[`G;n;I;};}_;Ȧ;Z;;9;<%R;=;<;d;;l<.<p<1hF< n< <"A<;`;m<l ;5;L;;4;s;;6;D;լ;ȱ;|;ߑ;;7;rv;KJ;,>;v;fٟ;[;t;j;;s;ؙ;<& <";< s;+;Q;;o;qA;C?;f;c;;J;i;h;;(;WQ;l,;eB;j;;;Wy";dI;;;ͷ<;Xm;<;y;;U;m;<R< ;;;+8>T=O@<Z;;,h;K;;C;:v;=;O;;Ќ;2?;j>T; ;Z<<*<};:;1&;R;z7;;F;6 ;ˋ;b;qW;; 5;hs;YM;wB;!;;@;O;v4;*_;‘ ;1(;A;V&];@N ;ia;uK;&;5;;M<#>>$911`>C=<*E>@?=<+;$2;J:9\z;;;q̲;H; ;J:% ;%?;;;G;*;v`;T;~;O;;=;մo:h;t;;o&;(;h;1;;;TS;˚;º;U;{.;;IH;|^;M;_;;;{;˃h;;;F;<t<?>i=I;W;;<;';w;;W;;x;ɔO;XI;*;y;;;l;f;u;;;;;D(;;;b;";g;Z~u;a*v;!;;<a;~;zvM!>7=N<_pp;j;-;Tw; $;D;O;$;;; ;v;o";M";<< -E>`P="O;r;yo7;Y#;;l;;;G<#(<.;.;j;i=;[J;;r;ֱ;^l;6;::;O;plC;;nz;TE;W;c;c0;C;m<,<99;B< ;K;:;*;;݈`;p7;LQ<; ==0nW; ';;!< N|;=2;]u;h;(; ;@;;};2l;ս;;xO;;9;U;:;;BB;x<-=;hQ;;L;r;;E;J;t;\;;ԝ;;}G;,;!><3C;<;2;;k;^;=;hs;;t;ߨ;K;ٞ{;;5;t;j;Ӊ(;x>;z;y;;ۿvNK=e;;ĕ);;;p;9 ;;;r);T7C;;o;;pR;z#<1';$; ;.0; -;(;;;|;F;;x;;x;@^;;^;;!;ϸ;X;8;h=;W;՟;Ri;Ҩ3;;;;;@;fO%;4;; ;Q2;;8;;;c;;T;<3<&< ]; ;R?;<pI<;;;KE<W<#<+8<;;;;c;; ;߀;%;lf;/;J;;z;;;ֳ;;pK;,;Z<;y;;;7; ;ܤ;ZD; -0;V;t;aQ;A;;N;o;[;ЦP;Ծ;`=;;k<;;#;k;B;;;ZYDaM>O< <.;e;;?<&<A;;;ީ;Kx;7;,f;@;tt;U;v;;;ѱ;Q;<$;ϙ;g;?-;&;;;qe;;;;l;}?;f_;ɞn;;WS;;N< D;N< x;b;0;p-;V;_;|;_;F;6;rA;K;;n;;7;P;KM;;@;S;3q;R;c;F; o;^o;f;;d;,;5;ph;IL;3;5;#;¼;I;q;< };d;9B;$;W< X;f6;;;~;!r;;ɒ5;<;;g<Y<(;JL;;;;-<.l</;;|A; ;;;;o;af;=<F;n<<Ѕ>b;ɷ;ZRm;m8;[C; <pD;¨;<15$;~;;;;Q;;;;U;@;;;;H;k0;_;s;D;;;y};;a5;:;;1(;f;;Sw;K;D;:%U;r;V; u;I|;;;R;z"~; ;P;;m#;h;2<<'3<% y<; -;#;#a<;/;R;q<;Yrc;v:ֵ;ŝ;;.;yD;SI< ~;(4<s< ;X<)<@;;韲;u_h;$i;I<1<4;yu<Ψ= -!w m;;*=~;;A<+;;sm;;5;;_;;W;6v;M̻;a;`1;;׋1;K(;9;}XB;;;d;;S ;l;<;j;;;n;Gm;V>n;F;r&;?;@:ب;*;;E;:;5:):X;4;wET;y;;`;{C;-;<%$;ѡ;Lt;};;=,<4<=<_D;[;BB;M;;N;1;|;F;;j;<;@ -;F<Z<S<;;v;h -;<";J|N;};;];z;1;D~;V_;k\<5<6K<^#> ^>s7>%ڍ=4<7`> =0<<P;;:?;$;0; gj<Sc;k;&;c;;C?;7;);nn>;_% ;x,;q ;H;;$;㐈;;<9S{/L>=!<2ͤ;-;;`;m;8^;MP;?3;@;YC;[;f;;M;̋;?m;";nj;';h;;;~n;K;t;xI;~;׈;|;;َ;aD;v;vn\;p-;";^.-;';;ʒ`;;D;? ;AV;;;Ǻ_;;;ʥ;d`;ƺ;Y{;hh;6!;; s;4;z*;l;%;l;}0d;2F;Ge{;; M;E;}<8;F\;,6; D5;T:O;N ;q$;h4;Lm;W&M;:o;?;*;;};9;;w&; X;-:^;D(;x;)|9; ;;);:;T;z+;&;S;q;q:ֲA;A[;h,#;u:&;q};$;ݙf;6;˛;ʕ;;ͬ;_;b;w;dp;h;P;r0;s;?;;D8:a;ys;l;t;(;p;K14;n;^;;ƙ;٘;;;;[;x;{E;SE;;i;;o;;%K;БF;);Ͷ~;z;K7;;k;;;T<;*;8{;Q4;;`;f<#;c;;[b;j@;<(ȫ;w;4`;;;W4;3;'<$a;ĉ;r<<?;͍=<;!;y-;f0;UJ;;B,;1x;+; [;.T;м;7\;^;he;l;Lc;o;#;0 -;|5;;N;A5;;;;_t;;;1l;E;$;`d;;};R;c#;u;n9;; ;5L;; ,;%q;w(;;;pl;t};ǭ,;|< Y;;8;;u;r;*;T;1m -;;[;Ɔ;;v:;};s;s;Xz;Hl;o<;`;C;n;; };g:;n;m;;;n;O0;}Q\;l;~W;;Q<$<;e;;V;;x|;Qe;;b;;4;V; ;};;";;;w׌;q<<hV;<;M!;};r;Y;4!;(;9N;Τ9;~1;Xyg;Ê;HS;;|_;;U:Az;2;b;e;Zo;;k;~;޽; -<;q;Yr;n;ID;;y;QD;;o;5_;dD;;h;;q;V%;d;;q;l;ߍ;[';zB;T(;<;];;!f;W;W5;;2;Z@;;R;;;;ݟ;}e;E ;;e;PɌ;;a;v;y;a;;M;l;i;;J;֧;&m;iR;[; ;;;N?s;̟;;~;Vz;m;Ra;*d< g>; -;<s<3;*Q;;Ǽ;Z;&r;;̀o;;]=\<`<4<`Vz;آ;^T;x;;v.;{9;< < -;a;ѝ;c#<j;9v<[; ;b;;`;T;$;(;a؋;c;.Rb;{vA;>m;`;w;f;;*$;c,q;#;;S;f;;K;j;;nK;;C;֍;3;;c;[;< -<{; 7;u;d;t0;e;~#;@2;-;;j;;sZU;(5;;я ;;`;d;qS;];ˢ;;i;Js;1;;P;nk;{;;;8;";Z;\z;6 -x;w;&;2;;;h;$;;';<V;K;ǒ;Ȇ;*;6;;;ɀ<Bw< *B<;;Z)< -'D;Ħ;X;&;3F;h;f];B;V;U;Ry;֙;D\3;_;:lq=;Ħ;N;Lnj;;c;2;;1;e;>;<;w,;;w;;=3;Ty;F;Xp;;|,;%a; <;1;F;6ӓ;,;F;YM ;+<D;;F;^;GSI;ak;=,9;@;`;;S;x;;+;Q;XS;@_;t;19;)j;K;%:X;A+;;3;ͣ;3;[;;0g;;;ͯ; ;$;|Q;[;';;;Ó;z";L;;$;l^+;G3g;;;פ&;;[;k;R;'+;転;Ћ1;^;W;;;d;w];;9~;;m|e;4;^;Q/;W`;;\;&;ru;;k\y;Ò;;';;';mB2;裼; -;< :0;r|;;ԅ;a;<4;;ɞ;h;ߐ;\};l8G;B<N;;;1;k;q;;C;^;$ ;;B;-;;]R;o|;M ;^;N;D;;m/;B;4;;< 8;֢<$;^;;,;;;<;;;z0;; -;ģ;';|;;';;}>;;ў;.;s;;`n;{;5<;8;,;G;-;;-;m ;;;Q-l; ʓ;F;;s%p;;ƨ<};; ;;% ;j;"p;lj;; ;;;X;;9< X;V?y;{u;Y;V [;R;ѓ;;W; ;`mY;zE;,;P;h;3`;~;ƈ;g[;93;;u;O;fz; ;O;oD;V;~;`z;o;R@;;;/;;*;T;ҫ<;;;;;;;;;;:;e;_Q;3;Hw;W;q;;3;a;AH;;;z;w/;2;d;;լ;7;;;;:;I;o; ;};P<;p;X;;Т;;`;c;s;;;v;/';.;Z<;h;o;;;;&;ͣv;a;;{;E*;?;i;~4;;;w;b:A;@t;;;; ;xa;;w;Z;S;*;m;2;M;IU;a;x;O ;Ds;ˡ;u;;;;Ѝ;nr;Z<ZU;;Ȧ;T;3;w;;&%;`<}R;];C:\~;׆;1;;s;;u-;Ja;;b;K;a;em;R;+;U;;v;7;;j;g;;΍;jL|;@I;=;8;\L;{;;;ɖ;<;ދ;=;;;c;w;|9;];;LK<^;9;e;;l; 9;;z;l;);<(;;;vB;p[;2;;R ;z;+<&H;#;;r;;o9;ZG; ;;2;S;̀;)< << ;;S;;;l;X;;;㺽;e;@;;y;8;Y;;~;;>;;eE;B5;eo;v;!;w;u; ';Є;;;꿄;;w;0;;H;;;; ; -J;Vd;;X=;x;v;M<y;;;z;Ŵ+;l;=;&;(d;;;;};;/;Ž; \;;,;U;o;;; ;ݒ;Y;M;L;;F<;H;B;|R;佱;޴;;y/;i;\;5; 2;g`;0);;;Z;;&;";|qy;+a;B;y;;^:Q;V;^ ;?m;c;;FT;ą;x;b;S;e;s;M;gS;D;;3M^;L;o;J+V;V; /';y -;N|;V ;u;$; ;0;cj;|;;;G֗;c];x^;Ul;k-r;G;V;*);;;;;];Sc;">;;;s;[f;=a;;O;н;ݷ;@; ;ߔ;u;r;;I;'<E|<k;;q;C;9;=;;;˱;m;;<;;Q;F;l;H;;;-;;ϨXTENSION= 'IMAGE ' / Image extension BITPIX = -32 / array data type NAXIS = 2 / number of array dimensions NAXIS1 = 100 NAXIS2 = 100 PCOUNT = 0 / number of parameters GCOUNT = 1 / number of groups EXTNAME = 'VAR_RNOISE' / extension name EXTVER = 1 / extension value WCSAXES = 2 / Number of coordinate axes CRPIX1 = -400.0 / Pixel coordinate of reference point CRPIX2 = -100.0 / Pixel coordinate of reference point CDELT1 = 1.0 / Coordinate increment at reference point CDELT2 = 1.0 / Coordinate increment at reference point CRVAL1 = 0.0 / Coordinate value at reference point CRVAL2 = 0.0 / Coordinate value at reference point LATPOLE = 90.0 / [deg] Native latitude of celestial pole WCSNAME = 'DEFAULTS' / Coordinate system title MJDREF = 0.0 / [d] MJD of fiducial time END <Q<8W<-<<V<1<?<%"<t<}<$2< T<]<<<<c<3\<+*< -<3<<<0< ߷;< 0<0 c!<< <<L<#@<]<s -< "< 3< G<<N<<-+i<`e<7-<<b< -T< < l<%'<ik<9=<*<*<<<;p<5 <<<Ps< -a< -<< cX< n<$/g< v<t<<O<35<;<*><RL<Es<4<$ <,#s< -<:<+m<%Fg<<<S<U<*<+<h]<T<<&<4< T<@<<(|<<w<pZ<4<5< <{<.<<Z><"/<4;<&;E<<<Q<b<+YH< < -$<<4< < k<#I<P<<7<P<d<<< &}<.<]#<<c<< < <<6Y;<<<+x8<% <<pA<V<Ț<3< -.<<<7<<<<;<w%<6<' <$<8:< <+ < <m<.<'< <<E<7/<<&9<;<._<<H<$<'9<'<*S<^H< O<B<=r<.<+?<=\<0<@C <"<O< "<i<F]< -D<e<<<] <<&<Ux<0\<Z<<5<C<d< <z<T<R<S<}<:<<&<1<y<P<wD<v< S<r<5b0<u<0u<# <˳<'x<"1<<=<)<->< <"<(Š<*UV<0n<<z<<4'<h<2w<<\<<:m<( < ե<z<(u<!<`<7<<<< S1<j<%<<6<;<I<jc<.M<J<p <Y<<:<8<&<,<'!<$n<1{u<<.<A< <<<<<<"<{<U<%< <,<< <<<<<yx<< %<< -=<T<#J<G<2 <d<K<$S<|<J<0HZ_y<%<Є<~<$<%<o<<<%<<`< <"< tz<<#'c<; V<'".<Nt<8<2<@7<><n<$<<<<(Z<^`<'<3<+<1̀<< х<ř<C< <F<#f<P;7<͚<ї< `<1<u[<'<<z<OM< < <<|< -<<1|a<R<=K<g <@s<`<#ْ<< <$ί<*<z<$:<1T<n<.<<:2V<<1<<Ӳ<|<;жc<]F<D<s<95<<X<"<9bP<_<<]<$h<<M<A<*@<t<#<ͅ<`<e<,X<:]d<5]<$ <#U< <<'<<o<<=<t<h<L<C<<< -WH<`N<Â<<I<2$<"m<~<}<7<|{<< <0v<{7<&T<)s<<R< <l<<z<*Գ<<1 < -ަ<x< -<%< <l<8<-/<#9<_.<<.<+F~<><N<,<#S<<%1<<2< <_<<)3<%.< -<{|<3<}< !<@<7t<Q~<&<Ăm<#< '<-<\uj<7&+<.<<%)q<b;<2l<#^< O<<m<<)U<6wJ<+@<?)<<_<$;G< Y< B#7<#T˅<-<;Mh<(<2<݅<<)<9<" )<)$p< 5<]<<n< "<(<8½<<<<6<<,<"W<<ST<<؇<,rk<Y<-&< <<<<#\p<<;< <+8<#m<><L<<<I<"<Y<<+<8f<8K<<r<u<1<-w<<*Q<_< 2< <~<%j<+<`<`<+<L<&ڙ<\<!<<%rR)<<%_<=^<<w<>< p<1b)<{<<4<<: =[o<<<Ý<4 <;<#d<6.<<.\#<<"<.Xd<$P<=<.)<<?<<<4;o< ^'<m&<&<bU<$<J<-<<<<Q<*<2 q< <&<+c<<<">"{<# <2<<Fq<6<#<<0Y<(=f<ڀ/<<U<"j{<<<-8<8k<6<: <<=z<N<(<;7<bR< -<<1g<)<t<~ -<< Wb<l<2<<j<<#-<< <JP<<7<I<#<v<<(,<<'<>e<-F<< ~<,Zu<#Z&<<;W<<On<<5'X<<8<< < ~<4< ~<&58<Zf< ɓ<<9OM<"S7<$<$<%v<[<?k<<$x<̢<x <%y<]<{<u<p<6<l:<< < <$8<,[<.<J<'<<$M<*<<$<Wx<<`<<)<-.<b<:K<^P<3Ky<>cR<7.<<1Է.<<"Z&<<i88 *(7<@}7߭7~766/7467R7r6?H7I~7bE677=671Ϊ6s76677i77G7877~A7Z,6{6Ϯ7P777^%6f7GE7[7Ia66467He6E767K<7P@Z657X6Q6V07&7ŝ7k7 S{77"o7c|7k7 J-6677j7]7677"8!q8"7Pj8]9;9T8_&8W~8Euc7*7F617 -]66-7 66967e7 77 ξ7T7'6xKr7"u08!7M88$8fG87^7V727|ij6~5&*7v7+'7 67Q67!7Z 7!U6)7{cv6c7)68\7o6K794h67pe78M7D70~7R7 P76L7!6#͊71,7^]r77dh7;X7E7$6@6{76*877-6367,n7}7 h66.$6n6c7[l;7J777?7D]A7/z7 474h774 7o_7Y888Sz88-688k8U888)B07<76e7$6Z6C76ղ+7؝7%!7de7t8IR7 7ci7P7[p7 7왼8#9r81w8?8W77_7b<Ռ6dV6WI67X7W7PP7?7q2767i6(r`777 7 &717g#7&e7o~7Ց6R=7Ǣ87H7HN6t*6Ґ77qm679*6g677`7%P7,67Z/7d76767a7n(7 I6<7*7<6766R@77/7p}7I7Y07P7l 7JV27_n77s}A7SZ6w6 7ɱ7778Z8%89 8(7j7 7@M67J7^l7O6L7L56l,6P77G|7377?R70۱7_*8$6881sA87 8;4j7]97PM7:7C6V77OV77`y79R6)HE6}K7K6777]6`767%~7}C7$36P76T76ʵ7 7:J7v6qY67776b7K77&H66݃7D 6{h6y70C6tX7z7La26?6 676>7 -7#D{6:6q7H7 -78T+7 -+7&67)666~69t7 36,6bp6ϣ8888G77 7s|67172+7NC7\/6r7Dj7 7O7v616D6-p6C6^656$|6ѳ7r -7-%Y6K6z6s7767?o7786N,756S6i5>67/776b6sE6u6Fv7716׎6:76J6p6u?7N6[7<7v72|I7+8 7\{6*7K79S77ā7vk7Z57IR?7n66Cf7P,7L7 -G7CQ7`7f{8T477E7/7*7`v87l7N8-T87Ik7E7((66 "6o7$#6E6!7'7{GG7y{8785\7777%7,267]{7a6-767R7> -|7i7gdR6h66%666c6 d6$]66l7'`x7 3636K"5H6726?6&]7&P7Bi66l:7O+7J{716M7"7=D66g96j{7W˖72) 6ލ7 6ڛ6~QZ7;l7IPT77X7˞6377l77*7GL~7P?77v7G66rŝ6778.6ۮ7. F77 8Fg7\= -8ێ9=t88 r7777+V6C7&[7,66r6j6᳟77&,>7`\7-&77T)7%7&8p"7a6776#77h 7~_6H6dlq77 7t66A6U6[ 65Wn666i56666n7*7!6I676"76\67U60796:%666r36F73617h 65787i547]'76.6آA7B7V}7wx6s6T7A1n77 5B7 F8988'> 77pg7767 M7(6ѽ6j7966ׇ7z6(57667!6ȱZ6@7R67 S6ĥ7 6~7)6+b6B7wb7ym7>{67B7E6qT7L7G7 _6 S7 7!66x7]V6ފ67,7!H7Hh7397|7_77pC768k81=z2<զ85a7 !7G77P7l8A/9 9 @9]99o;9_\J9e6KF7%6/7Ya37.U7j77 77Y666o67ə8=#8277&7 Ң67B7.b6777J>7"77T777s7w7ky_7 l7S7m266n%6U7<7@6Z72p7F776T6IC7k;76`7!b666چ6ə7>4697L-7!6^66~6~\6{67Aa6 7hj7+p7'777.7716w747KI7^ֿ7877 -7D7w 7^6ΧE7I7ʑ8W7x7Jc8`O9:,9[;;;@9\7j<77'I7n7vj7֗7Jn67$m7J P6K7MGQ6W%74`G7vs08]87&6-}71R7 757F7L88X7O 8 7¸7Ɲ77 ?x7776ڼ6a_66k96]7t77qe6{66u757N·67. 77YZ7n6j6%6ɩ6ճ 7P6 6=73'7!^7! 616i6p66 G7(6Sh!788\7>7l7˨7|6e77Fn775]7*H77]7=B7<7:ۛ6 -e7%L7z"7$6/78b9G9p:bn=;> -0=w";X(8x8b?7j6꫆76C%7n666n8N7SU76>o77l77U7$Bv6C7_7_7 -B78q 8!77Գ:8h7k7;Ч67:n6Y7 ,5{ 7>r7,7v*w7P77 67>7"u:7V276Di|7Z6$7k7%7"r7$76؝7@76 7?I7\:7G7O77271=7T0u7*LB6Z7777\7f!67ʌ7h7Q|7=`67n8/7)7770h7D.7-7lL798x7U78v99D;{f=E>>z<%~l88_k76ө76 d7N6"6Ge7q7?6g~67d6|6n6c6 D7 6 n7:җ576qy7Q7NQ68179k7q6T:6L;67+6,7TO6h^76à$667Q676l 7177y&7<79-7(^6_6F66,M7>I6dz617#v70:N75W7g~7;k6966甏676N_666D6,7866q7+q7%@8@8V87u7P7j7|[b777'C77Q7m7d8!0D7i8#hk8s -9B:%!;9I===dc;F88F7ik7 O+75676s77k:6Pk6r/6q7"Gy67B,7zd676s7) 67 6`7Ww7]E77H{7o7PZ7d7hB7|M7W^7$71]7mq76Q75&q7B7u7W7gͅ6z7v6ۯ8 i82(8 7 NG67)6677J64v7.t76L7!7{St7b7H7)!C6 67P97L7C76Շ67L7X7L77899D9F78=O7l7t7b67N$(7E7C7b7*٭78-37k78pة9:Xڙ97;<<_;zT647 7C6~q6:x66M77i7x7Y?6L6UN5l6D-66}C:6w6}6lS7?'6&77$|6h̞6D%7>w7^*b77>7,57A6*6Wu67 -6|w7i;7Ll6M6777|`7p8Qr88TB7$7U6;6,#6Һb7TI7,6Q7|/7.O|6z77 7Z 7W7U6777;7b7G6i7*5k;66\777 u8'ap98477ID6+7=K7'6ҧ757;O77ԋ6q7{7tmC7,7x8 c999; -\357W7k7U76XT6U6RV7B/6E87N7Z7+:7L'7 7`g7k7N78 '8 Ӝ7ޝ7?We677q947=g6F747u7_ 7%w7Jo7<]7V71P8 (A9z9 _: -<<)< N7W7666œ76t6:7'07.77i!677,~'7B!6Ɖ6)4676076H5q7@7$C7.6Wx6z66I73c}77 J7R6[7%67)6!B7 7 -e7nw7+i78O7V7N7=77I66x7T6872 -776=7\6777 -707-7%!7;O6,7@7b7u7"7N{7ũ7777G77+7N77L7dG70 7;[686 7[L7Y~P7w7Y76v7=mQ7!7j7l89 -B9 -#9I5:;42:97@$6"6װ7)7b7*ɾ6 -q6Z6׆o6܄7(6t6oD66"5i7666;78666~7$7"˫7M6Y6%+66>@5>O7h7!rr7=7ɺ7l77$17&t"6l6 @6/7Z޴77177v77.6T7`*7ZO7I[7V67Er7$77!Y87.X7w7o796rr7~C7u7 77>^67O7C18e7P7w8S -O8X8^8QK8U8x&7+7qs6 -7=7P%667G7 6467"67-67:T6D67L7u67/77_-67176G67/6617T6N6|7a727D7 7V7[8-Q7N88876ܱ67ր7qJ7?4Vkc7 6ε6+ 6V6cU6566d6&70ɨ6p7n7a7fb6d7+Y+7:77G7j;7G7D@$6i{6n797?6 -888_7`7x770ZY7~Ҁ6.6l7F8b8*8F$8B8p37`47 70u8a"8x8XL18v8"16(7L7I6{07 7#Y7z6X77f7$7X77Q{ 7E7@7%Z^6ӷ67%76؃7,u7p6W67t6㻥6@7D 7ty7MN7%7 7l78:8O97a7܇88w,66i666yP7"D7>6OD6T~9776.686N7)4P6/ 6s6g6_77$5m~6(7V-7\7²7\7Y7p6u7Ew7A$7&6d7E7/7x77> 6f#6ؤ6O6I667)8+7888c87"~K7_8:8/8fm8 J7n7 Y_7o676=7X[6U7YL57 -d737zk778uE77hRA7oʩ7~277ci7{727*7?7 76P7 6;~7!6757!C71787b77s67m77B]6B6ź`76d7u6ڪV69:6}7/"o7e66X'65[6d(6|6U67-H]57%_7[77$Kg7D#6)76B76t6s7h62Q6s/7CA7Lԗ7%z67,6J)7!6f6?*72:6E7bk7x8xr8l9C8/\8k8)P8>877nb77Q_78g8 77x 776v6☮7a77G6eb67V77<6R7(~,7o7}`7A 7U7s7hq7)77u7^6b|6"^6o5=7+6-;7tq7 7\r7777'%6r7 6f7C6/6ŜU67*k5I!6DP6|7T5h|6*6 -6&@6q6.0[6@]6Nt6a6j5@3ڄ6gd7@6'@7g B7*ki6A96de6ΰ2667"656ѣ66P7P6D7N75 ~77K73716/^7KL,8 r7[8>8C88888< -8s8 7a8Mѝ77 -?6_7N7#.75 71B7\6O7>77c667@k678g777I7e*7)08$N7A7i7g16Ѭ6v666577"7L 7 I67e7u67i7K7R6G7,606"6wH7P7 6Ӂ6I6cO5wo6X667 96#6 6S7 -V6!6`Z7.^66/W6}76]656n6u7P36%7!G6@`76.66p6L#7w7 "7&D 7n916!7M 6h7]X7*p7٫8|h9,T:;:g9!88X8477(7ݼ7&In67 ."6^7nƌ6!6kB6]z70 67&M71737>37 -r[7iD8~m8O8'7:7{+6^7;17T77dJ6l6796?X\6L6}7%6wQ74.76+7.;7{6m716_76 -v75/6#7^67QZ6W,!6 6߫7)56ziQ76SG7n67/86`6 6}S7!qI6햝6\6Q66!7HL7gx7;6767657C%76U7wY7]*756d766Ǡ7o;7|77fF7*7I7֎6f7O7+9 -:rG:N:; :څ{:s=99+48 7c 7T7;7&d7mT7u,7 7qz7.s7=7s+7X77767<7*848X87U7g777x67<{7lU7L.797q[7!1[6v67d777Fi7 7M(7G7#P6D7-t6$73R7 7B726Z6-Z76e7/i7$7 7=F7UO!7D63:7()70a7#c6,66)6۾7=d7@?716Q6D6#_7F{6w7]77$7 -7m7)7C#7?z17K7)ԑ7|ax8k87p78Y:Nc;%8b::G:;-s:2: ->;-:94d7Q8858 A7.7 6s6:7^ 7D727j79$7B~w7hV7C7lU789!7 73;7[7*ݣ7($77@677tJf7<7Xf7tx7.>67 %7 -8y7+7#6WJ6Y74i677}6yI7j8Y84t[7e746~6767fo66I7w~7X676&j77p17BO7B67Jkn6a7:*5]<6`Cp6꬗7 Y677D=p;#;oϷ;A=!9K78$8 8a`H8 ;77W7!7f7+H7T`7567q65!77<7$a7 V7jy66w7947I67U77j7%z7R~7 -j7[ޡ627E!F526717E*79]|67xD67aa66+7(H7s88l7(7~}L7*$676d7T97$a7n606x 7W6W9V727 ~707 h6|727G6m=67=L7f&6u7876?7 X6Վ6e6T77,7Jo8XX7t78^p8)8 Փ839 7g:k:d<4G?z?~>,A.^8E7\847[6ѻ:7,5377Ah&7a_7i :8p77z6|7}a7PL7ԙ7OE6c6~Q7t7@7^67Fr7oY7=7l737u9p7 7f_6Q>67e7S7 76M 6׏67 -7#,7%777 -b7y7 DG7Ru6G6гx7-&|7QZ6y6g)7[7u7 -؎7R7"l7"V68nI7$%7b7Bf6|6n#P7|7i76m6~k7+U7[r7{77ǂ777,+8c8`7s(7s9$;!Y;;C=^>F=c;;!:ǝ9Z`88:\8r885 77.7Ó7l7N)77 D6)875787ڝ7@؄7%K7Ԅ7 g68787k֗7"8!7)7=6-f6 7M -77=}7l74o7&,787'77`6=7NX7T7*66N6"7Sv77C7w -6o6?6g6?7"667aQ7M67u7ӌ7O+y666f96"6Vr67 -66 G6վ7,7M6W6Ԡ7+7_ \7th7N$7l7876C7SD777;7e99y: Z:ܗ:VF:&;7:S2:Ǟ;:f8ҹ8P8B9 ]87Ψ7P777%(7y7'6d 6tw7~777V7v7z.77%7g k7d97M<8N6)6kXQ666W7 7, 7Ā78,76ʙ7*m7!=66L7x<776Hg6*/6666D67,7;;6X6#6JM666666 6C>67bZ66`7876g5NVv78`76Z6>7D876J+6Ѹ5263967667d V6WQ7}7D766XE7C8l8h9:;F:k:":}c:1 9988)'8uX997w77607ER97#7;7}87I6W7+Ȏ67YC6~7777|g7L}2876`6V7l7LiA7@k7(78$7Ds[7 M7zG7636|67-7҈7C76"6A7Kp6{67_67US6`@6ە=6~6|7U7O7) -7D76z>7X67S267rmK6tm7Tq7KB 7`87v7zZW56O6Ò7*@7l7hC67gi7ks7o7 7҂746v7f$7#789ŝ8]:8Q:h:O9>?88b(87t7ˏ9#e9d9 u7 m7387Ur#7#6N67I:@7ƚ7l677@7^tp6V7wA737Eq7n6~7ǒ7<7=i6,67CG7m:7rVK7T73877;#{75<7l7O7d7wG71]7[,76K7? 66}7*66igS67Av7067'6$&7F6݉7@6#7TP6?{7^775yh67Q657$7+R66@(7Fd6A6Lp65`766B7'7 $S7^x7Ƞ6-7Ic7I%6ܲ~7'H48Xe8E8 Mq8888`8S7N8-p87 8y8)b88}8pJ7H|7776Q7I^6`667[6t7'75%6O7l6y7Zi7Ƥ*77727r7"y7'q6776ߊ7?=B677V6o85\N67A7|>!6jz7W 7E7m6D6.6Z77'G6i)16(~6eO6,7 6D{7b67.7W[6X6\?7?79M7607Χ6D7#6R66K$ 6D 6<>6UXE6ȫ6#6f6$7S7-:5^s72714^6707.[6_7728_ 8k8&7(7(I6>67Ï797C8%188188[7F -77xf8787u[i7/u8/*f8"7 h 7V6ul66d7B677B7R7 O7^76 67m7ph6u)7,7Ay6/7K57M6ؿ67H6G6g`6Tn6dl7pn7ͷ7W5757`*{7 7ޏ7w67M67R6 -j67%6/6E77 '6r7$`y7jb7F66=7{'67!K66-6Ha7! 7$6H6l 6Q7"7]&7`>7k7m7 <76/R76 7=K7/8(-8U&8*S_8I7=N74CC6r67e8E8x>8Xv878< 8G7=767F6_8.O88j76I@6 }7&D67+!6'6T7 &77ީ8 _7# 7u577%67^C|7"7H`7(6~6n06E*6A77;6R6"77n,7 G77Ө66V7 j*6N7h8:N7i77X6X6O6P77q7N77 -6 7`E7ge6\6r77 a7o:6Ƕ+7"7>j6ȳ7x74W6+6;7 767l7dg66j:7*}6F677EHy66988W 84288&6(R77V88c18%Z8F8!77Bfb747J d7*D;6;j8C8H8z7ڼ76776/ 77/f6d7/^7>}7bA6ħ7~|7t*<7 ӎ7H66!V7`6%74A7j96(796P6F7!76 H6q77c6㍉66p6t6P-8 8h -77c86f76Vx?57'I66R7987w7!7A$"6{E67:7A_7 I6lW7#]6W96Et6]?670y6!6ѽ,7:,7h7+76#7M6R7n67Z7l7[]7tDu887눶8s87&6Z7ZY7S77q8 -8L8 |6WE7 7!7*7f6Q787Z7C77$627 7B݋7K77LT6X6 C6ڈ636:BM7>06^7U6sF66 o76н6Z86 -;6z66i66[7S>i7F667`K6-=z66i^R6e6 Z7QR7 7p71B67P6_o6^7:7J7< -66y17W/6}w7SJ7567\7@76776L70=7%M6ֽ777 67U7k77 S7)K76V67]67X7078 7j7Vu7O'7\z70 -6s)7$707]8W7d7[ӂ7 7 7Ls7667iRn7s6 7S7o7W/s7pe66ݳC6T7N767Q?7Wϗ7)H 6u87,7ʯ7J^7kt674.6 97$f6x6o2*=6a*7,; -63|706ybg6J6u 46۩W7F466d75ν637*W77+666.79.7F$7Np7"6N7!J7>s96v67Q7%7iW7L,7kf}6&7G7u7:-77q60~656ҩ77@ t77 177p7(m7^7L7|77ڰ7=78[7Ť7v77<6!N677t7Ot8B8+7V47_6yr.7/7rj7`77+7m77#5n6N77²7bo7C7"7 6©7ֲ 77w}67Xa7M%66z,7)e 7uZE7qj77E'7:47R&76V7G7;{'76ߟ6n6Vb77F 777<6ȿ6)&7:e-7jd76Y7m\787d% 7a7N777Uw78m7&Sv6zb78=7o<79p)76d'77L]7&768*7mD6=7T#7S e7”7]779#qe9nru8Vŀ728&d8377n77S7rG%7-6E7\T6063677 -}7`7L7C6%77z7;7377`7@7X74R673(8r7{7R687TK6u/7_74776!7& 06a7*M6ZV706k6r637^9n7ś7k6h7W7B7GH7Q|66#6 36,!6 6.6^!7N6X477C`7ő7Ԉ7'7I$6v7H#T7er707@7X7u@87G7V,R77%~7|Gw7%1<7V67%76>6kZ7w*7<7V{r77n8:S˸:;9D7]>8 77{7s7/9U7M67e7I7 7T;7 ol7Tw6K67j7i7777#7h587j777'7't78?7 -7e77&77kG7Z 7>7G6=7P56F6~7/6)7e60m6F716C646F6877+67|C6i7#K7BYj6z6665ҔY6_7+e627y\65nf6.B7JR7@5kX71)7MS67[8D8zЦ8Vb7P 7977aE7,7H-7T6n6kX67M3c6_$6,7;7͛7 =R8L:n:&9Ǿ7IA7@B7î7Ƽ7y7'A797A_%7Y7b~46`7'k7; 7;=6K7%76/o76@7g7Tr<6+77$376&7(j6!C7Z7H/7E77[6O6M!666<^7Zn7X7&y]6<6Z6r6Fp6/7ih7>D6w/6S7y7?6]p6=7%(*60e6n 77A7p7M7$-76Gv7ID7 }7J7'888P80L81L8)8n87O7b7zkb7?r7T<7r7673R7C7m7577_49G598أ7^7Q77dJ#7E7"7{u6U7C6ff7+n7X<7*7q77V 7_7S̩7>{6Ҩ 6E6l7 6c7(m7757w67G6͔|7CL7}7z 8 l7Y7{V,7&76Z67"H7 6V7%6X66276Y68GR6u6Ľ6x7l76*;q6z616-7~636DZ7]6"7?77%L7N767;77 u -7J|7Si788894:T~9cߵ8Z69Q8ZA7H7{+7,7l767u)7:d7ޗ7u>@7g47wo7,78r8B8)97t7ʟ`77k7gm7LI7'7*i7e7q707xE77e7rn7s7fz7. 7>7 7>7ZhM7ܐ747u67666A^6rj77oF7à8iF8 78 Y7U7+7ߠ7_?67W7 w6R6.6^76{67:6I^7366mu5o7D6mK7(~7'6ɞ_7`~6W6s7-77C7,v6,7j7t7b7,s7J/7,88q2 :4ת<&a"887-b57) 7W7Z&7-7m838872n7%u7L,@7ޥ7 -8280n8$8b818L7z7&8V{7865.77h6x7?7U675367(z7J76s7H7=7 -7'6Be6u7,;!7rW7,T667c,7}7 8j77{37m7T6<7 7PK<70*6~A7&7! 7O686s666Ŋ -66Ӿ74m677MW6q%6=6y6B97[A,7<7Y7#%7N78n8T7=7Rs7P8#8r:b<օ=e<A9j8]8Jl87iN7f?)767Qv7 88*8E^87U'K7\E7c78{!8ɽ788;L8Y88@,78W?777FL7d7A77$)7*=d7#707 7k;7O27y6O6~f/7>6nX67Uö7{357 -H7b6f7?7?Lv77I$ 7ɋ7Z7k6n66S76cJ7c26i[6Š6 636y7R{#67Z6~/7J6D6"77576b66OӶ7&6O#7" g67p71d788 j8]77(88׽:1}<98F8a8"'7z77֘7!77a}87uU77^6S6BU77 7@677^67G7 7'06uj6M6c7R7O s7,;7w7<7ؚ7=7a6䜙7!67ġ7`67dM657Nh7g6J7 S7D7u666%6|E6`6x7X+ -77n76 66B67)x7CQK7%77{8Gl8ѩ7i7 7נ88ď8dz9 :9A8M88518<7Uyf77u6f7 97Ab6J7Y6x7N77Þ8`88:}l<]X<@9;(!88Uup8W7lP7f78_8Qv7k97(g7ږ7Ԯ77M7_ w6q76S7 B7"7;h74T6v86,66ƒ48#7Lc66 6\]7.6\5#R67)6Y7t7M6}7= -7o6!67S7{K5d 6767q6>7l7`>6y=\7h7J7F66I76o67I66v7V67dz7\"A7AC6US7-77‘Y8[gK8I89պ8:X89188ǻ7nB7R77f7:77(7v7 7;8%]6T77}E7u8s8L:WV6277&U7t7 7&7%;7V77)7 6?7I7(6o7U7g -7 Ij76WO72r7Dh66̙6"57{676˼6@46c66z7 76,7a7&7zE7؂7v57F97577`78}L8827 78[7C`:7 7Qx7r7S6~7ǟ7Ei72.77X76R788B9F;Nӿ;,:;*H8v8k88Nz8W8%8MI8'87F8a8N]e8677|7G*{7%777E 7T5]7l7R72767656U7gm7 '7%7R*6r77X78Ya8q7N6tH7B7.H7˙7]777 7G77l7*=7;s7 >.7f7΅6Ph7174f6|78#7]8.8~O<8r!8**]8<&7~7j888;"S73'7z<8bz77571;7EE67L6sk7a787 7$+677q72R7!j7\Y5ϋ6!6^667LR6A7:6"G67lc7 7M6kT366XS7[7To77S -7t7lW7!=S7K77`@8=|77g77686dr7@F7JͰ7iu7I7M7h^7Inb7uf7j7MO7 A06U7 -67Ji6b7-78@i8)8$7'7x -7:88C84;Tq< <;*8rF8]7i7*7ZY76Oa7>)l727u67mY:6Z6-m77з7?77767{ 7>|S7\:^6M)L7(]q677DU73276J6707w8 |7F77ΐ6>7x7(79Z;6d6f7a(6A!6^u67>7(47(t7Y7&m7Op7Tn7"'71582n77ے6찡627?77B6Թs7E7z6,8@6L7477Ve6P(7#M6ݵ667:ߒ6d7&36}7206lk7o(717Ϩ7.7w9^777N87F77N?8!88"k:ͷ;^;eG:8A\8w7F7 K7)s6F76m7Dݞ67G7#7@6:7A:7 P7+ q7B46KF67`7X6^6S7;X{76J7 Y77L7C{77|7U636H7V6s77 -V7DKL77n07KC7y7>L78ddR7D6,785a77 717N6uj777R=07 77a7(77ܭJ774f77(7_a|7>{D7H70x7}7!$D7 -7%$6py767:qS7 6_;7@37PTQ77277&77c@]7/8 R88JT8H9\n9{p8_88n|L8-7\p67(]6ɸ7#A67)M6 -7IV7<7 7"+77\`6֟*76̫\67r77\%7^07@877~7B7IL7.g7\$7/M7?7~7"(7dO7s6V7 -y6c:6r7!7W6746`7Ǣ7RKt8L7/=S7M?7]Ӓ7 Y7 q7 26 W7)076s66v72gr7jZ6Q7LK7 6W97S$6MZ7Mf627$G6]6 6f7c^7y -7>7OD7mT7K~7i7~7%7V!777 7ې77"77 7P A6B7,M\7?8]7LJj88(8)%8 hd87#77 656ñ7ea6Q>6ڄ7p7]6667f726$U6Z7Y6;72MJ7KQ[9/`==P80t7y7|@o7'&6*7lC6UH7J@7 U7!7 R6T7U6F7 -İ7[7xb/7[6676# 78H7|6 85{7h7FX7E7u6ӓ6r]6[6|Z7Qy7Lj7#!7П7;v7)s7~k6B7O.!6 -7p6%6_#67$kC7Tl7{7,K7S7736z@7d7ΰ7u7vi7cL77M7C<73677QQ7X7u-77;78]j;8L+766277x6*7 -6A76(67-7 687t976o7Ev7 -6d676Һ7>9i67\7'7/7C7@?7ˎ7j%r757R67 IJ7(#6V4777.k7ee*77S7Qi7Fq7\778va7*6nl67V 7Wx756 57t)6@7Uڿ77ە,8fn8{c7PH6Џ74P6ĭ7K7h9=6O77)O6 6&>6m77`k6F6d -7\7l7[78lߟ8C<7z6p7"7A76lm77!6m7)7b77!S72(697Y7X 66%66mV7Ty-6f6 H67>;\>Q#r9q8TO7FN7P+7 78f}7M`748G6S7H77U777a7}776/66R6\6CS7rQ78 -s=7Gf77Y665s67l;t8%17*/6Pc7%>6h"76C7#6w7ч76ť6ⲍ72;7E8E8B7fW7*6[6v6&6oB7 76d66Ѷ7b6@7T/56)266̱77b6tC7%7w6`.6ڡ6;7@7P67C77XY7h<^7 7[7m57Փ726,7c6>7q~66777W377%f6H67`ޑ6L6ơ6A7ar7u7kL17Ϸ706#7M 7"N727e7o -7w76j6` 7F 76E6W7z7+{6d77J7O`7/D 6)Op6o86t6=w567@67 7 K6),6M7z6O75Ш7S77w27;7ʤ677y73(7u7n6V7v7LR6k6 s67O1l7KC88oE9 c 8C706ٚ7>7UZ7H_77^o7\Y6^67p6`606ś7 6x66.6/L7737/f6 677}H6J6v~+66G7V6{7@86I7aM}671dm76k78677à&7P7ߣw6:6F]6^6.7s?7/;7q87N76$ *6w74[j71F7T7 .787QS73 7%67O 7?6+707^FT6667787D?7U,7J7C77k677ө7:Ƶ6ˆ777787~66X66867W )67'8p>8!<7ڏ=8H77wv7S787c|z77^8 {z7~N6'67R7W6ad7+M-76i7ˤ7.?77|7e77Pe6ۙ7z6Ϊ7o7`7I7717!~7X6HK6U77"7I70J7{k7^7;7c797GK7_7M:6C6Y60l7d7!6 7Q797 67F6L&67(6y 77%667 C67@6)7 6`h6W 67e77797R7k 68!7i7e7, -Y6Qdg7k7,6:7k6R7GFD7$7uT89)/9V77 7 7 6V7u7@_75c88Z37P797e 7"Q7I6=6[6T{a7M+7S 7 D7[72777 f77lY7Ɨ67 77^7Zh7¤f5S 6A6C6a7>7pm}7½7(7A647b6r6t7G=7M60K6[)66F7B6f7 6[E'6о7P6G6=76jQ666 z7cԪ6=7\Y6ؠ76Z6 7'373F7-W7t!8 -.H7s:6666ޙV7`7-7d7#67&<77+6%o6E0767u27t7 8 9iF9Bg7״77f77+A 7s7_<77xP8DR7x778W7;-77)L<77\7=7977 Y7!7d:K747$ˑ7G<67A67B76Q6.6o7j788Q.7}7I 6G66|7!76[6966$7Mʝ6-7A-6sp697_7Z6477T6g6-766кR7n6 -6767dV6ۓC7* 7o7F7B E7[7F f7Fr|6 6G7%B7fo67*7(66ף67d47 9R7>7O!70ݗ7,8k8(NZ7U77$7L 7#X6Vg7Bl7MT97D{7(=7 /86W67%7^7MQ70:7-76&7R7>7Ur777@6w7mL7?7QKF7%66`7)7L7 -z7V6r6B C7.6G77I67M7lR6~6 -7s7".7ZGQ6'77277v|6"d7-7_7;747$j7k.7ZL6b6qJ6I7'66,66أ77Z6g6$i7!7O6,7 65m6lF6'6A6eu6l676 6s6&6L86i۬6Կ6˩7!e5c66xu6܎6I̲6u6i6Y66X6)46C*6Q$7z-6.7M7'6qT6϶626Ln6>6o7T 5ǶE6'Q7 7K7y6e6V7#6x[76б65(7\6Z6+W6`6=I71Db7677,7 7ԧ7-Z7766I7aY7X}6x7#R7&!7/|s7&7Z8)7q7C6Z96RP6`FV7.6s,5f6Y6?7);!6B6i7;6c6ښ7X6$m76w67lw66h76Hb696w@q667 R7A6 -6E77o7 66{ -7e6_!6~H6ǣ7"k6~6{5K666`79V4M75G7#6ٌ66YF5/M6@07$:]7h7Zŏ7t27857$U7(27A36#6m7*<7P6r6!6~767>66cwc7Eɷ7թ57 7C778/7hS7/6-a7(B6ǯ{6zGk5m787|8c7:7w7*7H78LXU7B6t76%U6<6gt6{7u6\Q7)B71A6!66y26"77366nL5H567U66M6)74Z6R66L6$656b7;d7666)(6ٌ77#Ӝ6Wp6Q5+5?6+6} -6C6_:6=6u6576M6c6ͩ67!888^i8M778*8`)7L7Df6 -e6!"6ks77I6u;6R5jn75"7x\7G7O7]7^s7^7˪7tu7}777>7%76X67.88&8-79o9}Y9E8$ 88B2I7Af6Xy7s6H 7[77q7s^)7 ^6ς6b6(6676h6yG66l7uK6f6˧65Թc6i7"C66u7>rM6"7?7I7+706&726y~j6[6D6(7* 5*J677B 6f63a4*5%7D -k6 6E6Y7 6!6'n6%8 t8y8 8gS9V!8*7H88:]7g7)(76u6&6:66c78?66Z7[V{7c:28=Y7608 =89uJu8df7d8Zy8.d7P7A;77療7796N;3;:3888RD7s778@a8n8dFD7B6P626Ƚ6R$6͌6v77K=0666ݝ6X5c6W(86N 66kA6ȫ697-#74;7sɔ66ta6$6(457/6b5rb6#.7!|7e7=7:L86#66637&t67@o6Rj>7$7~7$86$8MG`8\8R]:=; X;3Ʋ94A88)7E777O,6NU(6}6F$7NA6ޯV7 k67YI7_>7M779;;Z:;:88Dd77%76.167kĿ798 : 4;fC;:8~7;77\6o6_8&8c!e87]u>6m6!o6O7{6L6{6w727-66&675&77M6ߓ7CG7N7Q6Ѷ7 }6%67R7H7l7m7 37v7el6z6i~6vA6X8Y6]77t66NS7&76N7 8.Y]8+9](;<<:486*8f7[7֯+7777 (7 IJ7^7,Ù7Д76E77Xn778I$:Gͥ;B;s;87&7Q7, -76>78C8*8:l:9.718; 48!77=l7zS78-7`79k7D*67s?6#6Nn6b707A7 -6 -78]7h4777%%7&X6778sZ67HL71 6-77uP7]7C77o7 657&u7,C86>J78 76QC7m7m˟6`6566V7G&6h7417OT6r7(85؅8cnE9 E;6op<';98/V89737F70φ77X=747fS6"7E37Bi7 6Ѭ7`889M ;*?;dBB:|&8nz8R8v7v7 77Ρ7z7>8%8jLm837w7/8"7k7=S7M7 7S7h7O7e5Ѱ$672 66976]q7&#W66x777i76h7wo7^97*77%67>7 -*767ZU7fj6 77{7SR67?76T6V67<66b=6627D7r7cnF737]+6g7c7]9788X7]78q8iw7Hѣ9z:lFw985k8g86RA7?667Fcw7D6n67j7O7 75778+7߽"809N9v L8w8'>87K7_6Gə7 9A6&7GSR7a37v7)777 717Ac>77:–7`6*7W7e7H7,G7Jk67[x -756 - 7G6z756O6M679E7F7Z|7v8U8W6<6̬7\7,Kh7.*6ԟ7_VW6ϻ6Kv7^$77N6@(7,A66:6f7K_66 667"Z6곳76bU7 -77=l76٨178y8F8*77D8y7*8T 767A`6{Ey7Nͩ7Zqu6776+#7ܲ6؋67m27[7{N7"7b+7%+7Ӫ7塱7ڎ7^7B747:U7m7_X7.6r7J7U7m76! 76X756ݞd771<7Cϕ6u7PE6867-7jz6E675=#5&^6#6=6:7&7gh67a89jM9iH8=71"O6'y7c,6%666696667!}F7G7XL6%595/7A!7§7'7 6{6z7 ͖666H6Y7/787P=6ܶJ7@7F8!8X 8j>7n7I 67&UV7F`67]7.o*7m7i6)J6677V65J7[J7i6P7h7}S:7T8C7 -7B67`u7 h7gu76*O6767'796!7R67\u7)7Q7@66Տ6>7,7iK6@6[ 6 65C7qx6'7GG6+7.`7 767899e -$7$6ny66}7/<65+6[7b6+6X5~7767+66Wp:7#'6l66A666z6l6l}7C:7Au6y606$~7.77a67787l7 *77.ɣ76xQ7 7+7X3686'n7<:7+6y6D6+6x6EH7#g7RX7V a7 7)p767Mh775L7@P7eg7y6֘6x7 747H7W7ʝ7`77\7u<7+Zk6a(7679/6z~66u7T77ޜ67 ݪ766776>7887r6슺77~;7T6p{27% 7'/53 6k66]56V 6$6G6 Yu5윴6677k?O6K.6I626w 6ul67W 7'7467O6`7H6W6|7|7`2777!7T߄77)e7v87Q7?37%e6K6YL6 77,,6C66M&7674e7t97$;7N6u6;ˮ6 -73W6"6;77D6|5(6"G5:-6E_6.6<48q06'K6T77E6}<467nr76[7S7 j7)6ܨ6Y}617 6 -e7ZK7i777d^7p?77j6Q78p8n7z77#6J6Q7 7-g6 g7zy77qߛ6!7mh7T76O677GU6X6s6֑|6Z6v(6֚6yh6N6y6Ez6z65v r6K6B66:7l7{N727[!:7_'7e%6꫌666I666272B6!(6F6Y{k5q7"vq5d7 %7?V6~6]i66j6e7Ko7.86R71$7 -7${7#y6I6q6y6%76A;7`*7O>7l7+7iQ6 7E77,y73Y7G7+77(7K|J7*7HT77D`77B7;7$7877-8{6b~7C7ey6E7>85C7 --&717.7]~7h7?7ŏ7"6k>7-xt7Is76媀77`F65o666rP6y>6767;}7$6 6d7_;7a7677 ! 7q6.6ݫ6=7e7@ 7 7O7p67A616 6k"6ƍ6637J67b7WB75y6y{7]6Z7ZD:7:-m7<]7dW6Ȉ6ْ7:7646{Ϥ6*n7q6܇w7.6p6Z{7 }66:736Z73N>7T8=747h?7UV~7ma7 :7R6zn7=7׌7hN7C7677*7 -6}l7W77f7~8 v7i*67@7)-6 66 6;D6G6%07Qg7 -7I67vv7306&3606I6]s6'6ƭ5ј6ox7 7+77y6V<-7[[6f7-7+m66h~7i7+f7"7?Rm77678"?7TLj607$7 7}57R7=7}[6ɰ76OF7R`7n 7]7 '7@7L7(na7]b7@7]7`7i6‹6j777~7(56Ogn60P6'*6R7/7S8 x7 7f77W77K7T7317@7-7n77| 7.67I6277pk6V77_t7ˠ7*k76Rq78˘7ks6-67C6a6>ڐ7o6 676aH6ס6@ -776w5 6馍7]?7e6p 6x7 -]6J7cu7gr6T6| 6v6a%6T6M6p77t7nc7o!7/"h7Lt7f̂67/67%7C7}7ǫ7A6t6i7D7?&7467;7:7*r.697H6#7r7s7V6z7W6h66V6(6BI&6s7it6M7.(]7F77;7.Ec6۷77rR6V7G7R877M57N5:7^.7!7:775l6 7bZ7]787jx6*7F67+=7.674}S66X6JK6q6[77F6E]6X6C7$?7/6ۤ77&7Y6Ǻ717p7Y7&V606j6777(75r7JmM77>m5ļ7,,7T7խ7U7b7WK7yo|66C7e7?'36O7$889%o8 -6=w7~6 7g7O7yW6c7 L767k67y7Y6}k7s7:O6J7qh7!aP6Z7)-6L 707`7@6a\7O76۵73!P78%89]777;7t7d67W 77I7716M77o6դ7W77-.667D-?6O7[66羱7Zy7,28(8VX'77 (6P6瑄7"o6E7|7%@7o7'67|77O{64>'7wű7K#7A7sL7 -$7fcL7s7737 m756x&6*7WA78ޮ8&8y*7B66e7$V{737Q57776N7u 7707@7Ů6˫)7WU_7e6:5j6aV76/f6E6(66F\\66|6 636߮6t 6N676e7U&6v6k7 Ɛ6 -7SO6b07]6E6 y66676p767H6g7'N6Gr70}67 V7 -^k6l67D7r=7`6f97/J%6Ɨ77^ 7 k6Ğt6UVs7 m74q6l?6 }6:6}}667q -7,T6f66wW7o6ڝA7:Z7 ]677`†7'970777ss&7N7\N6`77B7 R7ZN7i7˴7k6ɋ.67{m7U7[ -7:L7+7g6u6S$6r(66q*S7m16Yu6M64޳7AQ6`6[+27f6 7?7"7l76Ё:6>%66?6ƻ7%07)716y&6{6%657\7q(8"$6ͨ6.P6}6˓6~57y7Eg776{6`6>6Ik6U6 6l5667 6l8_6~76(6&6}6P6kZ$6t568U6!6$y7h=w767#V773e6¿7 -tb566}-667%K7N1666/R6 h6O6X/7U71H7?7PG7O>-6o6o7717v{77 -6œ5q 6!6d6o16.6y67~/}66.y65u63 7'86[7\6K7Tq7;N77+!656:7À7t7do727 6~*7"71~7<=\7X77X 647o66:72 7<6 767P6&s747zKu7/7*67[66 6҃7S66v77+}6i6*7mp6Һ7x6ȝ-73]P677_i\7Xs7'7̡7W7.76i6o6s}66I7Oo73m^7fj 72776b6j77]61^76Ʌ66I77!7:6z7D7\!6I7D6j6$6o6l5ǯ7Q 7*p6 -6}J7<77:?6[5"67a-{7+67B{7{6ޚ6У7I75S7a%76n6|7$6wo6J7L37y$7,7FY6 _66`7pO7ud7nq6k7XzM71CP66Z66f66}C6ل[676656qa6]7h*7E6F7:=7u7\p7#7K7y 7/ec6葁7K&71ҩ7q7e6!C6@7O777[7R787v-6 B7 -7Pk6\6u6 646|76]r6*l7؆7&z 7 06o6QJu6D66 7R7w7TҪ7 7DF7G6`$6G6r75;7LŞ757=7j7%+7tx71e77*n}7*7h1'7D|T7vO7z6>6U56677*6[j7[56?6s6_6|6 6M67B976Q^66)87P-76` 6 7/!{6\:6RK7[*6Н7#7,6\f6j7|6L7"66l6E]777q<6A77(;78E)86Ϡ72pp57j76Z637JR7 ;6`7N6}^6Y-7 [7J67*}6zs7 -?7Z7*777(f7+˂7]667OS7D7+an6'67H67]I7l7 7/7oU7!77R 77؂6k_7R8u8qz8qe7eC7~X7$SO7N77:-7;ҵ7Ms-747|67|66fEv7(7tV7_6J-7r7-6Y7;/7Wh7 $7K6N7y(73k6b7%7@7,7ʖ7 A>6 ~7r6>6js66tq666< >6YT67C7b8 -n7aj7V8\8"+7d7`x6{67 7=4j6v46F77!v7Y66Y6>6#06o67 Fn7+%66zr77U}66b&7r#7;7 76!66*46;6ף:7g6a@x7Ne77J7A,7.7L?7zsn7U7$:x7-7A-757(q7VD777KG7#7'#6w7Z8?7p6̱6.7I2t7tŹ756_6+7?O70T7Z%\7:j7qE73iw6j67v`79177X17f7|zZ66;BR66eo77e6y6P7&N7%S6b6I!7y7y8 gL837wC6Hz7]8767M66Ls6X6K7Ϝ73`7a66چ7m677b7,,777i7?>7B76És7u i77_g7r7P7/6677R67 u7 -z7n -7N26>Qt78677L7b7P 7_ n77@6s7C7477^h6A677f77M7C'7o66,7C:6Wf7,E6ŷ77"67^7X6\72~S877hX772K-770O7E(7)7h(7Z7 .666L73@67D!6U6x6qV7_7C 7t|77=kk6e6D67o7;d7p=6S77F76667a6+[7\3D7,J757eF7R7}H77/71 6F67S@7K67Y ~7<7fB7m g7Ñ7 -7=7s7G77 7H7L77O8%7777 7&467{0727b 667 w7%7,F8NSp887H]6M97:67N636r7"D67647 -:7>Q7vp7'i797*6ګ7SL7V7(7B7oӰ7D7:74 6k7!ך657&U7-j7737w7OD7Ge6QS76|77$E 757 7 -676R7 7 7$r6š7#E6bm6f6{76(a7a977S.7d^7e'6g6ӥ77 -q7iM6?7@7I7.72m6̛F7Wo7&k/77AD77Ǥ7-7H667.Y87z77h7Nc7<777746Z7V7178 -o7I6d76 -7;7,Z7̱7+717_ 6fd77E7Zg727 ;7 67+6Ζ626 7.V,7aZ66Ȧ7"T67 7X?T67u[77r7;JM7c73V73675۔77Z7U 7>7F~7:J!7B7u717 D\67;h7T967 =7/7L7E\6r 7Ad7C667J7d7FU6}47(\6r 7;<77+k7M6l67735U6 77WC777687 5W7Hm77me7k7XI7?6B"7;:V76e67j7rv7?6I7(=C7(]7x7u657.7_6n6066\76h6e7uM7&67 6YG7 )76(-7Dt7.96f_J7;5ב6&6~77178c7 -6 6L 6w76X/26_5렼6햳6<6 r6W7 6q71#77K|8 8O>7v6=6 -7+_7f84n8 -s{7m7k66"6-G77~6Ҙ7̽7O7 `36v6Y7'7H7+7Ed74X67j7IIc7y7k7 6Ϧ 7'7hR7j7$7-$6ۓ7 Io7ѿ6'5;7/Kh7.\7(6L+6`7v275]66ǚq7W767X6Ǻ7p6F6G6$37Ly7d7[} 7m7j6쒇7-07:]o7#oI6N6I6:6*7+X66IhF67E7=7r[73*7Tm7(KQ72s773w7d7>73|7MQP6Pf6=ǜ7d6E73q78Q7}7"UO7]PXTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / array data type NAXIS = 2 / number of array dimensions NAXIS1 = 42736 / length of dimension 1 NAXIS2 = 1 / length of dimension 2 PCOUNT = 0 / number of group parameters GCOUNT = 1 / number of groups TFIELDS = 1 / number of table fields TTYPE1 = 'ASDF_METADATA' TFORM1 = '42736B ' EXTNAME = 'ASDF ' / extension name END #ASDF 1.0.0 -#ASDF_STANDARD 1.5.0 -%YAML 1.1 -%TAG ! tag:stsci.edu:asdf/ ---- !core/asdf-1.1.0 -asdf_library: !core/software-1.0.0 {author: The ASDF Developers, homepage: 'http://github.com/asdf-format/asdf', - name: asdf, version: 2.14.3} -history: - extensions: - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension.BuiltinExtension - software: !core/software-1.0.0 {name: asdf, version: 2.14.3} - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension._manifest.ManifestExtension - extension_uri: asdf://asdf-format.org/astronomy/coordinates/extensions/coordinates-1.0.0 - software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension._manifest.ManifestExtension - extension_uri: asdf://asdf-format.org/astronomy/gwcs/extensions/gwcs-1.0.0 - software: !core/software-1.0.0 {name: gwcs, version: 0.18.3} - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension._manifest.ManifestExtension - extension_uri: asdf://asdf-format.org/core/extensions/core-1.5.0 - software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension._manifest.ManifestExtension - extension_uri: asdf://asdf-format.org/transform/extensions/transform-1.5.0 - software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension._manifest.ManifestExtension - extension_uri: asdf://astropy.org/astropy/extensions/astropy-1.0.0 - software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} -_fits_hash: aa146c2da5bb14591888ea2809c5b2cd39dd4c6a1f5a53d68d0de64b79d18585 -area: !core/ndarray-1.0.0 - source: fits:AREA,1 - datatype: float32 - byteorder: big - shape: [2048, 2048] -data: !core/ndarray-1.0.0 - source: fits:SCI,1 - datatype: float32 - byteorder: big - shape: [2048, 2048] -dq: !core/ndarray-1.0.0 - source: fits:DQ,1 - datatype: uint32 - byteorder: big - shape: [2048, 2048] -err: !core/ndarray-1.0.0 - source: fits:ERR,1 - datatype: float32 - byteorder: big - shape: [2048, 2048] -meta: - aperture: {name: NRCA5_FULL, position_angle: 275.2528706117782, pps_name: NRCALL_FULL} - asn: {pool_name: none, table_name: asn_level2b.json} - background: {} - bunit_data: MJy/sr - bunit_err: MJy/sr - cal_step: {assign_wcs: COMPLETE, dark_sub: COMPLETE, dq_init: COMPLETE, flat_field: COMPLETE, - gain_scale: SKIPPED, group_scale: SKIPPED, ipc: COMPLETE, jump: SKIPPED, linearity: COMPLETE, - persistence: COMPLETE, photom: COMPLETE, ramp_fit: COMPLETE, refpix: COMPLETE, - saturation: COMPLETE, superbias: COMPLETE, tweakreg: COMPLETE} - calibration_software_revision: RELEASE - calibration_software_version: 1.9.4 - compress: false - coordinates: {reference_frame: ICRS} - data_processing_software_version: 2022_2a - date: '2023-02-22T19:12:00.333' - dither: {position_number: 1, primary_dither_type: '1', primary_points: 1, primary_type: NONE, - subpixel_pattern: STANDARD, subpixel_total_points: 4, total_points: 4, x_offset: -60.80311063470378, - y_offset: -63.25322769560025} - ephemeris: {reference_frame: EME2000, spatial_x_bary: 59870440.18206555, spatial_x_geo: 464745027.91907, - spatial_y_bary: -128971809.4217552, spatial_y_geo: -1298530102.58757, spatial_z_bary: -56385875.05851612, - spatial_z_geo: -1072634133.71563, time: 59776.40277777778, type: Predicted, velocity_x_bary: 26.87043559784812, - velocity_x_geo: 57.2915811629463, velocity_y_bary: 10.80664264909184, velocity_y_geo: 12.556224610402, - velocity_z_bary: 4.720441084687461, velocity_z_geo: 41.4072552632994} - exposure: {clock_adjust_rate: 0.0, count: 9, data_problem: false, datamode: 31, - drop_frames1: 0, drop_frames3: 0, duration: 42.947, effective_exposure_time: 42.947, - elapsed_exposure_time: 42.947, end_time: 59776.40344791667, end_time_mjd: 59776.40344791667, - end_time_tdb: 59776.40680276134, exposure_time: 42.947, frame_divisor: 2, frame_time: 10.73677, - group_time: 21.47354, groupgap: 0, integration_time: 42.94708, mid_time: 59776.40313724624, - mid_time_mjd: 59776.40313724624, mid_time_tdb: 59776.40649210423, nframes: 2, - ngroups: 2, nints: 1, noutputs: 4, nresets_at_start: 1, nresets_between_ints: 1, - nsamples: 1, osf_file: 2022197T124606499_002_osf.xml, prime_parallel: PRIME, readpatt: BRIGHT2, - sample_time: 10.0, sca_num: 485, start_time: 59776.40282657581, start_time_mjd: 59776.40282657581, - start_time_tdb: 59776.40618144711, type: NRC_IMAGE, zero_frame: true} - filename: jw01227002001_02105_00001_nrcalong_destrip_tweakregstep.fits - group_id: jw01227002001_02105_1 - guidestar: {gs_dec: -72.19487491390106, gs_id: S0W8273581, gs_mag: 16.53066253662109, - gs_order: 1, gs_pcs_mode: FINEGUIDE, gs_ra: 14.87304998699964, gs_start_time: '2022-07-16 - 09:11:50.1890000', gs_stop_time: '2022-07-16 09:13:18.2530000', gs_udec: 0.000161019298902668, - gs_umag: 0.07830334454774857, gs_ura: 0.000309062073595355, gs_v3_pa_science: 275.1600184488465, - visit_end_time: '2022-07-16 09:48:38.9550000'} - hga_move: false - instrument: {channel: LONG, detector: NRCALONG, filter: F444W, lamp_mode: NONE, - module: A, name: NIRCAM, pupil: CLEAR, pupil_imaging_lens: false} - model_type: ImageModel - nircam_focus: {fa1value: 0, fa2value: 0, fa3value: 0, fam_la1: 0, fam_la2: 0, fam_la3: 0, - faphase1: 'NULL', faphase2: 'NULL', faphase3: 'NULL', fastep1: 0, fastep2: 0, - fastep3: 0, faunit1: 0.0, faunit2: 0.0, faunit3: 0.0} - nwfsest: 0.0 - observation: {activity_id: '05', bkgdtarg: false, date: '2022-07-16', date_beg: '2022-07-16T09:40:04.216', - date_end: '2022-07-16T09:40:57.900', exposure_number: '1', obs_id: V01227002001P0000000002105, - observation_folder: NIRCam Imaging - Observation Epoch 1, observation_label: NIRcam - imaging, observation_number: '002', program_number: '01227', sequence_id: '1', - template: NIRCam Imaging, time: '09:40:04.216', visit_group: '02', visit_id: '01227002001', - visit_number: '001'} - origin: STSCI - oss_software_version: 8.4.11 - photometry: {conversion_megajanskys: 0.39250001311302185, conversion_microjanskys: 9.225489294810032, - pixelarea_arcsecsq: 0.00396, pixelarea_steradians: 9.31e-14} - pointing: {dec_v1: -72.1705913196639, pa_v3: 275.7585278550421, ra_v1: 14.26338813102824} - prd_software_version: PRDOPSSOC-055 - program: {category: GTO, pi_name: 'Meixner, Margaret', science_category: Stellar - Populations, title: 'NGC 346: Star Formation at Low Metallicity in the Small - Magellanic Cloud'} - pwfseet: 59775.46035327546 - ref_file: - area: {name: 'crds://jwst_nircam_area_0015.fits'} - camera: {name: N/A} - collimator: {name: N/A} - crds: {context_used: jwst_1046.pmap, sw_version: 11.16.19} - dark: {name: 'crds://jwst_nircam_dark_0378.fits'} - dflat: {name: N/A} - disperser: {name: N/A} - distortion: {name: 'crds://jwst_nircam_distortion_0141.asdf'} - fflat: {name: N/A} - filteroffset: {name: 'crds://jwst_nircam_filteroffset_0007.asdf'} - flat: {name: 'crds://jwst_nircam_flat_0574.fits'} - fore: {name: N/A} - fpa: {name: N/A} - gain: {name: 'crds://jwst_nircam_gain_0097.fits'} - ifufore: {name: N/A} - ifupost: {name: N/A} - ifuslicer: {name: N/A} - ipc: {name: 'crds://jwst_nircam_ipc_0028.fits'} - linearity: {name: 'crds://jwst_nircam_linearity_0052.fits'} - mask: {name: 'crds://jwst_nircam_mask_0063.fits'} - msa: {name: N/A} - ote: {name: N/A} - persat: {name: 'crds://jwst_nircam_persat_0021.fits'} - photom: {name: 'crds://jwst_nircam_photom_0111.fits'} - readnoise: {name: 'crds://jwst_nircam_readnoise_0184.fits'} - regions: {name: N/A} - saturation: {name: 'crds://jwst_nircam_saturation_0097.fits'} - sflat: {name: N/A} - specwcs: {name: N/A} - superbias: {name: 'crds://jwst_nircam_superbias_0152.fits'} - trapdensity: {name: 'crds://jwst_nircam_trapdensity_0004.fits'} - trappars: {name: 'crds://jwst_nircam_trappars_0002.fits'} - wavelengthrange: {name: N/A} - subarray: {fastaxis: -1, name: FULL, slowaxis: 2, xsize: 2048, xstart: 1, ysize: 2048, - ystart: 1} - target: {catalog_name: NGC 346, dec: -72.16920833333336, dec_uncertainty: 0.1, proper_motion_dec: 0.0, - proper_motion_ra: 0.0, proposer_dec: -72.16920833333334, proposer_name: NGC-346, - proposer_ra: 14.77060458333333, ra: 14.77060458333333, ra_uncertainty: 0.1, type: FIXED} - telescope: JWST - time: {barycentric_correction: 289.8608800023794, barycentric_expend: 59776.40680276135, - barycentric_expmid: 59776.40649210423, barycentric_expstart: 59776.40618144711, - heliocentric_correction: 289.3635997083038, heliocentric_expend: 59776.40679700577, - heliocentric_expmid: 59776.40648634866, heliocentric_expstart: 59776.40617569155} - time_sys: UTC - time_unit: s - velocity_aberration: {scale_factor: 1.000014379756885, va_dec_ref: -72.15458726830636, - va_ra_ref: 14.71874755259901} - visit: {crowded_field: false, engdb_pointing_quality: CALCULATED_TRACK_TR_202111, - engineering_quality: OK, exp_only: false, internal_target: false, start_time: '2022-07-16 - 09:06:02.0300000', status: SUCCESSFUL, too_visit: false, total_exposures: 12, - tsovisit: false, type: PRIME_TARGETED_FIXED} - wcs: ! - name: '' - steps: - - ! - frame: ! - axes_names: [x, y] - axes_order: [0, 1] - axis_physical_types: ['custom:x', 'custom:y'] - name: detector - unit: [!unit/unit-1.0.0 pixel, !unit/unit-1.0.0 pixel] - transform: !transform/compose-1.2.0 - bounding_box: !transform/property/bounding_box-1.0.0 - ignore: [] - intervals: - x0: [-0.5, 2047.5] - x1: [-0.5, 2047.5] - order: C - forward: - - !transform/concatenate-1.2.0 - forward: - - !transform/shift-1.2.0 - inputs: [x] - offset: -0.147 - outputs: [y] - - !transform/shift-1.2.0 - inputs: [x] - offset: -0.16 - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/concatenate-1.2.0 - forward: - - &id001 !transform/shift-1.2.0 - inputs: [x] - offset: 1.0 - outputs: [y] - - *id001 - inputs: [x0, x1] - outputs: [y0, y1] - - !transform/concatenate-1.2.0 - forward: - - !transform/shift-1.2.0 - inputs: [x] - offset: -1024.5 - outputs: [y] - - !transform/shift-1.2.0 - inputs: [x] - offset: -1024.5 - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - inputs: [x0, x1] - outputs: [y0, y1] - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/remap_axes-1.3.0 - inputs: [x0, x1] - mapping: [0, 1, 0, 1] - outputs: [x0, x1, x2, x3] - - !transform/concatenate-1.2.0 - forward: - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 0 - datatype: float64 - byteorder: little - shape: [6, 6] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 1 - datatype: float64 - byteorder: little - shape: [6, 6] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - inputs: [x0, y0, x1, y1] - outputs: [z0, z1] - inputs: [x0, x1] - inverse: !transform/compose-1.2.0 - forward: - - !transform/remap_axes-1.3.0 - inputs: [x0, x1] - mapping: [0, 1, 0, 1] - outputs: [x0, x1, x2, x3] - - !transform/concatenate-1.2.0 - forward: - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 2 - datatype: float64 - byteorder: little - shape: [6, 6] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 3 - datatype: float64 - byteorder: little - shape: [6, 6] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - inputs: [x0, y0, x1, y1] - outputs: [z0, z1] - inputs: [x0, x1] - outputs: [z0, z1] - outputs: [z0, z1] - - !transform/compose-1.2.0 - forward: - - !transform/remap_axes-1.3.0 - inputs: [x0, x1] - mapping: [0, 1, 0, 1] - outputs: [x0, x1, x2, x3] - - !transform/concatenate-1.2.0 - forward: - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 4 - datatype: float64 - byteorder: little - shape: [2, 2] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 5 - datatype: float64 - byteorder: little - shape: [2, 2] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - inputs: [x0, y0, x1, y1] - outputs: [z0, z1] - inputs: [x0, x1] - inverse: !transform/compose-1.2.0 - forward: - - !transform/remap_axes-1.3.0 - inputs: [x0, x1] - mapping: [0, 1, 0, 1] - outputs: [x0, x1, x2, x3] - - !transform/concatenate-1.2.0 - forward: - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 6 - datatype: float64 - byteorder: little - shape: [2, 2] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 7 - datatype: float64 - byteorder: little - shape: [2, 2] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - inputs: [x0, y0, x1, y1] - outputs: [z0, z1] - inputs: [x0, x1] - outputs: [z0, z1] - outputs: [z0, z1] - inputs: [x0, x1] - outputs: [z0, z1] - inputs: [x0, x1] - outputs: [z0, z1] - - !transform/concatenate-1.2.0 - forward: - - !transform/shift-1.2.0 - inputs: [x] - offset: 85.9392976 - outputs: [y] - - !transform/shift-1.2.0 - inputs: [x] - offset: -493.5106528 - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - inputs: [x0, x1] - outputs: [y0, y1] - inputs: [x0, x1] - outputs: [y0, y1] - - ! - frame: ! - axes_names: [v2, v3] - axes_order: [0, 1] - axis_physical_types: ['custom:v2', 'custom:v3'] - name: v2v3 - unit: [!unit/unit-1.0.0 arcsec, !unit/unit-1.0.0 arcsec] - transform: !transform/compose-1.2.0 - forward: - - !transform/concatenate-1.2.0 - forward: - - !transform/scale-1.2.0 - factor: 1.000014379756885 - inputs: [x] - name: dva_scale_v2 - outputs: [y] - - !transform/scale-1.2.0 - factor: 1.000014379756885 - inputs: [x] - name: dva_scale_v3 - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - - !transform/concatenate-1.2.0 - forward: - - !transform/shift-1.2.0 - inputs: [x] - name: dva_v2_shift - offset: -0.0012348994412606628 - outputs: [y] - - !transform/shift-1.2.0 - inputs: [x] - name: dva_v3_shift - offset: 0.007097660184435235 - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - inputs: [x0, x1] - name: DVA_Correction - outputs: [y0, y1] - - ! - frame: ! - axes_names: [v2, v3] - axes_order: [0, 1] - axis_physical_types: ['custom:v2', 'custom:v3'] - name: v2v3vacorr - unit: [!unit/unit-1.0.0 arcsec, !unit/unit-1.0.0 arcsec] - transform: !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - &id004 !transform/concatenate-1.2.0 - forward: - - &id002 !transform/scale-1.2.0 - factor: 0.0002777777777777778 - inputs: [x] - name: arcsec_to_deg_1D - outputs: [y] - - *id002 - inputs: [x0, x1] - name: arcsec_to_deg_2D - outputs: [y0, y1] - - &id005 ! - inputs: [lon, lat] - name: s2c - outputs: [x, y, z] - transform_type: spherical_to_cartesian - wrap_lon_at: 180 - inputs: [x0, x1] - outputs: [x, y, z] - - &id006 !transform/rotate_sequence_3d-1.0.0 - angles: [0.02385489722222222, 0.13710748305555553, 275.3272320917782] - axes_order: zyx - inputs: [x, y, z] - name: det_to_optic_axis - outputs: [x, y, z] - rotation_type: cartesian - inputs: [x0, x1] - outputs: [x, y, z] - - &id007 !transform/compose-1.2.0 - forward: - - !transform/divide-1.2.0 - forward: - - !transform/remap_axes-1.3.0 - inputs: [x0, x1, x2] - mapping: [0, 1, 2] - name: xyz - outputs: [x0, x1, x2] - - !transform/remap_axes-1.3.0 - inputs: [x0, x1, x2] - mapping: [0, 0, 0] - n_inputs: 3 - name: xxx - outputs: [x0, x1, x2] - inputs: [x0, x1, x2] - outputs: [x0, x1, x2] - - !transform/remap_axes-1.3.0 - inputs: [x0, x1, x2] - mapping: [1, 2] - name: xtyt - outputs: [x0, x1] - inputs: [x0, x1, x2] - name: Cartesian 3D to TAN - outputs: [x0, x1] - inputs: [x0, x1] - outputs: [x0, x1] - - !transform/affine-1.3.0 - inputs: [x, y] - matrix: !core/ndarray-1.0.0 - source: 8 - datatype: float64 - byteorder: little - shape: [2, 2] - name: tp_affine - outputs: [x, y] - translation: !core/ndarray-1.0.0 - source: 9 - datatype: float64 - byteorder: little - shape: [2] - inputs: [x0, x1] - outputs: [x, y] - - &id008 !transform/compose-1.2.0 - forward: - - !transform/remap_axes-1.3.0 - inputs: [x0, x1] - mapping: [0, 0, 1] - name: xtyt2xyz - outputs: [x0, x1, x2] - - !transform/concatenate-1.2.0 - forward: - - !transform/constant-1.4.0 - dimensions: 1 - inputs: [x] - name: one - outputs: [y] - value: 1.0 - - !transform/identity-1.2.0 - inputs: [x0, x1] - n_dims: 2 - name: I(2D) - outputs: [x0, x1] - inputs: [x, x0, x1] - outputs: [y, x0, x1] - inputs: [x0, x1] - name: TAN to cartesian 3D - outputs: [y, x0, x1] - inputs: [x0, x1] - outputs: [y, x0, x1] - - &id009 !transform/rotate_sequence_3d-1.0.0 - angles: [-275.3272320917782, -0.13710748305555553, -0.02385489722222222] - axes_order: xyz - inputs: [x, y, z] - name: optic_axis_to_det - outputs: [x, y, z] - rotation_type: cartesian - inputs: [x0, x1] - outputs: [x, y, z] - - &id010 ! - inputs: [x, y, z] - name: c2s - outputs: [lon, lat] - transform_type: cartesian_to_spherical - wrap_lon_at: 180 - inputs: [x0, x1] - outputs: [lon, lat] - - &id011 !transform/concatenate-1.2.0 - forward: - - &id003 !transform/scale-1.2.0 - factor: 3600.0 - inputs: [x] - name: deg_to_arcsec_1D - outputs: [y] - - *id003 - inputs: [x0, x1] - name: deg_to_arcsec_2D - outputs: [y0, y1] - inputs: [x0, x1] - inverse: !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - *id004 - - *id005 - inputs: [x0, x1] - outputs: [x, y, z] - - *id006 - inputs: [x0, x1] - outputs: [x, y, z] - - *id007 - inputs: [x0, x1] - outputs: [x0, x1] - - !transform/affine-1.3.0 - inputs: [x, y] - matrix: !core/ndarray-1.0.0 - source: 10 - datatype: float64 - byteorder: little - shape: [2, 2] - name: tp_affine_inv - outputs: [x, y] - translation: !core/ndarray-1.0.0 - source: 11 - datatype: float64 - byteorder: little - shape: [2] - inputs: [x0, x1] - outputs: [x, y] - - *id008 - inputs: [x0, x1] - outputs: [y, x0, x1] - - *id009 - inputs: [x0, x1] - outputs: [x, y, z] - - *id010 - inputs: [x0, x1] - outputs: [lon, lat] - - *id011 - inputs: [x0, x1] - name: Inverse JWST tangent-plane linear correction. v1 - outputs: [y0, y1] - name: JWST tangent-plane linear correction. v1 - outputs: [y0, y1] - - ! - frame: ! - axes_names: [v2, v3] - axes_order: [0, 1] - axis_physical_types: ['custom:v2', 'custom:v3'] - name: v2v3corr - unit: [!unit/unit-1.0.0 arcsec, !unit/unit-1.0.0 arcsec] - transform: !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/concatenate-1.2.0 - forward: - - !transform/scale-1.2.0 - factor: 0.0002777777777777778 - inputs: [x] - outputs: [y] - - !transform/scale-1.2.0 - factor: 0.0002777777777777778 - inputs: [x] - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - - ! - inputs: [lon, lat] - outputs: [x, y, z] - transform_type: spherical_to_cartesian - wrap_lon_at: 180 - inputs: [x0, x1] - outputs: [x, y, z] - - !transform/rotate_sequence_3d-1.0.0 - angles: [0.02385489722222222, 0.13710748305555553, 275.3272320917782, - -72.16009119964826, -14.71648616672556] - axes_order: zyxyz - inputs: [x, y, z] - outputs: [x, y, z] - rotation_type: cartesian - inputs: [x0, x1] - outputs: [x, y, z] - - ! - inputs: [x, y, z] - outputs: [lon, lat] - transform_type: cartesian_to_spherical - wrap_lon_at: 360 - inputs: [x0, x1] - name: v23tosky - outputs: [lon, lat] - - ! - frame: ! - axes_names: [lon, lat] - axes_order: [0, 1] - axis_physical_types: [pos.eq.ra, pos.eq.dec] - name: world - reference_frame: ! - frame_attributes: {} - unit: [!unit/unit-1.0.0 deg, !unit/unit-1.0.0 deg] - transform: null - wcsinfo: {a_0_2: -1.5441462125375664e-06, a_0_3: 1.1576170251777338e-11, a_1_1: -1.1658681796908348e-05, - a_1_2: 1.800703104987611e-09, a_2_0: 1.875783214693795e-06, a_2_1: -3.417826887680133e-11, - a_3_0: 1.6574158249176175e-09, a_order: 3, ap_0_1: 2.405415481772904e-07, ap_0_2: 1.5372781779315933e-06, - ap_0_3: 2.6749655514877405e-11, ap_1_0: -1.012094168762638e-05, ap_1_1: 1.1538368426913403e-05, - ap_1_2: -1.582012711590931e-09, ap_2_0: -1.8581589726512397e-06, ap_2_1: -8.776317114247704e-11, - ap_3_0: -1.6887066653433474e-09, ap_order: 3, b_0_2: -6.7569735377014745e-06, - b_0_3: 1.727457718750039e-09, b_1_1: 3.586138515976404e-06, b_1_2: -5.975313111959979e-11, - b_2_0: 4.983259323858914e-06, b_2_1: 1.6903801311562894e-09, b_3_0: -3.4577498036033734e-12, - b_order: 3, bp_0_1: -9.737463129200488e-06, bp_0_2: 6.69251029719917e-06, bp_0_3: -1.6232978266553711e-09, - bp_1_0: 6.064879635893418e-07, bp_1_1: -3.5485995773127807e-06, bp_1_2: -6.962718673988636e-11, - bp_2_0: -4.9627504099013245e-06, bp_2_1: -1.8305143035768496e-09, bp_3_0: 3.896896610063778e-11, - bp_order: 3, cd1_1: -1.5746058246773e-06, cd1_2: -1.7442912326848e-05, cd2_1: -1.7361070541297e-05, - cd2_2: 1.6101826065551e-06, crpix1: 1024.5, crpix2: 1024.5, crval1: 14.716432352536, - crval2: -72.160069881457, ctype1: RA---TAN-SIP, ctype2: DEC--TAN-SIP, cunit1: deg, - cunit2: deg, dec_ref: -72.16009119964826, latpole: -72.160069881457, lonpole: 180.0, - mjdref: 0.0, ra_ref: 14.71648616672556, radesys: ICRS, roll_ref: 275.3272320917782, - s_region: POLYGON ICRS 14.780090014 -72.143669890 14.663478433 -72.140800818 - 14.652507372 -72.176045532 14.769949792 -72.179796115, sipiverr: 0.07087210649587973, - sipmxerr: 0.09659505373293537, v2_ref: 85.87763, v3_ref: -493.586939, v3yangle: -0.07436148, - velosys: -4305.99, vparity: -1, wcsaxes: 2, wcsname: world} -source_catalog: ! - colnames: [x, y, reffile_ra, reffile_dec] - columns: - - !core/column-1.0.0 - data: !core/ndarray-1.0.0 - source: 12 - datatype: float64 - byteorder: little - shape: [264] - name: x - - !core/column-1.0.0 - data: !core/ndarray-1.0.0 - source: 13 - datatype: float64 - byteorder: little - shape: [264] - name: y - - !core/column-1.0.0 - data: !core/ndarray-1.0.0 - source: 14 - datatype: float64 - byteorder: little - shape: [264] - name: reffile_ra - - !core/column-1.0.0 - data: !core/ndarray-1.0.0 - source: 15 - datatype: float64 - byteorder: little - shape: [264] - name: reffile_dec - qtable: false -var_flat: !core/ndarray-1.0.0 - source: fits:VAR_FLAT,1 - datatype: float32 - byteorder: big - shape: [2048, 2048] -var_poisson: !core/ndarray-1.0.0 - source: fits:VAR_POISSON,1 - datatype: float32 - byteorder: big - shape: [2048, 2048] -var_rnoise: !core/ndarray-1.0.0 - source: fits:VAR_RNOISE,1 - datatype: float32 - byteorder: big - shape: [2048, 2048] -... -BLK0   b gYOQU] -A0z -ZQ<}vE^5w 5wΎni<`xUSM< -)q=m0NZjkb<㕧n~rY<bg}'<BLK0   `!4QhJk_(rC%?md\\t=&d;QTvGiB-==;cܼnba<5^r=?hs<ċD'!<˫N<Y}0BLK0   Mp̈[>bRp.EvH|)<ߙ%9?;#E>= 00K*"/@p`W$g?ƢiؾB\Y2?Ml?cA84a>->]"6I˽Poix۾duv(D^ݽ @(>u\׽~ಈ:BLK0   U{#*1Udc˕/@U[? پD@ľY -xkѽ! ? -eX厼M>8-_={=tE*T -ցܾژ2eW8&ܽ7 p>b+>0c|ߧ=lrp=u&Ƚ!=BLK0 0h^=vGJ)dO^ BLK0 ЙAnj?$^ ?dOBLK0 0h^=vGJ)dO^ BLK0 ЙAnj?$^ ?dOBLK0 dG%o `c?+ 8z^8?`?BLK0# U8 -( >_~;VBLK0 Zh-P&2`?* 8?z^8c?BLK0@|Ňa JdNh=䐠>BLK0@@@KT* 0m)j@J4u@PS;u@}W}@Q 0#@ $2>e@n1/k͆@Gc@EE@NYbDG@BSGz@Nt@]Ys@ob7l@߉$>@wߖ@s^Jh@sU_|@2 (@a@iD@%Ty=@)'~@8#-@߃.W'@HLuƊ@j9t@/g4@tg @3*@3nݜ@"/v0@:u}t@i!鐏@6oNt@pN}@ĩ@=ɜ|@5f@nb@|qL@5q-L@.wS.@"u7@i?݇@X3@2L'@j\K@eWt2f@:-j@0dG@"|I@ Ì@s)>=@S@ଳ.|@ ҃]@Wtih_U@O7Qr@~F@\v3@9:Oor@nF^:"v@d,z@ UxT{@dC%@RJI@A@oI|@:@ӝ@ C@59t7@F>SC@pV@Tݔ@r@Ⲓ|@S }@q\l@DI(@`@@u@K+ ~@lkL0ۆ@as@Gԡs@v@ & @y/h@q2 a@-%c9͛@8a)H@Cqx@̼!%p@-}@<;Uv@Dp崏@>~\@>;|6@:G"@{/a@I8@UA{@\m 2V@,g^$@Ǝ@%d tm@;qi@*@@] 2@T{U=0V@ŵN @nu@t^s@wi Y@%y@+@)s.ρ@1lhK@͑@V/@^5n{@}VY~@O"\4@i@hˢݙp@ sf@,kQ@ӯH@9@Fw|y@e[@L(r@@Z@vr@uӵ@U6@ExE@Y4=T@Hsf}+@hyg=@Xdr|@@aN@KӘܕ@ ((y@oN!@P\@:C>@,6lʅ@z(ir@Խ[Ǎ@f @-@Kr-@JQ7H@} 4@ BD@0^ڙ@hVKi@H?2-=[@ϰ҂6@;t0`@q$ʝ@d~ϑ7f@Z{@ņTt^Of@qioԂ@pb3\@9@Urc@"ZAz@$r-Ie@S6@1Ƈ@L @jn`#}@LM%@^p\w@Y=n@9@aE'@K$g{p@Tr@G9w@ M@^X@@K[+ w@Π@Q(W,~@7S@X^p@dcFԀ@Ef@X]2@o i@X/@d{@ڻs@q]@jxb4|@z@@K@n,x@f+<@3kJ@]@tF@Fm@#w@a:d@bpJ@(~:@=~@\)'N@gOl@ʹk@'*@%*M@Y~b/@rnږ@>|/@3b8@@eq@أÊ<@||@s.ښ@ 9L@ @Ӟ@jf) @%@#і%@tOh#X@͚@@@9a@)QЉ<@tPG@ں@R'1@BLK0@@@|3#INw,<@db,@}^{.@4@ꇭ&7@^8@y^!9@Z燎<@'gA@XI0B@7;D@$#y-E@x\RE@kk`,F@`^ -G@)lvG@G@e~K@nhߊ[M@t\O@U~MP@ UP@P@ƐbP@͔&5P@آQ@iLQ@~q]EQ@;++jQ@ZZtQ@-*jiQ@>*hQ@99aR@aS@GhK?(S@#aS@TS@-n{S@`bT@*-2T@X۾,W@$xX@*oV#X@ySX@7T}Y@X@3Y@X@Y@7rrE[@&ܾ[@֮[@'h[@jT[@c4]@F{^@ _@ |P`@<[`@Xm0a@5Ga@)4Ha@Yma@.a@1G>b@v̖b@/Pb@a-Xb@Mm c@ec@N_mc@+n}uc@iqc@ d@_P'd@F d@*e@Pe@ļ-=e@yы@؋@@3YL@:V@eI@(p@'&PZ@is@PkT@t(Tk@^5Ϝ%@wx@矱@X4@1͕@C @2岍@x^)q@+ :@&`N@9]%Y@lJ7z@+Gؒ@1ra@>F@O*-@`-@--@/\t -@'-@&-@ Y-@1-@-@5:-@VnX-@ى-@84-@L-@q/q-@ER-@u%G-@b@-@u+U-@GC|:-@QX--@>@-@"ͳ-@Z -@\2-@G\-@v{-@Jۮz-@ -@0% -@J-@:]-@]YX&-@ɹ^-@8-@an-@T\-@2ε-@ʱ9-@bz-@"Ȁ-@1_ ~-@}3{~-@i-@)1 -@j-@Д~|-@g$B#-@Gr-@Ԁ-@[_,-@}Ҁ-@|-@k :!t-@+MF}-@8*-@bmB|-@1(~-@+z-@mUm~-@e/}-@y_K~~-@upU~~-@3\rz-@d%}-@y-@Dx-@~Uy-@0@By-@zx-@Wx-@m5t-@s" Mt-@1qt-@t-@; -ut-@ s-@~r-@ܣq-@Ner-@+O8s-@ Rq-@HGp-@i$Jr-@xi-q-@i|pp-@Ɇ"`n-@hn-@1p-@z%4n-@#o-@ܥn-@zAk-@'}l-@k-@f5k-@9ti-@ng-@i-@o# -1h-@Nsih-@)g-@7FNd-@q/d-@{c-@5c-@ d-@(8, \-@#8]-@܈-[-@":_Gw[-@LW-@ -(W-@62;V-@mEU-@qMYS-@mS-@eQ-@\Q-@gD'O-@BLK0@@@ʭg#7NskdS3$(fn R} R* R R [ -Rb R# -R~s8 R'> R(> RCͩ R(* R^ R7 -t R{ R3Ro -R_$Y,j RU R -RHY RU Rf, R R:ؖ& RF0 RdI' -RfP R` R)ㄮ R< ROE RQ,4 -RK  RtqS -R2 RYd R-M R7 R%ަd RDc6 R R(N> -Rg RkX Rf@ -R{` -RKg -RX -Rc*c R6{Q -Rc, RN Rb 9 -R4 -RYoB RLM'~ R[(UM R}.  R \ Rc R@xEl -R@ Rˢ E R !ѧ R R3mZ -Ry= R+w Riu?U RYd: R+" R* RI˒J R - -RS- -R: R1i R @ -Rn R) -R -R Rc Rܰ -RK7 Ryf R=R R>`yW R/C R4TvU R@. RIè -R1?E R &| RT¸ - -Re" R;.OQ -R e -R]J R"0 RJJ RQ_; RS R Rv4 RK R*G8 R Rf R}E R!)}| -R2l -RHx RXV Rc RV& Rw RXӫ -RK RIOq -R+ѣs -Rh/W RK_ ReB RO+; -R^ RW3|Ux RL! -Rq Rrs8 -RT -R RO -R R]H,= RFkH -Rp) -R0LW -R.m -RMWA Ro R"u R Rw Rb R7o -R1Fß RB -RM5 -R4 -R!y Rj R E Rq\ Rv -R6,W Ra@ -R!x4J -Rˆ5E -Ru! -Rʅ -Rʔ R`o -Rn? -R1 -RO R]a -RD! -Rҍ -RbE4 Rh RiԍT -R/ -Re - -R(ɲ -R0U R\ -R=) -Rn -RiAV RB- R~ Ra){ -R:| -R R,"V6 R6 -Rz R:'Q -REQ -R<K -RJ -RG]( R -R$ R -RB R+ -RoB@> RS -R - R7lf -R3B R\b -R9 R/e -R])p -RIp -Rf^XH - RZ -R -R \ No newline at end of file diff --git a/tests/dat/psf.fits b/tests/dat/psf.fits deleted file mode 100644 index 5fe8976..0000000 --- a/tests/dat/psf.fits +++ /dev/null @@ -1,179 +0,0 @@ -SIMPLE = T / conforms to FITS standard BITPIX = -64 / array data type NAXIS = 2 / number of array dimensions NAXIS1 = 79 NAXIS2 = 79 PLANE1 = 'Wavefront Intensity' WAVELEN = 4.35160615344742E-06 / Weighted mean wavelength in meters DIFFLMT = 0.12113008096630436 / Diffraction limit lambda/D in arcsec OVERSAMP= 1 / These data are rebinned to detector pixels DET_SAMP= 4 / Oversampling factor for MFT to detector plane PIXELSCL= 0.062908505 / Scale in arcsec/pix (after oversampling) FOV = 4.969771895 / Field of view in arcsec (full array) NWAVES = 21 / Number of wavelengths used in calculation WAVE0 = 3.87791860148869E-06 / Wavelength 0 WGHT0 = 0.03584629467077997 / Wavelength weight 0 WAVE1 = 3.93557901087890E-06 / Wavelength 1 WGHT1 = 0.06985620195018562 / Wavelength weight 1 WAVE2 = 3.99323942026911E-06 / Wavelength 2 WGHT2 = 0.07044126181863943 / Wavelength weight 2 WAVE3 = 4.05089982965931E-06 / Wavelength 3 WGHT3 = 0.0685489310259146 / Wavelength weight 3 WAVE4 = 4.10856023904952E-06 / Wavelength 4 WGHT4 = 0.0665537768993983 / Wavelength weight 4 WAVE5 = 4.16622064843973E-06 / Wavelength 5 WGHT5 = 0.06435431698912018 / Wavelength weight 5 WAVE6 = 4.22388105782994E-06 / Wavelength 6 WGHT6 = 0.06180149292040501 / Wavelength weight 6 WAVE7 = 4.28154146722015E-06 / Wavelength 7 WGHT7 = 0.05936659904389356 / Wavelength weight 7 WAVE8 = 4.33920187661036E-06 / Wavelength 8 WGHT8 = 0.05682811003267167 / Wavelength weight 8 WAVE9 = 4.39686228600057E-06 / Wavelength 9 WGHT9 = 0.05393243718902959 / Wavelength weight 9 WAVE10 = 4.45452269539078E-06 / Wavelength 10 WGHT10 = 0.051363503475181115 / Wavelength weight 10 WAVE11 = 4.51218310478099E-06 / Wavelength 11 WGHT11 = 0.048895437675352454 / Wavelength weight 11 WAVE12 = 4.56984351417120E-06 / Wavelength 12 WGHT12 = 0.04606700086326605 / Wavelength weight 12 WAVE13 = 4.62750392356140E-06 / Wavelength 13 WGHT13 = 0.04307486762489836 / Wavelength weight 13 WAVE14 = 4.68516433295161E-06 / Wavelength 14 WGHT14 = 0.03979461000009608 / Wavelength weight 14 WAVE15 = 4.74282474234182E-06 / Wavelength 15 WGHT15 = 0.037348430004224116 / Wavelength weight 15 WAVE16 = 4.80048515173203E-06 / Wavelength 16 WGHT16 = 0.034940153982138816 / Wavelength weight 16 WAVE17 = 4.85814556112224E-06 / Wavelength 17 WGHT17 = 0.03244093109605857 / Wavelength weight 17 WAVE18 = 4.91580597051245E-06 / Wavelength 18 WGHT18 = 0.028766955598295632 / Wavelength weight 18 WAVE19 = 4.97346637990266E-06 / Wavelength 19 WGHT19 = 0.02116813224741475 / Wavelength weight 19 WAVE20 = 5.03112678929287E-06 / Wavelength 20 WGHT20 = 0.00861055489303618 / Wavelength weight 20 FFTTYPE = 'numpy.fft' / Algorithm for FFTs: numpy or fftw NORMALIZ= 'first ' / PSF normalization method JITRTYPE= 'Gaussian convolution' / Type of jitter applied JITRSIGM= 0.0008 / Gaussian sigma for jitter, per axis [arcsec] JITRSTRL= 1.0 / Strehl reduction from jitter PUPILINT= 'jwst_pupil_RevW_npix1024.fits.gz' / Pupil aperture intensity source PUPILOPD= 'JWST_OTE_OPD_cycle1_example_2022-07-30.fits' / Pupil OPD source OPD_FILE= 'JWST_OTE_OPD_cycle1_example_2022-07-30.fits' / Pupil OPD file name OPDSLICE= 0 / Pupil OPD slice number, if file is a datacube INSTRUME= 'NIRCam ' / Instrument FILTER = 'F444W ' / Filter name EXTNAME = 'DET_SAMP' / This extension is oversampled. DATE = '2023-12-06T12:02:32' / Date of calculation AUTHOR = 'conor@fulmarus' / username@host for calculation VERSION = '1.2.1 ' / WebbPSF software version DATAVERS= '1.2.1 ' / WebbPSF reference data files version DET_NAME= 'NRCA5 ' / Name of detector on this instrument DET_X = 1024.0 / Detector X pixel position of array center DET_Y = 1024.0 / Detector Y pixel position of array center DET_V2 = 1.4328451011230692 / [arcmin] Det. pos. in telescope V2,V3 coord sysDET_V3 = -8.225701747182827 / [arcmin] Det. pos. in telescope V2,V3 coord sysTEL_WFE = 65.13660331564881 / [nm] Telescope pupil RMS wavefront error OTETHMDL= 'BOL ' / OTE Thermal slew model case OTETHSTA= 0.0 / OTE Starting pitch angle for thermal slew modelOTETHEND= 0.0 / OTE Ending pitch angle for thermal slew model OTETHRDT= 0.0 / OTE Thermal slew model delta time after slew OTETHRWF= 0.0 / OTE WFE amplitude from 'thermal slew' term OTEFRLWF= 0.0 / OTE WFE amplitude from 'frill tension' term OTEIECWF= 0.0 / OTE WFE amplitude from 'IEC thermal cycling' teSI_WFE = 119.84523244548679 / [nm] instrument pupil RMS wavefront error SIWFETYP= 'Interpolated' / SI WFE was interpolated between available meas.SIWFEFPT= 'MIMF5 ' / Closest ISIM CV3 WFE meas. field point SIZERN1 = -1.7661060097145E-08 / [m] SI WFE coeff for Zernike term 1 SIZERN2 = -1.2164177515807E-09 / [m] SI WFE coeff for Zernike term 2 SIZERN3 = 1.88015639884695E-09 / [m] SI WFE coeff for Zernike term 3 SIZERN4 = -1.3557071137664E-07 / [m] SI WFE coeff for Zernike term 4 SIZERN5 = -9.8343084050630E-09 / [m] SI WFE coeff for Zernike term 5 SIZERN6 = -6.6637283504119E-09 / [m] SI WFE coeff for Zernike term 6 SIZERN7 = 4.18964750357920E-09 / [m] SI WFE coeff for Zernike term 7 SIZERN8 = -7.3957093394697E-09 / [m] SI WFE coeff for Zernike term 8 SIZERN9 = -2.1772374198963E-09 / [m] SI WFE coeff for Zernike term 9 SIZERN10= 3.18381682971321E-09 / [m] SI WFE coeff for Zernike term 10 SIZERN11= 1.49884761302630E-09 / [m] SI WFE coeff for Zernike term 11 SIZERN12= -2.6979637047381E-09 / [m] SI WFE coeff for Zernike term 12 SIZERN13= -1.0441355734315E-09 / [m] SI WFE coeff for Zernike term 13 SIZERN14= -1.6115832289791E-10 / [m] SI WFE coeff for Zernike term 14 SIZERN15= -2.0883293688593E-09 / [m] SI WFE coeff for Zernike term 15 SIZERN16= -1.1555833035573E-09 / [m] SI WFE coeff for Zernike term 16 SIZERN17= -6.4907117939972E-09 / [m] SI WFE coeff for Zernike term 17 SIZERN18= -1.8145569473581E-09 / [m] SI WFE coeff for Zernike term 18 SIZERN19= 7.25543545402281E-10 / [m] SI WFE coeff for Zernike term 19 SIZERN20= -2.7775443636189E-09 / [m] SI WFE coeff for Zernike term 20 SIZERN21= -8.2116570067397E-10 / [m] SI WFE coeff for Zernike term 21 SIZERN22= 1.74952390561406E-09 / [m] SI WFE coeff for Zernike term 22 SIZERN23= 2.38615098705438E-09 / [m] SI WFE coeff for Zernike term 23 SIZERN24= -7.3888286198176E-10 / [m] SI WFE coeff for Zernike term 24 SIZERN25= -3.9509066763614E-10 / [m] SI WFE coeff for Zernike term 25 SIZERN26= 1.26205553375334E-09 / [m] SI WFE coeff for Zernike term 26 SIZERN27= 3.70751570258882E-10 / [m] SI WFE coeff for Zernike term 27 SIZERN28= 1.37623092880560E-08 / [m] SI WFE coeff for Zernike term 28 SIZERN29= 2.32973271955339E-09 / [m] SI WFE coeff for Zernike term 29 SIZERN30= 6.81623710736620E-11 / [m] SI WFE coeff for Zernike term 30 SIZERN31= 1.09981601297325E-09 / [m] SI WFE coeff for Zernike term 31 SIZERN32= -4.5263322709727E-10 / [m] SI WFE coeff for Zernike term 32 SIZERN33= 4.48901866263560E-10 / [m] SI WFE coeff for Zernike term 33 SIZERN34= 1.27914778761064E-09 / [m] SI WFE coeff for Zernike term 34 SIZERN35= 8.23794280873810E-10 / [m] SI WFE coeff for Zernike term 35 APERNAME= 'NRCA5_FULL' / SIAF aperture name MODULE = 'A ' / NIRCam module: A or B CHANNEL = 'Long ' / NIRCam channel: long or short PILIN = 'False ' / Pupil imaging lens in optical path: T/F CALCSAMP= 4 / This much oversampling used in calculation HISTORY Created wavefront: wavelength=3.878e-06 m, diam=6.603 m HISTORY using array size (1024, 1024) HISTORY Multiplied WF by phasor for Pupil plane: JWST Entrance Pupil HISTORY Propagating wavefront to Coordinate Inversion in y axis. HISTORY Inverted axis direction for Y axes HISTORY Multiplied WF by phasor for Pupil plane: NIRCamLWA internal WFE at V2VHISTORY 3=(1.43,-8.23)', near MIMF5 HISTORY Propagating wavefront to Detector plane: NIRCam detector (79x79 pixelsHISTORY , 0.063 arcsec / pix). HISTORY Propagating w/ MFT: 0.0157 arcsec / pix fov=41.028 lam/D npixHISTORY =316 HISTORY Calculation completed in 6.583 seconds HISTORY Created by POPPY version 1.0.3 END >v:Je>,~v>d>Y4e>k>| 1r> u>$p>{wĠT><#7>OBߧ>0I.>m:>Ciԍ=>)ph>R#»>U%> >Z$>FE>5m]>+qi>b7C>L ->U>OƘ> y$>`*>F>fsMR>P*Rb>/k(>ǹ9|S>_}`F>RT`>}S]>C .c>_~>إ >䞣A>-y>Ĭ?7>>0>}Ƶ>#[>>U6a>vgvt>E7>U>9@,h> v3#>̷>(D$>ΰtF>Y& v>g7X>G0H>AH>TR>Sev>S\>rÐ3N>sz>_>bwc>A>Ҵvy>IB~Q>w>`tF>Y6T>@^> Q>?Yg>E>DօMW>}b>f>Ϋ>&7s>p8>6a">@|U>78C>e5> Ӑ>Ae)ގ>])L><}|>; F>=,>>J\> –Z>2eZf>ً>7jW>LH>|">y#>|GX>4M>gpB>,{>!S~An>42>7f'ۚ>`8>KQD>r1>w PC>E1A8>ρg ->A>}_>#詨:>(O>§ >+y0M>⫱>zK>ŏ0n>|>Q#>gAHW>8*>Fߖ2>!˸@>B|>ǘv+H>l>2lry>l`KD#>+>*> *h>:ER>NU>Ej5F>?2M>Tؘ~>kwZQ>y>W-@) ->1鯠>j>b1>4ٝk>]D\'>C<>rA_>M,I>| >CQ>Kj'>CQ~>)u>{L;>{>xi> 3>n#1> s I<>py>aĪdN>" {>a|($>uS>|i>H>lZQ>:JB(>?FI3>¨N>p:o>4$R >#<>A/F>H ->n|D _>y>xg؏T>P%9>rzP#>#`*>[Ø>P*>_x>[]z>Hcr>>uyo>k>=SVa\.>qr܍>r>c-n֮> A>쫞}>ˀWځ>֡y7Jq>y>!f>Dȅ >^Sr>֗b>љh^@4>}>Є '>*">i\>E\z$7>+8¶>(>.re@ >ľ ]>\ͦ>l> 3>lt> 0>}j >;>Thh>=2G>Hq>ܟ>KN>xu>4E>'\@>Rʍc>3->f>O>p9XXF>6>{I>-N$v><;5ܸ>X9>j5̦>Uى>ae> `L>v6E'>~B>PaAn>:@J*>3}/ n>>fVS>.>/+B>R>^I>S{)>Jz>Léh>١ݒ>Qq|>9]r>ޠq\a>/?E>Q@> дBd>+>!>i:>ŠyÐ>Ū/P>Μ: ^\>>y'>5>jU4An>+hQ>a ᏷>Ƌ9C>aKB>*i>V!>ɑps>i4g>6> -A>|F>4hd>%+u/>?PD<>濾ڈ >a\+>cd>th>сՏ>z1v>}Sc>,>J=r>C6>7fi>:[>g>B >Z4݁>~8>QM化>1$H>Y$>-6>)_>-|>[(>2>1;>f0a>}4r>sǓ>F>vHgg>4@A8>~5h>f`J>V(>miE>$E> *>D1 }>?Z0|>/ $>0#$>/Dcr>8]>ٍmF>yD>=>#+>J,w>Z@Eb>A-ʗ> Ja:>7->T^Ե>v%{:>5J>D4,j>p> >D`>W->>{hIf>@o>ͻBJՉ>X>-3> Td(>er1\>:>[8'>RuΠ>ȡ>ib>κw\D>H>14>NL>ɡ~>dg>\>|O->_ >/X>r.>M>@ <>&->l>`>9j>_}>Z[>Q$>ފ4P6>:ߞ>#D>|jlv.> -`4>_y3>m@>+>6(b>H7>3ԏ>+7>XM> -V>Yu>ee𩿢>wY>7l>$2>80>j>[%o;>UՄ>%f>t*b>0wph>ovώ>rZ>\4>];>FzS>Ha&L>|k>A[e7H>;|7>>~)> >U>{15x>kv>(̷.*> &>L,> !>g>j>DŊ>PAJ>"q>ԩf"ݬ>Ώ5@ >ħj">C5T> p>6A5n>WU`\>xW>Ԝv >ǧs&>Ò>䈺VZ>D>2KB>;OD>/l>k>a$׎>o>T$i>kZ>!1>\e5>p|y>躅i+>_ n >3L@>0,>i5c>%;ңݻ>U >mRo3>#Nڈ> -*q>TlѰ>-;W>dx6>k^p>v&)>6t>5L>籙>>+D0>D>|>^o=$!>ϵ>>y+b|>H6>,>P>iZ>r\ >xb`ףq>{C>4L>>z./N>' 2>E>.g]>8>>.>Bce~>H >s[<>D6>RTQ>xG>6\PW>iL1>a>[RPwkL>sQk>aH:>IK>%.[n>E׆X>^ D^>@ C>M;J!.>m9>i> V>K >@Ad>2\>#6^>͊+.>ڔ>k,L>Ǫ>a>Ί0Y>ƱI@e>w)->+>'c9 >J̎>`L>p*>R|zR>>Tw > H>ݞ>>́>oBT>6ۈ>/>3` >>f&C>y |>.o.u$>9>܂꽕>߶fU>y9># Qɝ>0t|>Z u>#]6>c'b>>d>*q6>+u>+ ->TJ>>&X>i83>kNԷ~>Ė%Q>Cr.t>V0=rf>d32>r꠆23>-'>2> ڗ>[*">?>D>wk>C*H>,x>|" L>Ѯqò>bDb>6=^X><>>;_I>D/>ZE >͌+uL>7.>$->wQ^>ү;+>Lb -n>_Of-'T> ->ro5>Z0j> -4,>-I->~>۹t[>f|EZ>b@0>ԩ*֚>~-_>ƛںH> w)>P%M3> l>0|UzN> Ϗʤ&>މB>gB<9>f hg&>ɾ}k>D$>K<ۂK>=+(> C)y>V,2H>Z>;>Gz>90&C> x>㱣>–>">Ė>{W+>Fll>>{>ſ>ӳ>m7p>S>lijB>mx8>dq( >^h|$>pdt$>['>ܻ0w>VZ>S>uKyˊr>FX6>4B1>bؐ>]>ccam>K?+>>2Z6$6>b2WW>] ->)=>s>6^q>6F`\>Kf(,>$>/j^f>>MP$>B.>> N>Ȩ8ς>yv>FV>ࠖt>/yK>r>Ռ%>X=>>a/>h>,>A>5y#>N\>!y>lS'>SUWJ>x+Hwl>k >TOB>J{>Bc>-xX>L>:o>e:>KFT>>x>?G>vnX>Qo|>IzH>B=9>7d>#)B>Nlu>VO>p&P>#'U>Dc>Tؽ4>YU>)h>Šܷ>>"8>K|>*u9>Os>B>ijQ>`R>80>ߴ,>Ĕ\:>;[&x>ʧnm>śIE0>N_>h>>l,>HV4K4>*>R萸>et >c+d>'l4=>ґ[>@z">%op>Ep>n>X ؂> >9>5C>b{b~>Bd}>E>NZh>F\grq>ܨԜ'aL>jZFd>^m^>؜@> ;ڌ>~z_)>VX>QӤ> I>Ȏ`>հce>û4>:C>녍6M/">f>>Kƣv>[C@>&ۂ^ >u ϳ>v;.>J>F5Q>4H>-?>6>/k 6R>Q?->ML>b\4>O>8ZX>oԱ>s ܪ>+#L>%r>IhN>Õ'>r5}0>¹x>RvL6>c}Ub>KsR>! >*>>Xl(F>Ȫ8控>x ->Ŭ> ->VqH>Qer>`>8\!>Oj'>Ą:X>i=> >ȿ2ZR>kC>sR>6e>آbX>0yn>]؛Ӈ>N3-> -f>Br*g>#F>ħV\">st>>:6>2h5J>=G~>ҪO>ql>>Rx>شAdɩ>3.>-}fF>܅7N>RƠ>ۆB5>3섴D>'Hq@4>)N>- 1>ۣq2Nt>iF6>@$C>i ->kXn rJ>+=J>FwM>ǚ.8E>':r>b- $L>(Z>* >; >n$>[ r?>Vy~H>5"Q*>bu>>V={>)d珀z>os>I>Ky7)>X >p">k>e)>79> KE>k5>1 - Q>ᑽ>^O>gkA>"k>; >/o;;>Muh>|n">p`>>=>@>c>)0>p>B^7>ǒ ͟> :C>{v>3 5>A6Uh>"$%>Tq>#i˃>ƥ+ ,>RkM>Uԃ>cuIQ>w.sZ>>Y|>5|^RX>žx>J^> CH4>҆D>>H˵UF>~b/ >'R=bZ>){D> p>2߱r>~e=>:8w:>qhx>὾\>@{6>w>`>g>瘼<Ը> t]>>69B>R%T>:Ta=>+b;>Y&; ->s>!H쐎>E>X)>7>[k 2>'>K -Iv>-Rע>`wRT>tl >*H>ߣM> p5>ijW>2RҞT>ȉ{>޲!>Ҿ>I1>z 3i=>+_z>ok>P>I>x!ε>ȅC>M\K?>ɌIdD>L > 1?>ax]> i\>žVfN>>kN>В+p>A4>K5X9>: Can>|UJ>A>oP>>T@>5j{>ob<]>E7>Fe>xl>[ꩨ>R Xi>ʼnn>2>bi׀#>ket=>÷dl) > -L'K>f讗>^>V?h&>b3>A+I[_v>O,,>Y m>*M>u`+>>.+:>%9 ->ߔ:>7d䰔>v@F>۫bJ>}bjI.> NJȘ >Qe>^TX>zg>#_d?>Ǟv֊>>ֿ!>>Iq>[;>~K>&s>w$*>{Cф>jv,f >z>|>ͧҗ>wCQ4>!T+>_ǵ>ǥd#>bu$>\n->ݻ+>q5CO>­1k2o:>ŢJ1j> I|>oR>u4>B;U:>b>DUsr>*tk>~FP>RZ>`&>' T">;>Ӵp >P5>I>ƿ o>e>EcZ:>D2j1>A3g>{8>h.T>sRӇ*>>~H>H>U>nj> O>Xl;>0`E>TF>aft>ˢ!H>b >Pځ5>ۉn>z~'>Ԉ!>g,l> {ϣ>2RL>᧭>g>l>ܹf>F>>\6>H><7/>>Z)> y>ʷ ->op>FޯZ>O=Y>3ofl>ـJZ>O]*>xB >лA z>&;.>[Hc>~>\6>._>e>\>ӫm#>3夞:>֏ LH>ϩƢ9>k f>K9N>rN>{~Xf>͞>>–>H *2>ǿt>.>GsL>y3^dP>Ϗ^+>fB@>rjU>3+>o>-\iˍ>%&>Ƅ Hj>]>ygs>SM"=>v*>8U>&*Gv>䐃M`'>8>ܚ>c/>n >¤4]VF>d>n&>Ѿ>ն(>Xw`:6>ķQ0>3Y9>"xDb>M9OD>,w)r>.|.>ԝzx.>6>O#@*>5;=vX>({.h>۲>k)]U>"z>mo>tQ%n >i0x>ҋ'^><;2X#>)NON>XLJ~>nK>[v>4>Ϻ>ζF¨2>ǎͼN>SĿ0>af>8>)g>x>,ԛ ->+瑄>$Y>I> q>f > \>F>cV>2Z4>⠁ ->Lp0%>[ o>m -ZG>*!`>} zC>ӽqʁ^>K=2i>3 - >y>V>sǡL˟>%u>nǪ>MLK>Nv>}>ʘ&:ᮁ>״t>!JG>e`1>jj>Ҧ`_> -y>d >;>H>]dU>P8Rڪ>T>B5j>y $>ǰ K1>_{T(>.\x>~iTO>@yI>$x>b->>(ύ>)>kB|>Uv6t>G>>z >,m3T>h@;B>1-[͊?D>C'>旝,H4>>ѱ<k>( >nj>kS>)?X>!+Y>P#6r> #>fDo>HnN>,X,X>nc S>l:Eɯ>2o6> j5#>*7>!g>-PTt>ĉm>@>ă\>DHF>U#`>ő 8>CGY>a^l>ѡ,">FG>>f#>El.>ԈS>Ԗn:u>؞J>༛7>G:T >boEG>g2˶>el>ȟ>ڗ-C >۰^>7.Q>\Px> ,>ɩC(>Ѵ<ެ>໳iL ^>ز+>^> >j> 4I1E>g[>LJ -:;>q>"_>a$> t>:BA>Ή/5>֮Čʎ>j+EH>ՀXp>.+"@ >b j>d>ͣeF>WI)i>->% o!>)cT>ڿb`Z>ejwxL>;N?IgXN>9$>U_Q >Xe~>ҥO>dZ>^$>pdJ>>a >ϱ @$}>Ӑ\paA>B|>.-.Zo>9m>>Xg)>YO>Ţ_)>_$>h `>Ċ>#(o>niӚk>- -M>j[{R>q'>ED>,ajY>9`(>c̙>ѳsZ>֑10j>|Kn>߮Ke:>ڑFe>=">?xӿ|>KXl>[U>xf>+!n>e>u5x>#;>z ->]>ɜ> .t.>\lfCb>Ү/I\>א7d>*>5*>>v\>P,>a>gF>l>nnڇ>Xȓ>X >H$>l±$>1Q؆>Yگ>࢚k3<>v@>F"y>ئxu>143>>Y胡>^ /8>a5 ?>(<>u :>N o> i<?߼ށw>mjɞ>ЈJ>HG>\jF>i6!>똬n+>m ->6>HD>G)T>Ј$<_>'J>䵃xHWS>ڨp>ƸcX"> -"|>okx=>;3<>jH;>ѥl>!nKц>,[ѽU>۶:Þ9>\s*>0>H >.$X@>QYc>ܷ[">S >|G >nF>ci̠>CPxv>c0ɖ>OK> Q\դ> i>~)>np>Lb>m0X>.'z>gm>O> >ƾ4)j>0ÿ^>y;9 >*o>ܻ2If>׭{G$>ۉ85" p>eXR>nnF>l͌>Kn0R>R-Y>S:>=r\l>€o>oX|>՝A)>C;L>ذ=>R%#>j>]>ȵN>OoV>n@>ӲY,>CG>l >ǐIT*R>γ V>4A>8>k ?*)^>@>>vR>Ԣ>{>ʔN&>u%Y>y%|>?K>VbW>IJ>߸>. =>ku&>Оʁh>``>ћ,>0Ayl>}n"x>LI>+=E>* >I@4,>I&>DZs'>)L>e>(>CD_>GA>/׊>ŧH>=K>~AZ>p<>-\ >I?xzs>q Ub>~Sz>>^68>찇87>@<>t)h>iԶ \>ݛ5>.s>><P>(>5A>M3>% >'#\ڦ>to'">W^4.n>I >_Q7>N6>1aq>\+6>M'w>Сd;>]\>l >T4k>ۉs+^>B~O.>Ӱ>A;>+ݼ>A`> [ٸs>Ҫ"L>4o>6 - 6>Dy>讹>W㲐>jAs/>ာ>2!?7>.d>~;ah>_T>L -6>0̘Y>IR>7z>f>Il>JZt>-d>Ք_$>䔋C>3>$1>ꍅ,l>ֿpr>hR>tTX>Ԋc^>k&w>@JC>Z-P/>o1>߉mB>ܐr:> >h Ͳ*l> -X9>W{>%O1(>g1w݆>kA\>eڞ>|><>2 R>'b!>DSR>UZ>P.2> y+Ǵ> B.(V>USu~>Ѧ->䐂u>{WKh>ld>+yaaT>$/>xB>@7>"'>AL>mA9>pZ>d=KS/>w>k3}w>–.>ʩ y>>qHf >7N]>䩅d>_rU:B>yěe>i஭!>Ojࡌ>MZo,>==>[>UIMh>k1a>p'nI>΄@>L"? 'xz?09ȏ>%%6>{lW>ND>?I >׶C>>9>D>.{K2>I19P>0RI?>ClsF>%|9G1.>xIQ ->*ȳ>ՍJ.&j>OP>wrZ>A>N -m>i4 >nZE>sD>: `^>l >ٖ #,>"h>)Yv>..uh&>ݓR>pe'>\Wi>`}e>#,^=o>pt> +,>ޤ_;Ϊ;>H%{9>lY>Z_d>V8ѝ>w 3+ԥ>E;>">鹙̾\b>(>T%@T@>hpe>_;Z>a>B=P5>j>)7> 9r>&U>sj2>gR>|XWL>> ν>N>l[>?z >ߞqb>q!>Y>`7>m?}G>5ja5I>σ> B>->޾F >|>ՅU)j>K#Ro>PzJ>{+>`H>xl8?]>6>Y>Y >GY1>5?>P1>B2A->竅FvY>LK+|>舭>"33R>$>Ѓ5|>䖂 .B>ނwa>S3{>j>(-#>MO?N>ֲ@$>$L>ᤷo>U>P;I>@!>r&!>!v|>+_G>g~R\>K >s^>[|>@>6:M >cIv>Mwa>'c%>s7C>ԗE.>ؼ‡.>S>܌>9[ -H>xW>)5(>ƭoе> &> MVjh>8X>yK>Η>vP>0j^> ׅJ>uR~>*o\a><>>Lo>DB7>>׃묛>( >[H$S> +>[Wu>bԏ>|8Έ>x(9>~h>m~>sA>̇(>|>5$>-x6>ضPx>[>QD\>|>.qn>Q>ht>J!>ox>۳>%n>9w4>Q>"۸>KK*>Ud>򅶚N">5Y˂g>O[|>֏Vdo>~~:>ƻ,v:Z>UZOo>ad>ȹ2&>ˮNFX>ҬC>uO2}>)ø>Q>L> \>Nّ,^6>Ct>4>v1Z>o3>ܷr>.V>ܱ> >Ȣ0>~`8>Ѡ̲P>ÇH-t>[>>|$>Xhzh>*a>p>OAD>,">fs[>qG1>錞8b>S]L>$Bͺ'>(>V~>]t>^|_^>5He>{>hÏY>]R}>̡^5>0FM6>vRD>"|>> _>$ۣ>ϴ>==d_>'M>{?7>BX?6?!>^>ŭ|>`' >g[$>S`<:>[ tn>|>pP>]4>|tl3'>T?֪4? #?5>hx>p>K8>rw~>|`@>K -r>Ǧ(Q_>L>+F5>;|j>?c˞>YkSOs>᱋z>N/Y">>Eu># C>H0>>ד _>˵ -E> *>ja!>R>儖WLE?>杻X:>1:>{ȉ>tSA>ۣt>>L_$>m<>Wwb>Y*>W~\>*Pg6ɻ>ڇ E8B>33>"5>;DN_>2i>w]>~27>9zoβ>!>(ƟZ>ŗ4>qÿ; >` Ld>-wb>i >4>3%lx>ܡ8>|"c >зxd>/j>ZN6>эOj17>{>п:1[>u[K`>OVDf->PTjF>Ɨu >")>:8Sy?vZ ?;l^?F? F֬Cu?)7$>%.>o.@H> Ef>p|\>ӌ2>˖umVf>ꩆj >HU >׿,A>$mAk>#>>A>2Ot?Osh ->n}G>?B>TO >"w>/]>K}(>CP>$>s857>>Y>Ej2>ȶ+\>ŪJX>Q=N>bbi>n{> nV>J6Er>eݚ>r,,>fD>A܋>W +>A'>tӝ>>䪏>S?b=P?[u?;?*>r>h:3%>{+>O}qOb>萘Fw>re>>6>u1>הs{g>"?(7D? -@? -.40?Az ?̽1-?i.pu^? ?0?ai>r8n8>4_>8g>?N>I` -s>cuv|>=wf(>M+8?8QZ?¿L? -{ѭd?_? -Y|? N>$< >Usf>k>JƟSa>9dǏ\>O?>᷋nL>P;6N|>xIur>?@?7?-f? %ͽ?w&>Vi>黆q~>f͓~>6S>` ~>G(2ף>g#<>@t<>dDR>B QO>ΩQl>vg8>>H>%>ȺH> '>6<>q>TE&>ߓ Z> 桠[>hY" >B>k>|>>R jz>v>-+> l?~CJ?i? -z:? .3?F?9Kɼ>lz> c>$e>7r>pÈ> d>dzh? mK z`X?״#)?a ?cEO?'}Bt -? .,P>͎=>t>sd> <]y>"A>ҽ{>=_>]]ܴ>#r>γ>ҦDzx?8?? ^?]?_[>>adRm>_tQ>)B5?7\?C>3!>s)g?o26? -NBX? F.~??+\? bs? nA>^7W{>c6v>}AJ>GL=h>ۙ*F>' h>ցFwg>ڬFwT>24y>tQZ>Z3Nr>ܸ>)WNr>M _j>МXM>ݥ$">wu>R 48>" Z> -{J4> >4>7.>뿅#>+./> 4Jt>p>W>bC5|>.:Ĵ>W >v>pm ->㴗e?? ^X ?=IRc? S6?TD@?ib>?>gKm?(U>>෾?Z?O=sJ?n+?uʻ?E?-Ux?q>V`|>0˨>,@ʄ>Kjvt>7f>%|z!2> -S.>-&|>S[L?)^:?!?@?!!꤄8?ߦ?w}?/>_wG>g,i>Y?o[Ŧ6?1^0>AR?a}u?o=? d?D{洝n?imBz? ~]?Dx >ˠ0>1zq>8@B>}>w4>̲.i$>B^~>6&V>(7?>Ͽc95>Rz>{ń$>nk~>UEUN>^{*>/c>挤tP>8^>>a,>$R%G>;h>ѬW(>S->?>n>d>ٜoG{>yj>=O>/_iJ>m^ ]d>I9`>[>>.`?+{K? 7T[1g?%^ ? vw7؎?x>? a>6HJX>z o>(+74>* d6?s?^a? ZJ? -yd?#DY?*VGAJ,?'oi>@0 -# ? - CdJW>*w7>N-? p4?%>0k?96?+Y=x/b?&ĭ? ?F͜?IS:>:5*>꿏wPM?}H?]?B(wx?N*^?;],U?&,Q?LY6?ZI? Opp>YF>C/>i>M=>S}2>甆t^|>`>H />8>3߰IL#>ֻ>׶ -jUh>G3J>\u?>˭P>ʼ1>'5 $>O:>+ybd>Ɉ{y>hyV>[i>q6Lj>g>I_ >8>E>Rz5>UB>5~>^ۧ>>>l>M>ZӶ>vH?>`L-f-?&FO*?x ?:y?2)ܖ>*>FL>$B>gй?31>Iͷn?jf?#&,Fl?2@YfD?0OOt?m ?#?!8H>송V?W O>z"gWx?%>=R>m(>E}? r<>%M?1x?IQ?*x?N]k2? -|38x?i>|J.w>! >ޙQE>>,V>a>r"> GqԢ>W)(> m.7>wb >ܻ>ٗ&>>.>]u۞>A>}mgl>Ї}N]>u>гCj> O>Ɯ 3>"p>4X>҅U}>":v>Ղ.۸k>gꦊ>}>BڴT>OpP>qaS>[1 >@q&>~>ݨ.6>ij>S>]N:>p?g4?h?,f9Z?p"< >̆}`?t*>x5w$>erHus>*Z>97>NCz?쑆?2 ?6h?)}>AV?6(.e~?2OE??}> ܿP?e?6\Ó?;:?./T9?5t~՚?385(xK?\aâLL>f!>6F\>q?Wmo?{>} -!>lDA>o?:`?|s( J?|g>Cվ>Hs>_$>ۍ>>\ڧX>= ->c暖v>K#0>g5>K![>߿Eq>6wuz>]CG]0>ߴac>~͸>Efb^>>[(> -/>;> D4>ˌF=">ͱ=NW}> &;ؚ>ͲsuP>ѤL'za>0Z>gH>3}>ѯ+>GN>$> yN>*D >.♾>΄>ij[^>ߤlE>fk@>U\զ>M=h&>|'q?2x$t?Im<>q:>҃>s5d>а->.>M)Z>hrMJ?dou%?9ʨ?'^O?-Z?/dz2K?d%.$?F,.?7'$?@ژ ?4.XB?/.o?*a^?+D?SϳȔB>o>?6?H">ܕ‡>֝?>T9z$??.>g7>`|`>;>$>%?{>r@>,vR>?>xNF>,؏>Z:1>ҋWg>Ͼ ->S^w>@^?˜>͗[k>;sp>s!~>A/>2+>{E>>ŠN>> -:U><Ѡܶ>hLS>ad.>=;>"EGQ*:Q> K[>˼X>d>Ƞ1C>J S>YNv>4ܺW>}>d>|&JB>)7hA>o%J?;z? -9wnt? HiJ?5Uy#?g,>ᄫ5'>朐d>s>aTw>`9FZ>&*>;c?Tʜ(7?,?538?4< -u?0?0e؆?# A?$>wY*>NW4l?!hNI?2;^Ol1?3Ppn?5:(?6!$?-"?_?myd?ʯfz>uAa/>go> .>D0?F?yp? q W?ڹT? -vNY>H?*L*>yL>㗓7>\>}o8k>T2>>u>X%>w>ʕk|4O>d0>h'Z>n>*A>nn>T l$ ^>|5S>-$|> #g5>SNо>Mc> -]>%&> >fA>ߥ*$> :>-UnG>>mT>,Ai>ŠS>D;;>+h>(/3>Ӛ4>QI@.>}>1>p5>  L?9x%?{>OB?{h>L`v>튂C>B. ->_6$>:aD>I dn$>J -y?+SLj?,r?,3h5>4dm>:!>񯍿HOv>!>wSB>b1?*? -E ?n;&~>kW>yK"*>(4>'^>i>H= ->-V0>/e>8(͕t>-|▂>>uf&t>˝T\>ؖ>rO>/M> d>yt-3,>5~J>‰P>a>iF'h>&KN> >@>9c >k!>3e>XN v>x>|7>!f''1>\@Dvx>E - > >"T'>TS0>ϯy& >n -g\>m~0>ʷ >Ͳod>ј9L+?T>@ɧ> j8>@>h R?>pH>ڥqL>DE>;>B+>ѭT>[ CbcB>n? d=O?$ ֵߧv>&.[s:>-"=>_"(>;?>Z7> ʒZ>(k?>pȕf>߫`>,ىn>ݠb>/{D>.ǭL>{>7lju>O>|H>2">ͮ> t>@.z> p; o>yz>aX>1,>Co>J'.b>Oq>4>^Yze>o `>1z9e> ޔ>ǻPE>÷;2>*5М>Y>ʭ o>&:K>4FD>Q&Lc>HL?3f>͞ %j~>┚'l>>NY>r4>: q>>O5J >m 7>"?M> -?Fi;y0?Cr? hu?R?-T>ԾQv>5>*x>FL'(>0r'n>F.T?={?-gyS?B^R~?TU?f^P?o*2?hSF}?W@ZW?D!?0Ķ#?{}k{>:|e>O>8>e/x>x>{O7? -3=ZK??ezo ?=? w6? -t>>;UTH}>?]ު?>UQݴ>ɁݳH>?Ơ&?>T{I>(2>ѕ$r>p FlՓ>Ʈ}>y j>%,>)l>rR>h-w>р>(^z>ĈA>I:">jM>i>7_9h>ES.k>/C=>9v3>6)>8uq>Ұ97,>Ђ -4e>AI>ЋǮ(|>fw>KP>upO`>ζd4>ـ>خ ->z-a> 7>3Cl'??uf?["?h#? %Qx?tl ?|ʅ0?,?2m?i? vr~? -8~? xW?.,Q>?(ٷ? yA6?ɛ?<W Z?]-G?hBd -?dHa ?[lN?Z>N?_").?hq?l"Ƿ?_TtTl?=HX,?{f -?©t|?(&q.vf?)FdE+zX?^? .e~? -=XO?qC?Aazm6?p?B}k?ō߇ ?ΈB > $K._>>È>=1q>FP>-pe>>ߢb>\.d>$a0*>ĦEUB>ͩu"=9>Җ¦ː>9\ϙT>tCRrp>٭h>k>)t*>۔>.b>֢"0>Y#>t!L>띂?<>l>d@>QU>᳔*>`M> a(>F}Nz>{6>tN>[p>d >n0>h>=H&&>|J>o 2?Qr? <̼??IU?bkR}?ܑmV?!Sc?!˼?Ė|?p1%@? /DM-?qe?+Jqk?@-?@)U#=?%Z?O^?&'3t?,k?>=I*Ox?:O#?'p?Ҏc!? >(}?\﷘?@~>p?AƲc?{~?.G+ "? nAtv?~gz? p??}(?1>rmt>u=o>e:>䋸܁l>@X<5\>w>6A>L&~ ->!:X>9am>Wǫ>POš>v>ě(>Xib>tV>Iv'>/%N>˨T>J >"?>C}>yy`f><3&>>;n> 4>]{wP>%$/H>shN ?}~?X?b,?XnAq?*I!I?`=?WT? -? ?v?"[d?FA?+0 B?IO?<3?d? Tl?-Tv?>ى?@Ƴ@?.UHHԖO?5 -?K`^?o5!Ni?gfD ?yPWQ?M9Bp?9?2[?w3 -?h̄0`?ofQ?KK'w2?5T?0FNz'?8俨P?/ 7M:k?'d?$kn?$am? 1? j+?D? -BÌ??"?[?t)>Zku>TI$T>>(X}m[>0oz>?[n>f:1>H]S>FZ> ΒJ>Vg(A>H~ >f >ꡭR>0z'T>꽓fD>t>3Q'N>R>1x.>4>Fh;>^x>*L>I6hs>tLM>Vn>ZC ->.0d`>|٬?'[^Tj?u?D? -Ŋ?0?ZCw?͟NjB>E>hj?I>-?>??`)P?$_&$?+x?' n?&E;?7d|6?: ژh?0\?>pqw?Edy?h'?]_/1? n5 ?&?gƂ?U0:?!x0?\}^?g2]&?DI#%u?:*?.v?Qe0?8=C=?3X?1&?0saI?/rn|?2hBh?+/ƯA?"#?^i?hf>?@?u1g?J=#k3\?h,3?js *D?;n_?ȁ?0"??loLq>VM|>Y=>)>K>>j:>8|i)>m@&>7>]2->Sup>1ߓ>PQp>菲m>餺Ns>Gs@>,>CG>wK>*ELK>IeZ}%>,>)&>.n>qɆ>\*Ԡ*?4B`?Jk_M?#?IN"V?oW>?>W,J>|*?أ!?cxDx?_yL#?ltU?E|?:@?ݍz?HjX?(n(G?9z?#x>QC>gʢ>)X>Hrtg>1>iS\>ʶ>to0 >o> -GGD>?M?R>?rh> hm>#*>"5n>F#|4>J')>^'>747C>i^+!>k(~>h͍nG>ZYsb>C:tj>NP>HG9>*>n$<>&Q>.s(>">:7n>y<>1 ,>%wK> iuN>Ou>'>]@L?j`9*?)Bb?d50?H~?Zm?6n`b? C,A?=e>/9hU??&o?;~\(9?:c7? ,ێ?6n??J}D?la(?o_Dx?Ul?}nn]?WtD?|}z?X/?p3bw2?mGdt ?J+bp?*(?(9?=G?9ix?$yH? r?7G#@?PFJ?fԫ? KL=?6? rm<?VJJ>9ai><> ݟ&,>2hDJ>.y>D l>9F t>EN>ע**>rV h[>R>>Ih>i̇ޅ>g]+>og&>'>Ɔ:28>?>"4>>5@>յ9=us>n> DOF4>5>H?[0X>yd>!H>Sf>:6>2YV|>D>>˱N’>ϩ4O>v%>1% ->H-T>BW)>ԒoP?*-e?|Mv>!P?]&A>? ? C[P"?d,y?kU?+{f>4? -^["Q?!?+?PO?#I^? *ȹ?s{ ?vLc?yÂmY?l?@X M?q? ^h>_iw>{>ī.>g>/M>80ZZ>쀔 >Y|ǣ>gP|I>>>Ԩj>󻁍\>F>S'>F>BiNV>Ҟy)1>Ҟ">nh@(̝>S.z-3>¸\>ewq->9TKo>9Zv>B/>?>W>BĽ>jm.>Jxq>>8>^(]p>%i`>CR>Xq>Uf>#>ޘN>̏6>ըR>y6y>f>D> T?AbgA?>za>lӸ>n|.>}Kկ?# S>ʣ`x>+L?YMT? n?9ė?*"xэ??;P?Rfٖ?f2]?p%?hg*2'?Tb\ey?C~v2?2Ԕ?-1F?N.ض>!r>e%>?M(?8$>qI>#?B? r,:e?s ?V#7z>@>(#ݨn>^K>gT>Öl>V}2U>ѥc%>=4^>$<>+Ȳx>hb >>P>z2>ccrI>_'>ǥjo[>ίw>*.Wz>ufYh>>?~>K>W>aS;>>nA>9 >ǀJ>'Bv->m|;">>IԔ 0>J>d\H>fL^>ƢY>Lm2>L>%f>z>˳D>N >Ѽr>s^x!>߻>S$>5S>.R>RŸS>.#y>N5 ->Qլ>1#>>QW4~? -?&(h[:?۟T?0v fp?@ J]%?3Z?MA]?\Q԰D?Oχq?8DՑ?CP?49@ ?zU"?$IJ,C?2\>y >{h>X;|^>'Erji>Ç >(G>?L>&6L>xc>!:ƒED>.RD>q>Ңh [>r~p>Ƿ>Z>o ގB>/ƣ!> ,>=o2t>tQ6~>CJޜ?^>f74>5i>QW>_55>Jwu>`>y&>r`g>DJ_(>Ù!eh>Ϛ~>#-n>rJ>P$k>xG\>X]>!i<>0 >b>;i>jV>W/ud>nt%]>)|17>>q>d!>ŧ2,SH>ĭN^>Ù@N>K >)=D>" sv=> Y1>z.@s>'ΗF>=W>A9`;>-,Ca_>rx_ >p)>D(m*>TDX? w(?/i#6[?;UCh*?2~^?+3 -?5C?$it2?U"5O?,Ԭ?9L?*K?8,_|O?/?3\G?;@?/̅? >x]/>+:E> e>@ D>`=.6@>9?>k?n?)>^VO0>rܫ ->[s>2c><p>Alb>Η ->G>V3:>L>v>W1>->[J5>x| >1ݛ>~5y> -(>@=n>;/E>>|>ǁ>8/Mr>'P,>!Elx>6@> >jBl>2>o'+~>/ha>57> rڶ>^=7>6f>ƈk>6\^>&J>d>ǜkFu>'>2'>@cZ P->Ÿ?>`$q>۹>-NZ>az04?L?0^%kAM?6/%`#b?2B$?/: ?1x/?$b>'h>ᰫb?Ik%?&@.?48?4o3`VU3p>8Ee>>Ǘ\@>*đߕ>슑bg=>a>p>6/?j&?an|D?m>uyJt^>,l>ޣRT9tf>Ȩ_A>>3Gr~>ƲbZ>/D6>Ϟx>4>Rv>(>ig1>dN>DŽ>>>f7>J9s0>>Jl>J+->Tծ^>Ɩ,>^gbȎ>ڨr>z/>8WN\>,>B>˦c>[ւzd>~zо>ϞJ>[%a>&gƵ?>ڳټ>e>Ý@V>IJs4>&JC>ǾO>|X>Yԃ~>_ `?~JN?qF?-_> >0~3R4>˯!h>Mɘ>4tĽ?2b ? He],?㘆ʨ=?EXJ?;2l?By+a?5ۄ?.G -A?(;?vc8? &鏤l?+X>T>mV˭}>[}/5> >/L>7EO>.>9>_N0>0W>d>v>%a׀>1=a>OTl>=.'{N>xBm>_>61VE>zFn>~2D^>aI> ->_So@>'>—g>|>\t>uh:> :s>\kQg> >1)ݛ>v 9 >t? ->J0%>X>ΔyF-> _">՝ g>ؽn>[$>bǀ>O>1=> qV>U78>` -+m>E2>Ȋ>=Suj>ڛ8#>1 -?ĐKY? K&?"㼖>V ->u67>å1V>ЇBN>"V?dC?Zm٦>vN) ? -~O?7e*?<'4)?.b5?3fR?0~%ĥ?+bp>LD7?0>-?7 <˼1>b)JB~> #f> -ȳ&>P- >[L? se?B`?7rqЎ>3c>'F>5{>݇F>ߴNJ>َ˧>>>C ->_OL/V>/38.>ϛJ>Y8>ZHG ->l>ѱ}KD>ɣmgBu>Ƃty>,_>Nl>`j>_;:>JIN>΄mY>]A>~ߤx>έ9Ϩ6>`cH> G1kz+>⬢D(>礴i>&N> %>F`;> Βxl>i#>I6ٱL>ߨ1"A>U/WT>>k4㓹>讪w>bI'A?.-΢?)?s&)?a& />+D_>Dwh>f!>쪉ͷ>s2o.? iNhPZ? RXXy?0ȸTy?/]s?{qr -?#짆&?![B8>?? -V`>¤?#m9W?'9K?;/B?.Ks3?0*/P?!/B?}9X?@?\C F? `>d?*+WV? )'INy?-m- ? GE&?E%3ٞ`?-?Df">.V>jm=>yj%>V>y$> ϡ>-}>>?*6>>>-> eIH >0 >6ږj>Z>[>f>Zkoq>ǀULv>L> 8>h!>ϝ{^S>2.>W9S>mB_^>X @>뺞Vҋ>M]~>鑔Ğ>0K1=>[>2>%>u'm1>V>+ >mq?I`"?ffLe?)]?U8EZ>-v>녑M?F_,?ZHh? ~w?A-6?%!  ?- I? k>EN?pS8? -[]><> z>4m>k2> ?49#?^-_?8Wcj?9}?+;\?&l_ 2? -+:>,ږ?֒U8>%o'> ?,R/>?[RjX? ٿk>b?eIM2G?NKgx?:R&j? GCd?^pm>IwJ>w>8EWa>(m>T >ІT<7 >8&nJC>8(>䕚>p<>}4^>+q >ޱ@k8)>wNa>&+G>ңx^0>bn>ș?˕X>@́R>Ӓ>lt>xq`>⾘|>5L> -A>Cnu>uc&o> 9>ߌG0>B0O>{[>-,>Eu>}3F>BJ>ߌЅ2?5̂?yAHTdAC>o(e>);@?ķY\?mC%6.>fpR>`T?*95z?PG2?gB ?WiL?!&՚? \? ]jSv$>ե3>AJ>gx>רnN>6>^%h>QQ >ӏnFZ>]9?0?.ܘ0?!~I?s{ -U-? ]'?cIo:>H?^>a? N?b၍?Dʎ3dT5>=Uq6>L؏?[? 6=VD?1:k? XQxb?uX>Z`>Z,>b\<>:>~Z>XZ,>E0@q>9'>ɨ>CAм>)t9+>̻ H>$,>@d>Yw0e(>чUa>]f>Q).K>Ӳ5]D>\I؏S>ԣr>vw@>t^}>7_>ܒVR>kBR>KW>NlOd>ͤ,($>tʶ>J8uF>_D?Re+xk?FYE?z, -Bt?~h>>Z4>epԮ@>z}ۍ/?랕>`9d>zwV>isT?_t:?zw/?pye?Y@(.?/ۋX?c$2>ef>E̘>I.>%.Ie>Yf>ϡ#)z>Ar*>y.X>qWX>0ě@>I0?%DV?`%2?*V]W?T<? l7kv>yݨpX>ڞĽ7i>S{s0? ɞ? .? o9>G3>+Ff>E%@?42\?+[=c? y n?+?ѧ|>Ŷ=>~g>0Ӝ@,>X9(>S3>ɓ<">>0Km>ء"(ϧ>E(>n{jMZ>[+>Կ=GC>ғ8ۆ>k?>łͧ>jU>D?옊|>}áC>=.>J\qu>k .>0Ϧ>Ojm>z>z>i_>̓z>w?M3\Q?O^Q>sB>oqA#>g?\>%y/>W w>uz>c<>@>]x#x>ٕƳh>{L?>Jg?Qό,?q? -Lk6? 5R?`:?|cu >l>^*`p>U N(>֊؋¹>F|->9E>ۋpQ,> Ý>A|?W?|Ss?["? d_?tew?8 -b?mt>UN>(|>.>J??D>vu{>\(>vO/> h>cpo;?o!+ ?1C~?(R>w&r>C!>yJN>(>瘚@>2@V">`*[)>֦ ⴛ8>ҟdBf>_+>擷>>ˉT/r>OI>(o>liU>,=<>mXX>nϿs>ϳy -> AL>~>ߗ94>䜛>ƱL>}VC>%e\>}~>h?0>>A7>>w|>oZ>1,v> k>N4yޭX>\%>r>~Hw>Ɖ>=F4>x>>j?/ƛ>QiT>YF>?6"?[ɂu? b%G?r^> UD>RWa>bE>/p">H#>Y4B>oDQ>#tUE>Z2>~8#?dwC? P?FSN>7P>( ?;>+:v>&(x>ɕ >>">~>%J}L>]:_>3qW>lN>y4;b>ocQl>Ry> >\#+^(> sJ>NOи>Z>a꧍>&8">G1>@ddb>+?n>ۯ+>'Y*P>Ќ*>u/>g,uL>1*>V?>bj>x}pP>ı>U>[S@>ï6:x>~h1>@l>\e.>8^l>p>Fl>${>ӕ}>wJN>ݏ$D>| U>a}|>)?O'>܀]>,e!>OFVW>>;!4>iq>1J>I `7>hLP>40Ĵ>_>0!P~>a?r&@?m#">>x ->+>_@>T' u\?28UP>δtQr,>0 C>yGl1R> -kT>g 4?J_'?קԼr>)~n>.@>i%W>}?gc+">`a>&pN@>˴eKV>f,>оHc>?|Z>9E>&>-N> ?>sVРx>Ct2x>Kݥy>U9o>;P>哚Jb>_VI>U6HE>HoFG>J>AF\>T(J}>ޔd^>օƈD->Л>ML'> N_3>1 UD>ZՈ6>ՉTw>آ[}>.Xy>ݫ)l>6>>r֚>T99t>?ƒ<=>? h >-x %>Ј~>vv>6 >">AWIH>\^>䥏sBj>_z\N> >U">P(>gm>y>vBg>ѻ>S>:AT>6 -ǀ>^ n+>]>ACڪ>LP>U:6> ZVN>,#>쿧2>Iwp?a<>cF> MvT?d|4>S쐌6>ժbc>G:>q=⁞>$Pe>vN>鍜qZ>`j >۫s:>YR>y>X{0>HJ>*qn8>b*m!*[>m.{U>^,X>,_g >d'H>Đ!> >ڢ>*m_>6m>[aF>e=:>H҆&>] *>q>ߍ~P ->7WT>ߐ7>ܾ)>s?F" Z>"?>[,>ȉԾI>* >|:FV>յM8w>>Opl>T@J>%=hc>|^b>#$J#%>W!~gp>9&T>鴖7~>@Hl>>Brk>D{2>'U Q>gl)3>9wSuL>>._>gV>(>Ű/LO>>l\F>`>>(}>Q)? m>!>ԜŒ>Dp>ϟX>~->Z} J>}? ͗QO>Ӽ`b> Y+؉?Tn ?Hɼ>pEAZ>Bhȟ>'&>OE3T">,I)>4?>V̓ >u\.Q>=9[>ևSX>ˣI8>L#U>=I@>h%p>֘)>&4>G]K>bG,>ɒ>~RH+>^I~,w>ď>ڷO">;(>?ht~>?MO>H)>}x>F>Z>O|^j9>cǜM>Ki4=>-]'Db>R{W>Эce~>Yu1mW>ieN>_>V>"G>U>xe>{>3'>ỿs>)6st>g:b>&>@XW>3!M>Y| ->'Lw>P>9>Ç@"B7>i;[R>S5f9">6zV>;L>^V&>>u) >Cr>\w?|??A}B> >WӁ>,O>/I8>lؾ>'9"?)Oh?G~\?W-D>[n>PQ>ɢ#>=8> P>[\>e8>Hl>\ ->7Y>/X>9eR>꣯0/>r>V">o(>9>>.>WC>P7e>8>붉Y2>{L >r//r>߾>TX>NIt>,.g>~Dd>C>a|>_>s>>{{%>%T?>E>֒pXj>S4>+{>ᵺ/>U>޶,>vl>>s#!>sS3[R>#%г>z#>ysS>,Il>d">] v>ڧa))>ߋ{B>d'DD>;2>Q>Ĵ*>¶Dv>1C|r> >$Kp>eNj>,>,q>kLP>|^>5]>^y>0rr>P>lyB>;y> >mF>z}?YěD? >j|?Ȁ>I&`>'}L>\JU>b>ӱU>ȼ{>4hW6*>> >> 0Vi>V3m>QM>vj ->tAB>A_> >8Wg|>f:>">ڮ= ->!h>Әw>fe>*'p=> @ (X>f%4>I/uր>9>>? -> $o>̎oU>톱.>Ex><^>)cV>ef>~V>pސ> !ɚ>,?>>-t1"n>"+:>bu>Ұɮ> - V>I*>i/1;zh>?>ֳ >!>Cl>]~H>$0A2O>iedBT>D->iV> 8ޤ(>SKP+$>>&CH>EM + (>ad`!>uY>2x^>nE>\> !>H'>S v>?b@>|{f>ӆ{>ζP0;> >t-*>FJ>*? D G>W>>٘N)1>뮶A>ID@>D[>Θ|u>!ɠ>KP@>! >g>|>1K>|>;e ->Q>Љ>>y>-X>]P\>wR5>+C>/>]M>$7}:>=a_k>f_> ->ޒYZ>ީ# >'_>J5v>7> -3>c]<>cv<6>ftŘ>+>hB> ->;7>"!>(N>f+NX>6AL>^N> -L>8Q>T&4>:k>Z3>PqI?$>܆D>F>R/3c>TƐ>"@>ٻNZ>Tip>ɍ\>>7>ΥQMI>y>8oSk>r>3i8>=>x[f;>z$ s>>5Y|KZ >KE`|>:+^W>Θ@>lU -> -#p>a7A>߮o*L]>.i>X!v?l U>ق>W84e]>g=X>%pvT>Zކ>;k>gw->rLK>ߢ>9bcz>αa5>e.{`>w>.ci>lIz>ԡn:K>Ƥ>>lkb>ؖAItۖ>m+u'>Į`>I:>* ->xɳ"><>߅}>L#>G5g>i*b>_>熝>ⓒfyN>it>~>VcmLA>N>yd o>_ѐS>>*׍<>Pak%>ܽոD>pd2>k0_w>{ 6M>:ܨp>ir>տk}>;Wh>yA1:>CbU>![N>a>ґ%\ُ>.@>>Res>IQRD>>W>AQ^>7h7>o/d>>n8N>Q>@ >ۃ?>Rx>cv82vj>Щ/>*Yp>ô >+>>.~>܆FU?f-.>q\>>~˼>ߘ>z1mu>ۣF>8'> > {̱9>#Hbq2>d6wW>̊ì> R.>sI>c$^>AՀ>9>,>솆e>{}Mb>lzn>{Uvc>U>a@>m{>Ӳja>$ɘ >|SL`>>Q{>sΌ>_dR>Q+->>FE >Mض>㢢B>5@ᴂ>AD;>焨hG@> (2>{ea>/y>d#Z>ނ >[B>2gC}>"L؝>l6v~>M>h*o>ЛB!>帧t!>\>% >v>Ր>& > ށ>%%.>˽>.>Xqt>!>gN"c>;2%>F>fui>*>a5LSp>O"0G> Uqc>p>ҩQAx>ClܨT>%T>+(>Wr>>sF>وl>[$ L?5Cl>zc6>ZF>N\4>U>3>N>>;>ʛ; c'>b2 >}Y>!t>㿃B4>ݥ j>d>xڎZ}A>4e3>{>Qg>U>ḑ2>‡\8>ѨA>1YX>Szև >ЏQ>&œu>5/>}f>fF>#U">`.>T/.> R>K=>Ϧ>f> ->Ȳ>c>~ w>ۛL>ԇe>ݸvU>ڐ>&r>ʆ]>ۂmP>½3f>-N6(\>Ԉx">c0YV>ՇD6>ԁ6>c>M:vB>n3h;>>LJ|i>@>lEr>KvV_x>Oàl~>ΖL>+*N>*F>~S>ӤaK >D>蹰>Hf=>Ԟ/ٳz>̼k>?y[>:o>̾&ց>i]>ӤPMW>ܨ* -t >b>n >2NHZ>,Y>Ơ&P>/'p>M>Ղ>I0p>̜;f>2#>ȍ1LJ?>v>w>!:T>֙>A9>ޫT[>X*!H>mB >82>ŲzQ>M >j>WqV >}={i>ʴ gL> j>nV3>ӁO>сydl>պL.v>By$>k}>n#n>R _>u5]>|>֟4>9>zųR>a>FB> -|tr>D>v?09>x`o ->{\AP>$>zSDG>6B>u׉ >M& >2>Ҝ9N<>2oi[>\z}>ʽ#|>—+H>>|(C>b=yE> -!5>\`h&>|WP>K">6>_>O· >փnQ>݈K7y>bD>RO]h>`>62G>TYb>]a{>Ri<#>aN>>yW>-/>+]>;">kv>֗̆>vXh\>@,Ǩ>u/R2> V>sN>Rp>p>>ۚ(u>:>Mb>Ԧ#>ʢ3>7֩B>a9C>eeD> 4>Nb,>#% >[N>^Q>[.>˳l.>)OӀ>KXe>ȉߥ>Fky>ʘ"!>ēj[K>0=l3>hi≯A%&>HRh> vc>ת37R>֜7b>3>ξ3>5>Q8>~ l>)x<_ >W>^`!F>m³E\>Vݒh2>rE+]>ΆR2x>Υn >˕BI>ÉZb>if+>;0M>Wen>=Is>f~f>bb>Ɏ@y˥>٨q,>S>f>uQ>Zޖ9>eU+>ϟٛD>xP>l0>+Qw> :>ܥꀖ>ڹ.O>޻񎧊>,0Ox>xbl> -_>Zk>S>j\2>㢶j :>P >kx >1=>~ >o!2>ϙxL>>3>Y>yO=YN>җoZ.>Q>dJ)D>:4>6^>1 `>Ͱ>]E`1.>L#>j>_S>Ï]ћ>Ƀ]>oǼ>̉>ΐ>>Ї?XB~>]P>5. >$E>w >ca+Nt>>MŹ>W>ζt >̭%a3F>ɅԔ>nl 2>\>" >W[1L>2u>ʹsL->/Vx>qH<>,Ooxh>UK>ٵR>üt՚>2 -e]sh>F4>ĺz1>nz[>,0F>O> x>mt=>cgg>6o>>`ꄼ>ʠNט ->Ɖl>7!>1)l>;C3>Q>m>Ҏ`;v>?K>乡>X{> -] >`B0>O>a1>͑!: $>߰T>>Pޤ>6)KUw>.ͽm2>Ԧ7>י|,-> cp'>?Ŀ >Y܍h>-o>aZ>̮'>6k1K>¢3]Ht>G6_>ﰢ`>Xeȇ>ʝ7>Ԝ`m>gΧ0> > Xj:t>T>Fm>>j, x>km;>ӬE>*ydO8>XXn>/t>ʌ.]R ->dl/> *>eL>y> n>&>ǩ?TeB>>  x>tu.>10+>aX>{_:1>#bP[>z,>(>{G>>ƒd>i*>c|71>h> -bYt>Y>Kz> ^~6>,p> qK:>>L`>"f>t'>ɠ ,>Ou2>N[V7>>ɶY> Yo >!5hC>=t>簮W>U,>@Bxh>}ɕ>¾> -(>}9q.>'WJ>e7s0b>`&;>f}l>܆$0>m>gp >ڛJ> -*&4>/j>y2>,' >?r>R>#>Y>{*x>eX>r8k>"b>/>ݥ2b>OPܾ>WQ>OR4>ў -6D>لм>Ł.N>¢sL>*>I.s>RW>wE>2>Umi>}#y>k -~b>Y >N >' 5/P>+h >@5a>->ˊ>N:>K"4Y>)>}[3Q>C{O> Z{H؂>R2>TD>Vތ>a1~W>NiІ>KN>|i-ML>O9>D3>762X>q: >8V^>=>}J GӤ>qi:>`'>6&X>3ޑ>z^>'Y>L] -~>泿Z:>܃`֗>o0^>Ͽ. >ѷFNs>tOc>9>8P}>bv>^6^>"V>L>H\o>nu>P6GV>ͱr$>w>='>`|4I>+9?>ݭ@>ML>tV^>)R>{>[oyPu>ƕ~>G/>?>k[><>>e>@7gp>> Ht>d>V%>wx(>z8>Xߚ$>B -n>VV>Y>^oJ>ë  > >Yh?5x>_Ok ->#BŠ>0N^ ->"(y f>ʼQ>(c>0 -T>^(>ïѢ>I3E>jU>>e W>*->\CS)`>L-w>|^6>@> e!\>@v>v(>ծЙ>uh>䑪>rt>pH r$>jc>Q^Q>+ky>)JʩN>"LU >ysu/>hViw>2؟&>אkiU>͆D[P>}뀀>F>D:b> at>ǫ>Яz2>$|>P\>C-3y>MtqdD>,:\``z>̊Y>Nm> -!, V>^D >J)>}8>Uv>t#[>V >n >#>QL>&>v7 >ze~>tg> ϾR>QWQ>]>wҔ{>4%/j>,4>ҁ> -B4 > xX>Lyn>l]\E+>Ĭ>3p<>(^ 9>< -'>9e>1n>U>)R|1.>?w!J>ꋳu>ʇ7>6Õ4>Lh>9uu$>ԫVO>i>!> ~>1 P/>>O>>%>9!->@8u>zom>+LyZ>GL>*x>TZd4>c2>EY( >>=>Z>Dy>>lW>3Ȉ>lCk>1vW%>X] >[m>X:>Ѫ>MɡZk>ܐ[ >FM5>Է.L$>nq>S >>sL瓽>,Q, y ->s>aaN>ہ>Rߤ@> "w'r>I|i>{k>i r>a|Q> [?ߴ> Qȼb>QL.>o>y_@>7 $>PL.>֋3> -~L>pT >k\>5vIC*H>6Pz>\F=!\>垂 > bh>(BD>Ƞ}>=V} (>f"96>c8P. ->ߝ>BH>.>>ڴ`y6>) SD`>aa>MmL>I>[>q;>D&l>Ř3>ό>l)> QC=>Po!>3X>6h~:>Ãq-x>>2C&>4Lv>.0>b*>n"> 0 >XL>)>R+9>(>Hx2>_BS>ԓc>kŒ9>WJ>Eb>˂. H>&l>7>բ>Ql>9>C`:>Jŧ>:Jc>Xz,>)>c9#>/q >:Y> >nTmk>'о>Y\>s>E1l>@.>8Ż>b_d>,-1D>/8>9+0pE>'4>QbH>$9>̾@N+>]>>y2W>'6wt>.>`ʄ>o>M3DM>ky>a:(>9w>Fg@>j>_i9A`>{:>O -1>Jᬠr>A j>YD>~iP{>~3)d>.@^>6G>Mz^>MO>7~>38q>#> !>Vu(> -щ>\`գm>wȀ>R-0>-^>>Y>/>0V>Ư>"uau>Eؚ>r'BF>gR X>:*ª>dY>c:/>YY>>8>'> ̯h> -s&>@Ds8]>rA'>Il>a>䴸P>z>4>{>d>ҶT>̤u Rf>04>29T>p2^>!!>9?ڞ>i9>1cM>+u\>Nu>O >iw>5:0>sEE>W7xX>*>*f>n);x>>R>ػ>->TJp>W`ܗ4>gv>| eF> `">}Y:>?B9>ZYp>K>'=D>j̔>iU>؄h0d>k a>D >ћC>(84>e8z>HX>_t>vt>J>Ț>-cH>LF>s$>o>~{ >j@Z~>FI>m^>3F>A"u}@>H$>]5X>q>}V;SI>>>&̲>>Qu4\>A1bk>H;>Y>ۅӨ8>EP->.>'>n$#>SU7>]> m+H> B$>f>>@q>ҏ:@>dU^>e_K>"j>y'>Ăp7>P.>ë'm >D\[>Uz ->՗>10Tl>U >T >PqJ>1|>F1>1">&› '>:'`>6>Ԓ\>}N -vH>6+5>>3ր9>Kj>v.~> :Q^>F&> >,*>bX>_^u> F -v> rFd>mn{>Hp>\Qk>s>,cg^>{!>trt>emv><>p{>cj $>X}>{\>E͈5(>>0>o\#>wy B>Z>ܟA>Z>,>*#> >Yp@;>D0O>~J>T>ާY>.RU>)$>QJv>Bl>A>g6">Ѓ >ݙiP>1s ">1;>@0 ,>"YY>O9E>`a>?d>s`4>’&>Kv>Ý>[@z9>voS>oڟU>4Dd>+ >]4Y6>3싊>ޱMW>;>5>tg>-/EV.>i0"E>2)Y6> !z>*2>}ԉx>~Ql>= ͒> leQN>,N>l(,k>uW>?@b>?Ael>؋> z>F1:Z?>B/7N>Z>B$0 >>MP>ﵸkM>>8 B>ϻ R*>+*ُ>I6>.fw>>>q>>:>ID>">6Et>zrIZ>zr]>*ǟJ>A>z>& > ~>L[]> %M>-;B>X2r>co4:> -*>< ->p>ĸw>v]>*e>4fz *>S>>;۠C!>؃9#l>։:>W#>ʆ#>#>ǟa#GF>qѥj>oJD>ɔ ->=d>W w>ȧUf>~>ףnW>e*>C>ExdVT:>6>hz)v>Q( J>v~>fL) ->IɄ>ݸD>oL\>m2>W,`ie>:>dF.>Yx>#&>a ->BV1>WV>~l>.=Cf>iy>}5Gm9>~1Hp>QN4>0B>D>V0D'> >`l- >ԃ>TBV>+je&>n?Fv>-T8F)>'0.@>[*>-7+>vjZS>\a>%58"> >O>/͙D>^>y>R};Ԓ>(<>t S=l>7D㭛>$>3"d>\$V6>T|>W's>rmDI>f@'d>d|>eO>љ/>/3>Ž38 >ů`.J> ]k>~D>|TC">T*o>>[o|XQ>΢r>ľy[>чl{>E,?>ӝ+>e >B -9U>ˠ? !G>³&(=>*C>>ɍ,>k|>ITr>agu>ɍSZ>„Հ>u& ->5v>])>2l>k<>DN>g7>E@Ƀ>}gB U>Co>fǑ<>Elm>(:>}a!>~ >2w>RD>b%>N> f>YT>d̃W>4}>3 uL>tl>A;l>&>Õ!Z>>2,ij>ҥ >G>9'|/> >AuF>7>JfL*>f>Қ d>O! >噏uK>O8>@igY>W>c!>>Br!V>nD_sO>/7: >ȻJ&>Ǯ -r>G/H>b>ѭ&>uR{>>6gP>+Iy> T5)Ͼ>- -xh>/J0>аW>%>A!>>:>A> +>rA>>6&>L B>ѡ)ԬD>ˀ{>>}>c ->Vb>ϰ-".> ->Þ"B|>(UX>ĉ+>ru}4>Xc)]4>R͹>F,>NA>n >|[>fNyh> -%->֔>&>'G> -$>\>|6>~>xe>Ww^>(ϻ$>#⏬RK>͇T?>b> dl>YrҒ>!{-i>' \ No newline at end of file diff --git a/tests/dat/readme.txt b/tests/dat/readme.txt new file mode 100644 index 0000000..be88641 --- /dev/null +++ b/tests/dat/readme.txt @@ -0,0 +1 @@ +This folder is where test data will be loaded from test releases \ No newline at end of file diff --git a/tests/generic.py b/tests/generic.py index e69de29..f9a4946 100644 --- a/tests/generic.py +++ b/tests/generic.py @@ -0,0 +1,146 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +import os +import glob +from urllib import request +from typing import Final + +import numpy as np +import pytest +from astropy.io import fits + +from starbug2.constants import ( + STAR_BUG_TEST_DAT_ENV, ImageHeaderTags, MIRI_STRING, MIRI_IMAGE) +from starbug2.star_bug_config import StarBugMainConfig + +# paths to test files +TEST_PATH: Final[str | None] = os.getenv(STAR_BUG_TEST_DAT_ENV) +if TEST_PATH is None: + raise Exception("cant find the test data environmental variable") +TEST_PATH_STR: Final[str] = str(TEST_PATH) +TEST_IMAGE_FITS: Final[str] = os.path.join(TEST_PATH, "image.fits") +TEST_PSF_FITS: Final[str] = os.path.join(TEST_PATH, "psf.fits") +TEST_NGC_FITS: Final[str] = os.path.join(TEST_PATH, "ngc6822_F770W_i2d.fits") +TEST_README: Final[str] = os.path.join(TEST_PATH, "readme.txt") +TEST_BLANK: Final[str] = str(os.path.join(str(TEST_PATH), "blank.fits")) +TEST_AST_FILLED: Final[str] = str( + os.path.join(str(TEST_PATH), "inserted_image_for_test_1.fits")) +TEST_SEED = 42 + +# the filter string for tests to ensure they all use the same stuff +TEST_FILTER_STRING = "-s FILTER=F444W -G" +GITHUB_RELEASE_URL = ( + "https://github.com/alan-stokes/starbug2/releases/download/TEST_DATA/") +REQUIRED_FILES = ["image.fits", "psf.fits", "ngc6822_F770W_i2d.fits"] + + +def verify_test_data_exists() -> None: + # Check if the specific FITS file is missing + if not (os.path.exists(TEST_IMAGE_FITS) + and os.path.exists(TEST_PSF_FITS) + and os.path.exists(TEST_NGC_FITS)): + print( + "\n⚠️ Test file missing due to merge. " + "Downloading all from GitHub Releases...") + + for filename in REQUIRED_FILES: + file_path = os.path.join(str(TEST_PATH), filename) + url = f"{GITHUB_RELEASE_URL}/{filename}" + if not os.path.exists(file_path): + try: + request.urlretrieve(url, file_path) + except Exception as e: + pytest.fail( + f"Failed to download test asset from GitHub " + f"Release: {e}") + + +def create_default_config() -> StarBugMainConfig: + """ + creates a default config where everything points to the test output dir + :return: a config + :rtype StarBugMainConfig + """ + config: StarBugMainConfig = StarBugMainConfig() + config.output_file = TEST_PATH + return config + + +def clean() -> None: + """ + cleans up the test data folder for new tests. + :return: None + """ + files = glob.glob(os.path.join(str(TEST_PATH), "*")) + files.remove(TEST_IMAGE_FITS) + files.remove(TEST_PSF_FITS) + files.remove(TEST_NGC_FITS) + files.remove(TEST_README) + for file_name in files: + os.remove(file_name) + if os.path.exists("starbug.param"): + os.remove("starbug.param") + + +def check_shape(c, out) -> None: + """ + checks shape. + :param c: array 1 + :param out: array 2 + :return: None + """ + assert np.shape(c) == np.shape(out) + for m in range(len(c)): + for n in range(len(c[m])): + a = c[m][n] + b = out[m][n] + assert np.isnan(a) == np.isnan(b) + if not np.isnan(a) or not np.isnan(b): + assert a == b + + +def create_blank_fits(size=(2048, 2048)): + """ + creates a blank fits file. + :param size: the size of the fits file. + :return: None + """ + print(f"Generating blank space image of size {size[0]}x{size[1]}...") + + # Create a 2D numpy array of zeros (using float32 for standard precision) + blank_data = np.zeros(size, dtype=np.float32) + + # Create background noise: mean of 10.0 counts, standard deviation of 1.0 + rng = np.random.default_rng(seed=TEST_SEED) + background_noise = rng.normal(loc=10.0, scale=1.0, size=blank_data.shape) + + # Wrap the data inside a Primary HDU + primary_hdu = fits.PrimaryHDU(data=background_noise) + + # Add essential metadata headers so pipeline loaders don't choke + header = primary_hdu.header + header["EXTNAME"] = "PRIMARY" + header["OBJECT"] = "BLANK_SPACE_CI" + header["COMMENT"] = ( + "Artificial black space for starbug2 integration tests.") + header[ImageHeaderTags.DETECTOR] = MIRI_IMAGE + header[ImageHeaderTags.INSTRUMENT] = MIRI_STRING + + # Write the file out to disk + # overwrite=True ensures test scripts can recreate this file on + # every run + primary_hdu.writeto(TEST_BLANK, overwrite=True) + print(f"✅ Successfully saved to {TEST_BLANK}") diff --git a/tests/param_files/old_format.param b/tests/param_files/old_format.param new file mode 100644 index 0000000..d0e0f6b --- /dev/null +++ b/tests/param_files/old_format.param @@ -0,0 +1,279 @@ +## STARBUG CONFIG FILE +# Generated with starbug2-v0.7.7 +PARAM = STARBUGII PARAMETERS // COMMENT + +## GENERIC +// (0:false 1:true) +VERBOSE = 0 + +// Directory or filename to output to +OUTPUT = . + +// If using a non standard HDU name, name it here (str or int) +HDUNAME = + +// Set a custom filter for the image +FILTER = + +## DETECTION +// Custom FWHM for image (-1 to use WEBBPSF) +FWHM = -1.0 + +// Number of sigma above the median to clip out as background +SIGSKY = 2.5 + +// Source value minimum N sigma above background +SIGSRC = 6.1 + +// Run background2D step (usually finds more sources but takes time) +DOBGD2D = 1 + +// Run convolution step (usually finds more sources) +DOCONVL = 1 + +// Run source cleaning after detection (removes likely contaminants) +CLEANSRC = 1 + +// Lower limit of source sharpness (0 is not sharp) +SHARP_LO = 0.2 + +// Upper limit of source sharpness (1 is sharp) +SHARP_HI = 1.2 + +// Limit of source roundness1 (|roundness|>>0 is less round) +ROUND1_HI = 2.0 + +// Limit of source roundness2 (|roundness|>>0 is less round) +ROUND2_HI = 2.0 + +// Lower limit on source smoothness (0 is not smooth) +SMOOTH_LO = -1.0 + +// Upper limit on source smoothness (1 is smooth) +SMOOTH_HI = 2.0 + +// Radius (pix) of ricker wavelet +RICKER_R = 1.0 + +## APERTURE PHOTOMETRY +// Radius in number of pixels +APPHOT_R = 2.0 + +// Fraction encircled energy (mutually exclusive with APPHOT_R) +ENCENERGY = -1.0 + +// Sky annulus inner radius +SKY_RIN = 4.0 + +// Sky annulus outer radius +SKY_ROUT = 6.0 + +// Aperture correction file. See full manual for details +APCORR_FILE = + +## BACKGROUND ESTIMATION +// Aperture masking fixed radius (if zero, starbug will scale radii) +BGD_R = 0.0 + +// Aperture mask radius profile scaling factor +PROF_SCALE = 1.0 + +// Aperture mask radius profile slope +PROF_SLOPE = 0.5 + +// Background estimation kernel size (pix) +BOX_SIZE = 5 + +// Output region file to check the aperture mask radii +BGD_CHECKFILE = + +## PHOTOMETRY +// Detection file to use instead of detecting +AP_FILE = + +// Background estimation file +BGD_FILE = + +// Non default PSF file +PSF_FILE = /Users/crouzet/Work/JWST-data-reduction/1Zw18/photometry-2/PSF/JWST/F770W-MIRIM.fits + +// When loading an AP_FILE, do you want to use WCS or xy values (if available) +USE_WCS = 1 + +// Zero point (mag) to add to the magnitude columns +ZP_MAG = 8.9 + +// Minimum distance for grouping (pixels) between two sources +CRIT_SEP = + +// Force centroid position (1) or allow psf fitting to fit position too (0) +FORCE_POS = 0 + +// Maximum deviation from initial guess centroid position +MAX_XYDEV = 3p + +// Set fit size of psf (>0) or -1 to take PSF file dimensions +PSF_SIZE = -1 + +// Generate a residual image +GEN_RESIDUAL = 0 + +## SOURCE STATS +// Run crowding metric calculation (execution time scales N^2) +CALC_CROWD = 1 + +## CATALOGUE MATCHING +// Matching separation threshold in units arcsec +MATCH_THRESH = 0.1 + +// EXTRA columns to include in output matched table i.e sharpness +MATCH_COLS = + +// Keep sources that appear in NUM >= NEXP_THRESH (if -1 keep everything) +NEXP_THRESH = -1 + +// Bridge --band matching NIRCam and MIRI catalogues by ensuring NIRCam +// catalogue has a match in BRIDGE_COL +BRIDGE_COL = + +## ARTIFICIAL STAR TESTS +// Number of artificial star tests +NTESTS = 100 + +// Number of stars per artificial test +NSTARS = 10 + +// Number of pixels to crop around artificial star +SUBIMAGE = 500 + +// Bright limit of test magnitude +MAX_MAG = 18 + +// Faint limit of test magnitude +MIN_MAG = 28 + +// Output AST result as image with this filename +PLOTAST = + +## MISC EXTRAS +// DS9 region colour +REGION_COL = green + +// Scale region to flux if possible +REGION_SCAL = 1 + +// Region radius default +REGION_RAD = 3 + +// X column name to use for region +REGION_XCOL = RA + +// Y column name to use for region +REGION_YCOL = DEC + +// If X/Y column names correspond to WCS values +REGION_WCS = 1 + +// detector name used within psf generation +DET_NAME = + +// region table file name for generating regions +REGION_TAB = + +## ADDITIONAL UNSETTABLE / DERIVED PARAMETERS +// Custom analytical parameter grouping identifier string +PARAM_TAG = STARBUGII PARAMETERS + +## PIPELINE SWITCHES (BYPASS COMMAND LINE FLAGS) +// Run aperture photometry stage (0:false 1:true) +RUN_APPHOT = 0 + +// Run background estimation stage (0:false 1:true) +RUN_BGD_EST = 0 + +// Run star detection stage (0:false 1:true) +RUN_DETECT = 0 + +// Run source geometry analysis stage (0:false 1:true) +RUN_GEOM = 0 + +// Run catalogue matching stage (0:false 1:true) +RUN_MATCH = 0 + +// Run PSF photometry routine (0:false 1:true) +RUN_PSFPHOT = 0 + +// Run background subtraction stage (0:false 1:true) +RUN_BGDSUB = 0 + +## SYSTEM & EXECUTION CONTROL +// Number of processor cores to use for calculation +NCORES = 1 + +// Find files automatically (0:false 1:true) +FIND_FILE = 1 + +## EXTRA RUN GENERATION COMMAND SWITCHES +// Execute JWST data initialization steps (0:false 1:true) +INIT_JWST = 0 + +// Trigger PSF generation logic (0:false 1:true) +GEN_PSF = 0 + +// Trigger automation run generation scripts (0:false 1:true) +GEN_RUN = 0 + +// Filename target string to output generated region file +GEN_REGION = 0 + +## ADVANCED ARTIFICIAL STAR TEST CONTROLS +// Recover previous artificial star test state (0:false 1:true) +AST_RECOVER = 0 + +// Save frequency of progress during artificial star tests +AST_AUTOSAVE = 100 + +// Disable background logic during artificial star tests (0:false 1:true) +AST_NO_BGD = 0 + +// Disable PSF photometry during artificial star tests (0:false 1:true) +AST_NO_PSF = 0 + +## CATALOGUE MATCHING MODE SWITCHES +// Process matched catalogue across multiple bands (0:false 1:true) +MATCH_BAND = 0 + +// Run matching in cascade execution sequence (0:false 1:true) +MATCH_CASCADE = 0 + +// Use dither offsets during matching calculations (0:false 1:true) +MATCH_DITHER = 0 + +// Require exact row criteria matches across tables (0:false 1:true) +MATCH_EXACT = 0 + +// Force a full evaluation run across matching pipelines (0:false 1:true) +MATCH_FULL = 0 + +// Use generic operating specifications for matching (0:false 1:true) +MATCH_GENERIC = 0 + +// Error column label name target string for evaluation +MATCH_ERR_COL = eflux + +// Expression filter string for processing match masks +MATCH_MASK_EVAL = + +## DIAGNOSTIC PLOTTING MODULE SWITCHES +// Enable interactive test mode plotting panels (0:false 1:true) +PLOT_TEST = 0 + +// Dark frame visualization profile layout (0:false 1:true) +PLOT_DARK = 0 + +// Visual parameter validation target inspection parameter key +PLOT_INSPECT = + +// Target design stylesheet pattern configuration profile name +PLOT_STYLE = + diff --git a/tests/test_ast.py b/tests/test_ast.py index 32d8447..1d58dcc 100644 --- a/tests/test_ast.py +++ b/tests/test_ast.py @@ -1,28 +1,83 @@ -import os,glob +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +import os +from multiprocessing import shared_memory +from multiprocessing.shared_memory import SharedMemory +from typing import Final + +import numpy as np import pytest -from starbug2.bin import EXIT_SUCCESS, EXIT_EARLY, EXIT_FAIL, EXIT_MIXED from starbug2.bin.ast import ast_main +from starbug2.constants import ExitStates +from tests.generic import TEST_IMAGE_FITS, clean, verify_test_data_exists + +# main ast run +c: np.ndarray = np.array([0, 0, 0], dtype=np.int64) +share_memory: SharedMemory = ( + shared_memory.SharedMemory(create=True, size=c.nbytes)) +loading_buffer: np.ndarray = np.ndarray( + c.shape, dtype=c.dtype, buffer=share_memory.buf) +TEST_FILTER_STRING: Final[str] = "-s FILTER=F444W" + + +def run(s): + return ast_main( + s.split() + [TEST_IMAGE_FITS], share_memory, loading_buffer + ) -run = lambda s:ast_main(s.split()) def test_run_basic(): + verify_test_data_exists() clean() - assert run("starbug2-ast -N10 -S10 tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2-ast -N30 -S10 -n3 tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2-ast -N30 -S10 -n3 -o/tmp/ tests/dat/image.fits")==EXIT_SUCCESS + assert (run( + f"starbug2-ast -N10 -S10 {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + assert (run( + f"starbug2-ast -N30 -S10 -n3 {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + assert run( + f"starbug2-ast -N30 -S10 -n3 -o /tmp/" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS clean() -def test_run_harshinputs(): + +@pytest.mark.skipif( + os.getenv("RUN_STAR_BUG_PRODUCTION_TESTS") is None or + os.getenv("RUN_STAR_BUG_PRODUCTION_TESTS") == "false", + reason="Harsh stress test locked out of normal development runs due to " + "length of time to run, CPU resources required which nearly slags" + " the machine." +) +def test_run_harsh_inputs(): clean() - assert run("starbug2-ast -N1 -S1000 tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2-ast -N1000 -S1 tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2-ast -N10 -S10 -n100 tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2-ast -N1000 -S1000 -n1000 tests/dat/image.fits")==EXIT_SUCCESS - -def clean(): - files=glob.glob("tests/dat/*") - files.remove("tests/dat/image.fits") - files.remove("tests/dat/psf.fits") - for fname in files: os.remove(fname) - if os.path.exists("starbug.param"): os.remove("starbug.param") + assert (run( + f"starbug2-ast -N1 -S1000 {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + assert (run( + f"starbug2-ast -N1000 -S1 {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + assert (run( + f"starbug2-ast -N10 -S10 -n100 {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + assert run( + f"starbug2-ast -N1000 -S1000 -n1000" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + clean() + +if __name__ == "__main__": + # This allows you to run the harsh test directly. + test_run_harsh_inputs() diff --git a/tests/test_match_run.py b/tests/test_match_run.py index d7acd05..9f8883b 100644 --- a/tests/test_match_run.py +++ b/tests/test_match_run.py @@ -1,57 +1,128 @@ -import os,glob +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +import os +from typing import Final + import pytest -from starbug2.utils import wget -from starbug2.bin import EXIT_SUCCESS, EXIT_EARLY, EXIT_FAIL + from starbug2.bin.main import starbug_main from starbug2.bin.match import match_main -run = lambda s:match_main(s.split()) +from starbug2.constants import ExitStates +from tests.generic import ( + clean, TEST_IMAGE_FITS, TEST_FILTER_STRING, TEST_PATH_STR, + verify_test_data_exists, TEST_PATH) + +OUT_1_FITS: Final[str] = str(os.path.join(TEST_PATH_STR, "out1.fits")) +OUT_2_FITS: Final[str] = os.path.join(TEST_PATH_STR, "out2.fits") +OUT_1_AP_FITS: Final[str] = os.path.join(TEST_PATH_STR, "out1-ap.fits") +OUT_2_AP_FITS: Final[str] = os.path.join(TEST_PATH_STR, "out2-ap.fits") +IMAGE_AP_FITS: Final[str] = os.path.join(TEST_PATH_STR, "image-ap.fits") + + +def run(s): + return match_main(s.split()) + def test_match_start(): - assert run("starbug2-match")==EXIT_FAIL - assert run("starbug2-match -h")==EXIT_EARLY - assert run("starbug2-match -vh")==EXIT_EARLY - -def test_match_badinput(): - #clean() - assert run("starbug2-match ")==EXIT_FAIL - assert run("starbug2-match tests/dat/image.fits")==EXIT_EARLY - assert run("starbug2-match badinput.fits")==EXIT_FAIL - assert run("starbug2-match badinput.txt")==EXIT_FAIL - #assert run("starbug2-match tests/dat/image.fits tests/dat/image.fits")==EXIT_FAIL - starbug_main("starbug2 -D tests/dat/image.fits".split()) - assert run("starbug2-match tests/dat/image-ap.fits")==EXIT_EARLY - - #clean() - -def test_match_basicrunthrough(): - #clean() - starbug_main("starbug2 -Do tests/dat/out1.fits tests/dat/image.fits".split()) - starbug_main("starbug2 -Do tests/dat/out2.fits tests/dat/image.fits".split()) - assert run("starbug2-match tests/dat/out1-ap.fits tests/dat/out2-ap.fits")==EXIT_SUCCESS - assert run("starbug2-match -G tests/dat/out1-ap.fits tests/dat/out2-ap.fits")==EXIT_SUCCESS - assert run("starbug2-match -C tests/dat/out1-ap.fits tests/dat/out2-ap.fits")==EXIT_SUCCESS - #assert run("starbug2-match -D tests/dat/out1-ap.fits tests/dat/out2-ap.fits")==EXIT_SUCCESS - - assert run("starbug2-match -f tests/dat/out1-ap.fits tests/dat/out2-ap.fits")==EXIT_SUCCESS - assert run("starbug2-match -fG tests/dat/out1-ap.fits tests/dat/out2-ap.fits")==EXIT_SUCCESS - assert run("starbug2-match -fC tests/dat/out1-ap.fits tests/dat/out2-ap.fits")==EXIT_SUCCESS - #assert run("starbug2-match -fD tests/dat/out1-ap.fits tests/dat/out2-ap.fits")==EXIT_SUCCESS - #clean() + verify_test_data_exists() + assert (run(f"starbug2-match --output={TEST_PATH}") == + ExitStates.EXIT_EARLY) + assert (run(f"starbug2-match -h --output={TEST_PATH}") == + ExitStates.EXIT_SUCCESS) + assert (run(f"starbug2-match -vh --output={TEST_PATH}") == + ExitStates.EXIT_SUCCESS) -def test_mask(): - starbug_main("starbug2 -Do tests/dat/out1.fits tests/dat/image.fits".split()) - starbug_main("starbug2 -Do tests/dat/out2.fits tests/dat/image.fits".split()) - assert run("starbug2-match -vmF444W>20 tests/dat/out1-ap.fits tests/dat/out2-ap.fits")==EXIT_SUCCESS +def test_match_bad_input(): + assert (run(f"starbug2-match --output={TEST_PATH}") == + ExitStates.EXIT_EARLY) + assert (run(f"starbug2-match {TEST_IMAGE_FITS} --output={TEST_PATH}") == + ExitStates.EXIT_EARLY) + assert (run(f"starbug2-match badinput.fits --output={TEST_PATH}") == + ExitStates.EXIT_EARLY) + assert (run(f"starbug2-match badinput.txt --output={TEST_PATH}") == + ExitStates.EXIT_EARLY) + starbug_main( + f"starbug2 -D --output={TEST_PATH} {TEST_IMAGE_FITS} " + f"{TEST_FILTER_STRING}".split()) + assert (run(f"starbug2-match --output={TEST_PATH} {IMAGE_AP_FITS}") == + ExitStates.EXIT_EARLY) -@pytest.fixture(autouse=True) -def init(): - files=glob.glob("tests/dat/*") - files.remove("tests/dat/image.fits") - files.remove("tests/dat/psf.fits") - for fname in files: os.remove(fname) - if os.path.exists("starbug.param"): os.remove("starbug.param") +def test_match_basic_run_through(): + starbug_main( + f"starbug2 --output={TEST_PATH} -Do {OUT_1_FITS}" + f" {TEST_IMAGE_FITS}" + f" {TEST_FILTER_STRING}".split()) + starbug_main( + f"starbug2 --output={TEST_PATH} -Do {OUT_2_FITS} " + f" {TEST_IMAGE_FITS}" + f" {TEST_FILTER_STRING}".split()) + assert (run( + f"starbug2-match --output={TEST_PATH}" + f" {OUT_1_AP_FITS}" + f" {OUT_2_AP_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS) + assert (run( + f"starbug2-match" + f" {OUT_1_AP_FITS}" + f" {OUT_2_AP_FITS}" + f" {TEST_FILTER_STRING} --output={TEST_PATH}") == + ExitStates.EXIT_SUCCESS) + assert (run( + f"starbug2-match" + f" {OUT_1_AP_FITS}" + f" {OUT_2_AP_FITS}" + f" {TEST_FILTER_STRING} --output={TEST_PATH}") == + ExitStates.EXIT_SUCCESS) + assert (run( + f"starbug2-match" + f" {OUT_1_AP_FITS}" + f" {OUT_2_AP_FITS}" + f" {TEST_FILTER_STRING} --output={TEST_PATH}") == + ExitStates.EXIT_SUCCESS) + assert (run( + f"starbug2-match" + f" {OUT_1_AP_FITS}" + f" {OUT_2_AP_FITS}" + f" {TEST_FILTER_STRING} --output={TEST_PATH}") == + ExitStates.EXIT_SUCCESS) + assert (run( + f"starbug2-match" + f" {OUT_1_AP_FITS}" + f" {OUT_2_AP_FITS}" + f" {TEST_FILTER_STRING} " + f"--output={TEST_PATH}") == ExitStates.EXIT_SUCCESS) +def test_mask(): + starbug_main( + f"starbug2 --output={TEST_PATH} -Do " + f"{OUT_1_FITS} {TEST_IMAGE_FITS}" + f" -s FILTER=F444W".split()) + starbug_main( + f"starbug2 --output={TEST_PATH} -Do" + f"{OUT_2_FITS} {TEST_IMAGE_FITS}" + f" -s FILTER=F444W".split()) + assert run( + f"starbug2-match --output={TEST_PATH} -vmF444W>20 " + f"{OUT_1_AP_FITS} " + f"{OUT_2_AP_FITS}") == ExitStates.EXIT_SUCCESS + +@pytest.fixture(autouse=True) +def init(): + clean() diff --git a/tests/test_matching.py b/tests/test_matching.py index 1b6c962..dd6a50f 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -1,283 +1,405 @@ -import os,numpy as np +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +import os +import numpy as np +from typing import Final + import pytest -from starbug2.matching import GenericMatch, CascadeMatch, BandMatch, parse_mask, ExactValueMatch + +from starbug2.constants import HeaderTags, TableColumn +from starbug2.matching.band_match import BandMatch +from starbug2.matching.cascade_match import CascadeMatch +from starbug2.matching.exact_value_match import ExactValueMatch +from starbug2.matching.generic_match import GenericMatch +from starbug2.star_bug_config import StarBugMainConfig from starbug2.utils import import_table, fill_nan -from starbug2.param import load_default_params from starbug2.bin.main import starbug_main from astropy.table import Table +from astropy import units +from astropy.units import Quantity -@pytest.fixture(autouse=True) -def init(): +from tests.generic import ( + TEST_IMAGE_FITS, check_shape, clean, TEST_FILTER_STRING, + TEST_PATH_STR, TEST_PATH, verify_test_data_exists) + +IMAGE_2_FITS: Final[str] = os.path.join(TEST_PATH_STR, "image2.fits") +IMAGE_AP_FITS: Final[str] = os.path.join(TEST_PATH_STR, "image-ap.fits") +IMAGE_2_AP_FITS: Final[str] = os.path.join(TEST_PATH_STR, "image2-ap.fits") - if not os.path.exists("tests/dat/image-ap.fits"): - starbug_main("starbug2 -Ds SIGSRC=10 tests/dat/image.fits".split()) - starbug_main("starbug2 -Ds SIGSRC=3 -otests/dat/image2.fits tests/dat/image.fits".split()) +@pytest.fixture(autouse=True) +def init(): + verify_test_data_exists() + clean() + # noinspection SpellCheckingInspection + starbug_main( + f"starbug2 -Ds SIGSRC=5 {TEST_FILTER_STRING} --output={TEST_PATH}" + f" {TEST_IMAGE_FITS}".split()) + # noinspection SpellCheckingInspection + starbug_main( + f"starbug2 -Ds SIGSRC=3 --output={IMAGE_2_FITS} {TEST_FILTER_STRING}" + f" {TEST_IMAGE_FITS}".split()) + starbug_main( + f"starbug2 -d {IMAGE_AP_FITS} --background {TEST_IMAGE_FITS}" + f" --output={TEST_PATH} {TEST_FILTER_STRING}".split()) + os.system(f"cp {TEST_IMAGE_FITS} {IMAGE_2_FITS}") + starbug_main( + f"starbug2 -d {IMAGE_2_AP_FITS} --background {IMAGE_2_FITS}" + f" --output={TEST_PATH} {TEST_FILTER_STRING}".split()) def cats(): - t1=[[ 0.0, 0.0, 1.0, 0.1], - [ 0.1, 0.1, 1.0, 0.1], - [ 0.2, 0.1, 2.0, 0.2], - [ 0.1, 0.2, 2.0, 0.2], - [ 1.0, 1.0, 100, 0.1], - [ 1.1, 1.0, 100, 0.1], - #[ 2.0, 2.0, 201, 0.1], - ] - - t2=[[ 0.0, 0.0, 1.1, 0.1], - #[ 0.1, 0.1, 1.0, 0.1], - [ 0.2, 0.1, 2.1, 0.2], - [ 0.1, 0.2, 2.1, 0.2], - [ 1.0, 1.0, 101, 0.1], - [ 1.1, 1.0, 101, 0.1], - [ 2.0, 2.0, 201, 0.1], - ] - - cat1 = Table( np.array(t1), names=["RA","DEC","flux","eflux"], meta={"FILTER":'a'}) - cat2 = Table( np.array(t2), names=["RA","DEC","flux","eflux"], meta={"FILTER":'b'}) - return [cat1,cat2] - - - -class Test_GenericMatch(): - - def test_initialsing(self): - cats = [ import_table(f) for f in ("tests/dat/image-ap.fits", "tests/dat/image2-ap.fits")] - options = load_default_params() - - m=GenericMatch( ) - assert m.colnames is None - assert not m.filter - assert m.threshold.value == float(options.get("MATCH_THRESH")) - assert m.verbose == options.get("VERBOSE") - - m=GenericMatch(fltr="MAG", colnames=["RA"], threshold=0.5, verbose=True) - assert m.colnames == ["RA"] - assert m.filter == "MAG" - assert m.threshold.value == 0.5 - assert m.verbose == True + t1 = [[0.0, 0.0, 1.0, 0.1], + [0.1, 0.1, 1.0, 0.1], + [0.2, 0.1, 2.0, 0.2], + [0.1, 0.2, 2.0, 0.2], + [1.0, 1.0, 100, 0.1], + [1.1, 1.0, 100, 0.1], + ] + + t2 = [[0.0, 0.0, 1.1, 0.1], + [0.2, 0.1, 2.1, 0.2], + [0.1, 0.2, 2.1, 0.2], + [1.0, 1.0, 101, 0.1], + [1.1, 1.0, 101, 0.1], + [2.0, 2.0, 201, 0.1], + ] + + cat1 = Table( + np.array(t1), + names=[TableColumn.RA, TableColumn.DEC, TableColumn.FLUX, + TableColumn.E_FLUX], + meta={HeaderTags.FILTER: 'a'}) + cat2 = Table( + np.array(t2), + names=[TableColumn.RA, TableColumn.DEC, TableColumn.FLUX, + TableColumn.E_FLUX], + meta={HeaderTags.FILTER: 'b'}) + return [cat1, cat2] + + +class TestGenericMatch: + + def test_initialing(self): + clean() + config = StarBugMainConfig() + + m = GenericMatch() + assert m.col_names is None + assert not m.filter + assert m.threshold is None + assert m.verbose == config.verbose_logs + + m = GenericMatch( + filter_string=TableColumn.MAG, col_names=[TableColumn.RA], + threshold=config.match_threshold_arc_sec_as_an_arc_sec, + verbose=True) + assert m.col_names == [TableColumn.RA] + assert m.filter == TableColumn.MAG + assert (m.threshold.value == + config.match_threshold_arc_sec_as_an_arc_sec.value) + assert m.verbose is True assert isinstance(m.__str__(), str) def test_generic_match1(self): - cats = [ import_table(f) for f in ("tests/dat/image-ap.fits", "tests/dat/image2-ap.fits")] - m=GenericMatch() - - out=m(cats) + categories: list[Table | None] = [import_table(f) for f in ( + f"{IMAGE_AP_FITS}", f"{IMAGE_2_AP_FITS}")] + assert categories is not None + category1: Table | None = categories[0] + category2: Table | None = categories[1] + if category1 is None or category2 is None: + raise Exception("failed to import tables") + + config = StarBugMainConfig() + m: GenericMatch = GenericMatch( + threshold=config.match_threshold_arc_sec_as_an_arc_sec) + + out: Table = m(categories) assert isinstance(out, Table) - for name in cats[0].colnames: - if name != "Catalogue_Number": - assert "%s_1"%name in out.colnames - assert "%s_2"%name in out.colnames - assert len(out)>=len(cats[0]) - assert len(out)>=len(cats[1]) - assert m.filter=="F444W" - - out=m(cats, join_type="and") + name: str + for name in category1.colnames: + if name != TableColumn.CAT_NUM: + assert "%s_1" % name in out.colnames + assert "%s_2" % name in out.colnames + assert len(out) >= len(category1) + assert len(out) >= len(category2) + assert m.filter == "F444W" + + out = m(categories, join_type="and") print(out) - assert len(out)<=len(cats[0]) - assert len(out)<=len(cats[1]) + assert len(out) <= len(category1) + assert len(out) <= len(category2) + clean() def test_generic_match2(self): - cats = [ import_table(f) for f in ("tests/dat/image-ap.fits", "tests/dat/image2-ap.fits")] - m=GenericMatch( colnames=["RA"]) - out=m(cats) - - assert out.colnames == ["RA_1","RA_2"] - - def test_finishmatching(self): - cats = [ import_table(f) for f in ("tests/dat/image-ap.fits", "tests/dat/image2-ap.fits")] - m=GenericMatch() - out=m(cats) - av=m.finish_matching(out) - - - m=GenericMatch(colnames=["RA","DEC","flux"], fltr="F444W") - av=m.finish_matching(m.match(cats)) - assert av.colnames==["RA","DEC","flux","stdflux","flag","F444W","eF444W","NUM"] - - m=GenericMatch(colnames=["RA","DEC","flux"]) - for c in cats: del c.meta["FILTER"] - av=m.finish_matching(m.match(cats)) - assert av.colnames==["RA","DEC","flux","stdflux","flag","MAG","eMAG","NUM"] - - - + categories = [import_table(f) for f in ( + f"{IMAGE_AP_FITS}", + f"{IMAGE_2_AP_FITS}")] + config = StarBugMainConfig() + m = GenericMatch( + col_names=[TableColumn.RA], + threshold=config.match_threshold_arc_sec_as_an_arc_sec) + out = m(categories) + + assert out.colnames == [TableColumn.RA_1, TableColumn.RA_2] + clean() + + def test_finish_matching(self): + categories: list[Table | None] = [import_table(f) for f in ( + f"{IMAGE_AP_FITS}", + f"{IMAGE_2_AP_FITS}")] + config = StarBugMainConfig() + m: GenericMatch = GenericMatch( + threshold=config.match_threshold_arc_sec_as_an_arc_sec + ) + out: Table = m(categories) + m.finish_matching(out) + + category1: Table | None = categories[0] + category2: Table | None = categories[1] + if category1 is None or category2 is None: + raise Exception("failed to import tables") + + filter_string: Final[str] = "F444W" + m: GenericMatch = GenericMatch( + col_names=[TableColumn.RA, TableColumn.DEC, TableColumn.FLUX], + filter_string=filter_string, + threshold=config.match_threshold_arc_sec_as_an_arc_sec) + av: Table = m.finish_matching(m.match([category1, category2])) + # noinspection SpellCheckingInspection + assert av.colnames == [ + TableColumn.RA, TableColumn.DEC, TableColumn.FLUX, + TableColumn.STD_FLUX, TableColumn.FLAG, filter_string, + f"e{filter_string}", TableColumn.NUM] + + m: GenericMatch = GenericMatch( + col_names=[TableColumn.RA, TableColumn.DEC, TableColumn.FLUX], + threshold=config.match_threshold_arc_sec_as_an_arc_sec) + c: Table + for c in categories: + del c.meta[HeaderTags.FILTER] + av: Table = m.finish_matching(m.match([category1, category2])) + # noinspection SpellCheckingInspection + assert av.colnames == [ + TableColumn.RA, TableColumn.DEC, TableColumn.FLUX, + TableColumn.STD_FLUX, TableColumn.FLAG, TableColumn.MAG_UPPER, + TableColumn.ERROR_MAG, TableColumn.NUM] + clean() def test_vals(self): - m=GenericMatch() - out=m(cats()) - t=[[ 0.0, 0.0, 1.0, 0.1, 0.0, 0.0, 1.1, 0.1], - [ 0.1, 0.1, 1.0, 0.1, np.nan, np.nan, np.nan, np.nan], - [ 0.2, 0.1, 2.0, 0.2, 0.2, 0.1, 2.1, 0.2], - [ 0.1, 0.2, 2.0, 0.2, 0.1, 0.2, 2.1, 0.2], - [ 1.0, 1.0, 100, 0.1, 1.0, 1.0, 101, 0.1], - [ 1.1, 1.0, 100, 0.1, 1.1, 1.0, 101, 0.1], - [np.nan,np.nan,np.nan,np.nan, 2.0, 2.0, 201, 0.1] ] - c=Table(np.array(t), names=[ "RA_1","DEC_1","flux_1","eflux_1","RA_2","DEC_2","flux_2","eflux_2"]) - - assert np.shape(c)==np.shape(out) - for m in range(len(c)): - for n in range(len(c[m])): - a=c[m][n] - b=out[m][n] - assert np.isnan(a)==np.isnan(b) - if not np.isnan(a) or not np.isnan(b): - assert a==b - - -class Test_Cascade: - def test_cascadematch(self): - cats = [ import_table(f) for f in ("tests/dat/image-ap.fits", "tests/dat/image2-ap.fits")] - m=CascadeMatch() - out=m.match(cats) + config = StarBugMainConfig() + m = GenericMatch( + threshold=config.match_threshold_arc_sec_as_an_arc_sec) + out = m(cats()) + t = [[0.0, 0.0, 1.0, 0.1, 0.0, 0.0, 1.1, 0.1], + [0.1, 0.1, 1.0, 0.1, np.nan, np.nan, np.nan, np.nan], + [0.2, 0.1, 2.0, 0.2, 0.2, 0.1, 2.1, 0.2], + [0.1, 0.2, 2.0, 0.2, 0.1, 0.2, 2.1, 0.2], + [1.0, 1.0, 100, 0.1, 1.0, 1.0, 101, 0.1], + [1.1, 1.0, 100, 0.1, 1.1, 1.0, 101, 0.1], + [np.nan, np.nan, np.nan, np.nan, 2.0, 2.0, 201, 0.1]] + c = Table( + np.array(t), + names=[ + "RA_1", "DEC_1", "flux_1", "eflux_1", "RA_2", "DEC_2", + "flux_2", "eflux_2"]) + check_shape(c, out) + clean() + + +class TestCascade: + def test_cascade_match(self): + [import_table(f) for f in (f"{IMAGE_AP_FITS}", f"{IMAGE_2_AP_FITS}")] + config = StarBugMainConfig() + CascadeMatch(threshold=config.match_threshold_arc_sec_as_an_arc_sec) + clean() + def test_vals(self): - t=[[ 0.0, 0.0, 1.0, 0.1, 0.0, 0.0, 1.1, 0.1], - [ 0.1, 0.1, 1.0, 0.1, np.nan, np.nan, np.nan, np.nan], - [ 0.2, 0.1, 2.0, 0.2, 0.2, 0.1, 2.1, 0.2], - [ 0.1, 0.2, 2.0, 0.2, 0.1, 0.2, 2.1, 0.2], - [ 1.0, 1.0, 100, 0.1, 1.0, 1.0, 101, 0.1], - [ 1.1, 1.0, 100, 0.1, 1.1, 1.0, 101, 0.1], - [2.0, 2.0, 201, 0.1, np.nan,np.nan,np.nan,np.nan] ] - c=Table(np.array(t), names=[ "RA_1","DEC_1","flux_1","eflux_1","RA_2","DEC_2","flux_2","eflux_2"]) - m=CascadeMatch() - out=m.match(cats()) + t = [[0.0, 0.0, 1.0, 0.1, 0.0, 0.0, 1.1, 0.1], + [0.1, 0.1, 1.0, 0.1, np.nan, np.nan, np.nan, np.nan], + [0.2, 0.1, 2.0, 0.2, 0.2, 0.1, 2.1, 0.2], + [0.1, 0.2, 2.0, 0.2, 0.1, 0.2, 2.1, 0.2], + [1.0, 1.0, 100, 0.1, 1.0, 1.0, 101, 0.1], + [1.1, 1.0, 100, 0.1, 1.1, 1.0, 101, 0.1], + [2.0, 2.0, 201, 0.1, np.nan, np.nan, np.nan, np.nan]] + c = Table( + np.array(t), + names=["RA_1", "DEC_1", "flux_1", "eflux_1", "RA_2", "DEC_2", + "flux_2", "eflux_2"]) + m = CascadeMatch(threshold=Quantity(2, unit=units.arcsec)) + out = m.match(cats()) print(out) - assert np.shape(c)==np.shape(out) - for m in range(len(c)): - for n in range(len(c[m])): - a=c[m][n] - b=out[m][n] - assert np.isnan(a)==np.isnan(b) - if not np.isnan(a) or not np.isnan(b): - assert a==b - -class Test_BandMatch: - def test_init(self): - filters = ["a","b","c"] - m=BandMatch(fltr=filters) - assert m.filter==["a","b","c"] - - def test_order_catalogue_JWSTMETA(self): - a = Table(None, meta={"FILTER":'F115W'}) - b = Table(None, meta={"FILTER":'F187N'}) - c = Table(None, meta={"FILTER":'F770W'}) + check_shape(c, out) + clean() - m=BandMatch() - assert m.filter=="" - assert m.order_catalogues( [a,c,b] ) == [a,b,c] - assert m.filter==["F115W","F187N","F770W"] - def test_order_catalogue_JWSTcolnames(self): +class TestBandMatch: + def test_init(self): + clean() + filters = ["a", "b", "c"] + m = BandMatch(fltr=filters) + assert m.filter_list == ["a", "b", "c"] + + def test_order_catalogue_jwst_meta(self): + a = Table(None, meta={"FILTER": 'F115W'}) + b = Table(None, meta={"FILTER": 'F187N'}) + c = Table(None, meta={"FILTER": 'F770W'}) + + m = BandMatch() + assert m.filter is None + assert m.order_catalogues([a, c, b]) == [a, b, c] + assert m.filter_list == ["F115W", "F187N", "F770W"] + clean() + + def test_order_catalogue_jwst_col_names(self): a = Table(None, names=['F115W']) b = Table(None, names=['F187N']) c = Table(None, names=['F770W']) - m=BandMatch() - assert m.filter=="" - assert m.order_catalogues( [a,c,b] ) == [a,b,c] - assert m.filter==["F115W","F187N","F770W"] + m = BandMatch() + assert m.filter is None + assert m.order_catalogues([a, c, b]) == [a, b, c] + assert m.filter_list == ["F115W", "F187N", "F770W"] + clean() - def test_order_catalogue_filterMETA(self): - a = Table(None, meta={"FILTER":'a'}) - b = Table(None, meta={"FILTER":'b'}) - c = Table(None, meta={"FILTER":'c'}) + def test_order_catalogue_filter_meta(self): + a = Table(None, meta={"FILTER": 'a'}) + b = Table(None, meta={"FILTER": 'b'}) + c = Table(None, meta={"FILTER": 'c'}) - m=BandMatch(fltr=["a","b","c"]) - assert m.order_catalogues( [a,c,b] ) == [a,b,c] + m = BandMatch(fltr=["a", "b", "c"]) + assert m.order_catalogues([a, c, b]) == [a, b, c] + clean() - def test_order_catalogue_filtercolnames(self): + def test_order_catalogue_filter_col_names(self): a = Table(None, names=['a']) b = Table(None, names=['b']) c = Table(None, names=['c']) - m=BandMatch(fltr=["a","b","c"]) - assert m.order_catalogues( [a,c,b] ) == [a,b,c] + m = BandMatch(fltr=["a", "b", "c"]) + assert m.order_catalogues([a, c, b]) == [a, b, c] + clean() def test_match(self): - t1=[[1.,1.,1,1,0], - [2.,2.,2,2,0], - [3.,3.,3,3,0], - #[4.,4.,4,4,0], - ] - t2=[[1.,1.,1,1,0], - [2.,2.,2,2,0], - #[3.,3.,3,3,0], - [4.,4.,4,4,1], - ] - t3=[[1.,1.,1,1,0], - #[2.,2.,2,2,0], - #[3.,3.,3,3,0], - [4.,4.,4,4,2], - ] - - f=float - cats = [Table(np.array(t1), names=["RA","DEC","A","NUM","flag"], dtype=[f,f,f,f,np.uint16], meta={"FILTER":"A"}), - Table(np.array(t2), names=["RA","DEC","B","NUM","flag"], dtype=[f,f,f,f,np.uint16], meta={"FILTER":"B"}), - Table(np.array(t3), names=["RA","DEC","C","NUM","flag"], dtype=[f,f,f,f,np.uint16], meta={"FILTER":"C"})] - - - bm=BandMatch(fltr=["A","B","C"], threshold=[0.1,0.2]) - res=bm(cats) + t1 = [[1., 1., 1, 1, 0], + [2., 2., 2, 2, 0], + [3., 3., 3, 3, 0], + ] + t2 = [[1., 1., 1, 1, 0], + [2., 2., 2, 2, 0], + [4., 4., 4, 4, 1], + ] + t3 = [[1., 1., 1, 1, 0], + [4., 4., 4, 4, 2], + ] + + f = float + categories = [ + Table(np.array(t1), names=[ + TableColumn.RA, TableColumn.DEC, "A", TableColumn.NUM, + TableColumn.FLAG], + dtype=[f, f, f, f, np.uint16], + meta={HeaderTags.FILTER: "A"}), + Table(np.array(t2), names=[ + TableColumn.RA, TableColumn.DEC, "B", TableColumn.NUM, + TableColumn.FLAG], + dtype=[f, f, f, f, np.uint16], + meta={HeaderTags.FILTER: "B"}), + Table(np.array(t3), names=[ + TableColumn.RA, TableColumn.DEC, "C", TableColumn.NUM, + TableColumn.FLAG], + dtype=[f, f, f, f, np.uint16], + meta={HeaderTags.FILTER: "C"})] + + bm = BandMatch(fltr=["A", "B", "C"], + threshold=[0.1 * units.arcsec, 0.2 * units.arcsec]) + res = bm(categories) print(res) - assert res.colnames==[ "RA","DEC","NUM","flag", "A","B","C"] - #res=bm(cats, method="last") - #print(res) - res=bm(cats, method="bootstrap") - -def test_parsemask(): - table=import_table("tests/dat/image-ap.fits") - """ - tests=[ "F444W!=np.nan", - "F444W==np.nan", - "F444W>0", - "(F444W>0)&(F444W<20)", ## Dont like this "syntax" - "F444W+0" - ] - - for test in tests: - assert parse_mask(test,table) is not None - """ - -def test_matchwithmasks(): - t1=[[0,0,1], - [1,1,1], - [2,2,1], - [3,3,1]] - t2=[[0,0,1], - [1,1,1], - [2,2,0], - [3,3,1]] - t3=[[0,0,1], - [1,1,1], - [2,2,0], - [3,3,1]] - cat1=Table(np.array(t1,float),names=["RA","DEC","a"]) - cat2=Table(np.array(t2,float),names=["RA","DEC","a"]) - cat3=Table(np.array(t3,float),names=["RA","DEC","a"]) - mask=[ np.array([True,True,False,True]), None, np.array([True,True,True,False])] - - res=GenericMatch().match([cat1,cat2,cat3], mask=mask) + assert res.colnames == [ + TableColumn.RA, TableColumn.DEC, TableColumn.NUM, + TableColumn.FLAG, "A", "B", "C"] + bm(categories, method="bootstrap") + clean() + + +def test_parse_mask(): + import_table(f"{IMAGE_AP_FITS}") + clean() + + +def test_match_with_masks(): + t1 = [[0, 0, 1], + [1, 1, 1], + [2, 2, 1], + [3, 3, 1]] + t2 = [[0, 0, 1], + [1, 1, 1], + [2, 2, 0], + [3, 3, 1]] + t3 = [[0, 0, 1], + [1, 1, 1], + [2, 2, 0], + [3, 3, 1]] + cat1 = Table(np.array(t1, float), + names=[TableColumn.RA, TableColumn.DEC, "a"]) + cat2 = Table(np.array(t2, float), + names=[TableColumn.RA, TableColumn.DEC, "a"]) + cat3 = Table(np.array(t3, float), + names=[TableColumn.RA, TableColumn.DEC, "a"]) + mask = [ + np.array([True, True, False, True]), + None, + np.array([True, True, True, False])] + config = StarBugMainConfig() + res = GenericMatch( + threshold=config.match_threshold_arc_sec_as_an_arc_sec + ).match([cat1, cat2, cat3], mask=mask) print(res) - -def test_ExactMatch(): - cat1=Table(np.array([["CN1",1], ["CN2",1], ["CN3",1]]),names=["CN","i"], dtype=(str,int)) - cat2=Table(np.array([["CN2",2], ["CN3",2], ["CN4",2]]),names=["CN","i"], dtype=(str,int)) - cat3=Table(np.array([["CN3",3], ["CN4",3], ["CN5",3]]),names=["CN","i"], dtype=(str,int)) - - arr= [[1, np.nan, np.nan], - [1, 2, np.nan], - [1, 2, 3], - [np.nan, 2, 3], - [np.nan, np.nan, 3]] - correct=Table(np.array(arr), dtype=[int,int,int], names=["i_1","i_2","i_3"],masked=True) - for i,col in enumerate(correct.columns.values()): col.mask=np.isnan(np.array(arr)[:,i]) - correct.add_column(["CN1","CN2","CN3","CN4","CN5"],name="CN",index=0) - - res=ExactValueMatch(value="CN").match([cat1,cat2,cat3]) - assert all(res==fill_nan(correct)) - -if __name__=="__main__": - test_ExactMatch() + clean() + + +def test_exact_match(): + cat1 = Table( + np.array([["CN1", 1], ["CN2", 1], ["CN3", 1]]), + names=["CN", "i"], dtype=(str, int)) + cat2 = Table( + np.array([["CN2", 2], ["CN3", 2], ["CN4", 2]]), + names=["CN", "i"], dtype=(str, int)) + cat3 = Table( + np.array([["CN3", 3], ["CN4", 3], ["CN5", 3]]), + names=["CN", "i"], dtype=(str, int)) + + arr = [[1, np.nan, np.nan], + [1, 2, np.nan], + [1, 2, 3], + [np.nan, 2, 3], + [np.nan, np.nan, 3]] + correct = Table( + np.array(arr), dtype=[int, int, int], names=["i_1", "i_2", "i_3"], + masked=True) + for i, col in enumerate(correct.columns.values()): + col.mask = np.isnan(np.array(arr)[:, i]) + correct.add_column(["CN1", "CN2", "CN3", "CN4", "CN5"], + name="CN", index=0) + res = ExactValueMatch(value="CN").match([cat1, cat2, cat3]) + assert all(res == fill_nan(correct)) # type: ignore + clean() + + +if __name__ == "__main__": + test_exact_match() diff --git a/tests/test_misc.py b/tests/test_misc.py index 6d6d427..91c2197 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,13 +1,41 @@ -import os, glob -from starbug2 import filters -from starbug2.misc import * +"""Copyright (C) 2026 UKATC -def xtest_init(): - os.environ["STARBUG_DATDIR"]="/tmp/starbug" - d=os.getenv("STARBUG_DATDIR") - init_starbug() +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. - for f in filters: - assert glob.glob("%s/*%s*"%(d,f)) +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +import glob +import os +import pytest + +from starbug2.filters import STAR_BUG_FILTERS +from starbug2.constants import STARBUG_DATA_DIR +from starbug2.initialise_psf_data import init_starbug_for_jwst +from starbug2.star_bug_config import StarBugMainConfig +from tests.generic import verify_test_data_exists + + +@pytest.mark.skipif( + os.getenv("RUN_STAR_BUG_PRODUCTION_TESTS") is None or + os.getenv("RUN_STAR_BUG_PRODUCTION_TESTS") == "false", + reason="test_init locked out of normal development runs due to " + "length of time to run, CPU resources required which nearly slags" + " the machine." +) +def test_init(): + verify_test_data_exists() + os.environ[STARBUG_DATA_DIR] = "/tmp/starbug" + d = os.getenv(STARBUG_DATA_DIR) + init_starbug_for_jwst(StarBugMainConfig()) + + for f in STAR_BUG_FILTERS: + assert glob.glob("%s/*%s*" % (d, f)) diff --git a/tests/test_param.py b/tests/test_param.py index 16580b0..01fafa3 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -1,49 +1,96 @@ +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + import os -from starbug2 import param +from typing import Final + +import pytest +from starbug2.constants import STAR_BUG_PARAMS, PROBLEMATIC_FILTER_WARNING +from starbug2.star_bug_config import StarBugMainConfig +from tests.generic import TEST_PATH_STR, verify_test_data_exists + +TEST_PARAM_PATH: Final[str] = os.path.join( + TEST_PATH_STR, "../param_files/old_format.param") + def test_parse_param(): - assert type(param.parse_param("A=1//."))==dict + verify_test_data_exists() + assert type(StarBugMainConfig.parse_param("A=1//.")) == dict + + assert StarBugMainConfig.parse_param("A = 1 //.") == {'A': 1} + assert StarBugMainConfig.parse_param("A = B //.") == {'A': 'B'} + assert StarBugMainConfig.parse_param("A = B //.\n") == {'A': 'B'} - assert param.parse_param("A = 1 //.")=={'A':1} - assert param.parse_param("A = B //.")=={'A':'B'} - assert param.parse_param("A = B //.\n")=={'A':'B'} + assert StarBugMainConfig.parse_param("A = //.\n") == {'A': ''} + assert StarBugMainConfig.parse_param(" = //.") == {} - assert param.parse_param("A = //.\n")=={'A':''} - assert param.parse_param(" = //.")=={} + assert StarBugMainConfig.parse_param("A=B") == {"A": "B"} + assert StarBugMainConfig.parse_param("A=B/") == {"A": "B/"} + assert StarBugMainConfig.parse_param("A=B/.") == {"A": "B/."} + assert StarBugMainConfig.parse_param("A=1/.") == {"A": "1/."} - assert param.parse_param("A=B")=={"A":"B"} - assert param.parse_param("A=B/")=={"A":"B/"} - assert param.parse_param("A=B/.")=={"A":"B/."} - assert param.parse_param("A=1/.")=={"A":"1/."} + assert StarBugMainConfig.parse_param("A =1") == {"A": 1} + assert StarBugMainConfig.parse_param("A=1 ") == {"A": 1} + assert StarBugMainConfig.parse_param("A=1 a") == {"A": "1 a"} - assert param.parse_param("A =1")=={"A":1} - assert param.parse_param("A=1 ")=={"A":1} - assert param.parse_param("A=1 a")=={"A":"1 a"} def test_load_default_params(): - assert param.load_default_params()!={} - assert type(param.load_default_params()) == dict - assert "PARAM" in param.load_default_params().keys() - assert param.load_default_params().get("PARAM")=="STARBUGII PARAMETERS" + config: StarBugMainConfig = StarBugMainConfig() + assert config.param_tag == STAR_BUG_PARAMS def test_load_params(): - assert param.load_default_params() == param.load_params(None) - - assert param.load_params("doesnotexist")=={} + config: StarBugMainConfig = StarBugMainConfig.load_params("does_not_exist") os.system("starbug2 --local-param") - assert param.load_params("starbug.param")!={} - assert "PARAM" in param.load_params("starbug.param").keys() - assert param.load_params("starbug.param").get("PARAM")=="STARBUGII PARAMETERS" + second_config: StarBugMainConfig = ( + StarBugMainConfig.load_params("starbug.param")) + + for value, _ in config.MAIN_PARAM_FILE_MAP.values(): + assert getattr(config, value) == getattr(second_config, value) + assert second_config.param_tag == STAR_BUG_PARAMS + assert config.param_tag == STAR_BUG_PARAMS os.remove("starbug.param") + def test_update_params(): os.system("starbug2 --local-param") os.system("sed -i s/PARAM/PARAM1/g starbug.param") - assert "PARAM" not in param.load_params("starbug.param").keys() - assert param.update_paramfile("starbug.param") is None - assert param.update_paramfile("starbug.param") is None + with pytest.raises( + TypeError, + match="Param PARAM1 no longer works within Starbug2. Please " + "execute starbug2 --update-param"): + StarBugMainConfig.load_params("starbug.param") + os.remove("starbug.param") + + +def test_update_params_no_changes(): + os.system("starbug2 --local-param") + os.system("starbug2 --update-param") os.remove("starbug.param") + +def test_update_params_old_to_new(): + os.system(f"starbug2 -p {TEST_PARAM_PATH} --update-param") + + +def test_f150w2_filter(capsys): + config: StarBugMainConfig = StarBugMainConfig() + config.custom_filter = "F150W2" + + # check for warning + captured = capsys.readouterr() + assert PROBLEMATIC_FILTER_WARNING in captured.err diff --git a/tests/test_routines.py b/tests/test_routines.py index 8164ffe..3b3b409 100644 --- a/tests/test_routines.py +++ b/tests/test_routines.py @@ -1,76 +1,69 @@ -from starbug2 import routines -from astropy.table import Table, hstack +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + from astropy.io import fits -from astropy.wcs import WCS +from astropy.table import Table -image=fits.open("tests/dat/image.fits") +from starbug2.constants import SCI, TableColumn +from starbug2.routines.background_estimate_routine import ( + BackGroundEstimateRoutine) +from starbug2.routines.detection_routines import DetectionRoutine +from starbug2.routines.source_properties import SourceProperties +from tests.generic import TEST_IMAGE_FITS, verify_test_data_exists -class Test_Detection(): - im=image["SCI"].data - a=Table([[0,10],[0,10]],names=["xcentroid","ycentroid"]) - b=Table([[20,10,50],[20,10,0]],names=["xcentroid","ycentroid"]) - def test_Detection_Routine_none(self): - dt=routines.Detection_Routine() - assert dt.find_stars(None) is None +class TestDetection: + verify_test_data_exists() + im = fits.open(TEST_IMAGE_FITS)[SCI].data + a = Table([[0, 10], [0, 10]], + names=[TableColumn.X_CENTROID, TableColumn.Y_CENTROID]) + b = Table([[20, 10, 50], [20, 10, 0]], + names=[TableColumn.X_CENTROID, TableColumn.Y_CENTROID]) - def test_Detection_Routine_crashes(self): - dt=routines.Detection_Routine() - out=dt.find_stars(self.im.copy()) - assert out is not None + def test_detection_routine_none(self): + dt = DetectionRoutine() + assert len(dt.find_stars(None)) == 0 - def test_Detection_match(self): - dt=routines.Detection_Routine() - _a=self.a.copy() - _b=self.b.copy() - c=dt.match(_a,_b) - assert type(c)==Table - assert len(_a)==len(self.a) - assert len(_b)==len(self.b) - assert len(c)==4 + def test_detection_routine_crashes(self): + dt = DetectionRoutine() + out = dt.find_stars(self.im.copy()) + assert len(out) != 0 + + def test_detection_match(self): + dt = DetectionRoutine() + _a = self.a.copy() + _b = self.b.copy() + c = dt.match(_a, _b) + assert isinstance(c, Table) + assert len(_a) == len(self.a) + assert len(_b) == len(self.b) + assert len(c) == 4 def test_bkg2d(self): - b=routines.Detection_Routine()._bkg2d(self.im.copy()) - assert type(b)==type(self.im) - assert b.shape==self.im.shape + b = DetectionRoutine().bkg2d(self.im.copy()) + assert type(b) is type(self.im) + assert b.shape == self.im.shape -class Test_Background(): - def test_BackGround_Estimate_Routine_none(self): - bg=routines.BackGround_Estimate_Routine(None) +class TestBackground: + def test_background_estimate_routine_none(self): + bg = BackGroundEstimateRoutine(None) assert bg(None) is None -#def test_BackGround_Estimate_Routine_crashes(): -# pass -# -# -# -# -def test_SourceProperties_none(): - sp=routines.SourceProperties(None,None) - assert sp.calculate_crowding() is None - assert sp.calculate_geometry(1)==None - -#def test_SourceProperties_crashes(): -# ## yeh i know this isnt how youre supposed to test -# ## im just verifying it doesnt crash -# print("testing crashes") -# slist=Table.read("tests/dat/image-ap.fits", format="fits") -# sp=routines.SourceProperties(image["SCI"].data, slist[["xcentroid","ycentroid"]]) -# a=sp(fwhm=2.3) -# -# assert "crowding" in a.colnames -# assert len(a)==len(slist) -# -# b=sp(fwhm=2.3, do_crowd=0) -# assert "crowding" not in b.colnames -# assert len(b)==len(slist) -# -# -# -# -# -# -# -# +def test_source_properties_none(): + sp = SourceProperties(None, None) + assert sp.calculate_crowding() is None + assert sp.calculate_geometry(1) is None diff --git a/tests/test_run.py b/tests/test_run.py index f758148..1cf0c41 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,104 +1,186 @@ -import os,glob +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + +import os +from typing import Final + import pytest -from starbug2.utils import wget -from starbug2.bin import EXIT_SUCCESS, EXIT_EARLY, EXIT_FAIL, EXIT_MIXED from starbug2.bin.main import starbug_main +from starbug2.constants import ExitStates +from tests.generic import ( + clean, TEST_IMAGE_FITS, TEST_FILTER_STRING, TEST_PATH_STR, TEST_PATH) + +# different fit files paths +TEST_IMAGE_AP_FITS: Final[str] = os.path.join( + TEST_PATH_STR, "image-ap.fits") +TEST_PSF_FITS: Final[str] = os.path.join( + TEST_PATH_STR, "psf.fits") +TEST_IMAGE_BGD_FITS: Final[str] = os.path.join( + TEST_PATH_STR, "image-bgd.fits") +TEST_IMAGE_RES_FIT: Final[str] = os.path.join( + TEST_PATH_STR, "image-res.fits") +TEST_IMAGE_2_FITS: Final[str] = os.path.join( + TEST_PATH_STR, "image2.fits") + -run = lambda s:starbug_main(s.split()) +def run(s): + return starbug_main((s + f" --output={TEST_PATH}").split()) def test_start(): clean() - assert run("starbug2 -h")==EXIT_EARLY - assert run("starbug2 -vh")==EXIT_EARLY - assert run("starbug2 --version")==EXIT_EARLY - assert run("starbug2 -vDABPh")==EXIT_EARLY - assert run("starbug2")==EXIT_FAIL + assert run("starbug2 -h") == ExitStates.EXIT_EARLY + assert run("starbug2 -vh") == ExitStates.EXIT_EARLY + assert run("starbug2 --version") == ExitStates.EXIT_SUCCESS + assert run("starbug2 -vDABPh") == ExitStates.EXIT_EARLY + assert run("starbug2") == ExitStates.EXIT_FAIL + clean() + def test_param(): clean() - assert run("starbug2 --local-param")==EXIT_EARLY - assert run("starbug2 --update-param")==EXIT_EARLY - assert run("starbug2 -p starbug.param tests/dat/image.fits")==EXIT_SUCCESS + assert run("starbug2 --local-param") == ExitStates.EXIT_SUCCESS + assert run("starbug2 --update-param") == ExitStates.EXIT_SUCCESS + assert (run( + f"starbug2 -p starbug.param {TEST_IMAGE_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS) clean() - #run("rm starbug.param") + def test_detect(): clean() - #wget("https://app.box.com/s/f2trqcln5mjug3rigs9246202ztuu5fh", "image.fits") - assert run("starbug2 -v tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2 -D tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2 --detect tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2 -D -sSIGSKY=3 -sSIGSRC=15 tests/dat/image.fits")==EXIT_SUCCESS + assert (run( + f"starbug2 -v {TEST_IMAGE_FITS} {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + assert (run( + f"starbug2 -D {TEST_IMAGE_FITS} {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + assert run( + f"starbug2 --detect {TEST_IMAGE_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert (run(f"starbug2 -D -sSIGSKY=3 -sSIGSRC=15 {TEST_IMAGE_FITS}" + f" {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) clean() - #run("rm image*.fits") + def test_bgd(): clean() - assert run("starbug2 -D tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2 -d tests/dat/image-ap.fits -B tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2 -d tests/dat/image-ap.fits --background tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2 -vf -B tests/dat/image.fits")==EXIT_SUCCESS + assert (run( + f"starbug2 -D {TEST_IMAGE_FITS} {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + assert (run(f"starbug2 -d {TEST_IMAGE_AP_FITS}" + f" -B {TEST_IMAGE_FITS} {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + assert run(f"starbug2 -d {TEST_IMAGE_AP_FITS} " + f"--background {TEST_IMAGE_FITS} " + f"{TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -vf -B {TEST_IMAGE_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS clean() + def test_psf(): clean() - assert run("starbug2 -DB tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2 -d tests/dat/image-ap.fits -b tests/dat/image-bgd.fits -P tests/dat/image.fits -sPSF_FILE=tests/dat/psf.fits")==EXIT_SUCCESS - assert run("starbug2 -fP tests/dat/image.fits -sPSF_FILE=tests/dat/psf.fits")==EXIT_SUCCESS - assert run("starbug2 -d tests/dat/image-ap.fits -P tests/dat/image.fits -sPSF_FILE=tests/dat/psf.fits")==EXIT_SUCCESS - assert run("starbug2 -fBP tests/dat/image.fits -sPSF_FILE=tests/dat/psf.fits")==EXIT_SUCCESS - assert run("starbug2 -fPs GEN_RESIDUAL=1 tests/dat/image.fits -sPSF_FILE=tests/dat/psf.fits")==EXIT_SUCCESS + assert run(f"starbug2 -DB {TEST_IMAGE_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -d {TEST_IMAGE_AP_FITS} -b " + f"{TEST_IMAGE_BGD_FITS} -P {TEST_IMAGE_FITS} " + f"-sPSF_FILE={TEST_PSF_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -fP {TEST_IMAGE_FITS} " + f"-sPSF_FILE={TEST_PSF_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -d {TEST_IMAGE_AP_FITS}" + f" -P {TEST_IMAGE_FITS} " + f"-sPSF_FILE={TEST_PSF_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -fBP {TEST_IMAGE_FITS} " + f"-sPSF_FILE={TEST_PSF_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -fPs GEN_RESIDUAL=1 {TEST_IMAGE_FITS} " + f"-sPSF_FILE={TEST_PSF_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS clean() + def test_residual(): clean() - assert run("starbug2 -DB tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2 -fSs GEN_RESIDUAL=1 tests/dat/image.fits")==EXIT_SUCCESS - assert run("starbug2 tests/dat/image-res.fits")==EXIT_SUCCESS - assert run("starbug2 -D tests/dat/image-res.fits")==EXIT_SUCCESS - assert run("starbug2 -fB tests/dat/image-res.fits")==EXIT_SUCCESS - assert run("starbug2 -fP tests/dat/image-res.fits -sPSF_FILE=tests/dat/psf.fits")==EXIT_SUCCESS - assert run("starbug2 -fPs GEN_RESIDUAL=1 tests/dat/image-res.fits -sPSF_FILE=tests/dat/psf.fits")==EXIT_SUCCESS - - assert run("starbug2 -fSA tests/dat/image.fits")==EXIT_SUCCESS + assert run(f"starbug2 -DB {TEST_IMAGE_FITS} " + f"{TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run( + f"starbug2 -fSs GEN_RESIDUAL=1 {TEST_IMAGE_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 {TEST_IMAGE_RES_FIT}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -D {TEST_IMAGE_RES_FIT}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -fB {TEST_IMAGE_RES_FIT}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -fP {TEST_IMAGE_RES_FIT} " + f"-sPSF_FILE={TEST_PSF_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -fPs GEN_RESIDUAL=1 {TEST_IMAGE_RES_FIT}" + f" -sPSF_FILE={TEST_PSF_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + + assert run(f"starbug2 -fSA {TEST_IMAGE_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS clean() -def test_ncores(): +def test_n_cores(): clean() - os.system("cp tests/dat/image.fits tests/dat/image2.fits") - assert run("starbug2 tests/dat/image.fits tests/dat/image2.fits")==EXIT_SUCCESS - assert run("starbug2 -n2 tests/dat/image.fits tests/dat/image2.fits")==EXIT_SUCCESS - assert run("starbug2 -vD tests/dat/image.fits tests/dat/image2.fits")==EXIT_SUCCESS - assert run("starbug2 -Dn0 tests/dat/image.fits tests/dat/image2.fits")==EXIT_SUCCESS - assert run("starbug2 -Dn1 tests/dat/image.fits tests/dat/image2.fits")==EXIT_SUCCESS - assert run("starbug2 -Dn2 tests/dat/image.fits tests/dat/image2.fits")==EXIT_SUCCESS - assert run("starbug2 -Dn4 tests/dat/image.fits tests/dat/image2.fits")==EXIT_SUCCESS - - assert run("starbug2 -DBP tests/dat/image.fits tests/dat/image2.fits -sPSF_FILE=tests/dat/psf.fits")==EXIT_SUCCESS - assert run("starbug2 -vDBPn2 tests/dat/image.fits tests/dat/image2.fits -sPSF_FILE=tests/dat/psf.fits")==EXIT_SUCCESS - assert run("starbug2 -DM tests/dat/image.fits tests/dat/image2.fits")==EXIT_SUCCESS - assert run("starbug2 -DMn2 tests/dat/image.fits tests/dat/image2.fits")==EXIT_SUCCESS - - assert run("starbug2 -D tests/dat/image-ap.fits tests/dat/image.fits")==EXIT_MIXED - assert run("starbug2 -D bad.fits tests/dat/image.fits")==EXIT_MIXED + os.system(f"cp {TEST_IMAGE_FITS} {TEST_IMAGE_2_FITS}") + assert run( + f"starbug2 {TEST_IMAGE_FITS} {TEST_IMAGE_2_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert (run( + f"starbug2 -n2 {TEST_IMAGE_FITS} " + f"{TEST_IMAGE_2_FITS} {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + assert (run(f"starbug2 -vD {TEST_IMAGE_FITS} {TEST_IMAGE_2_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS) + + with pytest.raises( + ValueError, + match="Number of processes must be at least 1"): + run(f"starbug2 -Dn0 {TEST_IMAGE_FITS} {TEST_IMAGE_2_FITS}" + f" {TEST_FILTER_STRING}") + assert (run(f"starbug2 -Dn1 {TEST_IMAGE_FITS} {TEST_IMAGE_2_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS) + assert (run(f"starbug2 -Dn2 {TEST_IMAGE_FITS} {TEST_IMAGE_2_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS) + assert (run(f"starbug2 -Dn4 {TEST_IMAGE_FITS} {TEST_IMAGE_2_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS) + + assert run(f"starbug2 -DBP {TEST_IMAGE_FITS} {TEST_IMAGE_2_FITS} " + f"-sPSF_FILE={TEST_PSF_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert run(f"starbug2 -vDBPn2 {TEST_IMAGE_FITS} {TEST_IMAGE_2_FITS}" + f" -sPSF_FILE={TEST_PSF_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS + assert (run(f"starbug2 -DM {TEST_IMAGE_FITS} {TEST_IMAGE_2_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_SUCCESS) + assert (run(f"starbug2 -DMn2 {TEST_IMAGE_FITS} " + f"{TEST_IMAGE_2_FITS} {TEST_FILTER_STRING}") == + ExitStates.EXIT_SUCCESS) + + assert (run(f"starbug2 -D {TEST_IMAGE_AP_FITS} " + f"{TEST_IMAGE_FITS} {TEST_FILTER_STRING}") == + ExitStates.EXIT_MIXED) + assert run(f"starbug2 -D bad.fits {TEST_IMAGE_FITS}" + f" {TEST_FILTER_STRING}") == ExitStates.EXIT_MIXED clean() - - - - - - - - - - -def clean(): - files=glob.glob("tests/dat/*") - files.remove("tests/dat/image.fits") - files.remove("tests/dat/psf.fits") - for fname in files: os.remove(fname) - if os.path.exists("starbug.param"): os.remove("starbug.param") - - diff --git a/tests/test_starbug.py b/tests/test_starbug.py index 0104c88..48b4c86 100644 --- a/tests/test_starbug.py +++ b/tests/test_starbug.py @@ -1,5 +1,18 @@ -from starbug2.starbug import StarbugBase +"""Copyright (C) 2026 UKATC -class Test_Implementation: - image="tests/dat/image.fits" - +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + + +class TestImplementation: + image = "tests/dat/image.fits" diff --git a/tests/test_system_results.py b/tests/test_system_results.py new file mode 100644 index 0000000..b78a71b --- /dev/null +++ b/tests/test_system_results.py @@ -0,0 +1,166 @@ +from multiprocessing import shared_memory +from multiprocessing.shared_memory import SharedMemory +from typing import Final + +import numpy as np + +from starbug2.bin.ast import execute_artificial_stars +from starbug2.bin.main import starbug_internal_main +from starbug2.constants import ExitStates +from starbug2.initialise_psf_data import download_ap_corr_files +from starbug2.star_bug_config import StarBugMainConfig +from starbug2.starbug import StarbugBase +from tests import generic +from tests.generic import ( + TEST_PATH, TEST_BLANK, TEST_PATH_STR, create_default_config, + TEST_AST_FILLED) +import os + + +TEST_NGC_FITS: Final[str] = str( + os.path.join(str(TEST_PATH), "ngc6822_F770W_i2d.fits")) + + +class TestSystemResults: + + @staticmethod + def _assert_results( + lines: list[str], + expected_plain: int | None = None, + expected_background: int | None = None, + expected_con_vol: int | None = None, + expected_cleaning: int | None = None, + expected_total: int | None = None, ratio_low: float = 1, + ratio_high: float = 1): + """ + processes output and determines if it passes expectations. + + :param expected_plain: how many expected to find in plain search. + :param expected_background: how many expected to find in background + search. + :param expected_con_vol: how many expected to find in con search. + :param expected_cleaning: how many expected to be cleaned away. + :param expected_total: how many expected to find in total. + :param lines: the output lines. + :param ratio_low: the ratio for acceptance in low mode. + :param ratio_high: the ratio for acceptance in high mode. + :return: None + """ + plain_line: str | None = None + background_line: str | None = None + con_vol_line: str | None = None + cleaning: str | None = None + total_line: str | None = None + + for line in lines: + if "[PLAIN] pass:" in line: + plain_line = line + if "[BGD2D] pass:" in line: + background_line = line + if "[CONVL] pass" in line: + con_vol_line = line + if "cleaning" in line: + cleaning = line + if "Total" in line: + total_line = line + + assert ( + plain_line is not None and background_line is not None + and con_vol_line is not None and cleaning is not None + and total_line is not None) + + count_part: str = plain_line.split("pass: ")[1] + plain_count: int = int(count_part.split()[0]) + + count_part = background_line.split("pass: ")[1] + background_count: int = int(count_part.split()[0]) + + count_part = con_vol_line.split("pass: ")[1] + con_vol_count: int = int(count_part.split()[0]) + + count_part = cleaning.split("cleaning ")[1] + cleaning_count: int = int(count_part.split()[0]) + + count_part = total_line.split("Total: ")[1] + total_line_count: int = int(count_part.split()[0]) + + if expected_plain is not None: + assert ((expected_plain * ratio_low) <= plain_count + <= (expected_plain * ratio_high)) + if expected_background is not None: + assert ((expected_background * ratio_low) <= background_count + <= (expected_background * ratio_high)) + if expected_con_vol is not None: + assert ((expected_con_vol * ratio_low) <= con_vol_count + <= (expected_con_vol * ratio_high)) + if expected_cleaning is not None: + assert ((expected_cleaning * ratio_low) <= cleaning_count + <= (expected_cleaning * ratio_high)) + if expected_total is not None: + assert ((expected_total * ratio_low) <= total_line_count + <= (expected_total * ratio_high)) + + def test_detection_on_proper_fits_file(self, capsys): + config: StarBugMainConfig = create_default_config() + config.custom_filter = 'F770W' + config.fits_images = [TEST_NGC_FITS] + config.do_star_detection = True + config.verbose_logs = True + exit_state: int = starbug_internal_main(config) + assert exit_state == ExitStates.EXIT_SUCCESS + generic.clean() + + captured = capsys.readouterr() + lines = captured.out.splitlines() + self._assert_results(lines, 4461, 4508, 21854, 3433, 18421, 0.8, 1.2) + + def test_detection_on_artificial_stars(self, capsys): + generic.clean() + c: np.ndarray = np.array([0, 0, 0], dtype=np.int64) + share_memory: SharedMemory = ( + shared_memory.SharedMemory(create=True, size=c.nbytes)) + loading_buffer: np.ndarray = np.ndarray( + c.shape, dtype=c.dtype, buffer=share_memory.buf) + + # set up config for creating psf + psf_config: StarBugMainConfig = create_default_config() + psf_config.custom_filter = 'F770W' + psf_config.generate_psf = True + psf_config.detector_name = None + psf_config.psf_fit_size = None + starbug_internal_main(psf_config) + download_ap_corr_files(StarbugBase.get_data_path()) + + # set up config for artificial stars + config: StarBugMainConfig = create_default_config() + config.custom_filter = 'F770W' + config.fits_images = [TEST_BLANK] + config.verbose_logs = True + config.do_star_detection = True + + # config to set off detection without psf + config.stars_per_artificial_test = 15 + config.save_added_image = True + config.save_added_image_path = TEST_PATH_STR + config.zero_point_magnitude = 25 + config.sigma_source = 50 + config.test_magnitude_bright_limit = 15 + config.test_magnitude_faint_limit = 16 + + # create empty fits file + generic.create_blank_fits() + + # add stars + execute_artificial_stars( + TEST_BLANK, config, config.verbose_logs, 0, 1, 10, loading_buffer) + config.fits_images = [TEST_AST_FILLED] + + # execute detection. + exit_state: int = starbug_internal_main(config) + assert exit_state == ExitStates.EXIT_SUCCESS + + # check results. + captured = capsys.readouterr() + lines = captured.out.splitlines() + self._assert_results(lines, expected_total=15) + generic.clean() diff --git a/tests/test_utils.py b/tests/test_utils.py index 2ac64e4..a0c1d8b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,160 +1,200 @@ -import starbug2 +"""Copyright (C) 2026 UKATC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" + from starbug2 import utils import numpy as np -from astropy.table import Table, MaskedColumn +from astropy.table import Table from astropy.io import fits -def test_strnktn(): - assert utils.strnktn( "", 3, 'a') == "aaa" - assert utils.strnktn( "a", 3, 'a') == "aaaa" - assert utils.strnktn( "a", 0, 'a') == "a" - -def test_split_fname(): - fname="/path/to/file.fits" - d,f,e = utils.split_fname(fname) - assert d=="/path/to" - assert f=="file" - assert e==".fits" - - fname="file.fits" - d,f,e = utils.split_fname(fname) - assert d=="." - assert f=="file" - assert e==".fits" - - fname="file" - d,f,e = utils.split_fname(fname) - assert d=="." - assert f=="file" - assert e=="" - - -def test_flux2mag(): - ## input shape - assert len( utils.flux2mag(1, None, zp=1)[0]) ==1 - assert len( utils.flux2mag(np.ones(10), None, zp=1)[0]) ==10 - assert len( utils.flux2mag(np.full(10,np.nan), None, zp=1)[0]) ==10 - a,b=utils.flux2mag( np.empty(10), np.empty(10), zp=1) - assert len(a)==len(b) - a,b=utils.flux2mag( 1, 1, zp=1) - assert len(a)==len(b) - a,b=utils.flux2mag( 0, 0, zp=1) - assert len(a)==len(b) - - ## normal fluxed - flux=np.array( [1, 100, 999, 123, 3.4, 87654, np.pi] ) - fluxerr=None - mag,magerr=utils.flux2mag(flux,fluxerr, zp=1) - assert np.all(np.equal(mag , -2.5*np.log10(flux))) - - ## boundary fluxes - flux=np.array( [0, 0.0, -1, np.nan] ) - fluxerr=None - mag,magerr=utils.flux2mag(flux,fluxerr,zp=1) +from starbug2.constants import Units +from tests.generic import check_shape + + +def test_str_nk_tn() -> None: + assert utils.append_chars("", 3, 'a') == "aaa" + assert utils.append_chars("a", 3, 'a') == "aaaa" + assert utils.append_chars("a", 0, 'a') == "a" + + +def test_split_f_name() -> None: + f_name = "/path/to/file.fits" + d, f, e = utils.split_file_name(f_name) + assert d == "/path/to" + assert f == "file" + assert e == ".fits" + + f_name = "file.fits" + d, f, e = utils.split_file_name(f_name) + assert d == "." + assert f == "file" + assert e == ".fits" + + f_name = "file" + d, f, e = utils.split_file_name(f_name) + assert d == "." + assert f == "file" + assert e == "" + + +def test_flux2mag() -> None: + # Input shape validation + assert len(utils.flux2mag(1, None, zp=1)[0]) == 1 + assert len(utils.flux2mag(np.ones(10), None, zp=1)[0]) == 10 + assert len(utils.flux2mag(np.full(10, np.nan), None, zp=1)[0]) == 10 + + a, b = utils.flux2mag(np.empty(10), np.empty(10), zp=1) + assert len(a) == len(b) + a, b = utils.flux2mag(1, 1, zp=1) + assert len(a) == len(b) + a, b = utils.flux2mag(0, 0, zp=1) + assert len(a) == len(b) + + # Normal flux validation + flux = np.array([1, 100, 999, 123, 3.4, 87654, np.pi]) + flux_err = None + mag, mag_err = utils.flux2mag(flux, flux_err, zp=1) + assert np.all(np.isclose(mag, -2.5 * np.log10(flux))) + + # Boundary fluxes + flux = np.array([0, 0.0, -1, np.nan]) + flux_err = None + mag, mag_err = utils.flux2mag(flux, flux_err, zp=1) assert np.isnan(mag).all() - assert utils.flux2mag( np.inf )[0] == -np.inf ##should be -inf - assert np.isnan(utils.flux2mag( -np.inf )[0]) ##Should be nan - - ##fluxerr - flux=np.array( [1234, 1, 0.00001, 10]) - fluxerr=np.array( [1,100,123456,1.234567] ) - mag,magerr=utils.flux2mag( np.ones(flux.shape), fluxerr,zp=1) - assert np.all(np.equal(magerr, 2.5*np.log10( 1.0+( fluxerr/np.ones(flux.shape)) ))) ##flux all 1 - mag,magerr=utils.flux2mag( flux,fluxerr,zp=1) - assert np.all(np.equal(magerr, 2.5*np.log10( 1.0+( fluxerr/flux) ))) ## random fluxes - - ## boundary fluxerrs - assert utils.flux2mag(1, None, zp=1)[1] ==0 + + # should be -inf + assert utils.flux2mag(np.inf)[0] == -np.inf + + # Should be nan + assert np.isnan(utils.flux2mag(-np.inf)[0]) + + # flux_err + flux = np.array([1234, 1, 0.00001, 10]) + flux_err = np.array([1, 100, 123456, 1.234567]) + mag, mag_err = utils.flux2mag(np.ones(flux.shape), flux_err, zp=1) + + # flux all 1 + assert np.all(np.equal( + mag_err, 2.5 * np.log10(1.0 + (flux_err / np.ones(flux.shape))))) + mag, mag_err = utils.flux2mag(flux, flux_err, zp=1) + + # random fluxes + assert np.all(np.equal( + mag_err, 2.5 * np.log10(1.0 + (flux_err / flux)))) + + # boundary flux_errs + assert utils.flux2mag(1, None, zp=1)[1] == 0 assert np.isnan(utils.flux2mag(1, np.nan, zp=1)[1]) assert np.isnan(utils.flux2mag(1, -1, zp=1)[1]) - ##ZPs - -def test_find_colnames(): - tab=Table(None, names=["A", "word", "word1", "word2", "notword", "_word"]) - res=utils.find_colnames(tab, "word") +def test_find_col_names() -> None: + # noinspection SpellCheckingInspection + tab = Table( + None, names=["A", "word", "word1", "word2", "notword", "_word"]) + res = utils.find_col_names(tab, "word") assert res is not None assert res == ["word", "word1", "word2"] - assert utils.find_colnames(tab, "badmatch")==[] + # noinspection SpellCheckingInspection + assert utils.find_col_names(tab, "badmatch") == [] + +def test_tab_append() -> None: + base = Table([[0, 0], [0, 0]], names=('a', 'b')) + tab = Table([[1, 1], [1, 1]], names=('a', 'b')) + exp = Table([[0, 0, 1, 1], [0, 0, 1, 1]], names=('a', 'b')) + out = utils.combine_tables(base, tab) -def test_tabppend(): - base=Table( [[0,0], [0,0]], names=('a','b')) - tab =Table( [[1,1], [1,1]], names=('a', 'b')) - exp =Table( [[0,0,1,1],[0,0,1,1]], names=('a','b')) - out=utils.tabppend(base,tab) - assert np.all(out==exp) + # Safely compare tables via their underlying numpy structures + assert np.all(out.as_array() == exp.as_array()) - tab1=tab.copy() - out=utils.tabppend(None, tab1) - assert np.all( out==tab) ## tab is not a typo + tab1 = tab.copy() + out = utils.combine_tables(None, tab1) + assert np.all(out.as_array() == tab.as_array()) -def test_parse_unit(): - assert utils.parse_unit("10p") == (10, starbug2.PIX) - assert utils.parse_unit("10s") == (10, starbug2.ARCSEC) - assert utils.parse_unit("10m") == (10, starbug2.ARCMIN) - assert utils.parse_unit("10d") == (10, starbug2.DEG) +def test_parse_unit() -> None: + assert utils.parse_unit("10p") == (10, Units.PIX) + assert utils.parse_unit("10s") == (10, Units.ARCSEC) + assert utils.parse_unit("10m") == (10, Units.ARCMIN) + assert utils.parse_unit("10d") == (10, Units.DEG) - assert utils.parse_unit("10.1s") == (10.1, starbug2.ARCSEC) - assert utils.parse_unit("-10.1s") == (-10.1, starbug2.ARCSEC) - assert utils.parse_unit("0s") == (0, starbug2.ARCSEC) + assert utils.parse_unit("10.1s") == (10.1, Units.ARCSEC) + assert utils.parse_unit("-10.1s") == (-10.1, Units.ARCSEC) + assert utils.parse_unit("0s") == (0, Units.ARCSEC) assert utils.parse_unit("0") == (0, None) assert utils.parse_unit("") == (None, None) assert utils.parse_unit("p") == (None, None) -def test_rmduplicates(): - lst=["a","b","b","c","b","c"] - lst2=utils.rmduplicates(lst) - assert lst2==["a","b","c"] - - assert utils.rmduplicates([]) == [] - assert utils.rmduplicates(["a"]) == ["a"] - -def test_hcascade(): - t1=[[1,1,0], - [2,2,0], - [3,3,0], - #[4,4,0] - ] - t2=[[1,1,0], - [2,2,0], - [3,3,1], - [4,4,0] - ] - - tables=[Table(np.array(t1), names=["A","B","flag"], dtype=[float,float,np.uint16]), - Table(np.array(t2), names=["A","B","flag"], dtype=[float,float,np.uint16])] - nan=MaskedColumn(None,dtype=float).info.mask_val - nan=np.ma.masked - nan=np.nan - res=utils.hcascade(tables) - test=Table( np.ma.array([ [1,1,0,1,1,0], - [2,2,0,2,2,0], - [3,3,0,3,3,1], - [4,4,0,nan,nan,0]]), - - dtype=[float,float,np.uint16,float,float,np.uint16], - names=["A_1","B_1","flag_1","A_2","B_2","flag_2"]) - - res=utils.fill_nan(res) - assert np.shape(res)==np.shape(test) - for m in range(len(res)): - for n in range(len(res[m])): - a=res[m][n] - b=test[m][n] - assert np.isnan(a)==np.isnan(b) - if not np.isnan(a) or not np.isnan(b): - assert a==b - -def test_collapseheader(): - header=fits.Header( {"OK":0, - "PARAMFILE":"/PATH/TO/FILE/THAT/IS/TOO/LONG/FOR/A/HIERARCH/CARD", - "PARAMFILE2":"/PATH/TO/FILE/THAT/IS/TOO/LONG/FOR/A/HIERARCH/CARD"}) - - h=utils.collapse_header(header) + +def test_remove_duplicates() -> None: + lst = ["a", "b", "b", "c", "b", "c"] + lst2 = utils.remove_duplicates(lst) + assert lst2 == ["a", "b", "c"] + + assert utils.remove_duplicates([]) == [] + assert utils.remove_duplicates(["a"]) == ["a"] + + +def test_h_cascade() -> None: + t1 = [[1, 1, 0], + [2, 2, 0], + [3, 3, 0]] + + t2 = [[1, 1, 0], + [2, 2, 0], + [3, 3, 1], + [4, 4, 0]] + + tables = [ + Table(np.array(t1), names=["A", "B", "flag"], + dtype=[float, float, np.uint16]), + Table(np.array(t2), names=["A", "B", "flag"], + dtype=[float, float, np.uint16]) + ] + nan = np.nan + res = utils.h_cascade(tables) + + # Corrected alignment: Since t1 has fewer rows than t2, missing slots + # belong to t1 components + test = Table( + np.ma.array([ + [1, 1, 0, 1, 1, 0], + [2, 2, 0, 2, 2, 0], + [3, 3, 0, 3, 3, 1], + [4, 4, 0, nan, nan, 0] + ]), + dtype=[float, float, np.uint16, float, float, np.uint16], + names=["A_1", "B_1", "flag_1", "A_2", "B_2", "flag_2"] + ) + + res = utils.fill_nan(res) + check_shape(res, test) + + +def test_collapse_header() -> None: + # noinspection SpellCheckingInspection + header = fits.Header({ + "OK": 0, + "PARAMFILE": "/PATH/TO/FILE/THAT/IS/TOO/LONG/FOR/A/HIERARCH/CARD", + "PARAMFILE2": "/PATH/TO/FILE/THAT/IS/TOO/LONG/FOR/A/HIERARCH/CARD" + }) + + h = utils.collapse_header(header) assert h["COMMENT"] is not None - assert type(utils.collapse_header( {"a":"b"} ))==fits.Header + # Explicit type validation using isinstance instead of un-idiomatic direct + # type comparison + assert isinstance(utils.collapse_header({"a": "b"}), fits.Header) diff --git a/tests/xtest_artificialstars.py b/tests/xtest_artificialstars.py index 1a76248..9d934a1 100644 --- a/tests/xtest_artificialstars.py +++ b/tests/xtest_artificialstars.py @@ -1,10 +1,51 @@ -from starbug2.bin.afs import afs_main -from starbug2.bin import EXIT_SUCCESS, EXIT_FAIL -from starbug2.artificialstars import Artificial_StarsIII +"""Copyright (C) 2026 UKATC -run = lambda s : afs_main(["starbug2-afs"]+s.split()) +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. -def _test_run(): - assert run("tests/dat/image.fits")==EXIT_SUCCESS - assert run("nope")==EXIT_FAIL - +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .""" +from multiprocessing import shared_memory +from multiprocessing.shared_memory import SharedMemory + +import numpy as np + +from starbug2.bin.ast import ast_main +from starbug2.constants import ExitStates +from tests.generic import TEST_IMAGE_FITS + + +def run_ast_main(*args: str) -> int: + """ + Helper function to wrap ast_main execution with predictable sequence + arguments. + + :param args: Elements to append to the executable command array + :return: The exit state integer from the binary + """ + c: np.ndarray = np.array([0, 0, 0], dtype=np.int64) + share_memory: SharedMemory = ( + shared_memory.SharedMemory(create=True, size=c.nbytes)) + loading_buffer: np.ndarray = np.ndarray( + c.shape, dtype=c.dtype, buffer=share_memory.buf) + return ast_main( + ["starbug2-afs"] + list(args), share_memory, loading_buffer) + + +def test_ast_main_execution_states() -> None: + """ + Verify that ast_main correctly handles valid image inputs and expected + failures. + """ + # Test behaviour with a valid, verified target image path + assert run_ast_main(TEST_IMAGE_FITS) == ExitStates.EXIT_SUCCESS + + # Test behaviour with an unrecognised, non-existent target path + assert run_ast_main("nope") == ExitStates.EXIT_FAIL