diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index caec634..d162a49 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,66 +5,66 @@ name: CI Tests on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: linting: name: Lint with Flake8, Black runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - persist-credentials: true - - 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 - python -m pip install flake8 black - - 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 --extend-ignore=F821 --exclude .git,.idea,.mypy_cache,Notebooks/ - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics --extend-ignore=F821 --exclude .git,.idea,.mypy_cache,Notebooks/ - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Enforce Black codestyle. - uses: psf/black@stable - with: - options: "--check --verbose" - src: "./flows_get_brightest" - version: "22.10.0" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout code + uses: actions/checkout@v3 + with: + persist-credentials: true + - 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 + python -m pip install flake8 black + - 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 --extend-ignore=F821 --exclude .git,.idea,.mypy_cache,Notebooks/ + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics --extend-ignore=F821 --exclude .git,.idea,.mypy_cache,Notebooks/ + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Enforce Black codestyle. + uses: psf/black@stable + with: + options: "--check --verbose" + src: "./flows_finder" + version: "22.10.0" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} mypy: name: mypy runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - persist-credentials: true - - 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 - python -m pip install '.[test]' - python -m pip install mypy types-requests - - name: Run MyPy - id: runmypy - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - mypy flows_get_brightest/ typings/tendrils/ --config-file=pyproject.toml --check-untyped-defs + - name: Checkout code + uses: actions/checkout@v3 + with: + persist-credentials: true + - 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 + python -m pip install '.[test]' + python -m pip install mypy types-requests + - name: Run MyPy + id: runmypy + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mypy flows_finder/ typings/tendrils/ --config-file=pyproject.toml --check-untyped-defs build: strategy: @@ -76,28 +76,27 @@ jobs: name: Pytest on ${{ matrix.os }}, python 3.10 steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install '.[test]' - - name: Test with pytest - env: - FLOWS_API_TOKEN: ${{ secrets.FLOWS_API_TOKEN }} - run: | - pytest -v --cov-report "xml:coverage.xml" --cov - - name: Pytest coverage report - uses: MishaKav/pytest-coverage-comment@main - if: startsWith(${{matrix.os}},'ubuntu') - with: - pytest-xml-coverage-path: ./coverage.xml - github_token: ${{ secrets.GITHUB_TOKEN }} - title: Coverage Report - badge-title: Tests Coverage - hide-badge: false - hide-report: false - + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install '.[test]' + - name: Test with pytest + env: + FLOWS_API_TOKEN: ${{ secrets.FLOWS_API_TOKEN }} + run: | + pytest -v --cov-report "xml:coverage.xml" --cov + - name: Pytest coverage report + uses: MishaKav/pytest-coverage-comment@main + if: startsWith(${{matrix.os}},'ubuntu') + with: + pytest-xml-coverage-path: ./coverage.xml + github_token: ${{ secrets.GITHUB_TOKEN }} + title: Coverage Report + badge-title: Tests Coverage + hide-badge: false + hide-report: false diff --git a/flows_get_brightest/README.md b/flows_finder/README.md similarity index 100% rename from flows_get_brightest/README.md rename to flows_finder/README.md diff --git a/flows_get_brightest/__init__.py b/flows_finder/__init__.py similarity index 65% rename from flows_get_brightest/__init__.py rename to flows_finder/__init__.py index 40379c5..08b1848 100644 --- a/flows_get_brightest/__init__.py +++ b/flows_finder/__init__.py @@ -4,5 +4,8 @@ tendrils API. Extensible for other instruments and projects. Can optionally make a finding chart using NASA SkyView. """ +from . import make_fc as make_fc # noqa: F401 +from . import run_get_brightest as get_brightest # noqa: F401 + +# from .run_get_brightest import main as main # noqa: F401 from .version import __version__ as __version__ # noqa: F401 -from .run_get_brightest import main as main # noqa: F401 diff --git a/flows_finder/__main__.py b/flows_finder/__main__.py new file mode 100644 index 0000000..358f5aa --- /dev/null +++ b/flows_finder/__main__.py @@ -0,0 +1,3 @@ +from flows_finder.make_fc import main as main + +main() diff --git a/flows_finder/argparser.py b/flows_finder/argparser.py new file mode 100644 index 0000000..b4bfab1 --- /dev/null +++ b/flows_finder/argparser.py @@ -0,0 +1,176 @@ +import argparse +from typing import Any + +import astropy.units as u + +from .catalogs import SkyViewSurveys +from .instruments import FixedSizeInstrument, Hawki, Instrument +from .utils import StrEnum + + +class Parsers(StrEnum): + get_brightest = "get_brightest" + make_fc = "make_fc" + + +def get_defaults(use_parser: Parsers) -> argparse.ArgumentParser: + """Get default arguments for a given parser""" + + if use_parser == Parsers.get_brightest: + parser = argparse.ArgumentParser(description="Calculate Brightest Star & Optionally plot Finder Chart") + parser.add_argument( + "-t", "--target", help="calculate for this targetname or targetid", type=str, default="None", action="store" + ) + parser.add_argument("-r", "--rotate", help="rotation angle in degrees", type=float, default=0.0, action="store") + parser.add_argument("-a", "--shifta", help="shift alpha in arcsec", type=float, default=0.0, action="store") + parser.add_argument("-d", "--shiftd", help="shift delta in arcsec", type=float, default=0.0, action="store") + parser.add_argument("-p", "--plot", help="whether to query images and plot", action="store_true") + parser.add_argument( + "-i", + "--instrument", + help="instrument name", + choices=["Hawki", "FixedSize"], + type=str, + default="Hawki", + action="store", + ) + parser.add_argument( + "--size", + help=( + "Instrument FoV in arcmin (if using FixedSize instrument), finder chart will be roughly twice the size." + ), + type=float, + default=7.5, + action="store", + ) + return parser + elif use_parser == Parsers.make_fc: + parser = argparse.ArgumentParser(description="Make Finder chart") + group = parser.add_argument_group() + group.add_argument( + "image", + help="OPTIONAL: image to use for plot if not querying", + nargs="?", + type=str, + action="store", + default=None, + ) + group.add_argument( + "-s", + "--survey", + help=( + f"survey to query for image {[e.value for e in SkyViewSurveys]}, else pass path to fits image with WCS." + ), + action="store", + type=str, + default="DSS", + ) + parser.add_argument( + "-t", "--target", help="FLOWS Targetname or targetid", type=str, default="None", action="store" + ) + + parser.add_argument( + "-i", + "--instrument", + help="instrument name", + choices=["Hawki", "FixedSize"], + type=str, + default="FixedSize", + action="store", + ) + parser.add_argument( + "--size", + "--fov", + help="Instrument FoV in arcmin (if using FixedSize instrument), finder chart will be slightly bigger.", + type=float, + default=7.5, + action="store", + ) + parser.add_argument( + "--cmap", help="matplotlib colormap to use for image", type=str, default="gist_yarg", action="store" + ) + + group1 = parser.add_argument_group("Plot Scaling:") + group1.add_argument("--scale", help="How to scale image", type=str, default="linear", action="store") + group1.add_argument("--sigma", help="Sigma for Z-scaling", type=float, default=2.5, action="store") + group1.add_argument("--contrast", help="Contrast [0,1] for Z-scaling", type=float, default=0.25, action="store") + + group2 = parser.add_argument_group("Rotation & Shifts:") + group2.add_argument("-r", "--rotate", help="rotation angle in degrees", type=float, default=0.0, action="store") + group2.add_argument("-a", "--shifta", help="shift alpha in arcsec", type=float, default=0.0, action="store") + group2.add_argument("-d", "--shiftd", help="shift delta in arcsec", type=float, default=0.0, action="store") + return parser + + raise ValueError(f"Parser {use_parser} not supported") + + +def get_instrument(args: argparse.Namespace) -> type[Instrument]: + if args.instrument not in ["Hawki", "FixedSize"]: + raise ValueError(f"Instrument {args.instrument} not supported, use Hawki or FixedSize") + instrument: type[Instrument] = Hawki + if args.instrument == "FixedSize": + instrument = FixedSizeInstrument + instrument.field_hw = args.size << u.arcmin + return instrument + + +def check_flows_target(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int | str: + target = args.target.strip() + if target == "None": + parser.error("target id or name not provided, use -t or ") + elif target.isnumeric(): + args.target = int(args.target) + return args.target + + +def parse_brightest() -> tuple[float, int | str, float, float, bool, type[Instrument]]: + parser = get_defaults(Parsers.get_brightest) + args = parser.parse_args() + + args.target = check_flows_target(args, parser) + + instrument = get_instrument(args) + + return args.rotate, args.target, args.shifta, args.shiftd, args.plot, instrument + + +def parse_fc() -> ( + tuple[float, int | str, float, float, type[Instrument], str | None, str | None, str, str, float, float] +): + parser = get_defaults(Parsers.make_fc) + args = parser.parse_args() + + args.target = check_flows_target(args, parser) + instrument = get_instrument(args) + + if args.survey not in [e.value for e in SkyViewSurveys]: + raise ValueError(f"Survey {args.survey} not supported, use {[e.value for e in SkyViewSurveys]}") + + if args.image is not None: + args.survey = None + + if not (0 < args.contrast <= 1): + raise ValueError(f"Contrast must be between 0 and 1, got {args.contrast}") + + return ( + args.rotate, + args.target, + args.shifta, + args.shiftd, + instrument, + args.image, + args.survey, + args.cmap, + args.scale, + args.sigma, + args.contrast, + ) + + +def parse(use_parser: Parsers = Parsers.get_brightest) -> Any: + """Parse command line input to get target, position angle (rotate), alpha and delta offsets (shifta, shiftd)""" + match use_parser: + case Parsers.get_brightest: + return parse_brightest() + case Parsers.make_fc: + return parse_fc() diff --git a/flows_get_brightest/auth.py b/flows_finder/auth.py similarity index 100% rename from flows_get_brightest/auth.py rename to flows_finder/auth.py diff --git a/flows_get_brightest/catalogs.py b/flows_finder/catalogs.py similarity index 71% rename from flows_get_brightest/catalogs.py rename to flows_finder/catalogs.py index 3f5e13d..45cb1b8 100644 --- a/flows_get_brightest/catalogs.py +++ b/flows_finder/catalogs.py @@ -1,12 +1,14 @@ import warnings -from astropy.coordinates import SkyCoord, Angle -from astroquery.simbad import Simbad +from typing import Optional, cast + import astropy.units as u -from astroquery.skyview import SkyView -from astropy.table import Table +from astropy.coordinates import Angle, SkyCoord from astropy.io.fits import PrimaryHDU -from typing import Optional, cast -from .utils import numeric +from astropy.table import Table +from astroquery.simbad import Simbad +from astroquery.skyview import SkyView + +from .utils import StrEnum, numeric def query_simbad( @@ -84,14 +86,60 @@ def query_simbad( return results, simbad -def query_2mass_image(ra: float, dec: float, pixels: int = 2500, radius: numeric = 50) -> PrimaryHDU: # ImageHDU +class SkyViewSurveys(StrEnum): + TWO_MASS_H = "2MASS-H" + DSS = "DSS" + DSS_B = "DSS2-B" + DSS_R = "DSS2-R" + SDSS_G = "SDSS-G" + SDSS_R = "SDSS-R" + + +def query_skyview( + ra: float, + dec: float, + pixels: int = 2500, + radius: numeric = 20, + scale: str = "linear", + survey: str = SkyViewSurveys.DSS, +) -> PrimaryHDU: + """ + Query SkyView using astroquery + Parameters: + ra (float): Right Ascension of centre of search cone. + dec (float): Declination of centre of search cone. + radius (float, optional): + Returns: + dict: Dictionary of HDU objects. + """ + qradius: u.Quantity = radius << u.arcmin # type: ignore # Convert to astropy units + out = SkyView.get_images( + position="{}, {}".format(ra, dec), + survey=survey, + pixels=str(pixels), + coordinates="J2000", + scaling=scale, + radius=qradius, + ) + hdul = out.pop() + return cast(PrimaryHDU, hdul.pop()) + + +def query_2mass_image( + ra: float, + dec: float, + pixels: int = 2500, + radius: numeric = 50, + scale: str = "linear", + survey: str = SkyViewSurveys.TWO_MASS_H, +) -> PrimaryHDU: # ImageHDU qradius: u.Quantity = radius << u.arcmin # type: ignore # Convert to astropy units out = SkyView.get_images( position="{}, {}".format(ra, dec), - survey="2MASS-H", + survey=survey, pixels=str(pixels), coordinates="J2000", - scaling="Linear", + scaling=scale, radius=qradius, ) hdul = out.pop() diff --git a/flows_get_brightest/corner.py b/flows_finder/corner.py similarity index 100% rename from flows_get_brightest/corner.py rename to flows_finder/corner.py diff --git a/flows_get_brightest/instruments.py b/flows_finder/instruments.py similarity index 97% rename from flows_get_brightest/instruments.py rename to flows_finder/instruments.py index d49e61a..6250f74 100644 --- a/flows_get_brightest/instruments.py +++ b/flows_finder/instruments.py @@ -1,13 +1,14 @@ from abc import ABC, abstractmethod from typing import Optional -from .target import Target -from .plan import Plan -from .corner import Corner -from .utils import numeric, is_quantity -from astropy.coordinates import SkyCoord import astropy.units as u import regions +from astropy.coordinates import SkyCoord + +from .corner import Corner +from .plan import Plan +from .target import Target +from .utils import is_quantity, numeric class Instrument(ABC): @@ -49,6 +50,10 @@ def nregions(self) -> int: def get_corners(self) -> list[Corner]: pass + @abstractmethod + def __str__(self) -> str: + pass + class Hawki(Instrument): """Class for storing the hardcoded chip and detector information of a VLT HAWKI pointing""" @@ -138,6 +143,9 @@ def _get_pa_sep(coord1: SkyCoord, coord2: SkyCoord) -> tuple[u.Quantity, u.Quant sep = coord1.separation(coord2) return pa, sep + def __str__(self) -> str: + return "Hawki" + class FixedSizeInstrument(Instrument): """Generic Single field instrument with 7.5 arcminute field for making a finder chart.""" @@ -195,3 +203,6 @@ def default_point( self, alpha: u.Quantity = u.Quantity(0.0, u.arcsec), delta: u.Quantity = u.Quantity(0.0, u.arcsec) ) -> SkyCoord: return self.offset(alpha, delta) + + def __str__(self) -> str: + return f"Fov:{self.field_hw}" diff --git a/flows_finder/make_fc.py b/flows_finder/make_fc.py new file mode 100644 index 0000000..7c1ffa1 --- /dev/null +++ b/flows_finder/make_fc.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import warnings +from typing import cast + +from erfa import ErfaWarning + +from .argparser import parse_fc +from .auth import test_connection +from .observer import get_flows_observer +from .plan import make_plan +from .plots import Plotter, get_zscaler + +# Most useless warnings ever spammed for every operation by this package! +warnings.filterwarnings("ignore", category=ErfaWarning, append=True) +warnings.filterwarnings("ignore", message="invalid value", category=RuntimeWarning, append=True) + + +def main(): + # Parse input + rot, tid, shifta, shiftd, inst, image, survey, cmap, scale, sigma, contrast = parse_fc() + + # Test connection to flows: + test_connection() + + # Whether to query for image or use local image + plan = make_plan(rot, shifta, shiftd, image=image, survey=survey, scale=scale) + + # Create observer + obs = get_flows_observer(plan, tid, inst) + + # Make finding chart if requested + radius = cast(float, inst.field_hw.value) * 1.2 + + zscaler = get_zscaler(krej=sigma, contrast=contrast) + Plotter(obs, cmap=cmap).make_finding_chart(radius=radius, zscaler=zscaler) + + +if __name__ == "__main__": + main() diff --git a/flows_get_brightest/observer.py b/flows_finder/observer.py similarity index 82% rename from flows_get_brightest/observer.py rename to flows_finder/observer.py index 900fcf8..3093d8c 100644 --- a/flows_get_brightest/observer.py +++ b/flows_finder/observer.py @@ -1,25 +1,26 @@ from typing import Optional, Union -from astropy.coordinates import SkyCoord import astropy.units as u +import numpy as np +import regions +from astropy.coordinates import SkyCoord +from astropy.io.fits import PrimaryHDU +from astropy.io.fits import open as fits_open from astropy.time import Time from astropy.wcs import WCS from astropy.wcs.utils import celestial_frame_to_wcs -from astropy.io.fits import PrimaryHDU -import regions -import numpy as np from numpy.typing import NDArray from tendrils import api -from .instruments import Instrument, Hawki +from .catalogs import query_simbad, query_skyview +from .instruments import Hawki, Instrument +from .plan import Plan from .target import Target from .utils import numeric, tabular -from .catalogs import query_2mass_image, query_simbad -from .plan import Plan class Observer: - def __init__(self, instrument: Instrument, target: Target, plan: Plan, verbose: bool = False): + def __init__(self, instrument: Instrument, target: Target, plan: Plan, verbose: bool = False) -> None: self.target = target # target info self.plan = plan # Store offsets and rotations. self.ins = instrument # store instrument specific info @@ -35,7 +36,13 @@ def __init__(self, instrument: Instrument, target: Target, plan: Plan, verbose: f"equivalent: {self.refcat_coords.frame.is_equivalent_frame(self.simbad_coords.frame)}" ". If not, there might be a slight mismatch in alignment in current year." ) - self.wcs = self.get_wcs(self.refcat_coords.frame) + + self.image: Optional[PrimaryHDU] = None + use_frame: Optional[object] = self.refcat_coords.frame + if self.plan.local_image is not None: + self.image = self.get_image() + use_frame = None + self.wcs = self.get_wcs(frame=use_frame) def _make_refcat_catalog(self, mask: bool = True, mask_dict: Optional[dict[str, float]] = None) -> tabular: refcat = api.get_catalog(self.target.tid) @@ -73,11 +80,20 @@ def get_wcs(self, frame: Optional[object] = None, header: Optional[object] = Non return wcs elif header is not None: return WCS(header) + elif self.image is not None: + return WCS(self.image.header) else: raise AttributeError("Both frame and header were None, cannot calculate WCS") def get_image(self, pixels: int = 2500, radius: numeric = 50) -> PrimaryHDU: - return query_2mass_image(self.target.ra, self.target.dec, pixels, radius) + if self.image is not None: # cached image + return self.image + if self.plan.local_image is not None: + with fits_open(self.plan.local_image) as hdul: + return hdul[0] + return query_skyview( + self.target.ra, self.target.dec, pixels, radius, scale=self.plan.image_scale, survey=self.plan.survey + ) def regions_to_physical(self) -> list[regions.RectangleSkyRegion]: pixel_regions = [] @@ -102,7 +118,9 @@ def check_bright_stars(self, region: regions.RectangleSkyRegion, wcs: Optional[W def get_flows_observer( - rot: numeric, tid: Union[int, str], shifta: numeric, shiftd: numeric, instrument: type[Instrument] = Hawki + plan: Plan, + tid: Union[int, str], + instrument: type[Instrument] = Hawki, ) -> Observer: """ Returns the H-mag of the brightest star in the given region. @@ -113,6 +131,6 @@ def get_flows_observer( # Create Observer target = Target(tid, target_info["ra"], target_info["decl"], target_info["skycoord"], target_info) - plan = Plan.from_numeric(rot, shifta, shiftd) + hawki = instrument(target.coords) return Observer(hawki, target, plan) diff --git a/flows_get_brightest/plan.py b/flows_finder/plan.py similarity index 52% rename from flows_get_brightest/plan.py rename to flows_finder/plan.py index 998d910..69cb027 100644 --- a/flows_get_brightest/plan.py +++ b/flows_finder/plan.py @@ -1,6 +1,10 @@ from dataclasses import dataclass -import numpy as np +from typing import Optional + import astropy.units as u +import numpy as np + +from .catalogs import SkyViewSurveys from .utils import numeric @@ -11,11 +15,21 @@ class Plan: delta: u.Quantity = u.Quantity(0, u.deg) rotate: bool = False shift: bool = False + survey: str = SkyViewSurveys.TWO_MASS_H.value # Planned filter for survey image. + image_scale: str = "linear" + local_image: Optional[str] = None def __post_init__(self) -> None: self.shift = self.set_shift() self.rotate = self.set_rotation() - # self._sanitize_quanity_input() + + def plan_finder_chart( + self, image: Optional[str] = None, survey: Optional[str] = None, scale: str = "linear" + ) -> None: + self.local_image = image + self.image_scale = scale + if survey is not None: + self.survey = survey def set_rotation(self) -> bool: if self.rotation == u.Quantity(0, u.deg): @@ -26,22 +40,19 @@ def set_shift(self) -> bool: shift = (np.array((self.alpha.value, self.delta.value)) == 0.0).all() # skip shift if alpha and delta 0 return not shift - # def _sanitize_quanity_input(self) -> None: - # self.alpha = self.alpha << u.arcsec # type: ignore - # self.delta = self.delta << u.arcsec # type: ignore - # self.rotation = self.rotation << u.deg # type: ignore - @classmethod def from_numeric(cls, rotation: numeric, alpha: numeric, delta: numeric) -> "Plan": return cls(rotation=rotation << u.deg, alpha=alpha << u.arcsec, delta=delta << u.arcsec) # type: ignore - # def finalize(self) -> FinalizedPlan: - # if self._finalized(self): - # return FinalizedPlan(self.rotation, self.alpha, self.delta, self.rotate, self.shift) - # raise ValueError('Plan not finalized, attributes must be quantites.') - # @staticmethod - # def _finalized(p: Any) -> TypeGuard[FinalizedPlan]: - # if isinstance(p.rotation, u.Quantity) and isinstance(p.alpha, u.Quantity) and isinstance(p.delta, u.Quantity): - # return True - # return False +def make_plan( + rotation: numeric = 0.0, + alpha: numeric = 0.0, + delta: numeric = 0.0, + image: Optional[str] = None, + survey: Optional[str] = SkyViewSurveys.TWO_MASS_H.value, + scale: str = "linear", +) -> Plan: + plan = Plan.from_numeric(rotation, alpha, delta) + plan.plan_finder_chart(image=image, survey=survey, scale=scale) + return plan diff --git a/flows_get_brightest/plots.py b/flows_finder/plots.py similarity index 89% rename from flows_get_brightest/plots.py rename to flows_finder/plots.py index d434995..ff08927 100644 --- a/flows_get_brightest/plots.py +++ b/flows_finder/plots.py @@ -1,17 +1,19 @@ import copy import logging -from typing import cast from dataclasses import dataclass +from typing import cast + import astropy.visualization as viz -from matplotlib.colors import Normalize -from matplotlib.axes import Axes import matplotlib.pyplot as plt import numpy as np -from numpy.typing import NDArray -from astropy.visualization import ZScaleInterval from astropy.io.fits import Header, PrimaryHDU +from astropy.visualization import ZScaleInterval +from matplotlib.axes import Axes +from matplotlib.colors import Normalize from matplotlib.ticker import MaxNLocator from mpl_toolkits.axes_grid1 import make_axes_locatable +from numpy.typing import NDArray + from .observer import Observer from .utils import numeric @@ -29,27 +31,43 @@ def fromHDU(cls, hdu: PrimaryHDU) -> "Image": return cls(data=cast(np.ndarray, hdu.data), header=hdu.header) +def get_zscaler(nsamples: int = 1000, contrast: float = 0.25, krej: float = 2.5) -> ZScaleInterval: + zscale = ZScaleInterval(nsamples=nsamples, contrast=contrast, krej=krej) + return zscale + + class Plotter: """ Takes an observer with WCS, Target, Plan, Instrument, Regions, and Corners and makes a finding chart. + cmap: str = "viridis" """ - def __init__(self, obs: Observer): + def __init__(self, obs: Observer, cmap: str = "gist_yarg") -> None: self.obs = obs + self.cmap = cmap def plot(self) -> None: """not implemented yet""" pass def make_finding_chart( - self, plot_refcat: bool = True, plot_simbad: bool = True, savefig: bool = True, radius: numeric = 14 + self, + plot_refcat: bool = True, + plot_simbad: bool = True, + savefig: bool = True, + radius: numeric = 14, + zscaler: ZScaleInterval = get_zscaler(), ) -> Axes: - """Make finding chart for a given observation.""" + """Make finding chart for a given observation. + scale: str = "linear" one of "log", "linear", "sqrt", "asinh", "histeq", "sinh", "squared" + + """ obs = self.obs + imagehdu = obs.get_image(radius=radius) - zscale = ZScaleInterval() image = Image.fromHDU(imagehdu) - vmin, vmax = zscale.get_limits(image.data.flat) + vmin, vmax = zscaler.get_limits(image.data.flat) + obs.wcs = obs.get_wcs(header=image.header) regions = obs.regions_to_physical() @@ -58,9 +76,21 @@ def make_finding_chart( for i, region in enumerate(regions): region.plot(ax=ax, edgecolor="cyan", linestyle="-.", label=obs.ins.region_names[i]) - self.plot_image(image.data, ax=ax, cmap="viridis", scale="linear", vmin=vmin, vmax=vmax) + self.plot_image(image.data, ax=ax, cmap=self.cmap, scale=obs.plan.image_scale, vmin=vmin, vmax=vmax) tar_pix = obs.wcs.all_world2pix(obs.target.ra, obs.target.dec, 0) - ax.scatter(tar_pix[0], tar_pix[1], marker="*", s=250, label="SN", color="orange") + # ax.scatter( + # tar_pix[0], tar_pix[1], marker="*", s=250, label="SN", markerfacecolor="None", markeredgecolor="orange" + # ) + ax.plot( + tar_pix[0], + tar_pix[1], + marker="*", + markersize=20, + linestyle="None", + label="SN", + markerfacecolor="None", + markeredgecolor="orange", + ) if plot_refcat: refcat_stars = obs.refcat_coords.to_pixel(obs.wcs) @@ -90,7 +120,7 @@ def make_finding_chart( ) ax.legend(fontsize=15) - ax.set_title(f"{obs.target.info['target_name']} {obs.ins.__class__.__name__} FC") + ax.set_title(f"{obs.target.info['target_name']} {obs.ins} FC", fontsize=18, fontweight="semibold") if savefig: fig.savefig(f"{obs.target.info['target_name']}_{obs.ins.__class__.__name__}_finding_chart.png") plt.show() diff --git a/flows_get_brightest/run_get_brightest.py b/flows_finder/run_get_brightest.py similarity index 74% rename from flows_get_brightest/run_get_brightest.py rename to flows_finder/run_get_brightest.py index 7b887d9..00918e0 100644 --- a/flows_get_brightest/run_get_brightest.py +++ b/flows_finder/run_get_brightest.py @@ -1,12 +1,15 @@ from __future__ import annotations -from typing import cast + import warnings +from typing import cast + from erfa import ErfaWarning -from .plots import Plotter + +from .argparser import parse_brightest from .auth import test_connection from .observer import get_flows_observer -from .parser import parse - +from .plan import make_plan +from .plots import Plotter # Most useless warnings ever spammed for every operation by this package! warnings.filterwarnings("ignore", category=ErfaWarning, append=True) @@ -15,17 +18,18 @@ def main(): # Parse input - rot, tid, shifta, shiftd, make_fc, inst = parse() + rot, tid, shifta, shiftd, make_fc, inst = parse_brightest() # Test connection to flows: test_connection() # Print brightest star in (first) field - obs = get_flows_observer(rot, tid, shifta, shiftd, inst) + plan = make_plan(rot, shifta, shiftd) + obs = get_flows_observer(plan, tid, inst) obs.check_bright_stars(region=obs.regions[0]) # Make finding chart if requested - radius = cast(float, inst.field_hw.value) * 2 + radius = cast(float, inst.field_hw.value) * 2.0 if make_fc: Plotter(obs).make_finding_chart(radius=radius) diff --git a/flows_get_brightest/target.py b/flows_finder/target.py similarity index 100% rename from flows_get_brightest/target.py rename to flows_finder/target.py diff --git a/flows_finder/tests.py b/flows_finder/tests.py new file mode 100644 index 0000000..7c522c3 --- /dev/null +++ b/flows_finder/tests.py @@ -0,0 +1,180 @@ +import sys +from typing import Optional +from unittest import mock + +import astropy.units as u +import numpy as np +import pytest +from astropy.coordinates import SkyCoord + +from .argparser import parse_brightest, parse_fc +from .auth import test_connection +from .catalogs import SkyViewSurveys +from .instruments import FixedSizeInstrument, Hawki, Instrument +from .observer import Observer, get_flows_observer +from .plan import Plan, make_plan +from .plots import Plotter +from .utils import api_token + +rot, tid, shifta, shiftd = 30, 8, 10, 10 +token = api_token() + + +@pytest.mark.skip(reason="Skip until destructive overwrite is fixed") +def test_auth(monkeypatch): + """ + Test the auth module. + """ + with pytest.warns(RuntimeWarning): + monkeypatch.setattr("builtins.input", lambda _: "bad_token") + test_connection() + + +@pytest.mark.parametrize("img", [None, "test.fits"]) +@pytest.mark.parametrize("scale", ["linear", "log"]) +@pytest.mark.parametrize("survey", [survey.value for survey in SkyViewSurveys]) +def test_make_plan(img: Optional[str], scale: str, survey: str): + """ + Test the plan module. + """ + plan: Plan = make_plan(rot, shifta, shiftd, image=img) + assert plan.rotation.value == rot + assert plan.alpha.value == shifta + assert plan.delta.value == shiftd + assert plan.local_image == img + assert plan.survey in [s for s in SkyViewSurveys] # refactor to test output call of observer. + assert plan.image_scale in ["linear", "log"] # refactor to use enum and more meaningfull img test. + + +@pytest.fixture +def fixedinstrument_obs() -> Observer: + plan = make_plan(rot, shifta, shiftd) + plan.plan_finder_chart(image=None, survey=SkyViewSurveys.DSS.value, scale="linear") + return get_flows_observer(plan, tid, instrument=FixedSizeInstrument) + + +@pytest.fixture +def Hawki_obs() -> Observer: + plan = make_plan(rot, shifta, shiftd) + return get_flows_observer(plan, tid, instrument=Hawki) + + +@pytest.fixture +def observer(request) -> Observer: + return request.getfixturevalue(request.param) + + +@pytest.mark.parametrize("observer", ["fixedinstrument_obs", "Hawki_obs"], indirect=True) +def test_get_brightest(capsys, observer): + observer.check_bright_stars(region=observer.regions[0]) + captured = capsys.readouterr() + assert "Brightest star has" in captured.out + sys.stdout.write(captured.out) + sys.stderr.write(captured.err) + + +@pytest.mark.parametrize("observer", ["fixedinstrument_obs", "Hawki_obs"], indirect=True) +def test_plan(observer): + assert observer.plan.rotation == rot * u.deg # type: ignore + assert observer.plan.alpha == shifta * u.arcsec # type: ignore + assert observer.plan.delta == shiftd * u.arcsec # type: ignore + assert observer.plan.shift is True + assert observer.plan.rotate is True + + +@pytest.mark.parametrize("observer", ["fixedinstrument_obs", "Hawki_obs"], indirect=True) +def test_observer(observer): + isinstance(observer, Observer) + + +@pytest.mark.slow +@pytest.mark.parametrize("observer", ["fixedinstrument_obs", "Hawki_obs"], indirect=True) +def test_make_finding_chart(observer, monkeypatch): + import matplotlib.pyplot as plt + + monkeypatch.setattr(plt, "show", lambda: None) + plotter = Plotter(observer) + ax = plotter.make_finding_chart(observer, savefig=False) + assert len(ax.get_images()) > 0 + title = ax.get_title() + assert title.startswith(f"{observer.target.info['target_name']}") + assert title.endswith("FC") + + +# End to end test with Hawki. +ARGS0 = (0, 8, 0, 0, False, 12.2) +ARGS1 = (30, 8, 0, 0, False, 11.5) +ARGS2 = (30, 8, -50, 100, False, 12.3) +argnames = ["-r", "-t", "-a", "-d", "-p"] + + +@pytest.mark.parametrize("args", [ARGS0, ARGS1, ARGS2]) +def test_parse_brightest(args) -> None: + cmd_args: list[str] = [] + for i, arg in enumerate(args[:-1]): + if isinstance(arg, bool): + if arg is False: + continue + cmd_args.append(f"{argnames[i]} {arg}") + + with mock.patch("sys.argv", ["flows_get_brightest"] + list(cmd_args)): + rot, tid, shifta, shiftd, make_fc, inst = parse_brightest() + assert rot == args[0] + assert tid == args[1] + assert shifta == args[2] + assert shiftd == args[3] + assert make_fc == args[4] + assert inst == Hawki + + +@pytest.mark.slow +@pytest.mark.parametrize("args", [ARGS0, ARGS1, ARGS2]) +def test_end_to_end(args: tuple[int, int | str, int, int, bool, float]) -> None: + rot, tid, shifta, shiftd, make_fc, brightest = args + + # Print brightest star in field + plan = make_plan(rot, shifta, shiftd) + obs = get_flows_observer(plan, tid) + stars = obs.check_bright_stars(region=obs.regions[0]) + + assert pytest.approx(np.round(stars.min(), 1)) == brightest + + # Make finding chart if requested + if make_fc: + plotter = Plotter(obs) + assert plotter.obs == obs + + +# End to end test with Hawki. +NARGS0 = (0, 8, 0, 0, "DSS", "Hawki", 7.5, "viridis", "linear") +NARGS1 = (30, 8, 0, 0, "DSS", "FixedSize", 8.5, "gist_yarg", "linear") +NARGS2 = (30, 8, -50, 100, "2MASS-H", "FixedSize", 9.5, "gist_yarg", "asinh") +nargnames = ["-r=", "-t=", "-a=", "-d=", "-s=", "-i=", "--size=", "--cmap=", "--scale="] + + +@pytest.mark.parametrize("args", [NARGS0, NARGS1, NARGS2]) +def test_parse_fc(args): + cmd_args: list[str] = [] + for i, arg in enumerate(args): + cmd_args.append(f"{nargnames[i]}{arg}") + + with mock.patch("sys.argv", ["flows_get_brightest"] + list(cmd_args)): + rot, tid, shifta, shiftd, inst, image, survey, cmap, scale, sigma, contrast = parse_fc() + assert rot == args[0] + assert tid == args[1] + assert shifta == args[2] + assert shiftd == args[3] + assert survey == args[4] + assert isinstance(inst(SkyCoord(0, 0, unit="deg")), Instrument) + assert image is None + assert scale == args[8] + assert cmap == args[7] + assert isinstance(sigma, float) + assert isinstance(contrast, float) + assert not isinstance(image, str) + + if args[5] == "FixedSize": + assert inst == FixedSizeInstrument + pytest.approx(inst.field_hw, u.Quantity(args[6], u.arcmin)) + elif args[5] == "Hawki": + assert inst == Hawki diff --git a/flows_get_brightest/utils.py b/flows_finder/utils.py similarity index 88% rename from flows_get_brightest/utils.py rename to flows_finder/utils.py index 733decd..0a1d53b 100644 --- a/flows_get_brightest/utils.py +++ b/flows_finder/utils.py @@ -1,9 +1,11 @@ +import enum import os -from typing import Any, Union, TypeGuard, TypedDict +from typing import Any, TypedDict, TypeGuard, Union + import astropy.units as u +import tendrils.utils from astropy.coordinates import SkyCoord from astropy.table import Table -import tendrils.utils # custom types numeric = Union[float, int, u.Quantity] @@ -46,6 +48,13 @@ def is_quantity(qt: list[Any]) -> TypeGuard[list[u.Quantity]]: return True +class StrEnum(str, enum.Enum): + """Enum with string values.""" + + def __str__(self) -> str: + return str(self.value) + + if __name__ == "__main__": token = api_token() tendrils.utils.set_api_token("None") diff --git a/flows_finder/version.py b/flows_finder/version.py new file mode 100644 index 0000000..1a3ea32 --- /dev/null +++ b/flows_finder/version.py @@ -0,0 +1,2 @@ +version = "3.0.1" +__version__ = "3.0.1" diff --git a/flows_get_brightest/__main__.py b/flows_get_brightest/__main__.py deleted file mode 100644 index d6cb388..0000000 --- a/flows_get_brightest/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from flows_get_brightest import run_get_brightest - -run_get_brightest.main() diff --git a/flows_get_brightest/parser.py b/flows_get_brightest/parser.py deleted file mode 100644 index fe14302..0000000 --- a/flows_get_brightest/parser.py +++ /dev/null @@ -1,46 +0,0 @@ -import argparse -import astropy.units as u -from .instruments import Hawki, FixedSizeInstrument, Instrument - - -def parse() -> tuple[float, int | str, float, float, bool, type[Instrument]]: - """Parse command line input to get target, position angle (rotate), alpha and delta offsets (shifta, shiftd)""" - parser = argparse.ArgumentParser(description="Calculate Brightest Star") - parser.add_argument( - "-t", "--target", help="calculate for this targetname or targetid", type=str, default="None", action="store" - ) - parser.add_argument("-r", "--rotate", help="rotation angle in degrees", type=float, default=0.0, action="store") - parser.add_argument("-a", "--shifta", help="shift alpha in arcsec", type=float, default=0.0, action="store") - parser.add_argument("-d", "--shiftd", help="shift delta in arcsec", type=float, default=0.0, action="store") - parser.add_argument("-p", "--plot", help="whether to query images and plot", action="store_true") - parser.add_argument( - "-i", - "--instrument", - help="instrument name", - choices=["Hawki", "FixedSize"], - type=str, - default="Hawki", - action="store", - ) - parser.add_argument( - "--size", - help="Instrument FoV in arcmin (if using FixedSize instrument), finder chart will be roughly twice the size.", - type=float, - default=7.5, - action="store", - ) - - args = parser.parse_args() - if args.target == "None": - parser.error("target id or name not provided, use -t or ") - elif args.target.isnumeric(): - args.target = int(args.target) - - if args.instrument not in ["Hawki", "FixedSize"]: - raise ValueError(f"Instrument {args.instrument} not supported, use Hawki or FixedSize") - instrument: type[Instrument] = Hawki - if args.instrument == "FixedSize": - instrument = FixedSizeInstrument - instrument.field_hw = args.size << u.arcmin - - return args.rotate, args.target, args.shifta, args.shiftd, args.plot, instrument diff --git a/flows_get_brightest/tests.py b/flows_get_brightest/tests.py deleted file mode 100644 index 0df3cfc..0000000 --- a/flows_get_brightest/tests.py +++ /dev/null @@ -1,97 +0,0 @@ -import sys -import pytest -import astropy.units as u -import numpy as np -from .observer import get_flows_observer, Observer -from .auth import test_connection -from .plots import Plotter -from .utils import api_token -from .instruments import Hawki, FixedSizeInstrument - -rot, tid, shifta, shiftd = 30, 8, 10, 10 -token = api_token() - - -@pytest.mark.skip(reason="Skip until destructive overwrite is fixed") -def test_auth(monkeypatch): - """ - Test the auth module. - """ - with pytest.warns(RuntimeWarning): - monkeypatch.setattr("builtins.input", lambda _: "bad_token") - test_connection() - - -@pytest.fixture -def fixedinstrument_obs() -> Observer: - return get_flows_observer(rot, tid, shifta, shiftd, instrument=FixedSizeInstrument) - - -@pytest.fixture -def Hawki_obs() -> Observer: - return get_flows_observer(rot, tid, shifta, shiftd, instrument=Hawki) - - -@pytest.fixture -def observer(request) -> Observer: - return request.getfixturevalue(request.param) - - -@pytest.mark.parametrize("observer", ["fixedinstrument_obs", "Hawki_obs"], indirect=True) -def test_get_brightest(capsys, observer): - observer.check_bright_stars(region=observer.regions[0]) - captured = capsys.readouterr() - assert "Brightest star has" in captured.out - sys.stdout.write(captured.out) - sys.stderr.write(captured.err) - - -@pytest.mark.parametrize("observer", ["fixedinstrument_obs", "Hawki_obs"], indirect=True) -def test_plan(observer): - assert observer.plan.rotation == rot * u.deg # type: ignore - assert observer.plan.alpha == shifta * u.arcsec # type: ignore - assert observer.plan.delta == shiftd * u.arcsec # type: ignore - assert observer.plan.shift is True - assert observer.plan.rotate is True - - -@pytest.mark.parametrize("observer", ["fixedinstrument_obs", "Hawki_obs"], indirect=True) -def test_observer(observer): - isinstance(observer, Observer) - - -@pytest.mark.slow -@pytest.mark.parametrize("observer", ["fixedinstrument_obs", "Hawki_obs"], indirect=True) -def test_make_finding_chart(observer, monkeypatch): - import matplotlib.pyplot as plt - - monkeypatch.setattr(plt, "show", lambda: None) - plotter = Plotter(observer) - ax = plotter.make_finding_chart(observer, savefig=False) - assert len(ax.get_images()) > 0 - title = ax.get_title() - assert title.startswith(f"{observer.target.info['target_name']}") - assert title.endswith("FC") - - -# End to end test with Hawki. -ARGS0 = (0, 8, 0, 0, False, 12.2) -ARGS1 = (30, 8, 0, 0, False, 11.5) -ARGS2 = (30, 8, -50, 100, False, 12.3) - - -@pytest.mark.slow -@pytest.mark.parametrize("args", [ARGS0, ARGS1, ARGS2]) -def test_end_to_end(args: tuple[int, int | str, int, int, bool, float]) -> None: - rot, tid, shifta, shiftd, make_fc, brightest = args - - # Print brightest star in field - obs = get_flows_observer(rot, tid, shifta, shiftd) - stars = obs.check_bright_stars(region=obs.regions[0]) - - assert pytest.approx(np.round(stars.min(), 1)) == brightest - - # Make finding chart if requested - if make_fc: - plotter = Plotter(obs) - assert plotter.obs == obs diff --git a/flows_get_brightest/version.py b/flows_get_brightest/version.py deleted file mode 100644 index cfc15fe..0000000 --- a/flows_get_brightest/version.py +++ /dev/null @@ -1,2 +0,0 @@ -version = "2.2.1" -__version__ = "2.2.1" diff --git a/pyproject.toml b/pyproject.toml index 02058bc..877aba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["flit_core >=3.5,<4"] build-backend = "flit_core.buildapi" [project] -name = "flows_get_brightest" +name = "flows_finder" authors = [{name = "Emir Karamehmetoglu", email = "emir.k@phys.au.dk"}] readme = "README.md" license = {file = "LICENSE"} @@ -23,7 +23,8 @@ dependencies = [ Home = "https://github.com/SNflows/flows-tools/" [project.scripts] -get_brightest = "flows_get_brightest:main" +get_brightest = "flows_finder:get_brightest.main" +make_fc = "flows_finder:make_fc.main" [project.optional-dependencies] test = [ @@ -37,7 +38,7 @@ test = [ [tool.pytest.ini_options] minversion = "7.1.1" addopts = "-rA -q -p no:warnings" -testpaths = ["flows_get_brightest/tests.py"] +testpaths = ["flows_finder/tests.py"] markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] [tool.black]