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
-
+
[](https://github.com/conornally/starbug2/actions/workflows/python-app.yml)
[](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@A AuOAͼ@])?f&