Source code for spindoctor.nav_model.stars.smeared_psf

"""Smear-aware PSF rendering helpers.

When the spacecraft attitude rate is non-zero during an exposure, stars
smear into trails along the per-pixel motion vector.  ``psfmodel`` exposes
a smear-aware ``eval_rect(..., movement=, movement_granularity=)`` API
that integrates the PSF along the trail.  These helpers build the small
postage stamp returned by that call and choose a sensible
``movement_granularity`` from the smear amplitude.

A second helper computes the per-image smear vector ``(my, mx)`` (in
pixels) from the SPICE pointing brackets at the start and end of the
exposure, exposed as a free function so the orchestrator can drive it
from ``NavContext`` without subclassing the obs.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np

if TYPE_CHECKING:  # pragma: no cover - typing-only import
    from psfmodel import PSF

    from spindoctor.obs import ObsSnapshot
    from spindoctor.support.types import MutableStar, NDArrayFloatType

__all__ = [
    'compute_smear_vector_px',
    'movement_granularity_px',
    'render_smeared_psf',
    'smear_length_px',
]


[docs] def smear_length_px(move_v: float, move_u: float) -> float: """Return the smear length ``sqrt(my**2 + mx**2)`` in pixels. Parameters: move_v: Per-exposure smear amplitude along the V (row) axis. move_u: Per-exposure smear amplitude along the U (column) axis. Returns: Euclidean smear length in pixels (always >= 0). """ return float(np.hypot(move_v, move_u))
[docs] def movement_granularity_px(move_v: float, move_u: float, *, max_steps: int = 50) -> float: """Choose the ``movement_granularity`` step for ``psf.eval_rect``. Targets at most ``max_steps`` integration samples along the smear path, clamped to ``[0.1, 1.0]`` pixels per sample. The lower bound keeps the integration tractable for very short smears; the upper bound keeps long smears from sub-sampling the PSF. Parameters: move_v: Per-exposure smear amplitude along V. move_u: Per-exposure smear amplitude along U. max_steps: Maximum samples along the smear (default 50). Returns: Step size in pixels per integration sample. """ if max_steps <= 0: raise ValueError(f'max_steps must be > 0; got {max_steps!r}') coarse = max(abs(move_v) / max_steps, abs(move_u) / max_steps) return float(np.clip(coarse, 0.1, 1.0))
[docs] def render_smeared_psf( psf: PSF, *, star: MutableStar, max_movement_steps: int, ) -> NDArrayFloatType: """Render one star's smeared PSF stamp. The output stamp shape is ``(2 * psf_half_v + 1) x (2 * psf_half_u + 1)`` where each half-size accounts for the PSF support plus the rounded smear amplitude. The amplitude is folded into the half-sizes so the integrated stamp captures the entire trail without clipping. Parameters: psf: PSF instance from ``obs.star_psf()``. star: Star record carrying ``psf_size``, ``move_v``, ``move_u``, ``u``, ``v``, and ``dn``. max_movement_steps: Cap on the number of integration steps along the smear path; passed through to ``movement_granularity_px``. Returns: ``np.ndarray`` postage stamp containing the rendered PSF, in DN. """ psf_half_u = int(star.psf_size[1] + np.round(abs(star.move_u))) // 2 psf_half_v = int(star.psf_size[0] + np.round(abs(star.move_v))) // 2 move_gran = movement_granularity_px(star.move_v, star.move_u, max_steps=max_movement_steps) u_idx = star.u v_idx = star.v u_int = int(u_idx) v_int = int(v_idx) u_frac = float(u_idx - u_int) v_frac = float(v_idx - v_int) stamp = psf.eval_rect( (psf_half_v * 2 + 1, psf_half_u * 2 + 1), offset=(v_frac, u_frac), scale=star.dn, movement=(star.move_v, star.move_u), movement_granularity=move_gran, ) return np.asarray(stamp, dtype=np.float64)
[docs] def compute_smear_vector_px(obs: ObsSnapshot) -> tuple[float, float]: """Compute the per-image smear vector ``(my, mx)`` in pixels. Implements the design's "smear from SPICE bracket" approach: project the camera attitude at ``obs.time[0]`` and ``obs.time[1]`` into pixel coordinates by re-evaluating the boresight RA/DEC through the obs FOV at both bracket times, and take the difference. The result is the total per-exposure displacement of a star at the centre of the FOV. Parameters: obs: Observation snapshot. Returns: Tuple ``(my, mx)`` in pixels (vertical, horizontal). """ # ``obs.uv_from_ra_and_dec`` accepts ``tfrac`` in [0, 1] mapping the # exposure window onto a unit interval; tfrac=0 is the start ET and # tfrac=1 is the end ET. Differencing the two boresight projections # gives the per-exposure pointing displacement at the FOV centre. boresight_uv0 = obs.uv_from_ra_and_dec( obs.boresight_ra(), obs.boresight_dec(), tfrac=0.0, apparent=True, ) boresight_uv1 = obs.uv_from_ra_and_dec( obs.boresight_ra(), obs.boresight_dec(), tfrac=1.0, apparent=True, ) u0, v0 = boresight_uv0.to_scalars() u1, v1 = boresight_uv1.to_scalars() return float(v1.vals - v0.vals), float(u1.vals - u0.vals)