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 avicar.VicarImage, return a(host, id, filter_name)triple if this module owns the label, elseNone. Missions that are not delivered as VICAR returnNoneunconditionally.detect_fits(hdulist)— same shape but forastropy.io.fits.HDUList. Missions that are not delivered as FITS returnNoneunconditionally.matches(inst_host, inst_id)— quick host-level predicate used bypicmaker.instruments.lookup()once the cascade already has afilter_infotriple in hand (e.g. when--tintis 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], orNoneif the filter is genuinely unknown (the HST wavelength-inference path usesNoneto signal “unable to infer; keep the user’s colormap”).
Step-by-step
Create the module. Copy
src/picmaker/instruments/voyager.pyas a template — it uses the simplest of the four detection patterns (a constantFILTER_DICTplus aLAB02[: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']
Register the module. Open
src/picmaker/instruments/__init__.pyand add the new module to the three dispatch lists. If the new instrument only handles VICAR or only FITS, add it to that list plusALL_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.
Add a fixture recipe. Create
tests/fixture_recipes/mymission_myinst_recipe.pythat builds a tiny synthetic VICAR or FITS file with the metadata keys yourdetect_*reads. Run it once from the venv to create the fixture binary:python tests/fixture_recipes/mymission_myinst_recipe.pyThen add
tests/fixtures/mymission_myinst.vic(or.fits) to git.Wire it into the cross-instrument tests. Open
tests/test_io.pyand add an entry toINSTRUMENT_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_detectionparametrizes over this list, so the new entry exercises bothpicmaker.io.read_one_image_array()(via the parametrize) andpicmaker.instruments.lookup()(via the new fixture’sfilter_infotriple).Add direct unit tests for the per-instrument helpers. Open
tests/test_instruments_branches.pyand add a parametrize case for everytint_forbranch you want pinned, mirroring the existing Cassini and Voyager parametrize blocks.Add a snapshot. If the new fixture supports the
--tint,--default,--rot90, etc. combinations exercised bytests/fixture_recipes/generate_snapshots.py, append the fixture name toALL_FIXTURESin that file and regenerate:python tests/fixture_recipes/generate_snapshots.pyThe script writes new files under
tests/fixtures/expected/and rewritestests/snapshots_index.py. Both should be committed.Document it. Open
docs/user_guide.rstand 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_flagdoes not catch undocumented instruments today; this is author-discipline rather than CI-enforced.Run the full check suite.
bash scripts/run-all-checks.shThe 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.cassinikeeps the tint chain in a private helperpicmaker.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.hstderives 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.