Source code for ephemeris_tools.viewer_helpers

"""Planet viewer tool: PostScript diagram and tables (port of viewer3_*.f)."""

from __future__ import annotations

import logging
import math
from typing import TYPE_CHECKING, Any, TextIO, TypedDict, cast

import cspyce
import julian

from ephemeris_tools.angle_utils import dms_string
from ephemeris_tools.constants import (
    ARCMIN_PER_DEGREE,
    ARCSEC_PER_DEGREE,
    DEGREES_PER_CIRCLE,
    EARTH_ID,
    HALF_CIRCLE_DEGREES,
    NOON_SECONDS_OFFSET,
    SECONDS_PER_DAY,
    SUN_ID,
)
from ephemeris_tools.params import (
    ExtraStar,
    Observer,
    ViewerCenter,
    ViewerDisplayInfo,
    ViewerParams,
)
from ephemeris_tools.planets import (
    JUPITER_CONFIG,
    MARS_CONFIG,
    NEPTUNE_CONFIG,
    PLUTO_CONFIG,
    SATURN_CONFIG,
    URANUS_CONFIG,
)
from ephemeris_tools.planets.saturn import FRING_DNODE_DT, FRING_DPERI_DT
from ephemeris_tools.planets.uranus import (
    B1950_TO_J2000_URANUS,
    URANUS_REF_EPOCH_HOUR,
    URANUS_REF_EPOCH_YMD,
)
from ephemeris_tools.spice.bodmat import bodmat
from ephemeris_tools.spice.common import get_state
from ephemeris_tools.spice.geometry import (
    body_lonlat,
    body_phase,
    body_radec,
    body_ranges,
    planet_phase,
    planet_ranges,
)
from ephemeris_tools.spice.observer import observer_state
from ephemeris_tools.spice.rings import ring_opening
from ephemeris_tools.time_utils import day_from_ymd, tai_from_day_sec, tdb_from_tai

if TYPE_CHECKING:
    from ephemeris_tools.planets.base import PlanetConfig, RingSpec

logger = logging.getLogger(__name__)


class _RunViewerKwargs(TypedDict, total=True):
    """Keyword arguments for run_viewer (legacy signature from ViewerParams)."""

    planet_num: int
    time_str: str
    fov: float
    center_mode: str
    center_ra: float
    center_dec: float
    center_body_name: str | None
    center_ansa_name: str | None
    center_ansa_ew: str
    center_star_name: str | None
    viewpoint: str
    ephem_version: int
    ephem_display: str | None
    moon_ids: list[int] | None
    moon_selection_display: str | None
    blank_disks: bool
    ring_selection: list[str] | None
    ring_selection_display: str | None
    output_ps: TextIO | None
    output_txt: TextIO | None
    fov_unit: str | None
    observer_latitude: float | None
    observer_longitude: float | None
    observer_altitude: float | None
    observer_lon_dir: str
    viewpoint_display: str | None
    labels: str | None
    moon_points: float
    meridian_points: float
    opacity: str
    peris: str
    peripts: float
    arcmodel: str | None
    arcpts: float
    torus: bool
    torus_inc: float
    torus_rad: float
    moremoons: bool
    show_standard_stars: bool
    extra_star_name: str | None
    extra_star_ra_deg: float | None
    extra_star_dec_deg: float | None
    other_bodies: list[str] | None
    title: str


_PLANET_CONFIGS = {
    4: MARS_CONFIG,
    5: JUPITER_CONFIG,
    6: SATURN_CONFIG,
    7: URANUS_CONFIG,
    8: NEPTUNE_CONFIG,
    9: PLUTO_CONFIG,
}

_DEG2RAD = math.pi / 180.0
_RAD2DEG = 180.0 / math.pi
_RAD2ARCSEC = 180.0 / math.pi * ARCSEC_PER_DEGREE
_AU_KM = 149597870.7
_MAX_ARCSEC = DEGREES_PER_CIRCLE * ARCSEC_PER_DEGREE
_SEC_PER_DAY = SECONDS_PER_DAY
_PLUTO_CHARON_SEP_KM = 19571.0
# Moon label point sizes for small/medium/large selection (FORTRAN labelpts)
_MOON_LABEL_SMALL_PTS = 6.0
_MOON_LABEL_MEDIUM_PTS = 9.0
_MOON_LABEL_LARGE_PTS = 12.0


def _fortran_nint(value: float) -> int:
    """Return FORTRAN-compatible nearest integer (ties away from zero)."""
    return int(value + 0.5) if value >= 0.0 else int(value - 0.5)


def _fortran_fixed(value: float, decimals: int) -> str:
    """Format a float with FORTRAN tie-away rounding and fixed decimals."""
    scale = 10**decimals
    rounded = _fortran_nint(value * scale) / scale
    return f'{rounded:.{decimals}f}'


def _label_points_from_selection(labels: str | None) -> float:
    """Convert CGI label size selection to point size."""
    if labels is None:
        return 0.0
    s = labels.strip().lower()
    if not s or s == 'none':
        return 0.0
    if 'small' in s:
        return _MOON_LABEL_SMALL_PTS
    if 'medium' in s:
        return _MOON_LABEL_MEDIUM_PTS
    if 'large' in s:
        return _MOON_LABEL_LARGE_PTS
    return 0.0


def _ring_method_from_opacity(opacity: str | None) -> int:
    """Map CGI opacity selection to FORTRAN ring method integer."""
    if opacity is None:
        return 0
    selection = opacity.strip()
    if selection == 'Transparent':
        return 0
    if 'Semi-' in selection:
        return 1
    if selection == 'Opaque':
        return 2
    return 0


