Adding a new instrument

Every supported mission lives in its own module under picmaker.instruments. The four functions every instrument module exposes form a small structural protocol — there is no formal typing.Protocol declaration but each module is structurally identical and the tests pin the contract.

The four-function protocol

def detect_vicar(vic) -> tuple[str, str, str] | None: ...
def detect_fits(hdulist) -> tuple[str, str, str] | None: ...
def matches(inst_host: str, inst_id: str) -> bool: ...
def tint_for(inst_id: str, filter_name) -> list[tuple[int, int, int]] | None: ...
  • detect_vicar(vic) — given a vicar.VicarImage, return a (host, id, filter_name) triple if this module owns the label, else None. Missions that are not delivered as VICAR return None unconditionally.

  • detect_fits(hdulist) — same shape but for astropy.io.fits.HDUList. Missions that are not delivered as FITS return None unconditionally.

  • matches(inst_host, inst_id) — quick host-level predicate used by picmaker.instruments.lookup() once the cascade already has a filter_info triple in hand (e.g. when --tint is set on a non-detected file).

  • tint_for(inst_id, filter_name) — given the filter, return the three-stop colormap [black, tint, white], the two-stop fallback [black, white], or None if the filter is genuinely unknown (the HST wavelength-inference path uses None to signal “unable to infer; keep the user’s colormap”).

Step-by-step

  1. Create the module. Copy src/picmaker/instruments/voyager.py as a template — it uses the simplest of the four detection patterns (a constant FILTER_DICT plus a LAB02[:3] == 'VGR' predicate).

    """My-Mission MyInstrument detection and tint."""
    
    from typing import Any
    
    from vicar import VicarError
    
    _FILTER_DICT: dict[str, tuple[int, int, int]] = {
        'BLUE': (110, 110, 210),
        'RED':  (190, 100, 100),
        # ... per-filter tints here ...
    }
    
    
    def detect_vicar(vic: Any) -> tuple[str, str, str] | None:
        """Return ('MYMISSION', 'MYINST', filter) or None."""
        try:
            if vic['INSTRUMENT_HOST_NAME'] == 'MY MISSION':
                return ('MYMISSION', 'MYINST', vic['FILTER_NAME'])
        except (VicarError, KeyError):
            pass
        return None
    
    
    def detect_fits(hdulist: Any) -> tuple[str, str, str] | None:
        """Not delivered as FITS — always None."""
        return None
    
    
    def matches(inst_host: str, inst_id: str) -> bool:
        """Host-level predicate."""
        return inst_host.startswith('MY MISSION')
    
    
    def tint_for(
        inst_id: str, filter_name: Any
    ) -> list[tuple[int, int, int]] | None:
        """Return [black, tint, white] for known filters."""
        if not inst_id.startswith('MYINST'):
            return [(0, 0, 0), (255, 255, 255)]
        return [(0, 0, 0), _FILTER_DICT[filter_name], (255, 255, 255)]
    
    
    __all__ = ['detect_fits', 'detect_vicar', 'matches', 'tint_for']
    
  2. Register the module. Open src/picmaker/instruments/__init__.py and add the new module to the three dispatch lists. If the new instrument only handles VICAR or only FITS, add it to that list plus ALL_INSTRUMENTS:

    from picmaker.instruments import cassini, galileo, hst, mymission, nh, voyager
    
    VICAR_INSTRUMENTS = [cassini, galileo, voyager, mymission]
    FITS_INSTRUMENTS = [hst, nh]
    ALL_INSTRUMENTS = [cassini, voyager, galileo, hst, nh, mymission]
    

    Note

    Consolidating these three lists into one is tracked in issue #13. Until that lands, every new module needs entries in two or three places.

  3. Add a fixture recipe. Create tests/fixture_recipes/mymission_myinst_recipe.py that builds a tiny synthetic VICAR or FITS file with the metadata keys your detect_* reads. Run it once from the venv to create the fixture binary:

    python tests/fixture_recipes/mymission_myinst_recipe.py
    

    Then add tests/fixtures/mymission_myinst.vic (or .fits) to git.

  4. Wire it into the cross-instrument tests. Open tests/test_io.py and add an entry to INSTRUMENT_FIXTURES:

    INSTRUMENT_FIXTURES = [
        ('cassini_iss.vic', ('CASSINI', 'ISS', 'CL1+GRN'), False),
        # ... existing entries ...
        ('mymission_myinst.vic', ('MYMISSION', 'MYINST', 'BLUE'), False),
    ]
    

    tests/test_io.py::test_instrument_detection parametrizes over this list, so the new entry exercises both picmaker.io.read_one_image_array() (via the parametrize) and picmaker.instruments.lookup() (via the new fixture’s filter_info triple).

  5. Add direct unit tests for the per-instrument helpers. Open tests/test_instruments_branches.py and add a parametrize case for every tint_for branch you want pinned, mirroring the existing Cassini and Voyager parametrize blocks.

  6. Add a snapshot. If the new fixture supports the --tint, --default, --rot90, etc. combinations exercised by tests/fixture_recipes/generate_snapshots.py, append the fixture name to ALL_FIXTURES in that file and regenerate:

    python tests/fixture_recipes/generate_snapshots.py
    

    The script writes new files under tests/fixtures/expected/ and rewrites tests/snapshots_index.py. Both should be committed.

  7. Document it. Open docs/user_guide.rst and add a section under “Supported instruments and filters” describing the new instrument’s detection labels, filter set, and tint table.

    tests/test_cli.py::test_user_guide_documents_every_cli_flag does not catch undocumented instruments today; this is author-discipline rather than CI-enforced.

  8. Run the full check suite.

    bash scripts/run-all-checks.sh
    

    The new module must pass ruff, mypy strict, bandit, and vulture.

When to break the protocol

Two existing modules already deviate slightly from the four-function template:

  • picmaker.instruments.cassini keeps the tint chain in a private helper picmaker.instruments.cassini._iss_tint() rather than a public dict, because the chain is substring-based (IR, UV, BL, …) rather than a fixed mapping.

  • picmaker.instruments.hst derives the tint from wavelength-inferred-from-digits rather than a fixed mapping, and has special cases for NICMOS scaling, WFPC2 quad filters (FQUV* / FQCH4*), polarizers (POL0S / POL0L), and long-pass broadband filters (F350LP, F606W, LONG_PASS).

Both still expose the four-function protocol; the internal implementation just differs.