Fix/tensor row device lookup #312

Merged
menzel merged 4 commits from fix/tensor-row-device-lookup into main 2026-09-09 12:29:09 +02:00
3 changed files with 236 additions and 26 deletions
@@ -7,9 +7,77 @@ This dictionary is the input for this macro.
from __future__ import annotations
import traceback
import numpy as np
from bec_lib.logger import bec_logger
from bec_lib.scan_repeat import scan_repeat
# NOTE No module-level assignments in a macro: the loader rejects any ast.Assign at module
# level (bec_lib.macro_update_handler.has_executable_code) and refuses the whole file, so
# the logger is fetched inside the functions rather than bound to a module global.
def _row_context(row: dict) -> str:
"""One line of scan context, so a failure report identifies the row on its own."""
parts = [
f"sample '{row.get('sample_name')}'",
f"ID {row.get('sample_id')}",
f"template {row.get('template')}",
f"fast {row.get('fast_axis')} {row.get('fast_start')}..{row.get('fast_stop')}"
f" step {row.get('fast_step')}",
f"stepper {row.get('stepper_axis')} {row.get('stepper_start')}..{row.get('stepper_stop')}"
f" step {row.get('stepper_step')}",
f"exp_time {row.get('exposure_time')}",
]
if row.get("template") == "tensor":
parts.append(
f"rotx {row.get('rotx_axis')}={row.get('rotx')}, "
f"roty {row.get('roty_axis')}={row.get('roty')}"
)
return ", ".join(str(p) for p in parts)
def _report_failure(info_msg: str, exc: Exception, row: dict, phone_numbers) -> None:
"""Report a failure everywhere it might be looked for, without ever masking it.
The original exception is always re-raised by the caller. Notification channels are
best effort and individually guarded: an unreachable SciLog must not replace the
error it was trying to report, which is how a one-line DeviceConfigError went
undiagnosed through three retries and several beamtime hours.
The traceback is printed AND logged. Logging matters because bec_logger fans out to
the log files and to Redis, so the failure is then visible in `bec-log-monitor` and
afterwards in the logs -- not only on whichever console happened to run the macro.
"""
context = _row_context(row)
detail = traceback.format_exc()
print(f"{info_msg}\n {context}\n {type(exc).__name__}: {exc}\n{detail}")
bec_logger.logger.error(f"{info_msg} [{context}]\n{detail}")
try:
msg = bec.messaging.scilog.new()
msg.add_tags("error")
msg.add_text(info_msg)
msg.add_text(context)
msg.add_text(f"Error details: {type(exc).__name__}: {exc}")
msg.add_text(f"```\n{detail}\n```")
msg.send()
except Exception: # pylint: disable=broad-except
# Never let the reporting channel become the reported error.
print(f"Additionally, posting this failure to SciLog failed:\n{traceback.format_exc()}")
bec_logger.logger.warning(f"SciLog post failed while reporting: {info_msg}")
if phone_numbers:
try:
msg = bec.messaging.signal.new()
msg.add_text(f"{info_msg} {context}")
msg.send(scope=phone_numbers)
except Exception: # pylint: disable=broad-except
print(f"Additionally, the SMS notification failed:\n{traceback.format_exc()}")
bec_logger.logger.warning(f"SMS notification failed while reporting: {info_msg}")
# TODO Default True here means that ANY error will be retried up to 3 times. This is dangerous in the sense that
# if the error is due to a misconfiguration of the scan parameters, it will just keep retrying and failing.
@@ -69,11 +137,14 @@ def run_cont_grid_scan_for_table_row(
try:
# Move motors to the starting position of the scan
move_command = []
roty_motor = dev.devices[row["roty_axis"]]
# NOTE `dev` IS the device container (bec_lib client binds
# dev = device_manager.devices), so `dev.devices[...]` asks the container for a
# device named "devices" and raises DeviceConfigError before any motor moves.
roty_motor = dev[row["roty_axis"]]
setpoint_roty = roty_motor.setpoint.get(cached=True)
if not np.isclose(setpoint_roty, row["roty"]):
move_command.extend([row["roty_axis"], row["roty"]])
setpoint_rotx = dev.devices[row["rotx_axis"]].setpoint.get(cached=True)
setpoint_rotx = dev[row["rotx_axis"]].setpoint.get(cached=True)
if not np.isclose(setpoint_rotx, row["rotx"]):
move_command.extend([row["rotx_axis"], row["rotx"]])
# Wait for all motor movements to complete
@@ -84,18 +155,7 @@ def run_cont_grid_scan_for_table_row(
"Error while moving motors to starting position for sample "
f"'{row['sample_name']}' (ID: {row['sample_id']})."
)
print(info_msg)
# Send message to scilog with error tag and text
msg = bec.messaging.scilog.new()
msg.add_tags("error")
msg.add_text(info_msg)
msg.add_text(f"Error details: {e}")
msg.send()
# Notify the user via SMS if phone numbers are provided
if phone_numbers:
msg = bec.messaging.signal.new()
msg.add_text(info_msg)
msg.send(scope=phone_numbers)
_report_failure(info_msg, e, row, phone_numbers)
raise
# Scan command
@@ -118,16 +178,5 @@ def run_cont_grid_scan_for_table_row(
f"Error while executing cont_grid scan for sample '{row['sample_name']}' "
f"with ID '{row['sample_id']}'."
)
print(info_msg)
# Send message to scilog with error tag and text
msg = bec.messaging.scilog.new()
msg.add_tags("error")
msg.add_text(info_msg)
msg.add_text(f"Error details: {e}")
msg.send()
# Notify the user via SMS if phone numbers are provided
if phone_numbers:
msg = bec.messaging.signal.new()
msg.add_text(info_msg)
msg.send(scope=phone_numbers)
_report_failure(info_msg, e, row, phone_numbers)
raise
@@ -0,0 +1,62 @@
"""Static checks on the user macros.
Macros run in the BEC client namespace and are not covered by unit tests, so mistakes in
them surface only at the beamline -- and, because several macros wrap their body in a
try/except that reports to SciLog, often only as a generic message with the traceback
discarded. These checks catch the failure modes that are decidable statically.
"""
import ast
import pathlib
import pytest
from bec_lib.macro_update_handler import has_executable_code
MACRO_DIR = pathlib.Path(__file__).resolve().parents[2] / "csaxs_bec" / "macros"
MACROS = sorted(p for p in MACRO_DIR.glob("*.py") if p.name != "__init__.py")
def test_macro_directory_is_found():
"""Guard against the glob silently matching nothing if the layout changes."""
assert MACROS, f"no macros found under {MACRO_DIR}"
@pytest.mark.parametrize("macro", MACROS, ids=lambda p: p.name)
def test_macros_do_not_go_through_dev_devices(macro: pathlib.Path):
"""`dev` is already the device container, so `dev.devices` cannot resolve.
bec_lib binds `dev = device_manager.devices` in the client namespace, and
DeviceContainer.__getattr__ raises DeviceConfigError for an unknown attribute. So
`dev.devices[name]` asks for a device literally called "devices" and fails before
doing anything useful. Use `dev[name]`.
"""
tree = ast.parse(macro.read_text(), filename=str(macro))
offenders = [
node.lineno
for node in ast.walk(tree)
if isinstance(node, ast.Attribute)
and node.attr == "devices"
and isinstance(node.value, ast.Name)
and node.value.id == "dev"
]
assert not offenders, (
f"{macro.name}: line(s) {offenders} use dev.devices -- `dev` is the device "
"container itself, so this raises DeviceConfigError at runtime. Use dev[<name>]."
)
@pytest.mark.parametrize("macro", MACROS, ids=lambda p: p.name)
def test_macros_are_loadable_by_the_macro_loader(macro: pathlib.Path):
"""A module-level statement makes the loader refuse the whole file, silently.
bec_lib.macro_update_handler.has_executable_code rejects any module-level node that
is not an import, a def, a class, an annotated assignment or a docstring -- a plain
`logger = bec_logger.logger` is enough. The macro is then simply not loaded, and the
only trace is a WARNING in the log stream, so a broken macro looks like a macro that
was never there. The real checker is used here rather than a re-implementation.
"""
blocked, lineno = has_executable_code(macro.read_text())
assert not blocked, (
f"{macro.name}: module-level executable code at line {lineno}; the macro loader "
"will refuse the file. Move it inside a function, or use an annotated assignment."
)
@@ -0,0 +1,99 @@
"""Tests for the failure reporting of the cont_grid table-row macro.
The macro runs in the BEC client namespace, where `bec`, `dev` and `scans` are globals
injected by the client. The tests inject a mock `bec` into the module namespace so the
reporting path can be exercised without a session.
"""
from unittest import mock
import pytest
from csaxs_bec.macros import run_cont_grid_scan_for_table_row as macro
ROW = {
"sample_name": "test40ms",
"sample_id": "3c6e18db10b547aeb86804f524a49489",
"template": "tensor",
"fast_axis": "samx",
"fast_start": -1,
"fast_stop": 1,
"fast_step": 0.01,
"stepper_axis": "samy",
"stepper_start": 0,
"stepper_stop": 1,
"stepper_step": 0.1,
"exposure_time": 0.04,
"rotx_axis": "sgchi",
"rotx": 12.0,
"roty_axis": "sgphi",
"roty": 34.0,
}
@pytest.fixture
def mock_bec():
"""Inject a mock `bec` global, as the client would."""
with mock.patch.object(macro, "bec", mock.MagicMock(), create=True) as m:
yield m
def _raise_and_report(mock_row, phone_numbers=None):
"""Call the reporter from inside a real except block, so format_exc() has a traceback."""
try:
raise RuntimeError("the original failure")
except RuntimeError as exc:
macro._report_failure("Something went wrong.", exc, mock_row, phone_numbers)
return exc
def test_row_context_names_the_rotation_axes_for_tensor_rows():
"""A tensor failure report must identify which axes and angles were involved."""
context = macro._row_context(ROW)
assert "sgchi=12.0" in context
assert "sgphi=34.0" in context
assert "test40ms" in context
def test_row_context_omits_rotations_for_scanning_rows():
row = dict(ROW, template="scanning")
assert "sgchi" not in macro._row_context(row)
def test_report_prints_the_traceback(mock_bec, capsys):
"""The detail must reach the console, not only SciLog."""
_raise_and_report(ROW)
out = capsys.readouterr().out
assert "the original failure" in out
assert "RuntimeError" in out
assert "Traceback" in out
def test_scilog_failure_does_not_mask_the_original_error(mock_bec, capsys):
"""A broken SciLog must not become the reported error.
This is the defect that let a one-line DeviceConfigError survive three retries and
several hours: the unguarded post raised, replacing the exception it was reporting.
"""
mock_bec.messaging.scilog.new.side_effect = ConnectionError("scilog is down")
_raise_and_report(ROW) # must not raise
out = capsys.readouterr().out
assert "the original failure" in out # original still reported
assert "SciLog" in out # and the reporting failure is noted
def test_sms_failure_does_not_mask_the_original_error(mock_bec, capsys):
mock_bec.messaging.signal.new.side_effect = ConnectionError("no gateway")
_raise_and_report(ROW, phone_numbers=["+41000000000"]) # must not raise
out = capsys.readouterr().out
assert "the original failure" in out
assert "SMS" in out
def test_no_sms_is_attempted_without_phone_numbers(mock_bec):
_raise_and_report(ROW, phone_numbers=None)
mock_bec.messaging.signal.new.assert_not_called()