def _neptune_arc_model_index(arcmodel: str | None) -> int:
    """Return Neptune arc model index (1..3) from CGI arcmodel string."""
    if arcmodel is None:
        return 3
    if '#1' in arcmodel:
        return 1
    if '#2' in arcmodel:
        return 2
    if '#3' in arcmodel:
        return 3
    return 3


def _propagated_uranus_rings(et: float, cfg: PlanetConfig) -> tuple[list[float], list[float]]:
    """Propagate Uranus ring elements to et (viewer3_*.f ring propagation).

    Parameters:
        et: Ephemeris time (seconds).
        cfg: Planet config with rings (e.g. Uranus).

    Returns:
        (peri_deg_list, node_deg_list) in degrees.
    """
    y, m, d = URANUS_REF_EPOCH_YMD
    day0 = day_from_ymd(y, m, d)
    ref_tai = tai_from_day_sec(day0, float(URANUS_REF_EPOCH_HOUR * 3600))
    ref_et = tdb_from_tai(ref_tai)
    obs_pv = observer_state(et)
    state = get_state()
    _planet_dpv, dt = cspyce.spkapp(state.planet_id, et, 'J2000', obs_pv[:6].tolist(), 'LT')
    ddays = (et - ref_et - dt) / _SEC_PER_DAY

    def _norm_deg(deg: float) -> float:
        deg = deg % DEGREES_PER_CIRCLE
        return deg + DEGREES_PER_CIRCLE if deg < 0.0 else deg

    peri_deg_list = [
        _norm_deg(r.peri_rad * _RAD2DEG + r.dperi_dt * ddays + B1950_TO_J2000_URANUS)
        for r in cfg.rings
    ]
    node_deg_list = [
        _norm_deg(r.node_rad * _RAD2DEG + r.dnode_dt * ddays + B1950_TO_J2000_URANUS)
        for r in cfg.rings
    ]
    return (peri_deg_list, node_deg_list)


def _propagated_neptune_arcs(
    et: float,
    cfg: PlanetConfig,
    *,
    arc_model_index: int = 3,
) -> list[tuple[float, float]]:
    """Propagate Neptune arc longitudes to et (viewer3_*.f arc propagation).

    Parameters:
        et: Ephemeris time (seconds).
        cfg: Planet config with arcs (Neptune).

    Returns:
        List of (minlon_deg, maxlon_deg) per arc.
    """
    neptune_ref_jed = 2447757.0
    # FORTRAN uses FJUL_TAIofJD(...,2) for a JED reference epoch.
    # Use the non-deprecated rms-julian API for JED(TDB)->time(TDB).
    ref_et = float(cast(Any, julian).time_from_jd(neptune_ref_jed, timesys='TDB', jdsys='TDB'))
    obs_pv = observer_state(et)
    state = get_state()
    _planet_dpv, dt = cspyce.spkapp(state.planet_id, et, 'J2000', obs_pv[:6].tolist(), 'LT')
    ddays = (et - ref_et - dt) / _SEC_PER_DAY
    b1950_to_j2000_nep = 0.334321
    result = []
    arc_motion_deg_day = [820.1194, 820.1118, 820.1121][max(1, min(3, arc_model_index)) - 1]
    for arc in cfg.arcs:
        minlon = (
            arc.minlon_deg + arc_motion_deg_day * ddays + b1950_to_j2000_nep
        ) % DEGREES_PER_CIRCLE
        maxlon = (
            arc.maxlon_deg + arc_motion_deg_day * ddays + b1950_to_j2000_nep
        ) % DEGREES_PER_CIRCLE
        result.append((minlon, maxlon))
    return result


def _propagated_saturn_f_ring(et: float, cfg: PlanetConfig) -> tuple[float, float] | None:
    """Propagate Saturn F ring elements to et.

    Parameters:
        et: Ephemeris time (seconds).
        cfg: Planet config (Saturn with f_ring_index set).

    Returns:
        (peri_rad, node_rad) in radians, or None if not applicable.
    """
    if cfg.f_ring_index is None or cfg.planet_num != 6:
        return None

    ref_tai = tai_from_day_sec(0, NOON_SECONDS_OFFSET)
    ref_et = tdb_from_tai(ref_tai)
    obs_pv = observer_state(et)
    state = get_state()
    _planet_dpv, dt = cspyce.spkapp(state.planet_id, et, 'J2000', obs_pv[:6].tolist(), 'LT')
    # FORTRAN uses: (obs_time - ref_time - dt) in seconds directly
    elapsed_sec = et - ref_et - dt
    r = cfg.rings[cfg.f_ring_index]
    peri_rad = r.peri_rad + FRING_DPERI_DT * elapsed_sec
    node_rad = r.node_rad + FRING_DNODE_DT * elapsed_sec
    return (peri_rad, node_rad)


