1101 lines
39 KiB
Python
1101 lines
39 KiB
Python
"""The `status_server=` switch on eco.acquisition.daq_client.Daq.
|
|
|
|
Daq.__init__ builds PVs and an Assembly, so these tests drive the three
|
|
status-related callbacks on a bare Daq instance with only the attributes
|
|
they actually touch set - the point is the branch logic and the fallback,
|
|
not the rest of the class.
|
|
"""
|
|
|
|
import time
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from eco.acquisition.daq_client import Daq
|
|
|
|
|
|
class FakeScan:
|
|
def __init__(self, runno=42):
|
|
self.daq_run_number = types.SimpleNamespace(
|
|
get_current_value=lambda: runno
|
|
)
|
|
self._scratch = {}
|
|
self.scan_parameters = {}
|
|
self._values_done = [1]
|
|
|
|
def counter_scratch(self, name):
|
|
return self._scratch.setdefault(name, {})
|
|
|
|
def values_done(self):
|
|
return self._values_done
|
|
|
|
def set_scan_parameter(self, key, value):
|
|
self.scan_parameters[key] = value
|
|
|
|
|
|
class FakeStatusClient:
|
|
base_url = "http://fake:8091"
|
|
|
|
def __init__(self, snapshot=None, health=None, fail_with=None):
|
|
self._snapshot = snapshot or {
|
|
"status": {"bernina.a": 1},
|
|
"status_channels": {"bernina.a": "PV:A"},
|
|
"status_times": {"bernina.a": 0.0},
|
|
"selections": {},
|
|
"saved_to": "/data/p1/run0042/aux/status.json",
|
|
"generation": 3,
|
|
"snapshot_seconds": 0.12,
|
|
}
|
|
self._health = health or {
|
|
"ready": True, "n_initialized": 80, "n_target_names": 82, "n_failed": 2
|
|
}
|
|
self._fail_with = fail_with
|
|
self.calls = []
|
|
|
|
def snapshot(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
if self._fail_with:
|
|
raise self._fail_with
|
|
return dict(self._snapshot)
|
|
|
|
def wait_ready(self, timeout=None, progress=False):
|
|
if self._fail_with:
|
|
raise self._fail_with
|
|
return self._health
|
|
|
|
def health(self):
|
|
if self._fail_with:
|
|
raise self._fail_with
|
|
return {"ready": True, "state": "ready",
|
|
"last_init_finished": time.time(), **self._health}
|
|
|
|
|
|
def _daq(status_server=None, namespace=None, strict=False):
|
|
daq = Daq.__new__(Daq)
|
|
daq.name = "daq"
|
|
daq.instrument = None
|
|
daq._pgroup = "p12345"
|
|
daq.namespace = namespace
|
|
daq._status_server = status_server
|
|
daq._status_server_client = status_server
|
|
daq.status_server_timeout = 5.0
|
|
daq.status_server_snapshot_timeout = 60.0
|
|
daq.status_server_strict = strict
|
|
daq.status_server_wait_ready = 30
|
|
daq.status_server_max_age = 12 * 3600
|
|
daq.status_server_stale_action = "ask"
|
|
daq.status_server_stale_timeout = 0.01
|
|
# the pre-existing tests below cover the synchronous path; the async one
|
|
# has its own set further down
|
|
daq.status_server_async = False
|
|
daq.aux_calls = []
|
|
daq.append_aux = lambda *a, **kw: daq.aux_calls.append((a, kw))
|
|
return daq
|
|
|
|
|
|
class FakeAliasTree:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def get_all(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
return [{"alias": "bernina.a", "channel": "PV:A", "channeltype": "CA"}]
|
|
|
|
|
|
class FakeNamespace:
|
|
def __init__(self):
|
|
self.init_calls = []
|
|
self.status_calls = []
|
|
self.alias = FakeAliasTree()
|
|
|
|
def init_all(self, **kwargs):
|
|
self.init_calls.append(kwargs)
|
|
|
|
def get_status(self, **kwargs):
|
|
self.status_calls.append(kwargs)
|
|
return {"status": {"bernina.a": 99}, "status_channels": {}}
|
|
|
|
|
|
def test_status_client_is_built_from_a_url_string():
|
|
daq = Daq.__new__(Daq)
|
|
daq._status_server = "http://host:8091/"
|
|
daq._status_server_client = None
|
|
daq.status_server_timeout = 3.0
|
|
daq.status_server_snapshot_timeout = 60.0
|
|
client = daq.status_client
|
|
assert client.base_url == "http://host:8091"
|
|
assert daq.status_client is client # cached
|
|
|
|
|
|
def test_no_status_server_means_no_client():
|
|
daq = Daq.__new__(Daq)
|
|
daq._status_server = None
|
|
daq._status_server_client = None
|
|
assert daq.status_client is None
|
|
|
|
|
|
def test_init_namespace_uses_server_and_skips_local_init(capsys):
|
|
ns = FakeNamespace()
|
|
daq = _daq(status_server=FakeStatusClient(), namespace=ns)
|
|
daq.init_namespace()
|
|
assert ns.init_calls == []
|
|
assert "Using status server" in capsys.readouterr().out
|
|
|
|
|
|
def test_init_namespace_falls_back_to_local_init_when_server_is_down(capsys):
|
|
ns = FakeNamespace()
|
|
daq = _daq(
|
|
status_server=FakeStatusClient(fail_with=TimeoutError("no server")),
|
|
namespace=ns,
|
|
)
|
|
daq.init_namespace()
|
|
assert len(ns.init_calls) == 1
|
|
assert ns.init_calls[0]["background"] is False
|
|
out = capsys.readouterr().out
|
|
assert "WARNING" in out and "falling back" in out
|
|
|
|
|
|
def test_init_namespace_strict_mode_raises_instead_of_falling_back():
|
|
ns = FakeNamespace()
|
|
daq = _daq(
|
|
status_server=FakeStatusClient(fail_with=TimeoutError("no server")),
|
|
namespace=ns,
|
|
strict=True,
|
|
)
|
|
with pytest.raises(RuntimeError, match="status server"):
|
|
daq.init_namespace()
|
|
assert ns.init_calls == []
|
|
|
|
|
|
def test_start_status_uses_server_and_uploads_the_server_written_file():
|
|
client = FakeStatusClient()
|
|
daq = _daq(status_server=client, namespace=FakeNamespace())
|
|
scan = FakeScan(runno=42)
|
|
daq.append_start_status_to_scan(scan=scan)
|
|
|
|
assert client.calls[0]["save"] is True
|
|
assert client.calls[0]["key"] == "status_run_start"
|
|
assert client.calls[0]["run_number"] == 42
|
|
assert client.calls[0]["pgroup"] == "p12345"
|
|
|
|
stat = scan.counter_scratch("daq")["namespace_status"]["status_run_start"]
|
|
assert stat["status"] == {"bernina.a": 1}
|
|
# server-only bookkeeping is stripped before it reaches the run table
|
|
assert "saved_to" not in stat and "generation" not in stat
|
|
|
|
(args, kwargs) = daq.aux_calls[0]
|
|
assert args[0] == "/data/p1/run0042/aux/status.json"
|
|
assert kwargs["run_number"] == 42
|
|
|
|
|
|
def test_start_status_falls_back_to_local_namespace_on_server_error(monkeypatch):
|
|
ns = FakeNamespace()
|
|
daq = _daq(
|
|
status_server=FakeStatusClient(fail_with=ConnectionError("refused")),
|
|
namespace=ns,
|
|
)
|
|
scan = FakeScan()
|
|
# stop before the local file write, which targets a real beamline path
|
|
monkeypatch.setattr(daq, "get_last_run_number", lambda: 42, raising=False)
|
|
with pytest.raises(Exception):
|
|
# the local branch will fail writing to /sf/bernina/data/... in a
|
|
# test environment; what matters is that it got that far, i.e. it
|
|
# really did fall back instead of silently doing nothing.
|
|
daq.append_start_status_to_scan(scan=scan)
|
|
assert ns.status_calls, "did not fall back to namespace.get_status()"
|
|
|
|
|
|
def test_end_status_uses_server_and_sets_the_scan_parameter():
|
|
client = FakeStatusClient()
|
|
daq = _daq(status_server=client, namespace=FakeNamespace())
|
|
scan = FakeScan(runno=7)
|
|
daq.append_status_to_scan_and_store(scan)
|
|
assert client.calls[0]["key"] == "status_run_end"
|
|
assert scan.scan_parameters["status"] == "aux/status.json"
|
|
assert (
|
|
"status_run_end" in scan.counter_scratch("daq")["namespace_status"]
|
|
)
|
|
assert daq.aux_calls
|
|
|
|
|
|
def test_end_status_skipped_when_no_steps_were_done():
|
|
client = FakeStatusClient()
|
|
daq = _daq(status_server=client, namespace=FakeNamespace())
|
|
scan = FakeScan()
|
|
scan._values_done = []
|
|
daq.append_status_to_scan_and_store(scan)
|
|
assert client.calls == []
|
|
|
|
|
|
def test_async_write_job_is_joined_before_append_aux():
|
|
class AsyncClient(FakeStatusClient):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._snapshot["write_job_id"] = "job1"
|
|
self.joined = []
|
|
|
|
def wait_write_job(self, job_id, timeout=None):
|
|
self.joined.append(job_id)
|
|
return {"state": "done"}
|
|
|
|
client = AsyncClient()
|
|
daq = _daq(status_server=client, namespace=FakeNamespace())
|
|
result = daq._status_from_server("status_run_start", 5, "p1", write_async=True)
|
|
assert client.joined == ["job1"]
|
|
assert result[1] == "/data/p1/run0042/aux/status.json"
|
|
|
|
|
|
def test_snapshot_and_health_get_separate_timeouts():
|
|
"""A single 10 s timeout made every real snapshot fail: /health answers
|
|
in milliseconds, but a get_status() fan-out over ~14k bernina channels
|
|
takes 10-20 s."""
|
|
daq = Daq.__new__(Daq)
|
|
daq._status_server = "http://host:8091"
|
|
daq._status_server_client = None
|
|
daq.status_server_timeout = 10.0
|
|
daq.status_server_snapshot_timeout = 180.0
|
|
client = daq.status_client
|
|
assert client.timeout == 10.0
|
|
assert client.snapshot_timeout == 180.0
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# freshness gate and the async capture path
|
|
|
|
|
|
def _health(ready=True, age_s=0.0, **extra):
|
|
return {"ready": ready, "state": "ready" if ready else "initializing",
|
|
"last_init_finished": time.time() - age_s,
|
|
"n_initialized": 80, "n_target_names": 82, "n_failed": 2, **extra}
|
|
|
|
|
|
class HealthClient(FakeStatusClient):
|
|
base_url = "http://fake:8091"
|
|
|
|
def __init__(self, health=None, capture_fail=None, aliases_fail=None,
|
|
push_fail=None, start_recording_fail=None,
|
|
capture_recording_fail=None):
|
|
super().__init__()
|
|
self._health_body = health if health is not None else _health()
|
|
self.captures = []
|
|
self.alias_captures = []
|
|
self.pushes = []
|
|
self.recording_starts = []
|
|
self.recording_captures = []
|
|
self.reinits = 0
|
|
self._capture_fail = capture_fail
|
|
self._aliases_fail = aliases_fail
|
|
self._push_fail = push_fail
|
|
self._start_recording_fail = start_recording_fail
|
|
self._capture_recording_fail = capture_recording_fail
|
|
|
|
def start_recording(self, **kwargs):
|
|
if self._start_recording_fail:
|
|
raise self._start_recording_fail
|
|
self.recording_starts.append(kwargs)
|
|
return {"n_channels_attached": 100, "n_channels_requested": 120}
|
|
|
|
def capture_recording(self, recording_id, pgroup, run_number, **kwargs):
|
|
if self._capture_recording_fail:
|
|
raise self._capture_recording_fail
|
|
call = {"recording_id": recording_id, "pgroup": pgroup,
|
|
"run_number": run_number, **kwargs}
|
|
self.recording_captures.append(call)
|
|
return {"job_id": "j3", "path": "/data/p1/run0042/aux/monitors.esc.h5"}
|
|
|
|
def push_status(self, pgroup, run_number, values, key="status_run_start"):
|
|
if self._push_fail:
|
|
raise self._push_fail
|
|
call = {"pgroup": pgroup, "run_number": run_number, "values": values,
|
|
"key": key}
|
|
self.pushes.append(call)
|
|
return {"status": "ok", "n_values": len(values)}
|
|
|
|
def health(self):
|
|
return self._health_body
|
|
|
|
def capture(self, **kwargs):
|
|
if self._capture_fail:
|
|
raise self._capture_fail
|
|
self.captures.append(kwargs)
|
|
return {"job_id": "j1", "path": "/data/p1/run0042/aux/status.json"}
|
|
|
|
def capture_aliases(self, **kwargs):
|
|
if self._aliases_fail:
|
|
raise self._aliases_fail
|
|
self.alias_captures.append(kwargs)
|
|
return {"job_id": "j2", "path": "/data/p1/run0042/aux/aliases.json"}
|
|
|
|
def wait_write_job(self, job_id, timeout=None):
|
|
return {"state": "done", "path": "/data/p1/run0042/aux/status.json"}
|
|
|
|
def reinit(self, **kwargs):
|
|
self.reinits += 1
|
|
self._health_body = _health(age_s=0.0)
|
|
return self._health_body
|
|
|
|
|
|
def _daq_fresh(client, **kw):
|
|
daq = _daq(status_server=client, namespace=FakeNamespace())
|
|
daq.status_server_max_age = kw.pop("max_age", 12 * 3600)
|
|
daq.status_server_stale_action = kw.pop("stale_action", "ask")
|
|
daq.status_server_stale_timeout = kw.pop("stale_timeout", 0.01)
|
|
daq.status_server_async = kw.pop("async_", True)
|
|
return daq
|
|
|
|
|
|
def test_fresh_server_is_used():
|
|
daq = _daq_fresh(HealthClient(_health(age_s=3600)))
|
|
assert daq.use_status_server() is True
|
|
|
|
|
|
def test_unready_server_is_not_used(capsys):
|
|
daq = _daq_fresh(HealthClient(_health(ready=False)))
|
|
assert daq.use_status_server(verbose=True) is False
|
|
assert "initializing" in capsys.readouterr().out
|
|
|
|
|
|
def test_unreachable_server_is_not_used(capsys):
|
|
daq = _daq_fresh(HealthClient())
|
|
daq._status_server_client._health_body = None
|
|
|
|
def boom():
|
|
raise ConnectionError("refused")
|
|
|
|
daq._status_server_client.health = boom
|
|
assert daq.use_status_server(verbose=True) is False
|
|
assert "not reachable" in capsys.readouterr().out
|
|
|
|
|
|
def test_stale_server_falls_back_when_told_to(capsys):
|
|
client = HealthClient(_health(age_s=20 * 3600))
|
|
daq = _daq_fresh(client, stale_action="local")
|
|
assert daq.use_status_server(verbose=True) is False
|
|
assert client.reinits == 0
|
|
assert "13.0 h" not in capsys.readouterr().out # 20 h, not the limit
|
|
|
|
|
|
def test_stale_server_can_be_trusted_anyway():
|
|
client = HealthClient(_health(age_s=20 * 3600))
|
|
daq = _daq_fresh(client, stale_action="use")
|
|
assert daq.use_status_server() is True
|
|
assert client.reinits == 0
|
|
|
|
|
|
def test_stale_server_is_restarted_and_then_used(capsys):
|
|
client = HealthClient(_health(age_s=20 * 3600))
|
|
daq = _daq_fresh(client, stale_action="restart")
|
|
assert daq.use_status_server() is True
|
|
assert client.reinits == 1
|
|
assert "refreshed" in capsys.readouterr().out
|
|
|
|
|
|
def test_stale_prompt_without_a_tty_takes_the_restart(capsys):
|
|
"""A scan started from a script has nobody to answer; the timeout takes
|
|
the option that leaves the next run fast."""
|
|
client = HealthClient(_health(age_s=20 * 3600))
|
|
daq = _daq_fresh(client, stale_action="ask")
|
|
assert daq.use_status_server() is True
|
|
assert client.reinits == 1
|
|
out = capsys.readouterr().out
|
|
assert "20.0 h ago" in out and "restarting" in out
|
|
|
|
|
|
def test_stale_prompt_answered_with_l_uses_the_local_namespace(monkeypatch):
|
|
import inputimeout as _inputimeout
|
|
|
|
client = HealthClient(_health(age_s=20 * 3600))
|
|
daq = _daq_fresh(client, stale_action="ask")
|
|
monkeypatch.setattr(_inputimeout, "inputimeout", lambda **kw: "l")
|
|
assert daq.use_status_server() is False
|
|
assert client.reinits == 0
|
|
|
|
|
|
def test_max_age_none_disables_the_check():
|
|
client = HealthClient(_health(age_s=1000 * 3600))
|
|
daq = _daq_fresh(client, max_age=None)
|
|
assert daq.use_status_server() is True
|
|
assert client.reinits == 0
|
|
|
|
|
|
def test_scan_start_delegates_the_whole_capture_to_the_server(capsys):
|
|
client = HealthClient()
|
|
daq = _daq_fresh(client)
|
|
scan = FakeScan(runno=42)
|
|
daq.append_start_status_to_scan(scan=scan)
|
|
|
|
assert client.captures == [
|
|
{"pgroup": "p12345", "run_number": 42, "key": "status_run_start",
|
|
"upload": True, "keep_status": True}
|
|
]
|
|
# the client neither waits for the values nor uploads the file itself
|
|
assert client.calls == []
|
|
assert daq.aux_calls == []
|
|
block = scan.counter_scratch("daq")["namespace_status"]["status_run_start"]
|
|
assert block["written_by_status_server"].endswith("status.json")
|
|
assert "delegated" in capsys.readouterr().out
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# aliases: same server-first, local-fallback shape as status
|
|
|
|
|
|
def test_aliases_delegate_to_the_server_and_never_touch_the_local_namespace():
|
|
"""The bug this exists to fix: before this branch existed,
|
|
copy_aliases_to_scan always called self.namespace.alias.get_all()
|
|
unconditionally, even in status-server mode - against a namespace that
|
|
status-server mode deliberately never initializes."""
|
|
client = HealthClient()
|
|
ns = FakeNamespace()
|
|
daq = _daq_fresh(client)
|
|
daq.namespace = ns
|
|
scan = FakeScan(runno=42)
|
|
daq.copy_aliases_to_scan(scan)
|
|
|
|
assert client.alias_captures == [
|
|
{"pgroup": "p12345", "run_number": 42, "upload": True,
|
|
"channeltypes": None}
|
|
]
|
|
assert ns.alias.calls == [], "local namespace.alias was touched"
|
|
assert scan.scan_parameters["aliases"] == "aux/aliases.json"
|
|
job = scan.counter_scratch("daq")["status_jobs"]["aliases"]
|
|
assert job["path"].endswith("aliases.json")
|
|
|
|
|
|
def test_aliases_only_sent_on_the_first_step():
|
|
client = HealthClient()
|
|
daq = _daq_fresh(client)
|
|
daq.namespace = FakeNamespace()
|
|
scan = FakeScan(runno=42)
|
|
scan._values_done = [1, 2, 3] # not the first step
|
|
daq.copy_aliases_to_scan(scan)
|
|
assert client.alias_captures == []
|
|
|
|
|
|
def test_aliases_send_aliases_now_overrides_the_step_guard():
|
|
client = HealthClient()
|
|
daq = _daq_fresh(client)
|
|
daq.namespace = FakeNamespace()
|
|
scan = FakeScan(runno=42)
|
|
scan._values_done = [1, 2, 3]
|
|
daq.copy_aliases_to_scan(scan, send_aliases_now=True)
|
|
assert len(client.alias_captures) == 1
|
|
|
|
|
|
def test_aliases_capture_failure_falls_back_to_the_local_namespace(monkeypatch):
|
|
client = HealthClient(aliases_fail=ConnectionError("refused"))
|
|
ns = FakeNamespace()
|
|
daq = _daq_fresh(client)
|
|
daq.namespace = ns
|
|
scan = FakeScan(runno=42)
|
|
monkeypatch.setattr(daq, "get_last_run_number", lambda **kw: 42, raising=False)
|
|
with pytest.raises(Exception):
|
|
# the local branch fails writing to /sf/bernina/data/... in a test
|
|
# environment; what matters is that it got that far.
|
|
daq.copy_aliases_to_scan(scan)
|
|
assert ns.alias.calls, "did not fall back to namespace.alias.get_all()"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# compare_channels: server-first (compares the SERVER's OWN namespace, so
|
|
# this session's namespace is never forced), local fallback - see
|
|
# eco.aliases.channel_lists (tested directly in tests/test_channel_lists.py)
|
|
# and eco.status_server's /channels/compare endpoint (tested in
|
|
# tests/test_status_server.py).
|
|
|
|
|
|
class ChannelAliasTree:
|
|
"""Just enough of eco.aliases.Alias for compare_channels' local path -
|
|
unlike FakeAliasTree above, actually honors channeltypes so these tests
|
|
can put JF/BS-tagged entries in and see them (not) come back out."""
|
|
|
|
def __init__(self, entries):
|
|
self._entries = entries
|
|
self.calls = []
|
|
|
|
def get_all(self, joiner=".", channeltypes=None):
|
|
self.calls.append(channeltypes)
|
|
return [
|
|
e for e in self._entries
|
|
if not channeltypes or e["channeltype"] in channeltypes
|
|
]
|
|
|
|
|
|
class FakeChannelList:
|
|
def __init__(self, value):
|
|
self._value = value
|
|
|
|
def get_current_value(self):
|
|
return self._value
|
|
|
|
|
|
def _channels_namespace(entries):
|
|
ns = FakeNamespace()
|
|
ns.alias = ChannelAliasTree(entries)
|
|
return ns
|
|
|
|
|
|
def test_compare_channels_delegates_to_the_server():
|
|
client = HealthClient()
|
|
client.compare_channels = lambda list_names=None: {
|
|
"channels_JF": {"channeltype": "JF", "missing": ["JF03"],
|
|
"exceeding": [], "n_required": 2, "n_recorded": 1}
|
|
}
|
|
daq = _daq_fresh(client)
|
|
result = daq.compare_channels(verbose=False)
|
|
assert result["channels_JF"]["missing"] == ["JF03"]
|
|
|
|
|
|
def test_compare_channels_server_failure_falls_back_to_local(capsys):
|
|
client = HealthClient()
|
|
|
|
def boom(list_names=None):
|
|
raise ConnectionError("refused")
|
|
|
|
client.compare_channels = boom
|
|
daq = _daq_fresh(client)
|
|
daq.namespace = _channels_namespace([
|
|
{"alias": "jf03", "channel": "JF03", "channeltype": "JF"},
|
|
{"alias": "jf04", "channel": "JF04", "channeltype": "JF"},
|
|
])
|
|
daq.channels = {"channels_JF": FakeChannelList(["JF03"])}
|
|
result = daq.compare_channels(verbose=False)
|
|
assert result["channels_JF"]["missing"] == ["JF04"]
|
|
assert result["channels_JF"]["exceeding"] == []
|
|
assert "WARNING" in capsys.readouterr().out
|
|
|
|
|
|
def test_compare_channels_local_reports_missing_and_exceeding():
|
|
daq = _daq(status_server=None)
|
|
daq.namespace = _channels_namespace([
|
|
{"alias": "jf03", "channel": "JF03", "channeltype": "JF"},
|
|
])
|
|
daq.channels = {"channels_JF": FakeChannelList(["JF03", "JF_OLD"])}
|
|
result = daq.compare_channels(verbose=False)
|
|
assert result["channels_JF"]["missing"] == []
|
|
assert result["channels_JF"]["exceeding"] == ["JF_OLD"]
|
|
|
|
|
|
def test_compare_channels_raises_without_namespace_or_server():
|
|
daq = _daq(status_server=None, namespace=None)
|
|
with pytest.raises(RuntimeError, match="no local namespace"):
|
|
daq.compare_channels(verbose=False)
|
|
|
|
|
|
def test_compare_channels_prints_a_summary_by_default(capsys):
|
|
daq = _daq(status_server=None)
|
|
daq.namespace = _channels_namespace([
|
|
{"alias": "jf03", "channel": "JF03", "channeltype": "JF"},
|
|
])
|
|
daq.channels = {"channels_JF": FakeChannelList(["JF03"])}
|
|
daq.compare_channels()
|
|
out = capsys.readouterr().out
|
|
assert "channels_JF" in out and "OK" in out
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# start_scan_monitoring / end_scan_monitoring - wired into
|
|
# callbacks_start_scan/callbacks_end_scan (see Daq.__init__). There is no
|
|
# local fallback - a local recording would need this session's own
|
|
# namespace to hold a live monitor per channel for the scan's whole
|
|
# duration, exactly the per-session cost the status server exists to avoid
|
|
# - so these only ever do anything for a server-backed, daq-counter scan
|
|
# (any scan that runs this Daq's callbacks at all already implies "daq is a
|
|
# counter"); a scan with no status server configured gets no
|
|
# namespace_monitor.h5, the same way it already gets no server-backed
|
|
# aliases.json/status.json.
|
|
|
|
|
|
def test_start_scan_monitoring_starts_a_recording_named_for_the_run():
|
|
client = HealthClient()
|
|
daq = _daq_fresh(client)
|
|
scan = FakeScan(runno=42)
|
|
result = daq.start_scan_monitoring(scan)
|
|
|
|
assert client.recording_starts == [
|
|
{"recording_id": "p12345_run0042", "names": None, "mode": "throttle",
|
|
"min_interval": 0.1, "pgroup": "p12345", "run_number": 42}
|
|
]
|
|
assert result["n_channels_attached"] == 100
|
|
assert (
|
|
scan.counter_scratch("daq")["monitoring_recording_id"]
|
|
== "p12345_run0042"
|
|
)
|
|
|
|
|
|
def test_start_scan_monitoring_warns_about_failed_required_channels(capsys):
|
|
client = HealthClient()
|
|
client.start_recording = lambda **kw: {
|
|
"n_channels_attached": 100, "n_channels_requested": 120,
|
|
"failed_required": ["daq.something"],
|
|
}
|
|
daq = _daq_fresh(client)
|
|
scan = FakeScan(runno=42)
|
|
daq.start_scan_monitoring(scan)
|
|
out = capsys.readouterr().out
|
|
assert "REQUIRED" in out and "daq.something" in out
|
|
|
|
|
|
def test_start_scan_monitoring_is_a_noop_without_a_status_server():
|
|
daq = _daq_fresh(None)
|
|
daq._status_server = None
|
|
daq._status_server_client = None
|
|
scan = FakeScan(runno=42)
|
|
assert daq.start_scan_monitoring(scan) is None
|
|
|
|
|
|
def test_start_scan_monitoring_is_a_noop_when_the_server_is_not_used(capsys):
|
|
client = HealthClient(_health(ready=False))
|
|
daq = _daq_fresh(client)
|
|
scan = FakeScan(runno=42)
|
|
assert daq.start_scan_monitoring(scan) is None
|
|
assert client.recording_starts == []
|
|
assert "monitoring_recording_id" not in scan.counter_scratch("daq")
|
|
|
|
|
|
def test_start_scan_monitoring_failure_falls_back_to_doing_nothing(capsys):
|
|
client = HealthClient(start_recording_fail=ConnectionError("refused"))
|
|
daq = _daq_fresh(client)
|
|
scan = FakeScan(runno=42)
|
|
assert daq.start_scan_monitoring(scan) is None
|
|
assert "WARNING" in capsys.readouterr().out
|
|
assert "monitoring_recording_id" not in scan.counter_scratch("daq")
|
|
|
|
|
|
def test_end_scan_monitoring_is_a_noop_when_nothing_was_started():
|
|
client = HealthClient()
|
|
daq = _daq_fresh(client)
|
|
scan = FakeScan(runno=42)
|
|
assert daq.end_scan_monitoring(scan) is None
|
|
assert client.recording_captures == []
|
|
|
|
|
|
def test_end_scan_monitoring_captures_the_recording_started_for_this_scan():
|
|
client = HealthClient()
|
|
daq = _daq_fresh(client)
|
|
scan = FakeScan(runno=42)
|
|
daq.start_scan_monitoring(scan)
|
|
job = daq.end_scan_monitoring(scan)
|
|
|
|
assert client.recording_captures == [
|
|
{"recording_id": "p12345_run0042", "pgroup": "p12345",
|
|
"run_number": 42, "upload": True, "filename": "namespace_monitor.h5"}
|
|
]
|
|
assert job["job_id"] == "j3"
|
|
assert (
|
|
scan.counter_scratch("daq")["status_jobs"]["recording"]["job_id"] == "j3"
|
|
)
|
|
# registered the same way copy_aliases_to_scan/append_start_status_to_scan
|
|
# register "aliases"/"status", so scan_info_rel.json carries it too
|
|
assert scan.scan_parameters["monitors"] == "aux/namespace_monitor.h5"
|
|
|
|
|
|
def test_end_scan_monitoring_failure_does_not_raise(capsys):
|
|
client = HealthClient(capture_recording_fail=ConnectionError("refused"))
|
|
daq = _daq_fresh(client)
|
|
scan = FakeScan(runno=42)
|
|
daq.start_scan_monitoring(scan)
|
|
assert daq.end_scan_monitoring(scan) is None
|
|
assert "WARNING" in capsys.readouterr().out
|
|
|
|
|
|
def test_start_scan_monitoring_is_wired_into_callbacks_start_scan_after_run_number():
|
|
"""Needs scan.daq_run_number (set by
|
|
count_run_number_up_and_attach_to_scan) and the server-in-use decision
|
|
(cached by append_start_status_to_scan) - both must run first."""
|
|
import inspect
|
|
|
|
source = inspect.getsource(Daq.__init__)
|
|
assert (
|
|
source.index("count_run_number_up_and_attach_to_scan")
|
|
< source.index("append_start_status_to_scan")
|
|
< source.index("self.start_scan_monitoring")
|
|
)
|
|
|
|
|
|
def test_end_scan_monitoring_is_wired_into_callbacks_end_scan_before_scan_info():
|
|
"""Must run before callbacks_end_scan's copy_scan_info_to_raw, which is
|
|
what actually writes the "monitors" scan parameter end_scan_monitoring
|
|
registers into scan_info_rel.json. copy_scan_info_to_raw also appears
|
|
earlier, in callbacks_end_step - rindex() to get the callbacks_end_scan
|
|
occurrence, not that one."""
|
|
import inspect
|
|
|
|
source = inspect.getsource(Daq.__init__)
|
|
assert (
|
|
source.index("self.end_scan_monitoring")
|
|
< source.rindex("self.copy_scan_info_to_raw")
|
|
)
|
|
|
|
|
|
def test_scan_end_delegates_and_sets_the_scan_parameter():
|
|
client = HealthClient()
|
|
daq = _daq_fresh(client)
|
|
scan = FakeScan(runno=7)
|
|
daq.append_status_to_scan_and_store(scan)
|
|
assert client.captures[0]["key"] == "status_run_end"
|
|
assert scan.scan_parameters["status"] == "aux/status.json"
|
|
|
|
|
|
def test_the_server_decision_is_taken_once_per_scan():
|
|
"""Start from the server and end from the local namespace would be a
|
|
quietly inconsistent run."""
|
|
client = HealthClient()
|
|
daq = _daq_fresh(client)
|
|
scan = FakeScan(runno=7)
|
|
calls = []
|
|
real = daq.use_status_server
|
|
daq.use_status_server = lambda **kw: (calls.append(1), real(**kw))[1]
|
|
daq.append_start_status_to_scan(scan=scan)
|
|
daq.append_status_to_scan_and_store(scan)
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_capture_failure_falls_back_to_the_local_path(monkeypatch):
|
|
client = HealthClient(capture_fail=ConnectionError("refused"))
|
|
ns = FakeNamespace()
|
|
daq = _daq_fresh(client)
|
|
daq.namespace = ns
|
|
scan = FakeScan()
|
|
monkeypatch.setattr(daq, "get_last_run_number", lambda **kw: 42, raising=False)
|
|
with pytest.raises(Exception):
|
|
daq.append_start_status_to_scan(scan=scan)
|
|
assert ns.status_calls, "did not fall back to namespace.get_status()"
|
|
|
|
|
|
def test_write_status_waits_for_the_job_when_called_standalone(monkeypatch):
|
|
client = HealthClient()
|
|
daq = _daq_fresh(client)
|
|
monkeypatch.setattr(daq, "get_last_run_number", lambda **kw: 42, raising=False)
|
|
path = daq.write_status(pgroup="p1", run_number=42)
|
|
assert str(path).endswith("run0042/aux/status.json")
|
|
assert client.captures[0]["run_number"] == 42
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# the run table is filled from the server's values, not from its own CA reads
|
|
|
|
|
|
class RunTableSpy:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def append_run(self, runno, metadata=None, d=None, **kw):
|
|
self.calls.append({"runno": runno, "metadata": metadata, "d": d})
|
|
|
|
|
|
class RunTableScan(FakeScan):
|
|
"""FakeScan plus the attributes the run-table callback reads."""
|
|
|
|
def __init__(self, runno=42):
|
|
super().__init__(runno)
|
|
get = lambda v: types.SimpleNamespace(get_current_value=lambda: v)
|
|
self.description = get("a scan")
|
|
self.values_todo = get([[0.0], [1.0]])
|
|
self.counters_names = get(["daq"])
|
|
self.scan_command = get("ascan(...)")
|
|
self.pulses_per_step = [10, 10]
|
|
self.adjustables = [types.SimpleNamespace(name="dummy", Id="PV:DUMMY")]
|
|
self.initial_values = get({"dummy": 0.5})
|
|
|
|
|
|
def _runtable_daq(client, run_table):
|
|
daq = _daq_fresh(client)
|
|
daq.run_table = run_table
|
|
return daq
|
|
|
|
|
|
def test_runtable_gets_the_flat_status_mapping_not_the_whole_block():
|
|
rt = RunTableSpy()
|
|
daq = _runtable_daq(HealthClient(), rt)
|
|
scan = RunTableScan()
|
|
scan.counter_scratch("daq")["namespace_status"] = {
|
|
"status_run_start": {"status": {"bernina.a": 1}, "status_channels": {}}
|
|
}
|
|
daq._create_runtable_metadata_append_status_to_runtable(scan)
|
|
assert rt.calls[0]["d"] == {"bernina.a": 1}
|
|
|
|
|
|
def test_runtable_append_is_deferred_until_the_capture_finishes():
|
|
"""Filling it inline would either block the scan or make the run table do
|
|
the CA fan-out the server exists to remove."""
|
|
rt = RunTableSpy()
|
|
client = HealthClient()
|
|
daq = _runtable_daq(client, rt)
|
|
scan = RunTableScan(runno=7)
|
|
|
|
collected = {"job_id": "j1", "state": "done", "status": {"bernina.b": 2}}
|
|
client.wait_write_job = lambda job_id, timeout=None, include_status=False: collected
|
|
|
|
daq.append_start_status_to_scan(scan=scan)
|
|
daq._create_runtable_metadata_append_status_to_runtable(scan)
|
|
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline and not rt.calls:
|
|
time.sleep(0.02)
|
|
assert rt.calls, "run table row was never appended"
|
|
assert rt.calls[0]["d"] == {"bernina.b": 2}
|
|
assert rt.calls[0]["runno"] == 7
|
|
|
|
|
|
def test_runtable_row_is_still_written_when_the_capture_fails(capsys):
|
|
"""A run with no run-table row is worse than one filled the slow way."""
|
|
rt = RunTableSpy()
|
|
client = HealthClient()
|
|
daq = _runtable_daq(client, rt)
|
|
scan = RunTableScan(runno=8)
|
|
|
|
def _boom(job_id, timeout=None, include_status=False):
|
|
raise ConnectionError("server went away")
|
|
|
|
client.wait_write_job = _boom
|
|
daq.append_start_status_to_scan(scan=scan)
|
|
daq._create_runtable_metadata_append_status_to_runtable(scan)
|
|
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline and not rt.calls:
|
|
time.sleep(0.02)
|
|
assert rt.calls and rt.calls[0]["d"] == {}
|
|
assert "could not collect run 8 status" in capsys.readouterr().out
|
|
|
|
|
|
def test_runtable_append_is_inline_when_the_status_is_already_there():
|
|
rt = RunTableSpy()
|
|
daq = _runtable_daq(HealthClient(), rt)
|
|
daq.status_server_async = False
|
|
scan = RunTableScan(runno=9)
|
|
scan.counter_scratch("daq")["namespace_status"] = {
|
|
"status_run_start": {"status": {"bernina.c": 3}}
|
|
}
|
|
daq._create_runtable_metadata_append_status_to_runtable(scan)
|
|
assert rt.calls[0]["d"] == {"bernina.c": 3} # no thread involved
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# scans.acquiring_scan.* is pushed to the server -- see
|
|
# eco.status_server.namespace_store.NamespaceMonitorStore.push_status: the
|
|
# server can never poll these itself (no CA channel), so the client hands
|
|
# over the same values it already builds `metadata` from.
|
|
|
|
|
|
def test_push_acquiring_scan_status_sends_the_real_scan_values():
|
|
rt = RunTableSpy()
|
|
client = HealthClient()
|
|
client.wait_write_job = lambda job_id, timeout=None, include_status=False: {
|
|
"state": "done", "status": {}
|
|
}
|
|
daq = _runtable_daq(client, rt)
|
|
scan = RunTableScan(runno=7)
|
|
|
|
daq.append_start_status_to_scan(scan=scan)
|
|
daq._create_runtable_metadata_append_status_to_runtable(scan)
|
|
|
|
assert len(client.pushes) == 1
|
|
push = client.pushes[0]
|
|
assert push["pgroup"] == daq.pgroup
|
|
assert push["run_number"] == 7
|
|
assert push["key"] == "status_run_start"
|
|
values = push["values"]
|
|
assert values["scans.acquiring_scan.description"] == "a scan"
|
|
assert values["scans.acquiring_scan.scan_command"] == "ascan(...)"
|
|
assert values["scans.acquiring_scan.adjustables_names"] == ["dummy"]
|
|
assert values["scans.acquiring_scan.initial_values"] == {"dummy": 0.5}
|
|
assert values["scans.acquiring_scan.number_of_steps"] == 2
|
|
assert "scans.acquiring_scan.start_time" in values
|
|
|
|
|
|
def test_push_is_skipped_when_status_is_already_inline():
|
|
"""No server job means nothing will ever read a pushed value back, so
|
|
there is nothing to push."""
|
|
rt = RunTableSpy()
|
|
client = HealthClient()
|
|
daq = _runtable_daq(client, rt)
|
|
daq.status_server_async = False
|
|
scan = RunTableScan(runno=9)
|
|
scan.counter_scratch("daq")["namespace_status"] = {
|
|
"status_run_start": {"status": {"bernina.c": 3}}
|
|
}
|
|
daq._create_runtable_metadata_append_status_to_runtable(scan)
|
|
assert client.pushes == []
|
|
|
|
|
|
def test_push_failure_does_not_break_the_runtable_append(capsys):
|
|
"""A failed push must not cost the run its run-table row -- the row
|
|
still gets everything under metadata.* regardless."""
|
|
rt = RunTableSpy()
|
|
client = HealthClient(push_fail=ConnectionError("server went away"))
|
|
client.wait_write_job = lambda job_id, timeout=None, include_status=False: {
|
|
"state": "done", "status": {}
|
|
}
|
|
daq = _runtable_daq(client, rt)
|
|
scan = RunTableScan(runno=13)
|
|
|
|
daq.append_start_status_to_scan(scan=scan)
|
|
daq._create_runtable_metadata_append_status_to_runtable(scan)
|
|
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline and not rt.calls:
|
|
time.sleep(0.02)
|
|
assert rt.calls, "run table row was never appended"
|
|
assert rt.calls[0]["runno"] == 13
|
|
assert "could not push scans.acquiring_scan status" in capsys.readouterr().out
|
|
|
|
|
|
def test_only_the_start_block_keeps_its_values_on_the_server():
|
|
"""Holding a few MB for a block nobody collects is just a leak."""
|
|
client = HealthClient()
|
|
daq = _daq_fresh(client)
|
|
daq.append_start_status_to_scan(scan=RunTableScan(runno=11))
|
|
daq.append_status_to_scan_and_store(RunTableScan(runno=11))
|
|
assert client.captures[0]["keep_status"] is True
|
|
assert client.captures[1]["keep_status"] is False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# rate_multiplicator / get_detector_code_frequency: never a raw CA get, never
|
|
# a silent None reaching arithmetic (the pulse_id bug, 4th occurrence)
|
|
|
|
|
|
class FakeFreqMonitor:
|
|
"""Stands in for CallbackEpics(func="latest"): .data is the same dict
|
|
object the real one mutates in place, so a cached reference stays live."""
|
|
|
|
def __init__(self, initial=None):
|
|
self.data = {"value": initial}
|
|
self.started = False
|
|
|
|
def start(self, add_current_value=True):
|
|
self.started = True
|
|
|
|
def push(self, value):
|
|
self.data["value"] = value
|
|
|
|
|
|
class FreqDetector:
|
|
def __init__(self, value, pvname="TEST:Evt-1-Freq-I", monitorable=True):
|
|
self._value = value
|
|
self.pvname = pvname
|
|
self._monitor = FakeFreqMonitor(value) if monitorable else None
|
|
|
|
def get_current_value(self):
|
|
return self._value
|
|
|
|
def set_current_value_callback(self, func="accumulate", **kwargs):
|
|
if self._monitor is None:
|
|
raise AttributeError("not monitorable")
|
|
return self._monitor
|
|
|
|
|
|
def _event_master(freq_detector, code=50):
|
|
em = types.SimpleNamespace()
|
|
em.__dict__[f"code{code:03d}"] = types.SimpleNamespace(frequency=freq_detector)
|
|
return em
|
|
|
|
|
|
def _freq_daq(freq_detector, code=50):
|
|
daq = Daq.__new__(Daq)
|
|
daq._event_master = None
|
|
daq._detectors_event_code = None
|
|
daq._frequency_detector = None
|
|
daq._frequency_monitor = None
|
|
daq._frequency_latest = {"value": None}
|
|
event_master = _event_master(freq_detector, code=code)
|
|
# replicate the __init__ snippet directly, since Daq.__new__ skips it
|
|
try:
|
|
daq._frequency_detector = event_master.__dict__[
|
|
f"code{code:03d}"
|
|
].frequency
|
|
mon = daq._frequency_detector.set_current_value_callback(func="latest")
|
|
mon.start()
|
|
daq._frequency_monitor = mon
|
|
daq._frequency_latest = mon.data
|
|
except Exception:
|
|
pass
|
|
daq._detectors_event_code = code
|
|
return daq
|
|
|
|
|
|
def test_frequency_is_read_from_the_monitor_cache_not_a_fresh_get():
|
|
det = FreqDetector(50.0)
|
|
daq = _freq_daq(det)
|
|
assert daq._frequency_monitor.started is True
|
|
assert daq.get_detector_code_frequency() == 50.0
|
|
assert daq.rate_multiplicator == 2
|
|
|
|
|
|
def test_frequency_cache_follows_live_monitor_updates():
|
|
det = FreqDetector(50.0)
|
|
daq = _freq_daq(det)
|
|
det._monitor.push(25.0)
|
|
assert daq.get_detector_code_frequency() == 25.0
|
|
|
|
|
|
def test_a_transient_none_from_the_monitor_falls_back_to_a_direct_read():
|
|
"""The exact failure mode observed live: pyepics silently returns None
|
|
on a transient CA hiccup instead of raising."""
|
|
det = FreqDetector(50.0)
|
|
daq = _freq_daq(det)
|
|
det._monitor.push(None)
|
|
# get_current_value() still works even though the monitor cache is
|
|
# momentarily empty
|
|
assert daq.get_detector_code_frequency() == 50.0
|
|
|
|
|
|
def test_no_value_anywhere_raises_instead_of_dividing_by_none():
|
|
"""Before this fix: int(100 / freq) with freq=None -> TypeError, deep
|
|
inside retrieve(), killing a real scan mid-run."""
|
|
det = FreqDetector(None)
|
|
daq = _freq_daq(det)
|
|
with pytest.raises(TimeoutError, match="TEST:Evt-1-Freq-I"):
|
|
daq.get_detector_code_frequency()
|
|
with pytest.raises(TimeoutError):
|
|
daq.rate_multiplicator
|
|
|
|
|
|
def test_a_non_monitorable_frequency_falls_back_to_direct_reads():
|
|
"""MasterEventCodeFix (fixed CTA sequencer codes) has no PV to monitor
|
|
at all - set_current_value_callback isn't there, __init__'s attach must
|
|
not blow up, and reads should still work via get_current_value()."""
|
|
det = FreqDetector(50.0, monitorable=False)
|
|
daq = _freq_daq(det)
|
|
assert daq._frequency_monitor is None
|
|
assert daq.get_detector_code_frequency() == 50.0
|
|
|
|
|
|
def test_missing_event_master_does_not_crash_init():
|
|
daq = Daq.__new__(Daq)
|
|
daq._frequency_detector = None
|
|
daq._frequency_monitor = None
|
|
daq._frequency_latest = {"value": None}
|
|
daq._detectors_event_code = None
|
|
with pytest.raises(TimeoutError):
|
|
daq.get_detector_code_frequency()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# _create_runtable_metadata_append_status_to_runtable must honor
|
|
# append_status_info=False (it silently didn't - a regression from the old
|
|
# combined elog+run_table callback, which had the guard)
|
|
|
|
|
|
def test_runtable_metadata_callback_skips_when_append_status_info_is_false():
|
|
rt = RunTableSpy()
|
|
daq = _runtable_daq(HealthClient(), rt)
|
|
scan = RunTableScan(runno=99)
|
|
daq._create_runtable_metadata_append_status_to_runtable(
|
|
scan, append_status_info=False
|
|
)
|
|
assert rt.calls == [], (
|
|
"run_table.append_run() ran despite append_status_info=False - this "
|
|
"is what makes a session's first scan pay for Run_Table2's Google "
|
|
"Sheets authentication even when status collection was disabled"
|
|
)
|
|
|
|
|
|
def test_runtable_metadata_callback_still_runs_by_default():
|
|
rt = RunTableSpy()
|
|
daq = _runtable_daq(HealthClient(), rt)
|
|
scan = RunTableScan(runno=100)
|
|
daq._create_runtable_metadata_append_status_to_runtable(scan)
|
|
assert len(rt.calls) == 1
|