From 44a754afa40ca81bb17cc9b03c2434b85e9da54b Mon Sep 17 00:00:00 2001 From: sligara7 Date: Thu, 22 Oct 2026 15:34:26 -0400 Subject: [PATCH 1/5] Tomo dark/flat: never link another proposal's dark/flat RE.md is shared by the beamline and kept last_dark_flat_uid across a proposal switch, so tomo scans in a new proposal were exported with the previous proposal's dark/flat (HEX scans 249-256, pass-312143 -> scan 246, pass-314022). - switch_proposal removes last_dark_flat_uid/scan_id when moving to a different proposal (the GUI's queueserver path removes them from the worker's RE.md too). - The exporter links the start document's dark/flat only if it is from the same proposal, and skips an unresolvable uid with a warning instead of failing the export. An explicit dark_flat_id is still used as given. Assisted-by: copilot:claude-opus-5-5 --- src/hextools/exporters/tomography.py | 59 ++++++++++++++++------ src/hextools/gui/live_re_md_viewer.py | 5 ++ src/hextools/tomography/utils.py | 9 ++-- src/hextools/utils/__init__.py | 14 +++++- tests/test_dark_flat_proposal.py | 71 +++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 20 deletions(-) create mode 100644 tests/test_dark_flat_proposal.py diff --git a/src/hextools/exporters/tomography.py b/src/hextools/exporters/tomography.py index 3238adb..e26e612 100644 --- a/src/hextools/exporters/tomography.py +++ b/src/hextools/exporters/tomography.py @@ -1,7 +1,9 @@ """Exporters for tomography data.""" import logging +from collections.abc import Mapping from pathlib import Path +from typing import Any import h5py from bluesky_tiled_plugins import CatalogOfBlueskyRuns @@ -19,6 +21,46 @@ logger = logging.getLogger("hextools.exporters.tomography") +def resolve_dark_flat_run( + tiled_client: Mapping[Any, Any], + run_start: Mapping[str, Any], + dark_flat_id: str | int | None = None, +) -> BlueskyRunV3 | None: + """Return the dark/flat run to link into a tomography export, or None. + + An explicit ``dark_flat_id`` is used as given. Otherwise the run's + ``last_dark_flat_uid`` is used only if that dark/flat was taken in the same + proposal: RE.md keeps the uid across a proposal switch. + """ + if dark_flat_id is not None: + dark_flat_run = tiled_client[dark_flat_id] + if not isinstance(dark_flat_run, BlueskyRunV3): + raise ValueError( + f"Failed to resolve dark/flat run {dark_flat_id} to a BlueskyRunV3" + ) + return dark_flat_run + + uid = run_start.get("last_dark_flat_uid") + if uid is None: + logger.warning("No last_dark_flat_uid in the run start document") + return None + try: + dark_flat_run = tiled_client[uid] + except KeyError: + dark_flat_run = None + if not isinstance(dark_flat_run, BlueskyRunV3): + logger.warning(f"Dark/flat run {uid} not found; omitting dark/flat entries") + return None + dark_flat_session = dark_flat_run.start.get("data_session") + if dark_flat_session != run_start.get("data_session"): + logger.warning( + f"Dark/flat run {uid} is from {dark_flat_session}, not " + f"{run_start.get('data_session')}; omitting dark/flat entries" + ) + return None + return dark_flat_run + + def export_tomo_flyscan_to_h5( tomo_flyscan: BlueskyRunV3 | str | int, tiled_client: CatalogOfBlueskyRuns | None = None, @@ -44,21 +86,10 @@ def export_tomo_flyscan_to_h5( f" for proposal {proposal}, cycle {cycle}" ) - dark_flat_uid = dark_flat_id or run_start.get("last_dark_flat_uid") - - if dark_flat_uid is None: - logger.warning( - "No last_dark_flat_uid found in the run start document. " - "Omitting dark/flat entries" - ) - dark_flat_run = None - else: + dark_flat_run = resolve_dark_flat_run(tiled_client, run_start, dark_flat_id) + if dark_flat_run is not None: + dark_flat_uid = dark_flat_run.start.get("uid") logger.info(f"Using dark/flat run {dark_flat_uid}") - dark_flat_run = tiled_client[dark_flat_uid] - if not isinstance(dark_flat_run, BlueskyRunV3): - raise ValueError( - f"Failed to resolve dark/flat run {dark_flat_uid} to a BlueskyRunV3" - ) scan_id_dir_path = Path( f"/nsls2/data/hex/proposals/{cycle}/{proposal}/tomography/metadata/scan_{scan_id:05}" diff --git a/src/hextools/gui/live_re_md_viewer.py b/src/hextools/gui/live_re_md_viewer.py index 881544b..a72411f 100644 --- a/src/hextools/gui/live_re_md_viewer.py +++ b/src/hextools/gui/live_re_md_viewer.py @@ -39,6 +39,7 @@ ) from hextools.utils import ( + DARK_FLAT_MD_KEYS, get_proposal_info, is_running_in_ci, list_proposals, @@ -214,7 +215,11 @@ def _switch(self, proposal: int, username: str, password: str): response.get("msg") or "Couldn't read the RunEngine metadata." ) md = dict(response.get("re_metadata") or {}) + had = set(md) updated = switch_proposal(proposal, username, password, md=md) + if stale := [k for k in DARK_FLAT_MD_KEYS if k in had and k not in updated]: + # md is a copy; the worker's RE.md must lose them too. + client.script_upload(f"for _k in {stale!r}: RE.md.pop(_k, None)") if is_running_in_ci(): # Outside mock mode RE.md is Redis-backed, so the switch already reached # the worker. diff --git a/src/hextools/tomography/utils.py b/src/hextools/tomography/utils.py index 23563a5..cf31a7d 100644 --- a/src/hextools/tomography/utils.py +++ b/src/hextools/tomography/utils.py @@ -3,6 +3,8 @@ from collections.abc import MutableMapping from typing import Any +from hextools.utils import DARK_FLAT_MD_KEYS + class LastDarkFlatUIDTracker: """RunEngine subscription that tracks the last dark-flat scan UID and scan ID.""" @@ -16,7 +18,6 @@ def __call__(self, name: str, doc: dict[str, Any]): and doc.get("uid", None) and doc.get("plan_name", None) == "tomo_dark_flat" ): - last_uid = doc.get("uid", None) - last_scan_id = doc.get("scan_id", None) - self._metadata_dict["last_dark_flat_uid"] = last_uid - self._metadata_dict["last_dark_flat_scan_id"] = last_scan_id + uid_key, scan_id_key = DARK_FLAT_MD_KEYS + self._metadata_dict[uid_key] = doc.get("uid", None) + self._metadata_dict[scan_id_key] = doc.get("scan_id", None) diff --git a/src/hextools/utils/__init__.py b/src/hextools/utils/__init__.py index f64e588..ea6e7c7 100644 --- a/src/hextools/utils/__init__.py +++ b/src/hextools/utils/__init__.py @@ -408,6 +408,10 @@ def _run_sync_experiment( return output +# Set by LastDarkFlatUIDTracker; they belong to the proposal the dark/flat was taken in. +DARK_FLAT_MD_KEYS = ("last_dark_flat_uid", "last_dark_flat_scan_id") + + def switch_proposal( proposal_number: int, username: str, @@ -419,7 +423,8 @@ def switch_proposal( Runs ``nslsii``'s ``sync-experiment`` command, answering its prompts with the given credentials. In mock mode any credentials are accepted and only ``md`` - (e.g. ``RE.md``) is updated. + (e.g. ``RE.md``) is updated. Moving to a different proposal removes the + previous proposal's dark/flat (``DARK_FLAT_MD_KEYS``) from ``md``. Returns ------- @@ -427,10 +432,15 @@ def switch_proposal( The updated metadata: ``md`` if given (Redis-backed outside mock mode), else an empty dict. """ + new_session = f"pass-{proposal_number}" + if md is not None and md.get("data_session") != new_session: + for key in DARK_FLAT_MD_KEYS: + md.pop(key, None) + if is_running_in_ci(): if md is None: raise ValueError("In mock mode, the metadata to update must be given.") - md["data_session"] = f"pass-{proposal_number}" + md["data_session"] = new_session md["username"] = username md["start_datetime"] = datetime.now().isoformat() info = get_proposal_info(proposal_number) diff --git a/tests/test_dark_flat_proposal.py b/tests/test_dark_flat_proposal.py new file mode 100644 index 0000000..370177f --- /dev/null +++ b/tests/test_dark_flat_proposal.py @@ -0,0 +1,71 @@ +"""A tomography run's dark/flat must come from its own proposal. + +RE.md is shared by the whole beamline and keeps ``last_dark_flat_uid`` across a +proposal switch. At HEX on 2026-10-22, tomo scans 249-256 in pass-312143 were +exported with the dark/flat of scan 246 from pass-314022. +""" + +from unittest.mock import Mock + +import pytest +from bluesky_tiled_plugins.clients.bluesky_run import BlueskyRunV3 + +from hextools.exporters.tomography import resolve_dark_flat_run +from hextools.utils import DARK_FLAT_MD_KEYS, switch_proposal + + +def _run(data_session: str) -> Mock: + run = Mock(spec=BlueskyRunV3) + run.start = {"uid": f"uid-{data_session}", "data_session": data_session} + return run + + +@pytest.fixture +def mock_mode(monkeypatch): + monkeypatch.setenv("HEXTOOLS_RUNNING_IN_CI", "YES") + + +def test_switching_proposal_forgets_the_previous_proposals_dark_flat(mock_mode): + md = { + "data_session": "pass-314022", + "last_dark_flat_uid": "uid-246", + "last_dark_flat_scan_id": 246, + } + switch_proposal(312143, "user", "pw", md=md) + assert md["data_session"] == "pass-312143" + assert not set(DARK_FLAT_MD_KEYS) & md.keys() + + +def test_resyncing_the_same_proposal_keeps_its_dark_flat(mock_mode): + md = {"data_session": "pass-314022", "last_dark_flat_uid": "uid-246"} + switch_proposal(314022, "user", "pw", md=md) + assert md["last_dark_flat_uid"] == "uid-246" + + +def test_dark_flat_from_another_proposal_is_not_used(): + client = {"uid-246": _run("pass-314022")} + start = {"data_session": "pass-312143", "last_dark_flat_uid": "uid-246"} + assert resolve_dark_flat_run(client, start) is None + + +def test_dark_flat_from_the_same_proposal_is_used(): + dark_flat = _run("pass-312143") + client = {"uid-260": dark_flat} + start = {"data_session": "pass-312143", "last_dark_flat_uid": "uid-260"} + assert resolve_dark_flat_run(client, start) is dark_flat + + +def test_unresolvable_dark_flat_is_skipped_not_raised(): + start = {"data_session": "pass-312143", "last_dark_flat_uid": "gone"} + assert resolve_dark_flat_run({}, start) is None + + +def test_no_dark_flat_recorded(): + assert resolve_dark_flat_run({}, {"data_session": "pass-312143"}) is None + + +def test_explicit_dark_flat_id_is_honoured_across_proposals(): + dark_flat = _run("pass-314022") + client = {"uid-246": dark_flat} + start = {"data_session": "pass-312143"} + assert resolve_dark_flat_run(client, start, dark_flat_id="uid-246") is dark_flat From b2b9a6cc744b88faf53d58bf627925ce91f8d774 Mon Sep 17 00:00:00 2001 From: sligara7 Date: Fri, 23 Oct 2026 12:13:24 -0400 Subject: [PATCH 2/5] Dark/flat: match cycle too; clear keys only after a successful switch; drop the GUI worker script Review (jwlodek, Copilot): a same-proposal dark/flat from an earlier cycle is stale; a failed login must keep the current proposal's dark/flat. The exporter check covers every way of switching (GUI, CLI sync-experiment, queueserver worker), so the GUI no longer edits the worker's RE.md. Assisted-by: copilot:claude-opus-5-5 --- src/hextools/exporters/tomography.py | 14 ++++++--- src/hextools/gui/live_re_md_viewer.py | 5 --- src/hextools/utils/__init__.py | 12 +++++--- tests/test_dark_flat_proposal.py | 44 ++++++++++++++++++++++++--- 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/hextools/exporters/tomography.py b/src/hextools/exporters/tomography.py index e26e612..7a11336 100644 --- a/src/hextools/exporters/tomography.py +++ b/src/hextools/exporters/tomography.py @@ -30,7 +30,7 @@ def resolve_dark_flat_run( An explicit ``dark_flat_id`` is used as given. Otherwise the run's ``last_dark_flat_uid`` is used only if that dark/flat was taken in the same - proposal: RE.md keeps the uid across a proposal switch. + proposal and cycle: RE.md keeps the uid across a proposal switch. """ if dark_flat_id is not None: dark_flat_run = tiled_client[dark_flat_id] @@ -51,11 +51,15 @@ def resolve_dark_flat_run( if not isinstance(dark_flat_run, BlueskyRunV3): logger.warning(f"Dark/flat run {uid} not found; omitting dark/flat entries") return None - dark_flat_session = dark_flat_run.start.get("data_session") - if dark_flat_session != run_start.get("data_session"): + # A dark/flat from the same proposal in an earlier cycle is stale too. + dark_flat_from = tuple( + dark_flat_run.start.get(k) for k in ("data_session", "cycle") + ) + scan_from = tuple(run_start.get(k) for k in ("data_session", "cycle")) + if dark_flat_from != scan_from: logger.warning( - f"Dark/flat run {uid} is from {dark_flat_session}, not " - f"{run_start.get('data_session')}; omitting dark/flat entries" + f"Dark/flat run {uid} is from {dark_flat_from}, not {scan_from}; " + "omitting dark/flat entries" ) return None return dark_flat_run diff --git a/src/hextools/gui/live_re_md_viewer.py b/src/hextools/gui/live_re_md_viewer.py index a72411f..881544b 100644 --- a/src/hextools/gui/live_re_md_viewer.py +++ b/src/hextools/gui/live_re_md_viewer.py @@ -39,7 +39,6 @@ ) from hextools.utils import ( - DARK_FLAT_MD_KEYS, get_proposal_info, is_running_in_ci, list_proposals, @@ -215,11 +214,7 @@ def _switch(self, proposal: int, username: str, password: str): response.get("msg") or "Couldn't read the RunEngine metadata." ) md = dict(response.get("re_metadata") or {}) - had = set(md) updated = switch_proposal(proposal, username, password, md=md) - if stale := [k for k in DARK_FLAT_MD_KEYS if k in had and k not in updated]: - # md is a copy; the worker's RE.md must lose them too. - client.script_upload(f"for _k in {stale!r}: RE.md.pop(_k, None)") if is_running_in_ci(): # Outside mock mode RE.md is Redis-backed, so the switch already reached # the worker. diff --git a/src/hextools/utils/__init__.py b/src/hextools/utils/__init__.py index ea6e7c7..88f709e 100644 --- a/src/hextools/utils/__init__.py +++ b/src/hextools/utils/__init__.py @@ -433,9 +433,7 @@ def switch_proposal( an empty dict. """ new_session = f"pass-{proposal_number}" - if md is not None and md.get("data_session") != new_session: - for key in DARK_FLAT_MD_KEYS: - md.pop(key, None) + changing = md is not None and md.get("data_session") != new_session if is_running_in_ci(): if md is None: @@ -450,9 +448,13 @@ def switch_proposal( "type": info["type"], "pi_name": info["pi_name"], } - return md + else: + _run_sync_experiment(proposal_number, username, password, beamline) - _run_sync_experiment(proposal_number, username, password, beamline) + # Only once the switch succeeded: a failed login keeps the current proposal. + if md is not None and changing: + for key in DARK_FLAT_MD_KEYS: + md.pop(key, None) return md if md is not None else {} diff --git a/tests/test_dark_flat_proposal.py b/tests/test_dark_flat_proposal.py index 370177f..ac031dc 100644 --- a/tests/test_dark_flat_proposal.py +++ b/tests/test_dark_flat_proposal.py @@ -10,13 +10,18 @@ import pytest from bluesky_tiled_plugins.clients.bluesky_run import BlueskyRunV3 +from hextools import utils from hextools.exporters.tomography import resolve_dark_flat_run from hextools.utils import DARK_FLAT_MD_KEYS, switch_proposal -def _run(data_session: str) -> Mock: +def _run(data_session: str, cycle: str = "2026-3") -> Mock: run = Mock(spec=BlueskyRunV3) - run.start = {"uid": f"uid-{data_session}", "data_session": data_session} + run.start = { + "uid": f"uid-{data_session}", + "data_session": data_session, + "cycle": cycle, + } return run @@ -42,16 +47,47 @@ def test_resyncing_the_same_proposal_keeps_its_dark_flat(mock_mode): assert md["last_dark_flat_uid"] == "uid-246" +def test_failed_switch_keeps_the_current_proposals_dark_flat(monkeypatch): + monkeypatch.delenv("HEXTOOLS_RUNNING_IN_CI", raising=False) + + def _login_fails(*_args, **_kwargs): + raise RuntimeError("authentication failed") + + monkeypatch.setattr(utils, "_run_sync_experiment", _login_fails) + md = {"data_session": "pass-314022", "last_dark_flat_uid": "uid-246"} + with pytest.raises(RuntimeError, match="authentication"): + switch_proposal(312143, "user", "pw", md=md) + assert md == {"data_session": "pass-314022", "last_dark_flat_uid": "uid-246"} + + def test_dark_flat_from_another_proposal_is_not_used(): client = {"uid-246": _run("pass-314022")} - start = {"data_session": "pass-312143", "last_dark_flat_uid": "uid-246"} + start = { + "data_session": "pass-312143", + "cycle": "2026-3", + "last_dark_flat_uid": "uid-246", + } + assert resolve_dark_flat_run(client, start) is None + + +def test_dark_flat_from_an_earlier_cycle_is_not_used(): + client = {"uid-246": _run("pass-312143", cycle="2026-2")} + start = { + "data_session": "pass-312143", + "cycle": "2026-3", + "last_dark_flat_uid": "uid-246", + } assert resolve_dark_flat_run(client, start) is None def test_dark_flat_from_the_same_proposal_is_used(): dark_flat = _run("pass-312143") client = {"uid-260": dark_flat} - start = {"data_session": "pass-312143", "last_dark_flat_uid": "uid-260"} + start = { + "data_session": "pass-312143", + "cycle": "2026-3", + "last_dark_flat_uid": "uid-260", + } assert resolve_dark_flat_run(client, start) is dark_flat From 5d7fa637508b5272eecce4c6f55acdba6068d793 Mon Sep 17 00:00:00 2001 From: sligara7 Date: Fri, 23 Oct 2026 12:30:21 -0400 Subject: [PATCH 3/5] Dark/flat: clear via a plan stub called from reset_scan_id, not on proposal switch; warn on an unresolvable explicit id Review (jwlodek): switch_proposal no longer clears RE.md; clear_last_dark_flat() does, and reset_scan_id calls it, so it runs in the RE that owns RE.md (worker or local). The exporter warns and links no dark/flat when an explicit dark_flat_id does not resolve, instead of raising; resolve_dark_flat_run logs the dark/flat it uses. Assisted-by: copilot:claude-opus-5-5 --- src/hextools/exporters/tomography.py | 43 ++++++++++------------ src/hextools/utils/__init__.py | 43 ++++++++++++---------- tests/test_dark_flat_proposal.py | 53 ++++++++++------------------ 3 files changed, 62 insertions(+), 77 deletions(-) diff --git a/src/hextools/exporters/tomography.py b/src/hextools/exporters/tomography.py index 7a11336..fbc0fd6 100644 --- a/src/hextools/exporters/tomography.py +++ b/src/hextools/exporters/tomography.py @@ -32,15 +32,11 @@ def resolve_dark_flat_run( ``last_dark_flat_uid`` is used only if that dark/flat was taken in the same proposal and cycle: RE.md keeps the uid across a proposal switch. """ - if dark_flat_id is not None: - dark_flat_run = tiled_client[dark_flat_id] - if not isinstance(dark_flat_run, BlueskyRunV3): - raise ValueError( - f"Failed to resolve dark/flat run {dark_flat_id} to a BlueskyRunV3" - ) - return dark_flat_run - - uid = run_start.get("last_dark_flat_uid") + uid = ( + dark_flat_id + if dark_flat_id is not None + else run_start.get("last_dark_flat_uid") + ) if uid is None: logger.warning("No last_dark_flat_uid in the run start document") return None @@ -51,17 +47,19 @@ def resolve_dark_flat_run( if not isinstance(dark_flat_run, BlueskyRunV3): logger.warning(f"Dark/flat run {uid} not found; omitting dark/flat entries") return None - # A dark/flat from the same proposal in an earlier cycle is stale too. - dark_flat_from = tuple( - dark_flat_run.start.get(k) for k in ("data_session", "cycle") - ) - scan_from = tuple(run_start.get(k) for k in ("data_session", "cycle")) - if dark_flat_from != scan_from: - logger.warning( - f"Dark/flat run {uid} is from {dark_flat_from}, not {scan_from}; " - "omitting dark/flat entries" + if dark_flat_id is None: + # A dark/flat from the same proposal in an earlier cycle is stale too. + dark_flat_from = tuple( + dark_flat_run.start.get(k) for k in ("data_session", "cycle") ) - return None + scan_from = tuple(run_start.get(k) for k in ("data_session", "cycle")) + if dark_flat_from != scan_from: + logger.warning( + f"Dark/flat run {uid} is from {dark_flat_from}, not {scan_from}; " + "omitting dark/flat entries" + ) + return None + logger.info(f"Using dark/flat run {uid}") return dark_flat_run @@ -91,9 +89,6 @@ def export_tomo_flyscan_to_h5( ) dark_flat_run = resolve_dark_flat_run(tiled_client, run_start, dark_flat_id) - if dark_flat_run is not None: - dark_flat_uid = dark_flat_run.start.get("uid") - logger.info(f"Using dark/flat run {dark_flat_uid}") scan_id_dir_path = Path( f"/nsls2/data/hex/proposals/{cycle}/{proposal}/tomography/metadata/scan_{scan_id:05}" @@ -136,8 +131,8 @@ def export_tomo_flyscan_to_h5( missing = set(det_names) - found.keys() if missing: logger.warning( - f"Dark/flat run {dark_flat_uid} has no {stream_name} data for " - f"{sorted(missing)}" + f"Dark/flat run {dark_flat_run.start.get('uid')} has no " + f"{stream_name} data for {sorted(missing)}" ) for name in set(det_names) & found.keys(): relpath, dataset, _ = found[name] diff --git a/src/hextools/utils/__init__.py b/src/hextools/utils/__init__.py index 88f709e..07c6c57 100644 --- a/src/hextools/utils/__init__.py +++ b/src/hextools/utils/__init__.py @@ -408,10 +408,6 @@ def _run_sync_experiment( return output -# Set by LastDarkFlatUIDTracker; they belong to the proposal the dark/flat was taken in. -DARK_FLAT_MD_KEYS = ("last_dark_flat_uid", "last_dark_flat_scan_id") - - def switch_proposal( proposal_number: int, username: str, @@ -423,8 +419,7 @@ def switch_proposal( Runs ``nslsii``'s ``sync-experiment`` command, answering its prompts with the given credentials. In mock mode any credentials are accepted and only ``md`` - (e.g. ``RE.md``) is updated. Moving to a different proposal removes the - previous proposal's dark/flat (``DARK_FLAT_MD_KEYS``) from ``md``. + (e.g. ``RE.md``) is updated. Returns ------- @@ -432,13 +427,10 @@ def switch_proposal( The updated metadata: ``md`` if given (Redis-backed outside mock mode), else an empty dict. """ - new_session = f"pass-{proposal_number}" - changing = md is not None and md.get("data_session") != new_session - if is_running_in_ci(): if md is None: raise ValueError("In mock mode, the metadata to update must be given.") - md["data_session"] = new_session + md["data_session"] = f"pass-{proposal_number}" md["username"] = username md["start_datetime"] = datetime.now().isoformat() info = get_proposal_info(proposal_number) @@ -448,13 +440,9 @@ def switch_proposal( "type": info["type"], "pi_name": info["pi_name"], } - else: - _run_sync_experiment(proposal_number, username, password, beamline) + return md - # Only once the switch succeeded: a failed login keeps the current proposal. - if md is not None and changing: - for key in DARK_FLAT_MD_KEYS: - md.pop(key, None) + _run_sync_experiment(proposal_number, username, password, beamline) return md if md is not None else {} @@ -600,8 +588,26 @@ def print_device_tree(device: Device, indent: int = 0) -> None: print("\n".join(x)) +# Set by LastDarkFlatUIDTracker. +DARK_FLAT_MD_KEYS = ("last_dark_flat_uid", "last_dark_flat_scan_id") + + +def clear_last_dark_flat(RE: RunEngine | None = None): + """Forget the last dark/flat, so later scans link none until a new one is taken. + + Parameters + ---------- + RE : RunEngine | None, optional + The RunEngine to update. If None, it is taken from the IPython namespace. + """ + RE = ensure_available(RunEngine, RE=RE) + for key in DARK_FLAT_MD_KEYS: + RE.md.pop(key, None) + yield from bps.null() + + def reset_scan_id(new_scan_id: int = 0, RE: RunEngine | None = None): - """Reset the RunEngine's scan ID. + """Reset the RunEngine's scan ID, and forget the last dark/flat. Parameters ---------- @@ -612,4 +618,5 @@ def reset_scan_id(new_scan_id: int = 0, RE: RunEngine | None = None): """ RE = ensure_available(RunEngine, RE=RE) RE.md["scan_id"] = new_scan_id - yield from bps.null() + # Earlier dark/flats belong to the scans being numbered over. + yield from clear_last_dark_flat(RE=RE) diff --git a/tests/test_dark_flat_proposal.py b/tests/test_dark_flat_proposal.py index ac031dc..70dcdd7 100644 --- a/tests/test_dark_flat_proposal.py +++ b/tests/test_dark_flat_proposal.py @@ -1,4 +1,4 @@ -"""A tomography run's dark/flat must come from its own proposal. +"""A tomography run's dark/flat must come from its own proposal and cycle. RE.md is shared by the whole beamline and keeps ``last_dark_flat_uid`` across a proposal switch. At HEX on 2026-10-22, tomo scans 249-256 in pass-312143 were @@ -7,12 +7,11 @@ from unittest.mock import Mock -import pytest +from bluesky import RunEngine from bluesky_tiled_plugins.clients.bluesky_run import BlueskyRunV3 -from hextools import utils from hextools.exporters.tomography import resolve_dark_flat_run -from hextools.utils import DARK_FLAT_MD_KEYS, switch_proposal +from hextools.utils import DARK_FLAT_MD_KEYS, clear_last_dark_flat, reset_scan_id def _run(data_session: str, cycle: str = "2026-3") -> Mock: @@ -25,39 +24,18 @@ def _run(data_session: str, cycle: str = "2026-3") -> Mock: return run -@pytest.fixture -def mock_mode(monkeypatch): - monkeypatch.setenv("HEXTOOLS_RUNNING_IN_CI", "YES") +def test_clear_last_dark_flat(RE: RunEngine): + RE.md.update({"last_dark_flat_uid": "uid-246", "last_dark_flat_scan_id": 246}) + RE(clear_last_dark_flat(RE=RE)) + assert not set(DARK_FLAT_MD_KEYS) & RE.md.keys() + RE(clear_last_dark_flat(RE=RE)) # nothing left to clear is fine -def test_switching_proposal_forgets_the_previous_proposals_dark_flat(mock_mode): - md = { - "data_session": "pass-314022", - "last_dark_flat_uid": "uid-246", - "last_dark_flat_scan_id": 246, - } - switch_proposal(312143, "user", "pw", md=md) - assert md["data_session"] == "pass-312143" - assert not set(DARK_FLAT_MD_KEYS) & md.keys() - - -def test_resyncing_the_same_proposal_keeps_its_dark_flat(mock_mode): - md = {"data_session": "pass-314022", "last_dark_flat_uid": "uid-246"} - switch_proposal(314022, "user", "pw", md=md) - assert md["last_dark_flat_uid"] == "uid-246" - - -def test_failed_switch_keeps_the_current_proposals_dark_flat(monkeypatch): - monkeypatch.delenv("HEXTOOLS_RUNNING_IN_CI", raising=False) - - def _login_fails(*_args, **_kwargs): - raise RuntimeError("authentication failed") - - monkeypatch.setattr(utils, "_run_sync_experiment", _login_fails) - md = {"data_session": "pass-314022", "last_dark_flat_uid": "uid-246"} - with pytest.raises(RuntimeError, match="authentication"): - switch_proposal(312143, "user", "pw", md=md) - assert md == {"data_session": "pass-314022", "last_dark_flat_uid": "uid-246"} +def test_reset_scan_id_forgets_the_last_dark_flat(RE: RunEngine): + RE.md.update({"scan_id": 256, "last_dark_flat_uid": "uid-246"}) + RE(reset_scan_id(RE=RE)) + assert RE.md["scan_id"] == 0 + assert not set(DARK_FLAT_MD_KEYS) & RE.md.keys() def test_dark_flat_from_another_proposal_is_not_used(): @@ -105,3 +83,8 @@ def test_explicit_dark_flat_id_is_honoured_across_proposals(): client = {"uid-246": dark_flat} start = {"data_session": "pass-312143"} assert resolve_dark_flat_run(client, start, dark_flat_id="uid-246") is dark_flat + + +def test_unresolvable_explicit_dark_flat_id_is_skipped_not_raised(): + start = {"data_session": "pass-312143", "cycle": "2026-3"} + assert resolve_dark_flat_run({}, start, dark_flat_id="gone") is None From 7c38d8d0c2bcdfd1f11ebd42d3c065d20145200a Mon Sep 17 00:00:00 2001 From: Anthony Sligar <108553289+sligara7@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:43:26 -0400 Subject: [PATCH 4/5] Update src/hextools/exporters/tomography.py Co-authored-by: Jakub Wlodek --- src/hextools/exporters/tomography.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hextools/exporters/tomography.py b/src/hextools/exporters/tomography.py index fbc0fd6..28ae3d4 100644 --- a/src/hextools/exporters/tomography.py +++ b/src/hextools/exporters/tomography.py @@ -47,7 +47,7 @@ def resolve_dark_flat_run( if not isinstance(dark_flat_run, BlueskyRunV3): logger.warning(f"Dark/flat run {uid} not found; omitting dark/flat entries") return None - if dark_flat_id is None: + if dark_flat_run is not None: # A dark/flat from the same proposal in an earlier cycle is stale too. dark_flat_from = tuple( dark_flat_run.start.get(k) for k in ("data_session", "cycle") From 17dd40f52a760119805d2ab57c363ffc0328ffd8 Mon Sep 17 00:00:00 2001 From: sligara7 Date: Fri, 23 Oct 2026 12:48:21 -0400 Subject: [PATCH 5/5] Dark/flat: proposal + cycle check applies to an explicit dark_flat_id too Follows the accepted suggestion: drop the now always-true guard, update the docstring, and replace the 'explicit id honoured across proposals' test with one that refuses it and one that an explicit id overrides the start document. Assisted-by: copilot:claude-opus-5-5 --- src/hextools/exporters/tomography.py | 26 ++++++++++++-------------- tests/test_dark_flat_proposal.py | 20 +++++++++++++++----- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/hextools/exporters/tomography.py b/src/hextools/exporters/tomography.py index 28ae3d4..bd76998 100644 --- a/src/hextools/exporters/tomography.py +++ b/src/hextools/exporters/tomography.py @@ -28,9 +28,8 @@ def resolve_dark_flat_run( ) -> BlueskyRunV3 | None: """Return the dark/flat run to link into a tomography export, or None. - An explicit ``dark_flat_id`` is used as given. Otherwise the run's - ``last_dark_flat_uid`` is used only if that dark/flat was taken in the same - proposal and cycle: RE.md keeps the uid across a proposal switch. + Uses ``dark_flat_id`` if given, else the run's ``last_dark_flat_uid``, and + only if that dark/flat was taken in the same proposal and cycle as the run. """ uid = ( dark_flat_id @@ -47,18 +46,17 @@ def resolve_dark_flat_run( if not isinstance(dark_flat_run, BlueskyRunV3): logger.warning(f"Dark/flat run {uid} not found; omitting dark/flat entries") return None - if dark_flat_run is not None: - # A dark/flat from the same proposal in an earlier cycle is stale too. - dark_flat_from = tuple( - dark_flat_run.start.get(k) for k in ("data_session", "cycle") + # A dark/flat from the same proposal in an earlier cycle is stale too. + dark_flat_from = tuple( + dark_flat_run.start.get(k) for k in ("data_session", "cycle") + ) + scan_from = tuple(run_start.get(k) for k in ("data_session", "cycle")) + if dark_flat_from != scan_from: + logger.warning( + f"Dark/flat run {uid} is from {dark_flat_from}, not {scan_from}; " + "omitting dark/flat entries" ) - scan_from = tuple(run_start.get(k) for k in ("data_session", "cycle")) - if dark_flat_from != scan_from: - logger.warning( - f"Dark/flat run {uid} is from {dark_flat_from}, not {scan_from}; " - "omitting dark/flat entries" - ) - return None + return None logger.info(f"Using dark/flat run {uid}") return dark_flat_run diff --git a/tests/test_dark_flat_proposal.py b/tests/test_dark_flat_proposal.py index 70dcdd7..68a0003 100644 --- a/tests/test_dark_flat_proposal.py +++ b/tests/test_dark_flat_proposal.py @@ -78,11 +78,21 @@ def test_no_dark_flat_recorded(): assert resolve_dark_flat_run({}, {"data_session": "pass-312143"}) is None -def test_explicit_dark_flat_id_is_honoured_across_proposals(): - dark_flat = _run("pass-314022") - client = {"uid-246": dark_flat} - start = {"data_session": "pass-312143"} - assert resolve_dark_flat_run(client, start, dark_flat_id="uid-246") is dark_flat +def test_explicit_dark_flat_id_from_another_proposal_is_not_used(): + client = {"uid-246": _run("pass-314022")} + start = {"data_session": "pass-312143", "cycle": "2026-3"} + assert resolve_dark_flat_run(client, start, dark_flat_id="uid-246") is None + + +def test_explicit_dark_flat_id_overrides_the_start_document(): + dark_flat = _run("pass-312143") + client = {"uid-260": dark_flat, "uid-246": _run("pass-314022")} + start = { + "data_session": "pass-312143", + "cycle": "2026-3", + "last_dark_flat_uid": "uid-246", + } + assert resolve_dark_flat_run(client, start, dark_flat_id="uid-260") is dark_flat def test_unresolvable_explicit_dark_flat_id_is_skipped_not_raised():