"""Save-as-library-entry helper for the manual-navigation dialog.
The manual-nav dialog drives library curation: an operator picks an
offset by hand, then clicks "Save as Library Entry..." to drop a sidecar
into ``tests/integration/image_library/images/<class>/<image_id>.yaml``.
This module owns the YAML template and the obs-introspection helper so
the dialog (which is otherwise PyQt-only) keeps a thin UI surface.
The emitted YAML carries ``TODO``-style placeholders for every field the
dialog cannot infer from the observation (scene class, expected
technique, etc.). The library's own structural-invariants test
(:mod:`tests.integration.test_image_library`) refuses to load a sidecar
that still has placeholder enum values, so the operator must edit the
file before committing it.
"""
from __future__ import annotations
import math
import os
from dataclasses import dataclass
from datetime import date
from typing import Any
__all__ = [
'LibraryEntryDraft',
'build_sidecar_yaml',
'compute_image_url',
'infer_obs_metadata',
]
# Mission-string mapping — keys are class names of the supported obs subclasses.
# Mission codes match the dataset names in
# :mod:`spindoctor.dataset` (``coiss`` / ``vgiss`` / ``gossi`` / ``nhlorri``)
# upper-cased, so a sidecar's ``mission`` is unambiguous against a CLI
# invocation like ``sd_offset --dataset coiss``. Add a new entry here
# when adding a new instrument; keep the value in
# :data:`tests.integration.sidecar.ALLOWED_MISSIONS` in sync.
_OBS_CLASS_TO_MISSION: dict[str, str] = {
'ObsCassiniISS': 'COISS',
'ObsVoyagerISS': 'VGISS',
'ObsGalileoSSI': 'GOSSI',
'ObsNewHorizonsLORRI': 'NHLORRI',
}
[docs]
@dataclass(frozen=True)
class LibraryEntryDraft:
"""Auto-inferred fields the dialog seeds into a fresh sidecar."""
image_id: str
mission: str
camera: str
filter_combo: str
exposure_time_sec: float | None
image_datetime_utc: str | None
[docs]
def compute_image_url(obs: Any, config: Any = None) -> str:
"""Return the best ``image_url`` for the sidecar.
When the obs's absolute path lives under a known PDS3 holdings root
(resolved from ``PDS3_HOLDINGS_DIR`` first, then
``config.environment.pds3_holdings_root``), the URL is rewritten to
the opaque ``pds3://<relative-path>`` form the regression test
re-resolves at load time. Otherwise the raw absolute path is
returned so the operator at least sees what was navigated.
Parameters:
obs: Observation snapshot; ``obs.abspath`` is the file path.
config: Optional ``Config`` override for the
``environment.pds3_holdings_root`` fallback.
Returns:
Either ``'pds3://<relative-path>'`` or the raw absolute path
as a string. Empty string when ``obs`` carries no ``abspath``.
"""
abspath = getattr(obs, 'abspath', None)
if abspath is None:
return ''
abspath_str = str(abspath)
holdings_root = _resolve_pds3_holdings_root(config)
if holdings_root:
rel = _relative_to_holdings(abspath_str, holdings_root)
if rel is not None:
return f'pds3://{rel}'
return abspath_str
def _resolve_pds3_holdings_root(config: Any) -> str:
"""Return the configured PDS3 holdings root or ``''`` if unknown.
Order matches :class:`spindoctor.dataset.dataset_pds3.DataSetPDS3`:
``PDS3_HOLDINGS_DIR`` env var, then
``config.environment.pds3_holdings_root``. Missing or empty
configurations yield ``''``.
"""
env_root = os.environ.get('PDS3_HOLDINGS_DIR')
if env_root:
return env_root.rstrip('/')
if config is None:
try:
from spindoctor.config import DEFAULT_CONFIG
config = DEFAULT_CONFIG
except Exception: # pragma: no cover - bootstrap sandbox
return ''
try:
environment = config.environment
except Exception: # pragma: no cover - partial-config sandbox
return ''
cfg_root = getattr(environment, 'pds3_holdings_root', None)
if not cfg_root:
return ''
return str(cfg_root).rstrip('/')
def _relative_to_holdings(abspath_str: str, holdings_root: str) -> str | None:
"""Return the holdings-relative path or ``None`` if abspath is outside.
Tolerates trailing slashes and case-sensitive comparisons; does not
walk the filesystem. ``holdings_root`` is expected to already be
normalised by :func:`_resolve_pds3_holdings_root` (no trailing
slash).
"""
if not abspath_str.startswith(holdings_root):
return None
remainder = abspath_str[len(holdings_root) :]
return remainder.lstrip('/')
[docs]
def build_sidecar_yaml(
*,
draft: LibraryEntryDraft,
image_url: str,
offset_dv_px: float,
offset_du_px: float,
ui_version: str,
operator: str | None = None,
today: date | None = None,
constraint_normal_angle_deg: float | None = None,
) -> str:
"""Render the sidecar as a YAML string with placeholders for the rest.
The output is deliberately not an opaque blob: every placeholder is
a clear ``TODO_REPLACE_*`` string so the operator knows exactly what
to edit before committing. Fields the dialog can infer
(``offset_*_px``, ``mission``, ``camera``, ``filter_combo``,
``exposure_time_sec``, ``image_id``) are filled in directly.
When ``constraint_normal_angle_deg`` is set (the operator navigated
with the constrained-drag mode on a rank-1 scene), the ground truth
carries a ``constraint`` block — ``offset_along_normal_px`` computed
from the saved 2-D offset so the two stay consistent by construction —
and ``expected`` pins ``status_reason: rank_1_only``.
"""
op_name = operator or os.environ.get('USER') or 'unknown'
on_date = (today or date.today()).isoformat()
if draft.exposure_time_sec is None:
exposure_block = '# seconds; from obs.texp\nexposure_time_sec: TODO_REPLACE_EXPOSURE\n'
else:
exposure_block = f'exposure_time_sec: {draft.exposure_time_sec:.4f}\n'
if draft.image_datetime_utc is None:
datetime_block = (
'# UTC ISO 8601; from et_to_utc(obs.midtime)\n'
"image_datetime_utc: 'TODO_REPLACE_DATETIME'\n"
)
else:
datetime_block = f"image_datetime_utc: '{draft.image_datetime_utc}'\n"
constraint_block = ''
status_reason_block = ''
if constraint_normal_angle_deg is not None:
angle_rad = math.radians(constraint_normal_angle_deg)
along_normal = offset_dv_px * math.cos(angle_rad) + offset_du_px * math.sin(angle_rad)
constraint_block = (
' # Rank-1 scene: only the offset component along this normal is\n'
' # observable; (offset_dv_px, offset_du_px) is a representative\n'
' # point on the constraint line.\n'
' constraint:\n'
' type: rank_1\n'
f' normal_angle_deg: {constraint_normal_angle_deg:.4f}\n'
f' offset_along_normal_px: {along_normal:.4f}\n'
' # 1sigma along the normal; tighten for sharp edges.\n'
' uncertainty_px: 1.0\n'
)
status_reason_block = ' status_reason: rank_1_only\n'
return (
'schema_version: 1\n'
'# COISS | VGISS | GOSSI | NHLORRI\n'
f'mission: {draft.mission or "TODO_REPLACE_MISSION"}\n'
'# NAC | WAC | SSI | NA | WA | LORRI\n'
f'camera: {draft.camera or "TODO_REPLACE_CAMERA"}\n'
f'image_id: {draft.image_id or "TODO_REPLACE_IMAGE_ID"}\n'
+ datetime_block
+ exposure_block
+ "# canonicalized: filters sorted, '+'-joined\n"
+ f"filter_combo: '{draft.filter_combo}'\n"
+ f"image_url: '{image_url}'\n"
'\n'
'# First tag is the primary class; must match the directory the\n'
'# sidecar lives in.\n'
'scene_tags:\n'
' - TODO_REPLACE_PRIMARY_CLASS\n'
'\n'
'ground_truth:\n'
f' offset_dv_px: {offset_dv_px:.4f}\n'
f' offset_du_px: {offset_du_px:.4f}\n'
' # 1sigma marginal; tighten for bright stars / sharp limbs.\n'
' offset_uncertainty_px: 1.0\n' + constraint_block + ' source: operator_verified\n'
f' operator: {op_name}\n'
f' verified_date: {on_date}\n'
f" ui_version: 'spindoctor {ui_version}'\n"
' notes: |\n'
' TODO: describe the scene and any caveats.\n'
'\n'
'expected:\n'
' # success | failed | conflicted\n'
' status: success\n'
' # high | medium | low | failed\n'
' confidence_tier: high\n' + status_reason_block + ' # e.g. BodyLimbNav\n'
' primary_technique: TODO_REPLACE_TECHNIQUE\n'
' techniques_must_run: []\n'
' techniques_must_skip: []\n'
)