def _compute_ring_center_offsets(et: float, cfg: PlanetConfig) -> list[tuple[float, float, float]]:
    """Compute ring center offset vectors in J2000 km (viewer3_*.f ring_offsets).

    Parameters:
        et: Ephemeris time (seconds).
        cfg: Planet config with rings (Mars/Pluto use offsets).

    Returns:
        List of (x, y, z) offset in km per ring.
    """
    nrings = len(cfg.rings)
    offsets = [(0.0, 0.0, 0.0)] * nrings
    state = get_state()
    obs_pv = observer_state(et)

    if cfg.planet_num == 4 and cfg.ring_offsets_km:
        _planet_dpv, dt = cspyce.spkapp(state.planet_id, et, 'J2000', obs_pv[:6].tolist(), 'LT')
        planet_time = et - dt
        rotmat = bodmat(state.planet_id, planet_time)
        eq_pole = (rotmat[2][0], rotmat[2][1], rotmat[2][2])
        # Match FORTRAN viewer3_mar.f: use Mars relative to Sun with 'NONE' (no aberration)
        # so ring antisun direction matches SPKEZ(PLANET_ID, planet_time, 'J2000', 'NONE', SUN_ID).
        mars_from_sun, _ = cspyce.spkpos(  # type: ignore[attr-defined]
            str(state.planet_id),
            planet_time,
            'J2000',
            'NONE',
            str(SUN_ID),
        )
        anti_sun = (mars_from_sun[0], mars_from_sun[1], mars_from_sun[2])
        dot = eq_pole[0] * anti_sun[0] + eq_pole[1] * anti_sun[1] + eq_pole[2] * anti_sun[2]
        antisun = [
            anti_sun[0] - dot * eq_pole[0],
            anti_sun[1] - dot * eq_pole[1],
            anti_sun[2] - dot * eq_pole[2],
        ]
        n = math.sqrt(antisun[0] ** 2 + antisun[1] ** 2 + antisun[2] ** 2)
        if n > 1e-12:
            antisun = [antisun[0] / n, antisun[1] / n, antisun[2] / n]
            for i, shift_km in cfg.ring_offsets_km.items():
                if i < nrings:
                    offsets[i] = (
                        shift_km * antisun[0],
                        shift_km * antisun[1],
                        shift_km * antisun[2],
                    )

    if cfg.planet_num == 9 and cfg.barycenter_id is not None:
        bary_dpv, _ = cspyce.spkapp(cfg.barycenter_id, et, 'J2000', obs_pv[:6].tolist(), 'LT')
        planet_dpv, _ = cspyce.spkapp(state.planet_id, et, 'J2000', obs_pv[:6].tolist(), 'LT')
        dx = bary_dpv[0] - planet_dpv[0]
        dy = bary_dpv[1] - planet_dpv[1]
        dz = bary_dpv[2] - planet_dpv[2]
        for i in range(1, nrings):
            offsets[i] = (dx, dy, dz)

    return offsets


def _compute_mars_deimos_ring_node(et: float) -> float | None:
    """Compute Mars Deimos ring node longitude in radians.

    This mirrors the FORTRAN `viewer3_mar.f` logic used for `ring_nodes(3:4)`,
    where node longitude is measured relative to the equator ascending node.
    """
    state = get_state()
    if state.planet_num != 4:
        return None

    obs_pv = observer_state(et)
    _planet_dpv, dt = cspyce.spkapp(state.planet_id, et, 'J2000', obs_pv[:6].tolist(), 'LT')
    planet_time = et - dt
    planet_pv = cspyce.spkssb(state.planet_id, planet_time, 'J2000')
    rotmat = bodmat(state.planet_id, planet_time)

    # FORTRAN uses the third row of BODMAT as equator pole in J2000.
    eq_pole = [rotmat[2][0], rotmat[2][1], rotmat[2][2]]
    j2000_pole = [0.0, 0.0, 1.0]

    def _cross(a: list[float], b: list[float]) -> list[float]:
        return [
            a[1] * b[2] - a[2] * b[1],
            a[2] * b[0] - a[0] * b[2],
            a[0] * b[1] - a[1] * b[0],
        ]

    # Equator ascending node in body frame -> reference longitude.
    ascnode_eq = _cross(j2000_pole, eq_pole)
    tempvec = cspyce.mxv(rotmat, ascnode_eq)
    reflon = math.atan2(tempvec[1], tempvec[0])

    # Orbit-plane ascending node in body frame -> Deimos ring node longitude.
    orbit_pole = _cross(list(planet_pv[:3]), list(planet_pv[3:6]))
    ascnode_orbit = _cross(orbit_pole, eq_pole)
    tempvec = cspyce.mxv(rotmat, ascnode_orbit)
    nodelon = math.atan2(tempvec[1], tempvec[0])

    return nodelon - reflon


# Uranus viewer form option 71 = "Alpha, Beta, Eta, Gamma, Delta, Epsilon" (six major rings).
_URANUS_OPTION_71_NAMES: frozenset[str] = frozenset(
    {'Alpha', 'Beta', 'Eta', 'Gamma', 'Delta', 'Epsilon'}
)

_NEPTUNE_RING_NAME_TO_INDEX: dict[str, int] = {
    'galle': 0,
    'leverrier': 1,
    'arago': 2,
    'adams': 3,
}

_PLUTO_RING_NAME_TO_INDEX: dict[str, int] = {
    'charon': 0,
    'nix': 1,
    'hydra': 2,
    'kerberos': 3,
    'styx': 4,
}


