"""
Optional skyplothelper integration for WCS overlays and advanced axes.
mcf keeps compositing and basic WCS plotting in-core; compass roses, beam
markers, scale bars, offset coordinate frames, and other specialized
annotation live behind the optional ``multicolorfits[overlays]`` extra
(``skyplothelper``). Nothing in this module is imported at package import
time — callers pay for skyplothelper only when they use these helpers.
Simple case (opinionated wrappers)::
import multicolorfits as mcf
fig, ax = mcf.make_combined_figure(session), ...
mcf.overlays.add_compass(ax)
mcf.overlays.add_beam(ax, session.common_header)
mcf.overlays.add_scale_bar(ax, session.common_header)
Bring-your-own skyplothelper (full API)::
sph = mcf.overlays.skyplothelper()
wcs = sph.offset_coord_WCS(hdr, center)
sph.add_coord_overlay(ax, wcs, ...)
"""
from __future__ import annotations
import importlib
import importlib.util
import math
import warnings
from typing import Any, List, Optional
__all__ = [
'overlays_available',
'require_overlays',
'skyplothelper',
'SkyplotHelper',
'add_compass',
'add_beam',
'add_scale_bar',
'apply_overlays',
'apply_compose_overlays',
'offset_coord_wcs',
'make_offset_figure',
]
_INSTALL_HINT = 'pip install multicolorfits[overlays]'
def overlays_available() -> bool:
"""True if the optional ``skyplothelper`` package is importable."""
return importlib.util.find_spec('skyplothelper') is not None
[docs]
def require_overlays():
"""
Import and return the ``skyplothelper`` module.
Raises
------
ImportError
With install instructions when the optional extra is missing.
"""
try:
return importlib.import_module('skyplothelper')
except ImportError as exc:
raise ImportError(
"multicolorfits overlays require the optional 'skyplothelper' "
"package. Install with: %s" % _INSTALL_HINT
) from exc
class SkyplotHelper:
"""
Lazy attribute proxy to the full ``skyplothelper`` namespace.
For advanced plotting (offset overlays, graticules, globe frames, etc.)
prefer this over re-vendoring edge-case logic into mcf.
"""
def __getattr__(self, name: str) -> Any:
return getattr(require_overlays(), name)
def __repr__(self) -> str:
return 'SkyplotHelper(%s)' % ('available' if overlays_available() else 'not installed')
_sph_proxy = SkyplotHelper()
def skyplothelper():
"""Return the ``skyplothelper`` module (lazy). Alias for :func:`require_overlays`."""
return require_overlays()
def _header_astropy(header: Any) -> Any:
return getattr(header, 'astropy', header)
def _auto_scale_bar_asec(header: Any) -> float:
"""Pick a readable scale-bar length (~15% of the field width)."""
from .core.wcs_tools import arcsec_per_pixel
hdr = _header_astropy(header)
try:
pp = abs(float(arcsec_per_pixel(hdr)))
width = float(hdr['NAXIS1'])
raw = pp * width * 0.15
except Exception:
raw = 10.0
if raw <= 0 or not math.isfinite(raw):
return 10.0
exp = 10.0 ** math.floor(math.log10(raw))
for mult in (1.0, 2.0, 5.0, 10.0):
candidate = mult * exp
if candidate >= raw * 0.8:
return round(candidate, 6)
return round(10.0 * exp, 6)
def _scale_bar_label(length_asec: float) -> str:
if length_asec >= 60.0:
minutes = length_asec / 60.0
if abs(minutes - round(minutes)) < 1e-6:
return "%g'" % round(minutes)
return "%.1f'" % minutes
if abs(length_asec - round(length_asec)) < 1e-6:
return '%g"' % round(length_asec)
return '%.1f"' % length_asec
def _header_has_beam(header: Any) -> bool:
hdr = _header_astropy(header)
try:
return all(k in hdr for k in ('BMAJ', 'BMIN'))
except Exception:
return False
[docs]
def add_compass(ax: Any, **kwargs: Any) -> Any:
"""Add a WCS-aware north/east compass (delegates to skyplothelper)."""
return require_overlays().add_compass(ax, **kwargs)
[docs]
def add_beam(ax: Any, header: Any = None, *, loc: str = 'lower left',
style: str = 'crosshair', anchored: bool = True, **kwargs: Any) -> Any:
"""
Add a FITS-beam marker from ``BMAJ`` / ``BMIN`` / ``BPA`` in *header*.
Parameters
----------
ax : matplotlib Axes
Target WCS axes.
header : Header-like
FITS header or :class:`~multicolorfits.core.McfHeader`.
loc : str
Corner for anchored placement (``'lower left'``, ...).
style : str
Beam style passed to skyplothelper (``'crosshair'`` is typical for radio).
anchored : bool
If True (default), keep the beam in a fixed corner; otherwise draw at
the beam centre in data coordinates.
"""
if header is None:
raise ValueError('add_beam requires a FITS header with BMAJ/BMIN cards')
sph = require_overlays()
beam = sph.Beam.from_header(_header_astropy(header), ax=ax, style=style, **kwargs)
if anchored:
return beam.add_anchored(ax, loc=loc)
return beam.add_to(ax)
[docs]
def add_scale_bar(ax: Any, header: Any = None, *, length_asec: Optional[float] = None,
label: Optional[str] = None, **kwargs: Any) -> Any:
"""
Add an arcsecond scale bar (delegates to skyplothelper ``add_sizebar_asec``).
When *length_asec* is None or 0, a length of ~15% of the field width is
chosen automatically from the header pixel scale.
"""
if header is None:
raise ValueError('add_scale_bar requires a FITS header for pixel-scale conversion')
length = float(length_asec) if length_asec else 0.0
if length <= 0:
length = _auto_scale_bar_asec(header)
if label is None:
label = _scale_bar_label(length)
return require_overlays().add_sizebar_asec(ax, _header_astropy(header),
length, label, **kwargs)
[docs]
def apply_overlays(ax: Any, header: Any = None, *, compass: bool = False,
beam: bool = False, scale_bar: bool = False,
compass_loc: str = 'lower left', beam_loc: str = 'lower left',
beam_style: str = 'crosshair', scale_bar_asec: float = 0.0,
scale_bar_loc: int = 4, warn_missing: bool = True,
**kwargs: Any) -> List[Any]:
"""
Apply selected skyplothelper overlays to an existing axes.
Returns a list of overlay artists / handles created. When
``skyplothelper`` is not installed and *warn_missing* is True, emits a
``UserWarning`` and returns an empty list.
"""
if not (compass or beam or scale_bar):
return []
if not overlays_available():
if warn_missing:
warnings.warn(
'skyplothelper overlays were requested but the package is not '
'installed. Install with: %s' % _INSTALL_HINT,
UserWarning,
stacklevel=2,
)
return []
artists: List[Any] = []
compass_kw = dict(kwargs.get('compass_kwargs') or {})
beam_kw = dict(kwargs.get('beam_kwargs') or {})
scale_kw = dict(kwargs.get('scale_bar_kwargs') or {})
if compass:
artists.append(add_compass(ax, loc=compass_loc, **compass_kw))
if beam:
if header is None:
warnings.warn('Beam overlay requested but no header was provided',
UserWarning, stacklevel=2)
elif not _header_has_beam(header):
warnings.warn('Beam overlay requested but header has no BMAJ/BMIN cards',
UserWarning, stacklevel=2)
else:
artists.append(add_beam(ax, header, loc=beam_loc, style=beam_style, **beam_kw))
if scale_bar:
if header is None:
warnings.warn('Scale-bar overlay requested but no header was provided',
UserWarning, stacklevel=2)
else:
length = scale_bar_asec if scale_bar_asec and scale_bar_asec > 0 else None
artists.append(add_scale_bar(ax, header, length_asec=length,
loc=scale_bar_loc, **scale_kw))
return artists
[docs]
def apply_compose_overlays(ax: Any, header: Any, compose: Any,
*, warn_missing: bool = True) -> List[Any]:
"""
Apply overlay toggles from a :class:`~multicolorfits.session.ComposeState`.
Intended for :func:`~multicolorfits.figures.setup_combined_axes`; GUI
controls can flip the compose flags without importing skyplothelper until
a full WCS render is requested.
"""
scale_kw = {}
if getattr(compose, 'scale_bar_color', None):
scale_kw['color'] = compose.scale_bar_color
if getattr(compose, 'scale_bar_stroke_color', None) is not None:
scale_kw['stroke_color'] = compose.scale_bar_stroke_color
if getattr(compose, 'scale_bar_stroke_lw', None) is not None:
scale_kw['stroke_lw'] = float(compose.scale_bar_stroke_lw)
return apply_overlays(
ax,
header,
compass=getattr(compose, 'show_compass', False),
beam=getattr(compose, 'show_beam', False),
scale_bar=getattr(compose, 'show_scale_bar', False),
compass_loc=getattr(compose, 'compass_loc', 'lower left'),
beam_loc=getattr(compose, 'beam_loc', 'lower left'),
beam_style=getattr(compose, 'beam_style', 'crosshair'),
scale_bar_asec=float(getattr(compose, 'scale_bar_asec', 0.0) or 0.0),
scale_bar_loc=int(getattr(compose, 'scale_bar_loc', 4)),
scale_bar_kwargs=scale_kw,
warn_missing=warn_missing,
)
[docs]
def offset_coord_wcs(header: Any, center: Any, **kwargs: Any) -> Any:
"""
Build a locally linear offset WCS centred on *center* (skyplothelper).
For offset tick labels, graticule overlays, and cutout-style axes, use the
returned WCS with skyplothelper's ``add_coord_overlay`` / ``add_overlay_ticks``
rather than reimplementing those paths in mcf.
"""
return require_overlays().offset_coord_WCS(_header_astropy(header), center, **kwargs)