diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 27ee3dc..a64a361 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,8 +23,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - with: - submodules: recursive - name: Set up Python uses: actions/setup-python@v5 @@ -46,9 +44,7 @@ jobs: # autodoc imports the compiled module, so the Python API page cannot drift from the bindings - name: Install camera_interface - run: | - pip install . \ - --config-settings=cmake.define.INSTRUMENT=hispec_tracking_camera + run: pip install . - name: Install documentation toolchain run: pip install -r docs/requirements.txt diff --git a/.github/workflows/emulator-integration.yml b/.github/workflows/emulator-integration.yml index 74306b4..602c761 100644 --- a/.github/workflows/emulator-integration.yml +++ b/.github/workflows/emulator-integration.yml @@ -7,123 +7,12 @@ on: branches: [ "main" ] jobs: - frame-outputs-emulator-test: - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - libccfits-dev libcfitsio-dev libcurl4-openssl-dev \ - nlohmann-json3-dev libzmq3-dev \ - libboost-thread-dev libboost-chrono-dev - - - name: Build and install zmqpp from source - run: | - git clone --depth 1 https://github.com/zeromq/zmqpp.git /tmp/zmqpp - cd /tmp/zmqpp - mkdir build && cd build - cmake .. - make -j$(nproc) - sudo make install - sudo ldconfig - - - name: Build and install ImageStreamIO from source - run: | - git clone --depth 1 https://github.com/milk-org/ImageStreamIO.git /tmp/ImageStreamIO - cd /tmp/ImageStreamIO - mkdir build && cd build - cmake .. - make -j$(nproc) - sudo make install - sudo ldconfig - - - name: Build camerad, emulator, socksend, shm_reader - run: | - mkdir -p ${{github.workspace}}/build - cd ${{github.workspace}}/build - cmake -DCONTROLLER=archon -DINSTRUMENT=hispec_tracking_camera \ - -DENABLE_SHM_OUTPUT=ON -DImageStreamIO_DIR=/usr/local/lib/cmake .. - make camerad emulator socksend shm_reader -j$(nproc) - - - name: SHM + FITS frame outputs test - run: | - mkdir -p /tmp/ci_milk_shm /tmp/ci_fits_test - - bin/camerad-emulator config/frame_outputs_test/frame_outputs_test.cfg -i generic & - sleep 2 - bin/camerad --foreground --config config/frame_outputs_test/frame_outputs_test.cfg & - sleep 3 - - send() { bin/camerad-socksend -p 3131 -t 60 "$1"; } - - send "open" - send "load" - send "power on" - - # This cfg sets no LONGEXPOSURE, so a bare argument is sec; an explicit - # unit has to override that - send "exptime 1500 ms" | grep -q "^1.500" || { echo "unit suffix ignored"; exit 1; } - - send "exptime 1.5" - resp=$(send "expose 1") - echo "expose -> $resp" - echo "$resp" | grep -q "DONE" || exit 1 - - # DONE only means camerad accepted the command, not that the writers - # succeeded -- shm_reader's exit code and the FITS headers are the - # real pass/fail signal - sleep 1 - bin/camerad-shm-reader ci_frame_outputs_shm /tmp/ci_milk_shm - python3 python/tests/fits_header_check.py \ - /tmp/ci_fits_test/ci_frame_outputs_*.fits --exptime 1.5 - - image_size() { - bin/camerad-shm-reader ci_frame_outputs_shm /tmp/ci_milk_shm \ - | grep -o 'size=[0-9]*x[0-9]*' | head -1 - } - before=$(image_size) - - # read must refuse to fetch until capture is actually enabled - send "raw read" | grep -q "ERROR" || { echo "raw read ran with RAWENABLE=0"; exit 1; } - - # The Archon captures raw alongside the frame, so enabling it has to - # precede the exposure it should appear in - send "raw set RAWENABLE 1" - resp=$(send "expose 1") - echo "expose -> $resp" - echo "$resp" | grep -q "DONE" || exit 1 - - resp=$(send "raw read") - echo "raw read -> $resp" - echo "$resp" | grep -q "DONE" || exit 1 - sleep 1 - - # RAW has its own geometry, so it has to land in its own outputs rather - # than collide with the image file or resize the image's SHM stream - bin/camerad-shm-reader ci_frame_outputs_shm_raw /tmp/ci_milk_shm - ls /tmp/ci_fits_test/ci_frame_outputs_*_raw.fits - - after=$(image_size) - test "$before" = "$after" || { echo "image stream changed: $before -> $after"; exit 1; } - - pkill -f 'bin/camerad' || true - pkill -f 'bin/camerad-emulator' || true - python-module-emulator-test: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - with: - submodules: true - name: Install dependencies run: | @@ -159,7 +48,7 @@ jobs: run: | mkdir -p ${{github.workspace}}/build cd ${{github.workspace}}/build - cmake -DCONTROLLER=archon -DINSTRUMENT=hispec_tracking_camera \ + cmake -DCONTROLLER=archon \ -DENABLE_SHM_OUTPUT=ON -DImageStreamIO_DIR=/usr/local/lib/cmake \ -DBUILD_PYTHON_MODULE=ON .. make -j$(nproc) @@ -175,7 +64,9 @@ jobs: # asserts both writers itself since the SHM segment does not outlive it PYTHONPATH=lib python3 python/tests/emulator_integration.py \ --config config/frame_outputs_test/frame_outputs_test.cfg \ - --fits-dir /tmp/ci_fits_test + --fits-dir /tmp/ci_fits_test \ + --shm-dir /tmp/ci_milk_shm \ + --shm-segment ci_frame_outputs_shm pkill -f 'bin/camerad-emulator' || true @@ -193,85 +84,6 @@ jobs: PYTHONPATH=/tmp/camerad-prefix/lib python3 - <<'PY' import camera_interface as ci assert ci.__file__.startswith("/tmp/camerad-prefix/lib/"), ci.__file__ - assert ci.instrument_name() == "hispec_tracking_camera", ci.instrument_name() + assert ci.instrument_name() == "none", ci.instrument_name() print("imported", ci.__file__) PY - - cryoscope-emulator-test: - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - libccfits-dev libcfitsio-dev libcurl4-openssl-dev \ - nlohmann-json3-dev libzmq3-dev \ - libboost-thread-dev libboost-chrono-dev - - - name: Build and install zmqpp from source - run: | - git clone --depth 1 https://github.com/zeromq/zmqpp.git /tmp/zmqpp - cd /tmp/zmqpp - mkdir build && cd build - cmake .. - make -j$(nproc) - sudo make install - sudo ldconfig - - - name: Build camerad and emulator - run: | - mkdir -p ${{github.workspace}}/build - cd ${{github.workspace}}/build - cmake -DCONTROLLER=archon -DINSTR=hispec_tracking_camera -DDETECTOR_TYPE=Hxrg .. - make camerad emulator socksend -j$(nproc) - - - name: CryoScope synthetic test - run: | - bin/camerad-emulator config/cryoscope/cryoscope.cfg -i generic & - sleep 2 - bin/camerad --foreground --config config/cryoscope/cryoscope.cfg & - sleep 3 - - send() { bin/camerad-socksend -p 3031 -t 60 "$1"; } - - send "open" - send "load" - send "power on" - send "exptime 0.1" - resp=$(send "expose 1") - echo "expose -> $resp" - echo "$resp" | grep -q "DONE" || exit 1 - - pkill -f 'bin/camerad' || true - pkill -f 'bin/camerad-emulator' || true - sleep 1 - - - name: CryoScope FITS playback test - run: | - cp config/cryoscope/cryoscope.cfg /tmp/cryoscope_fits_test.cfg - echo "EMULATOR_DATADIR=config/cryoscope" >> /tmp/cryoscope_fits_test.cfg - - bin/camerad-emulator /tmp/cryoscope_fits_test.cfg -i generic & - sleep 2 - bin/camerad --foreground --config /tmp/cryoscope_fits_test.cfg & - sleep 3 - - send() { bin/camerad-socksend -p 3031 -t 60 "$1"; } - - send "open" - send "load" - send "power on" - send "mode VIDEORXR" - send "exptime 0.1" - resp=$(send "expose 1") - echo "expose -> $resp" - echo "$resp" | grep -q "DONE" || exit 1 - - pkill -f 'bin/camerad' || true - pkill -f 'bin/camerad-emulator' || true diff --git a/.github/workflows/update-submodules.yml b/.github/workflows/update-submodules.yml deleted file mode 100644 index 8348b99..0000000 --- a/.github/workflows/update-submodules.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Update Submodules - -on: - schedule: - # Run every night at 2 AM UTC - - cron: '0 2 * * *' - workflow_dispatch: # Allow manual trigger - -jobs: - update-submodules: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: true - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Update submodules to latest - id: update - run: | - # Update all submodules to their latest remote commits - git submodule update --remote --init --recursive - - # Check if there are any changes - if git diff --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - echo "No submodule updates found" - else - echo "has_changes=true" >> "$GITHUB_OUTPUT" - echo "Submodule updates found:" - git diff --submodule=short - - # Capture the diff for the PR body - { - echo "diff<> "$GITHUB_OUTPUT" - fi - - - name: Create Pull Request - if: steps.update.outputs.has_changes == 'true' - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "chore: update submodules" - title: "chore: Update submodules" - body: | - This PR updates submodules to their latest commits. - - **Updated submodules:** - ``` - ${{ steps.update.outputs.diff || 'See commit for details' }} - ``` - - --- - *This PR was automatically created by the update-submodules workflow.* - branch: automated/update-submodules - delete-branch: true - labels: | - automated - dependencies diff --git a/.gitmodules b/.gitmodules index 2d06502..f095d71 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,6 +10,3 @@ [submodule "camerad/Instruments/hispec"] path = camerad/Instruments/hispec url = https://github.com/CaltechOpticalObservatories/hispec-instrument.git -[submodule "camerad/Instruments/lris2"] - path = camerad/Instruments/lris2 - url = https://github.com/CaltechOpticalObservatories/lris2-camerad-instrument.git diff --git a/camerad/Instruments/lris2 b/camerad/Instruments/lris2 deleted file mode 160000 index a8d6378..0000000 --- a/camerad/Instruments/lris2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a8d63781d9777d06d4be5f3cc991b0afd07d896a diff --git a/config/bias_test/bias_test.cfg b/config/bias_test/bias_test.cfg index 3f32321..5ce3a08 100644 --- a/config/bias_test/bias_test.cfg +++ b/config/bias_test/bias_test.cfg @@ -21,7 +21,7 @@ ASYNCGROUP=none ARCHON_IP=localhost ARCHON_PORT=3132 -DEFAULT_FIRMWARE=camerad/Instruments/hispec_tracking_camera/config/hispecatc.acf +DEFAULT_FIRMWARE=config/demo/demo.acf EXPOSE_PARAM=Expose ABORT_PARAM=abort EXPTIME_MSEC_PARAM=exptime diff --git a/config/frame_outputs_test/frame_outputs_test.cfg b/config/frame_outputs_test/frame_outputs_test.cfg index 069f7b8..466c702 100644 --- a/config/frame_outputs_test/frame_outputs_test.cfg +++ b/config/frame_outputs_test/frame_outputs_test.cfg @@ -21,7 +21,7 @@ ASYNCGROUP=none ARCHON_IP=localhost ARCHON_PORT=3132 -DEFAULT_FIRMWARE=camerad/Instruments/hispec_tracking_camera/config/hispecatc.acf +DEFAULT_FIRMWARE=config/demo/demo.acf EXPOSE_PARAM=Expose ABORT_PARAM=abort EXPTIME_MSEC_PARAM=exptime diff --git a/docs/_ext/camerad_tables.py b/docs/_ext/camerad_tables.py index a2e830c..50db31a 100644 --- a/docs/_ext/camerad_tables.py +++ b/docs/_ext/camerad_tables.py @@ -275,7 +275,10 @@ def _balanced_body(text: str, open_index: int) -> str: @cache def load_fits_keywords() -> list[FitsKeyword]: - text = _read(str(ATC_DICTIONARY.relative_to(REPO_ROOT))) + # Instruments live in their own repositories, so this source is often absent + if not ATC_DICTIONARY.is_file(): + return [] + text = ATC_DICTIONARY.read_text(encoding="utf-8") block = _balanced_body(text, text.index("dictionary = {") + len("dictionary = ")) keywords = [] @@ -369,8 +372,14 @@ class CameradFitsKeywords(_TableDirective): option_spec = {"widths": str} def run(self) -> list[nodes.Node]: + keywords = load_fits_keywords() + if not keywords: + return [nodes.note("", nodes.paragraph( + text="This instrument's sources are not in this checkout, so its FITS " + "header dictionary cannot be listed here."))] + rows = [] - for entry in load_fits_keywords(): + for entry in keywords: default = entry.default_atc or entry.default_spec comment = entry.comment if entry.enum_values: diff --git a/python/tests/emulator_integration.py b/python/tests/emulator_integration.py index 7fefc8b..073c699 100644 --- a/python/tests/emulator_integration.py +++ b/python/tests/emulator_integration.py @@ -1,7 +1,8 @@ """Drive a camera through the camera_interface module against the emulator. -Mirrors the socket-based frame-outputs job so both control paths are covered by -the same assertions. Exits non-zero on the first failure. +The module owns the controller connection, so every wait here is on a condition +the camera reports rather than on a duration. Exits non-zero on the first +failure. """ from __future__ import annotations @@ -15,7 +16,6 @@ from fits_header_check import CheckFailed, check, check_file -EXPECTED_INSTRUMENT = "hispec_tracking_camera" EXPECTED_CONTROLLER = "archon" # The FITS writer queues and drops by design, so a test that wants to see a @@ -27,22 +27,21 @@ EXPTIME_S = 1.5 -def check_build_identity() -> None: - """Confirm the module was built for the instrument this test assumes.""" +def check_build_identity(expected_instrument: str) -> None: + """Confirm the module was built for the instrument this run expects.""" instrument = camera_interface.instrument_name() controller = camera_interface.controller_name() print(f"build: instrument={instrument} controller={controller}") - check(instrument == EXPECTED_INSTRUMENT, - f"expected instrument {EXPECTED_INSTRUMENT}, got {instrument}") + check(instrument == expected_instrument, + f"expected instrument {expected_instrument}, got {instrument}") check(controller == EXPECTED_CONTROLLER, f"expected controller {EXPECTED_CONTROLLER}, got {controller}") def check_introspection(camera: camera_interface.Camera) -> None: - """Confirm instrument commands enumerate and each one resolves.""" + """Confirm every enumerated instrument command resolves, and no other does.""" commands = camera.instrument_commands() print(f"instrument commands: {commands}") - check(bool(commands), "instrument reported no commands") for name in commands: check(camera.is_instrument_command(name), f"{name} was enumerated but is_instrument_command says no") @@ -50,6 +49,12 @@ def check_introspection(camera: camera_interface.Camera) -> None: "an unknown command was accepted as an instrument command") +def check_exptime_units(camera: camera_interface.Camera) -> None: + """Confirm an explicit unit overrides the config's default of seconds.""" + reply = camera.exptime("1500 ms").strip() + check(reply.startswith("1.500"), f"expected 1.500 from \"1500 ms\", got {reply!r}") + + def wait_for_writes(camera: camera_interface.Camera) -> list[dict]: """Return output status once every configured output has written, or time out.""" deadline = time.monotonic() + WRITE_TIMEOUT_S @@ -62,9 +67,53 @@ def wait_for_writes(camera: camera_interface.Camera) -> list[dict]: f"{camera.output_status()}") -def run(config_path: str, fits_dir: pathlib.Path) -> None: - """Take one exposure through the module and verify it reached disk.""" - check_build_identity() +def check_raw_needs_enabling(camera: camera_interface.Camera) -> None: + """Confirm a read is refused until capture is actually enabled.""" + try: + camera.controller_cmd("raw", "read") + except RuntimeError: + return + raise CheckFailed("raw read was accepted with RAWENABLE=0") + + +def check_raw_streams_apart(camera: camera_interface.Camera, + fits_dir: pathlib.Path, + image_files: list[pathlib.Path]) -> None: + """Confirm a raw capture lands beside the image rather than on top of it. + + RAW carries its own geometry, so sharing an output with the image would + either overwrite the file or resize the stream. + """ + before_raw = set(fits_dir.glob("*_raw.fits")) + + # The Archon captures raw alongside the frame, so enabling it has to + # precede the exposure it should appear in + camera.controller_cmd("raw", "set RAWENABLE 1") + camera.expose("1") + camera.controller_cmd("raw", "read") + wait_for_writes(camera) + + raw_files = sorted(set(fits_dir.glob("*_raw.fits")) - before_raw) + check(bool(raw_files), f"no raw FITS file appeared in {fits_dir}") + for path in raw_files: + print(f"raw: {path} ({path.stat().st_size} bytes)") + check(path not in image_files, f"{path} overwrote an image file") + + +def check_shm_streams_apart(shm_dir: pathlib.Path, segment: str) -> None: + """Confirm the raw frames went to their own segment, not the image's.""" + image = shm_dir / f"{segment}.im.shm" + raw = shm_dir / f"{segment}_raw.im.shm" + check(image.is_file(), f"no image segment at {image}") + check(raw.is_file(), f"no raw segment at {raw}; raw shared the image stream") + print(f"segments: {image.name} {image.stat().st_size}B, " + f"{raw.name} {raw.stat().st_size}B") + + +def run(config_path: str, fits_dir: pathlib.Path, expected_instrument: str, + shm_dir: pathlib.Path | None, segment: str | None) -> None: + """Take exposures through the module and verify they reached disk.""" + check_build_identity(expected_instrument) before = set(fits_dir.glob("*.fits")) @@ -72,10 +121,14 @@ def run(config_path: str, fits_dir: pathlib.Path) -> None: camera.open() camera.load() camera.power("on") + + check_exptime_units(camera) + camera.exptime(str(EXPTIME_S)) print(f"power={camera.power()} exptime={camera.exptime()}") check_introspection(camera) + check_raw_needs_enabling(camera) camera.expose("1") @@ -90,7 +143,11 @@ def run(config_path: str, fits_dir: pathlib.Path) -> None: check(bool(written), f"no new FITS file appeared in {fits_dir}") for path in written: print(f"wrote: {path} ({path.stat().st_size} bytes)") - check_file(path, EXPTIME_S) + check_file(path, EXPTIME_S, instrument=expected_instrument != "none") + + check_raw_streams_apart(camera, fits_dir, written) + if shm_dir is not None and segment is not None: + check_shm_streams_apart(shm_dir, segment) camera.close() @@ -101,10 +158,16 @@ def main() -> int: parser.add_argument("--config", required=True, help="camerad .cfg to use") parser.add_argument("--fits-dir", required=True, help="directory the FITS writer is configured to use") + parser.add_argument("--instrument", default="none", + help="instrument the module is expected to report") + parser.add_argument("--shm-dir", help="directory the SHM writer is configured to use") + parser.add_argument("--shm-segment", help="segment name the SHM writer is configured to use") args = parser.parse_args() try: - run(args.config, pathlib.Path(args.fits_dir)) + run(args.config, pathlib.Path(args.fits_dir), args.instrument, + pathlib.Path(args.shm_dir) if args.shm_dir else None, + args.shm_segment) except CheckFailed as failure: print(f"FAIL: {failure}", file=sys.stderr) return 1 diff --git a/python/tests/fits_header_check.py b/python/tests/fits_header_check.py index 5e7a020..fc7b8ea 100644 --- a/python/tests/fits_header_check.py +++ b/python/tests/fits_header_check.py @@ -1,10 +1,13 @@ -"""Assert the HISPEC ATC keywords in a FITS file the writer just produced. +"""Assert the keywords in a FITS file the writer just produced. -Every keyword here is a row of the ATC header definition document. The values are -what the emulator produces from MODE_DEFAULT of hispecatc.acf, so a keyword that -silently stops being populated, or starts reporting a placeholder, fails the run. +The writer itself contributes only a handful; the rest are rows of the HISPEC +ATC header definition document, supplied by that instrument's header dictionary, +with the values the emulator produces from MODE_DEFAULT of hispecatc.acf. A +keyword that silently stops being populated, or starts reporting a placeholder, +fails the run. Pass --no-instrument for a build that has no instrument module, +which writes the core keywords only. -Used by both emulator CI jobs, and standalone: +Used by the emulator CI job, and standalone: python3 python/tests/fits_header_check.py /tmp/ci_fits_test/*.fits """ @@ -20,9 +23,14 @@ CARD_LENGTH: Final = 80 BLOCK_LENGTH: Final = 2880 +# What the writer itself puts in every file, whatever instrument built it +CORE_KEYWORDS: Final = frozenset({"FRAMENO", "DATE", "FILENAME"}) + +# The rest come from an instrument's header dictionary, so a build with no +# instrument writes none of them. # CHANVERT is absent by design: the document marks it N/A for the ATC -REQUIRED_KEYWORDS: Final = frozenset({ - "ACQTIME", "CAMD_VER", "DETBITS", "EXPMJDST", "EXPTIME", "EXPTUNIT", "FILENAME", +INSTRUMENT_KEYWORDS: Final = frozenset({ + "ACQTIME", "CAMD_VER", "DETBITS", "EXPMJDST", "EXPTIME", "EXPTUNIT", "FILETYPE", "FIRMWARE", "FRAMETME", "LVLC_V1", "LVLC_V2", "LVLC_V3", "NCHANLS", "NREADS", "OPSMODE", "PIXTIME", "READMODE", "REFCHPOS", "REFPXAMP", "SKIPLNES", "SKIPROWS", "SUBFRAME", @@ -101,6 +109,16 @@ def as_number(header: dict[str, str], keyword: str) -> float: raise CheckFailed(f"{keyword}={header[keyword]!r} is not a number") from error +def check_core_values(path: pathlib.Path, header: dict[str, str]) -> None: + """Assert the keywords the writer produces for any build.""" + check(header["FILENAME"] == path.name, + f"FILENAME={header['FILENAME']!r}, expected {path.name!r}") + try: + datetime.datetime.fromisoformat(header["DATE"]) + except ValueError as error: + raise CheckFailed(f"DATE={header['DATE']!r} is not ISO 8601") from error + + def check_values(path: pathlib.Path, header: dict[str, str], exptime: float) -> None: """Assert the keywords whose values the emulator determines.""" # FREERUN is camerad's own keyword, not a document row, so it is only @@ -117,8 +135,6 @@ def check_values(path: pathlib.Path, header: dict[str, str], exptime: float) -> check(math.isclose(as_number(header, "EXPTIME"), exptime, rel_tol=1e-9), f"EXPTIME={header['EXPTIME']}, expected {exptime}") - check(header["FILENAME"] == path.name, - f"FILENAME={header['FILENAME']!r}, expected {path.name!r}") check(as_number(header, "EXPMJDST") > MJD_2020, f"EXPMJDST={header['EXPMJDST']} predates 2020") check(header["FIRMWARE"].endswith(".acf"), @@ -136,12 +152,20 @@ def check_values(path: pathlib.Path, header: dict[str, str], exptime: float) -> raise CheckFailed(f"ACQTIME={header['ACQTIME']!r} is not ISO 8601") from error -def check_file(path: pathlib.Path, exptime: float) -> None: - """Assert every required keyword is present in path, with the expected value.""" +def check_file(path: pathlib.Path, exptime: float, instrument: bool = True) -> None: + """Assert every required keyword is present in path, with the expected value. + + With instrument false, only the keywords the writer produces itself are + required, since the rest come from an instrument's header dictionary. + """ header = read_primary_header(path) - missing = sorted(REQUIRED_KEYWORDS - header.keys()) + required = CORE_KEYWORDS | (INSTRUMENT_KEYWORDS if instrument else frozenset()) + missing = sorted(required - header.keys()) check(not missing, f"{path.name}: missing keywords {missing}") - check_values(path, header, exptime) + + check_core_values(path, header) + if instrument: + check_values(path, header, exptime) print(f"headers ok: {path.name} ({len(header)} cards)") @@ -151,11 +175,13 @@ def main() -> int: parser.add_argument("paths", nargs="+", type=pathlib.Path, help="FITS files to check") parser.add_argument("--exptime", type=float, default=0.0, help="exposure time the file was taken with, in sec") + parser.add_argument("--no-instrument", action="store_true", + help="the file came from a build with no instrument module") args = parser.parse_args() try: for path in args.paths: - check_file(path, args.exptime) + check_file(path, args.exptime, instrument=not args.no_instrument) except CheckFailed as failure: print(f"FAIL: {failure}", file=sys.stderr) return 1