def _resolve_viewer_ring_flags(
    planet_num: int,
    ring_selection: list[str],
    rings: list[RingSpec],
) -> list[bool]:
    """Resolve ring selection tokens to per-ring visibility flags.

    Each token may be a numeric group code (e.g. ``71`` for all six
    major Uranus rings) or an individual ring name that is matched
    case-insensitively against the config ring entries.  Group codes
    are the same codes used by the FORTRAN web form (51, 52, 61, 62,
    63, 71, 81).

    Parameters:
        planet_num: Planet number (4-9).
        ring_selection: Raw tokens from the CLI (e.g. ``['alpha']``,
            ``['71']``, ``['alpha', 'beta']``).
        rings: List of RingSpec from planet config.

    Returns:
        One bool per ring; True = draw.
    """
    n = len(rings)
    if n == 0:
        return []
    flags = [False] * n
    if planet_num == 7:
        # Match FORTRAN viewer3_ura.f initialization exactly:
        # Six/Five/Four off by default; Alpha..Delta on; Lambda off;
        # Epsilon components on; outer Mu/Nu families off.
        for i in (3, 4, 5, 6, 7, 9, 10):
            if i < n:
                flags[i] = True

    # Split tokens into numeric group codes and individual ring names
    codes: list[int] = []
    names: set[str] = set()
    for tok in ring_selection:
        tok = tok.strip()
        if len(tok) == 0:
            continue
        try:
            codes.append(int(tok))
        except ValueError:
            names.add(tok.lower())

    # Apply numeric group codes (FORTRAN form compatibility)
    for opt in codes:
        if planet_num == 5:
            # Jupiter (viewer3_jup.f):
            # 51 Main -> ring flags 1,2 ; 52 Gossamer -> ring flags 3,4,5,6.
            if opt == 51:
                for i in (0, 1):
                    if i < n:
                        flags[i] = True
            elif opt == 52:
                for i in (2, 3, 4, 5):
                    if i < n:
                        flags[i] = True
        elif planet_num == 6:
            # Saturn: 61=Main (0), 62=G+E (1,2), 63=outer (3,4)
            if opt == 61 and n > 0:
                flags[0] = True
            elif opt == 62 and n >= 3:
                flags[1] = True
                flags[2] = True
            elif opt == 63 and n >= 5:
                flags[3] = True
                flags[4] = True
        elif planet_num == 7 and opt == 71:
            for i, r in enumerate(rings):
                if r.name in _URANUS_OPTION_71_NAMES:
                    flags[i] = True
        elif planet_num == 8 and opt == 81:
            # Neptune: 81=rings (LeVerrier, Adams; show all 4 by default)
            for i in range(n):
                flags[i] = True

    # Apply individual ring names (case-insensitive match against config)
    if names:
        if planet_num == 7:
            # Match FORTRAN viewer3_ura.f group semantics exactly.
            if 'nine major rings' in names:
                for i in (0, 1, 2):
                    if i < n:
                        flags[i] = True
            if 'all inner rings' in names:
                for i in (0, 1, 2, 8):
                    if i < n:
                        flags[i] = True
            if 'all rings' in names:
                for i in (0, 1, 2, 8, 11, 12, 13, 14, 15, 16):
                    if i < n:
                        flags[i] = True
        elif 'all rings' in names:
            for i in range(n):
                flags[i] = True
        if planet_num == 6:
            # FORTRAN viewer3_sat.f keeps A/B/C rings enabled.
            if {'a', 'b', 'c'} & names:
                for i in range(min(5, n)):
                    flags[i] = True
            # Saturn optional groups: F (6), G (7/8), E (9), FORTRAN 1-based.
            if 'f' in names and n > 5:
                flags[5] = True
            if 'g' in names:
                if n > 6:
                    flags[6] = True
                if n > 7:
                    flags[7] = True
            if 'e' in names and n > 8:
                flags[8] = True
        if planet_num == 8:
            for name, idx in _NEPTUNE_RING_NAME_TO_INDEX.items():
                if name in names and idx < n:
                    flags[idx] = True
        if planet_num == 5:
            if any('main' in name for name in names):
                for i in (0, 1):
                    if i < n:
                        flags[i] = True
            if any('gossamer' in name for name in names):
                for i in (2, 3, 4, 5):
                    if i < n:
                        flags[i] = True
        if planet_num == 9:
            for name, idx in _PLUTO_RING_NAME_TO_INDEX.items():
                if name in names and idx < n:
                    flags[idx] = True
        if planet_num == 4:
            # Mars ring selections are moon names mapped to ring-pair indices.
            if 'phobos' in names and n >= 2:
                flags[0] = True
                flags[1] = True
            if 'deimos' in names and n >= 4:
                flags[2] = True
                flags[3] = True
        for i, r in enumerate(rings):
            ring_name = r.name
            if ring_name is not None and ring_name.lower() in names:
                flags[i] = True

    return flags


def _compute_jupiter_torus_node(et: float) -> float | None:
    """Compute Io torus ring node in radians from Jupiter BODMAT (System III 248°).

    Parameters:
        et: Ephemeris time (seconds).

    Returns:
        Node longitude in radians, or None if not Jupiter.
    """
    state = get_state()
    if state.planet_num != 5:
        return None
    obs_pv = observer_state(et)
    _planet_dpv, dt = cspyce.spkapp(state.planet_id, et, 'J2000', obs_pv[:6].tolist(), 'LT')
    planet_time = et - dt
    rotmat = bodmat(state.planet_id, planet_time)
    eq_pole = [rotmat[2][0], rotmat[2][1], rotmat[2][2]]
    j2000_pole = [0.0, 0.0, 1.0]
    ascnode = cspyce.ucrss(j2000_pole, eq_pole)
    tempvec = cspyce.mxv(rotmat, ascnode)
    reflon = math.atan2(tempvec[1], tempvec[0])
    torus_node_rad = math.radians(248.0) - reflon
    return torus_node_rad


[docs] def get_planet_config(planet_num: int) -> PlanetConfig | None: """Return PlanetConfig for planet number (port of viewer3_* planet config). Parameters: planet_num: Planet index 4 (Mars) through 9 (Pluto). Returns: PlanetConfig or None if planet_num not supported. """ return _PLANET_CONFIGS.get(planet_num)
def _ra_hms(ra_rad: float) -> str: """Format RA in radians as 'hh mm ss.ssss' (hours, 4 decimals in seconds).""" ra_deg = ra_rad * _RAD2DEG ra_h = (ra_deg / 15.0) % 24.0 return dms_string(ra_h, 'hms', ndecimal=4) def _dec_dms(dec_rad: float) -> str: """Format Dec in radians as 'dd mm ss.sss' (degrees).""" dec_deg = dec_rad * _RAD2DEG return dms_string(dec_deg, 'dms', ndecimal=3) def _write_fov_table( stream: TextIO, *, et: float, cfg: PlanetConfig, planet_ra: float, planet_dec: float, body_ids: list[int], id_to_name: dict[int, str], neptune_arc_model: int = 3, ring_names: list[str] | None = None, ) -> None: """Write Field of View Description (J2000) and body/ring geometry tables.""" cosdec = math.cos(planet_dec) # FORTRAN uses arcsec for Earth observer, degrees for spacecraft state = get_state() is_earth_observer = state.obs_id == EARTH_ID or state.obs_id == 0 stream.write('\n') stream.write('Field of View Description (J2000)\n') stream.write('---------------------------------\n') stream.write('\n') if is_earth_observer: stream.write( ' Body RA Dec ' 'RA (deg) Dec (deg) dRA (") dDec (")\n' ) else: stream.write( ' Body RA Dec ' 'RA (deg) Dec (deg) dRA (deg) dDec (deg)\n' ) for body_id in body_ids: ra, dec = body_radec(et, body_id) delta_ra_deg = (ra - planet_ra) * _RAD2DEG if delta_ra_deg < -HALF_CIRCLE_DEGREES: delta_ra_deg += DEGREES_PER_CIRCLE if delta_ra_deg > HALF_CIRCLE_DEGREES: delta_ra_deg -= DEGREES_PER_CIRCLE if is_earth_observer: dra_display = delta_ra_deg * cosdec * ARCSEC_PER_DEGREE ddec_display = (dec - planet_dec) * _RAD2ARCSEC else: dra_display = delta_ra_deg * cosdec ddec_display = (dec - planet_dec) * _RAD2DEG ra_deg = ra * _RAD2DEG dec_deg = dec * _RAD2DEG name = id_to_name.get(body_id, str(body_id)) ra_str = _ra_hms(ra) dec_str = _dec_dms(dec) if is_earth_observer: fmt = ( f' {body_id:3d} {name:10s} {ra_str:>18} {dec_str:>18} ' f'{ra_deg:10.6f}{dec_deg:12.6f} {dra_display:10.4f} {ddec_display:10.4f}\n' ) else: fmt = ( f' {body_id:3d} {name:10s} {ra_str:>18} {dec_str:>18} ' f'{ra_deg:10.6f}{dec_deg:12.6f} {dra_display:11.6f} {ddec_display:11.6f}\n' ) stream.write(fmt) stream.write('\n') stream.write(' Sub-Observer ' + ' ' + 'Sub-Solar \n') lon_dir = cfg.longitude_direction stream.write( f' Body Lon(deg{lon_dir}) Lat(deg) Lon(deg{lon_dir}) Lat(deg) ' f'Phase(deg) Distance(10^6 km)\n' ) for body_id in body_ids: subobs_lon, subobs_lat, subsol_lon, subsol_lat = body_lonlat(et, body_id) phase = body_phase(et, body_id) _, obs_dist = body_ranges(et, body_id) name = id_to_name.get(body_id, str(body_id)) stream.write( f' {body_id:3d} {name:10s} ' f'{subobs_lon * _RAD2DEG:10.3f}{subobs_lat * _RAD2DEG:10.3f} ' f'{subsol_lon * _RAD2DEG:10.3f}{subsol_lat * _RAD2DEG:10.3f}' f'{phase * _RAD2DEG:13.5f}{obs_dist / 1e6:15.6f}\n' ) # Ring table and geometry (if rings exist). Order: ring table first, then sub-solar lat etc. if cfg.rings: ring_flags = ( _resolve_viewer_ring_flags(cfg.planet_num, ring_names or [], cfg.rings) if ring_names is not None else [] ) # Uranus: pericenter/ascending node table first (FORTRAN: first 10 rings only). if cfg.planet_num == 7: peri_deg_list, node_deg_list = _propagated_uranus_rings(et, cfg) stream.write('\n') stream.write( ' Ring Pericenter Ascending Node (deg, from ring plane ' 'ascending node)\n' ) n_uranus_table = 10 for i, r in enumerate(cfg.rings[:n_uranus_table]): if i >= len(ring_flags) or not ring_flags[i]: continue name = (r.name or '')[:10].ljust(10) peri_deg = 0.0 if r.ecc == 0.0 else peri_deg_list[i] node_deg = 0.0 if r.inc_rad == 0.0 else node_deg_list[i] stream.write(f' {name} {peri_deg:8.3f} {node_deg:8.3f}\n') # Resolve ring selection for Saturn F ring block (written after sun-planet table). f_ring_selected = ( cfg.f_ring_index is not None and cfg.planet_num == 6 and cfg.f_ring_index < len(cfg.rings) and cfg.f_ring_index < len(ring_flags) and ring_flags[cfg.f_ring_index] ) stream.write('\n') sun_dist, obs_dist = planet_ranges(et) phase_deg = planet_phase(et) * _RAD2DEG geom = ring_opening(et) sun_b_deg = geom.sun_b * _RAD2DEG sun_db_deg = geom.sun_db * _RAD2DEG litstr = '(lit)' if not geom.is_dark else '(unlit)' stream.write( f' Ring sub-solar latitude (deg): {sun_b_deg:9.5f} ' f'({sun_b_deg - sun_db_deg:9.5f} to {sun_b_deg + sun_db_deg:9.5f})\n' ) stream.write(f' Ring plane opening angle (deg): {geom.obs_b * _RAD2DEG:9.5f} {litstr}\n') stream.write(f' Ring center phase angle (deg): {phase_deg:9.5f}\n') stream.write( f' Sub-solar longitude (deg): {geom.sun_long * _RAD2DEG:9.5f} ' 'from ring plane ascending node\n' ) stream.write(f' Sub-observer longitude (deg): {geom.obs_long * _RAD2DEG:9.5f}\n') stream.write('\n') stream.write(f' Sun-planet distance (AU): {sun_dist / _AU_KM:9.5f}\n') stream.write(f' Observer-planet distance (AU): {obs_dist / _AU_KM:9.5f}\n') stream.write(f' Sun-planet distance (km): {sun_dist / 1e6:12.6f} x 10^6\n') stream.write(f' Observer-planet distance (km): {obs_dist / 1e6:12.6f} x 10^6\n') light_time_sec = obs_dist / 299792.458 stream.write(f' Light travel time (sec): {light_time_sec:12.6f}\n') stream.write('\n') # Saturn: F ring pericenter/node when selected (after sun-planet table). if f_ring_selected: propagated = _propagated_saturn_f_ring(et, cfg) if propagated is not None: peri_rad, node_rad = propagated peri_deg = (peri_rad * _RAD2DEG) % 360.0 node_deg = (node_rad * _RAD2DEG) % 360.0 else: if cfg.f_ring_index is not None: ring = cfg.rings[cfg.f_ring_index] peri_deg = (ring.peri_rad * _RAD2DEG) % 360.0 node_deg = (ring.node_rad * _RAD2DEG) % 360.0 else: peri_deg = 0.0 node_deg = 0.0 stream.write( f' F Ring pericenter (deg): {peri_deg:9.5f} from ring plane ascending node\n' ) stream.write(f' F Ring ascending node (deg): {node_deg:9.5f}\n') stream.write('\n') if cfg.arcs and cfg.planet_num == 8: arc_minmax = _propagated_neptune_arcs(et, cfg, arc_model_index=neptune_arc_model) stream.write('\n') for i in range(len(cfg.arcs) - 1, -1, -1): minlon, maxlon = arc_minmax[i] arc = cfg.arcs[i] name = (arc.name or '')[:12].ljust(12) suffix = 'from ring plane ascending node' if i == len(cfg.arcs) - 1 else '' stream.write(f' {name} longitude (deg): {minlon:9.5f} to {maxlon:9.5f} {suffix}\n') # FOV unit multipliers (deg) when fov is scale factor. FORTRAN viewer3_*.f fov_unit parsing. _FOV_UNIT_MULT_DEG = { 'galileo': 8.1e-3 * _RAD2DEG, 'galileo ssi': 8.1e-3 * _RAD2DEG, 'cassini iss wide': 61.2e-3 * _RAD2DEG, 'cassini iss narrow': 6.1e-3 * _RAD2DEG, 'cassini': 6.1e-3 * _RAD2DEG, 'vims 64x64': 32e-3 * _RAD2DEG, 'vims 12x12': 6e-3 * _RAD2DEG, 'uvis slit': 59e-3 * _RAD2DEG, 'lorri': 0.2907, 'voyager iss narrow': 7.292e-3 * _RAD2DEG, 'voyager iss wide': 5.463e-2 * _RAD2DEG, } def _fov_deg_from_unit( fov: float, fov_unit: str | None, *, et: float | None = None, cfg: PlanetConfig | None = None, ) -> float: """Convert FOV to degrees using fov_unit (e.g. arcmin -> deg). Parameters: fov: FOV value in the given unit. fov_unit: Unit string (deg, arcmin, arcsec, etc.) or None for degrees. Returns: FOV in degrees. """ if fov_unit is None or len(fov_unit) == 0: return fov s = fov_unit.strip().lower() if 'radii' in s and et is not None and cfg is not None: _sun_dist_km, obs_dist_km = planet_ranges(et, planet_id=cfg.planet_id) if obs_dist_km > 0: ratio = cfg.equatorial_radius_km / obs_dist_km clamped = max(-1.0, min(1.0, ratio)) fov_deg = fov * math.asin(clamped) * _RAD2DEG logger.debug( 'FOV radii: obs_dist_km=%.6e radius_km=%.4f fov_deg=%.10f', obs_dist_km, cfg.equatorial_radius_km, fov_deg, ) return fov_deg return fov if ('pluto-charon separation' in s or 'pluto charon separation' in s) and et is not None: _sun_dist_km, obs_dist_km = planet_ranges(et) if obs_dist_km > 0: ratio = _PLUTO_CHARON_SEP_KM / obs_dist_km clamped = max(-1.0, min(1.0, ratio)) return fov * math.asin(clamped) * _RAD2DEG return fov if ('kilometer' in s or s.strip() == 'km') and et is not None: _sun_dist_km, obs_dist_km = planet_ranges(et) if obs_dist_km > 0: ratio = 1.0 / obs_dist_km clamped = max(-1.0, min(1.0, ratio)) return fov * math.asin(clamped) * _RAD2DEG return fov if s in ('deg', 'degree', 'degrees'): return fov if s in ('arcmin', 'minutes of arc', 'minute of arc'): return fov / ARCMIN_PER_DEGREE if s in ('arcsec', 'seconds of arc', 'second of arc'): return fov / ARCSEC_PER_DEGREE if s in ('milliradians', 'milliradian', 'mrad'): return fov * (_RAD2DEG / 1000.0) if s in ('microradians', 'microradian', 'urad'): return fov * (_RAD2DEG / 1_000_000.0) for key, mult in sorted(_FOV_UNIT_MULT_DEG.items(), key=lambda kv: -len(kv[0])): if key in s: return fov * mult return fov def _normalize_body_name(name: str) -> str: """Normalize body-like names for forgiving CGI matching.""" return name.split('(')[0].strip().lower() def _resolve_center_body_id(cfg: PlanetConfig, center_body_name: str | None) -> int: """Resolve center body name to NAIF ID, defaulting to the planet.""" if center_body_name is None or center_body_name.strip() == '': return cfg.planet_id wanted = _normalize_body_name(center_body_name) if wanted == _normalize_body_name(cfg.planet_name): return cfg.planet_id if wanted == 'barycenter' and cfg.barycenter_id is not None: return cfg.barycenter_id for moon in cfg.moons: if moon.id == cfg.planet_id: continue if _normalize_body_name(moon.name) == wanted: return moon.id return cfg.planet_id def _resolve_center_ansa_radius_km(cfg: PlanetConfig, center_ansa_name: str | None) -> float | None: """Resolve center ring ansa name to a representative ring radius.""" if center_ansa_name is None or center_ansa_name.strip() == '': return None target = center_ansa_name.lower().replace(' ring', '').strip() # Jupiter viewer rings are named in FORTRAN but unnamed in Python config. if cfg.planet_num == 5: jupiter_radius_map = { 'halo': 122000.0, 'main': 129000.0, 'amalthea': 181350.0, 'thebe': 221900.0, } if target in jupiter_radius_map: return jupiter_radius_map[target] # Neptune viewer rings are indexed by name in FORTRAN, but the Python ring # specs are unnamed; map canonical names to the FORTRAN ring index. if cfg.planet_num == 8: neptune_index = { 'galle': 0, 'leverrier': 1, 'arago': 2, 'adams': 3, }.get(target) if neptune_index is not None and neptune_index < len(cfg.rings): return cfg.rings[neptune_index].outer_km if cfg.planet_num == 9: pluto_index = _PLUTO_RING_NAME_TO_INDEX.get(target) if pluto_index is not None and pluto_index < len(cfg.rings): return cfg.rings[pluto_index].outer_km if cfg.planet_num == 4: mars_radius_map = { 'phobos': 9378.0, 'deimos': 23459.0, } if target in mars_radius_map: return mars_radius_map[target] if cfg.planet_num == 6: # Match FORTRAN viewer3_sat.f center_ansa handling: # choose ring radius from the first character of center_ansa. saturn_index_by_first_char = { 'c': 1, # ring_rads(2) 'b': 2, # ring_rads(3) 'a': 4, # ring_rads(5) 'f': 5, # ring_rads(6) 'g': 7, # ring_rads(8) 'e': 8, # ring_rads(9) } first_char = center_ansa_name.strip().lower()[:1] saturn_index = saturn_index_by_first_char.get(first_char) if saturn_index is not None and saturn_index < len(cfg.rings): return cfg.rings[saturn_index].outer_km # FORTRAN viewer3_ura.f uses a custom mapping for Uranus center ansa: # first char '6'/'5'/'4' -> ring_rads(1)/(2)/(3); then Alpha, Beta, ...; Epsilon, Nu, Mu. if cfg.planet_num == 7: first_char = center_ansa_name.strip().lower()[:1] if first_char == '6' and len(cfg.rings) >= 1: return cfg.rings[0].outer_km if first_char == '5' and len(cfg.rings) >= 2: return cfg.rings[1].outer_km if first_char == '4' and len(cfg.rings) >= 3: return cfg.rings[2].outer_km if target == 'epsilon' and len(cfg.rings) >= 11: return 0.5 * (cfg.rings[9].outer_km + cfg.rings[10].outer_km) if target == 'nu' and len(cfg.rings) >= 13: return cfg.rings[12].outer_km if target == 'mu' and len(cfg.rings) >= 16: return cfg.rings[15].outer_km matched_radii: list[float] = [] for ring in cfg.rings: ring_name = ring.name or '' if len(ring_name) == 0: continue if ring_name.lower().replace(' ring', '').strip() == target: matched_radii.append(ring.outer_km) if len(matched_radii) == 0: return None # Prefer the outermost matching component for multi-part ring names. return max(matched_radii) def _strip_leading_option_code(text: str) -> str: """Strip leading numeric CGI option code from a selection label.""" s = text.strip() idx = 0 while idx < len(s) and s[idx].isdigit(): idx += 1 if idx == 0: return s while idx < len(s) and s[idx].isspace(): idx += 1 return s[idx:] if idx < len(s) else s def _viewer_call_kwargs_from_params(params: ViewerParams) -> _RunViewerKwargs: """Convert a ``ViewerParams`` object to legacy ``run_viewer`` keyword args.""" center_ra = ( params.center.ra_deg if params.center.mode == 'J2000' and params.center.ra_deg is not None else 0.0 ) center_dec = ( params.center.dec_deg if params.center.mode == 'J2000' and params.center.dec_deg is not None else 0.0 ) if params.observer.name is not None and params.observer.name.strip() != '': viewpoint = params.observer.name elif params.observer.latitude_deg is not None and params.observer.longitude_deg is not None: viewpoint = 'latlon' else: viewpoint = 'Earth' ring_display = None if params.display is not None and params.display.rings_display: ring_display = params.display.rings_display elif params.ring_names: ring_display = ', '.join(params.ring_names) moon_display = None if params.display is not None and params.display.moons_display: moon_display = params.display.moons_display viewpoint_display = None if params.display is not None and params.display.viewpoint_display: viewpoint_display = params.display.viewpoint_display return { 'planet_num': params.planet_num, 'time_str': params.time_str, 'fov': params.fov_value, 'center_mode': params.center.mode, 'center_ra': center_ra, 'center_dec': center_dec, 'center_body_name': params.center.body_name, 'center_ansa_name': params.center.ansa_name, 'center_ansa_ew': params.center.ansa_ew or 'east', 'center_star_name': params.center.star_name, 'viewpoint': viewpoint, 'ephem_version': params.ephem_version, 'ephem_display': params.display.ephem_display if params.display is not None else None, 'moon_ids': params.moon_ids, 'moon_selection_display': moon_display, 'blank_disks': params.blank_disks, 'ring_selection': params.ring_names, 'ring_selection_display': ring_display, 'output_ps': params.output_ps, 'output_txt': params.output_txt, 'fov_unit': params.fov_unit, 'observer_latitude': params.observer.latitude_deg, 'observer_longitude': params.observer.longitude_deg, 'observer_altitude': params.observer.altitude_m, 'observer_lon_dir': params.observer.lon_dir, 'viewpoint_display': viewpoint_display, 'labels': params.labels, 'moon_points': params.moonpts, 'meridian_points': 1.3 if params.meridians else 0.0, 'opacity': params.opacity, 'peris': params.peris, 'peripts': params.peripts, 'arcmodel': params.arcmodel, 'arcpts': params.arcpts, 'torus': params.torus, 'torus_inc': params.torus_inc, 'torus_rad': params.torus_rad, 'moremoons': params.moremoons, 'show_standard_stars': params.show_standard_stars, 'extra_star_name': params.extra_star.name if params.extra_star is not None else None, 'extra_star_ra_deg': params.extra_star.ra_deg if params.extra_star is not None else None, 'extra_star_dec_deg': params.extra_star.dec_deg if params.extra_star is not None else None, 'other_bodies': params.other_bodies, 'title': params.title, }
[docs] def viewer_params_from_legacy_kwargs(**kwargs: object) -> ViewerParams: """Build ViewerParams from flat legacy keyword arguments (e.g. for tests). Accepts the same keyword names as the legacy run_viewer signature (and _RunViewerKwargs). Used by tests that call run_viewer with keyword arguments. """ def _get(key: str, default: object = None) -> object: return kwargs.get(key, default) center = ViewerCenter( mode=str(_get('center_mode', 'J2000')), ra_deg=float(cast(Any, _get('center_ra', 0.0))) if _get('center_ra') is not None else None, dec_deg=float(cast(Any, _get('center_dec', 0.0))) if _get('center_dec') is not None else None, body_name=cast('str | None', _get('center_body_name')), ansa_name=cast('str | None', _get('center_ansa_name')), ansa_ew=str(_get('center_ansa_ew', 'east')) if _get('center_ansa_ew') else None, star_name=cast('str | None', _get('center_star_name')), ) observer = Observer( name=cast('str | None', _get('viewpoint')), latitude_deg=float(cast(Any, x)) if (x := _get('observer_latitude')) is not None else None, longitude_deg=float(cast(Any, x)) if (x := _get('observer_longitude')) is not None else None, altitude_m=float(cast(Any, x)) if (x := _get('observer_altitude')) is not None else None, lon_dir=str(_get('observer_lon_dir', 'east')), ) extra = _get('extra_star_name'), _get('extra_star_ra_deg'), _get('extra_star_dec_deg') if extra[0] is not None or extra[1] is not None or extra[2] is not None: extra_star = ExtraStar( name=str(extra[0] or ''), ra_deg=float(cast(Any, extra[1])) if extra[1] is not None else 0.0, dec_deg=float(cast(Any, extra[2])) if extra[2] is not None else 0.0, ) else: extra_star = None display = ViewerDisplayInfo( ephem_display=cast('str | None', _get('ephem_display')), moons_display=cast('str | None', _get('moon_selection_display')), rings_display=cast('str | None', _get('ring_selection_display')), viewpoint_display=cast('str | None', _get('viewpoint_display')), ) return ViewerParams( planet_num=int(cast(Any, _get('planet_num', 0))), time_str=str(_get('time_str', '')), fov_value=float(cast(Any, _get('fov', 1.0))), fov_unit=str(_get('fov_unit') or 'degrees'), center=center, observer=observer, ephem_version=int(cast(Any, _get('ephem_version', 0))), moon_ids=cast('list[int] | None', _get('moon_ids')), ring_names=cast('list[str] | None', _get('ring_selection')), blank_disks=bool(_get('blank_disks', False)), opacity=str(_get('opacity', 'Transparent')), peris=str(_get('peris', 'None') or 'None'), peripts=float(cast(Any, _get('peripts', 4.0))), labels=str(_get('labels') or 'Small (6 points)'), moonpts=float(cast(Any, _get('moon_points', 0.0))), meridians=bool(float(cast(Any, _get('meridian_points') or 0)) > 0), arcmodel=cast('str | None', _get('arcmodel')), arcpts=float(cast(Any, _get('arcpts', 4.0))), torus=bool(_get('torus', False)), torus_inc=float(cast(Any, _get('torus_inc', 6.8))), torus_rad=float(cast(Any, _get('torus_rad', 422000.0))), moremoons=bool(_get('moremoons', False)), show_standard_stars=bool(_get('show_standard_stars', False)), extra_star=extra_star, other_bodies=cast('list[str] | None', _get('other_bodies')), title=str(_get('title', '')), display=display, output_ps=cast('TextIO | None', _get('output_ps')), output_txt=cast('TextIO | None', _get('output_txt')), )