DetectorVirtual had no set_current_value_callback() at all, so a computed Detector could never be live-monitored no matter how monitorable its own parents were - it landed in a recording's n_attach_failed the same as a genuinely dead PV, for a structurally different reason. AdjustableVirtual already solved this generically (CallbackComposedValue: subscribe to every parent's own callback, recompute get_current_value() on any update, independent of whether a parent is CA-backed or itself composed) - give DetectorVirtual the same treatment via the same class, generalized to take its children explicitly instead of reaching into AdjustableVirtual's own ._adjustables attribute. Discovery is automatic: NamespaceMonitorStore already finds every monitorable status detector via isinstance(ts, MonitorableValueUpdate) (eco.elements.protocols), a pure duck-typed check - no wiring needed beyond DetectorVirtual having the method now. RecordingSession.start()'s recent seed-if-cheap logic (seed a channel with its current value for free when that costs no CA traffic) now also recognizes a CallbackComposedValue-backed monitor as free to seed - it recomputes from already-monitored parents rather than issuing a get of its own. Found and fixed along the way: CallbackComposedValue.start() only accepted add_current_value, not with_ctrlvars/auto_monitor - RecordingSession.start() (and anything else treating a monitor uniformly regardless of whether it is CA-backed or composed) calls .start() with all three, so this raised TypeError for any composed value that reached this path - previously unreachable for a DetectorVirtual (no method at all) and apparently never exercised for an AdjustableVirtual monitored this way either. Both kwargs are now forwarded to every child, recursively, matching CallbackEpics.start()'s interface exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
256 lines
8.9 KiB
Python
256 lines
8.9 KiB
Python
"""Push-based monitoring for computed values.
|
|
|
|
AdjustableVirtual and DetectorVirtual's set_current_value_callback() are
|
|
both backed by the shared eco.elements.adjustable.CallbackComposedValue,
|
|
which subscribes to every parent's own set_current_value_callback() and
|
|
recomputes get_current_value() whenever any of them reports an update - so
|
|
a value computed from other monitorable values can itself be monitored,
|
|
with no polling, independent of whether the chain bottoms out in CA or
|
|
something else. Refuses (NotImplementedError) rather than silently falling
|
|
back to polling when any parent does not implement
|
|
eco.elements.protocols.MonitorableValueUpdate.
|
|
|
|
No EPICS here: everything below is driven by plain fakes standing in for
|
|
the leaf level, so these tests describe the composition contract, not any
|
|
particular transport.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from eco.elements.adjustable import AdjustableVirtual, CallbackComposedValue
|
|
from eco.elements.detector import DetectorVirtual
|
|
|
|
|
|
class FakeMonitor:
|
|
"""Stands in for whatever set_current_value_callback() a leaf returns
|
|
(CallbackEpics, or another CallbackComposedValue): remembers the
|
|
callback CallbackComposedValue registered and lets a test fire it.
|
|
|
|
`fire()` updates the leaf's own current value before calling the
|
|
callback, mirroring how a real CA update lands in the PV's cache
|
|
*before* any callback fires - CallbackComposedValue deliberately
|
|
ignores the value/timestamp a child callback was fired with and
|
|
re-reads get_current_value() instead (see its _on_parent_update
|
|
docstring), so this side effect is what makes that recompute see the
|
|
fired value at all."""
|
|
|
|
def __init__(self, leaf, func, run_once=True):
|
|
self.leaf = leaf
|
|
self.func = func
|
|
self.run_once = run_once
|
|
self.started_with = None
|
|
self.stopped = False
|
|
|
|
def start(self, add_current_value=True, with_ctrlvars=True, auto_monitor=True):
|
|
self.started_with = {
|
|
"add_current_value": add_current_value,
|
|
"with_ctrlvars": with_ctrlvars,
|
|
"auto_monitor": auto_monitor,
|
|
}
|
|
if add_current_value:
|
|
self.func(timestamp=0.0)
|
|
|
|
def stop(self):
|
|
self.stopped = True
|
|
|
|
def fire(self, value=None, timestamp=1.0):
|
|
self.leaf._value = value
|
|
self.func(value=value, timestamp=timestamp)
|
|
|
|
|
|
class FakeLeaf:
|
|
"""A monitorable leaf: get_current_value() + set_current_value_callback(),
|
|
matching eco.elements.protocols.MonitorableValueUpdate. Also implements
|
|
set_target_value() so it can stand in as an AdjustableVirtual parent
|
|
too, not just a DetectorVirtual one."""
|
|
|
|
def __init__(self, name, value=0.0):
|
|
self.name = name
|
|
self._value = value
|
|
self.monitors = []
|
|
|
|
def get_current_value(self):
|
|
return self._value
|
|
|
|
def set_target_value(self, value, hold=False):
|
|
self._value = value
|
|
|
|
class _Changer:
|
|
def wait(self_inner):
|
|
pass
|
|
|
|
def stop(self_inner):
|
|
pass
|
|
|
|
return _Changer()
|
|
|
|
def set_current_value_callback(self, func="accumulate", run_once=True, **kwargs):
|
|
mon = FakeMonitor(self, func, run_once=run_once)
|
|
self.monitors.append(mon)
|
|
return mon
|
|
|
|
|
|
class NonMonitorableLeaf:
|
|
"""The failure case: no set_current_value_callback at all - same shape
|
|
a raw, non-PV-backed value (or one whose monitoring was never
|
|
implemented) would have."""
|
|
|
|
def __init__(self, name, value=0.0):
|
|
self.name = name
|
|
self._value = value
|
|
|
|
def get_current_value(self):
|
|
return self._value
|
|
|
|
def set_target_value(self, value, hold=False):
|
|
self._value = value
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# AdjustableVirtual
|
|
|
|
|
|
def test_adjustable_virtual_monitors_when_all_parents_are_monitorable():
|
|
a = FakeLeaf("a", 1.0)
|
|
b = FakeLeaf("b", 2.0)
|
|
v = AdjustableVirtual(
|
|
[a, b],
|
|
foo_get_current_value=lambda x, y: x + y,
|
|
foo_set_target_value_current_value=lambda val: (val, val),
|
|
name="v",
|
|
)
|
|
mon = v.set_current_value_callback(func="latest")
|
|
assert isinstance(mon, CallbackComposedValue)
|
|
mon.start(add_current_value=False)
|
|
|
|
a.monitors[0].fire(value=10.0)
|
|
assert mon.data["value"] == 12.0 # recomputed: a=10 (just fired), b=2
|
|
|
|
b.monitors[0].fire(value=20.0)
|
|
assert mon.data["value"] == 30.0 # a=10, b=20
|
|
|
|
|
|
def test_adjustable_virtual_seed_recomputes_from_current_values():
|
|
a = FakeLeaf("a", 1.0)
|
|
b = FakeLeaf("b", 2.0)
|
|
v = AdjustableVirtual(
|
|
[a, b],
|
|
foo_get_current_value=lambda x, y: x + y,
|
|
foo_set_target_value_current_value=lambda val: (val, val),
|
|
name="v",
|
|
)
|
|
mon = v.set_current_value_callback(func="latest")
|
|
mon.start(add_current_value=True)
|
|
assert mon.data["value"] == 3.0
|
|
|
|
|
|
def test_start_accepts_and_forwards_ca_style_kwargs():
|
|
"""CallbackComposedValue.start() must accept with_ctrlvars/auto_monitor
|
|
for interface compatibility with CallbackEpics.start() - a caller that
|
|
does not know whether a given channel is CA-backed or composed (e.g.
|
|
RecordingSession.start(), which calls .start() the same way on every
|
|
monitor it gets back) must be able to call either uniformly."""
|
|
a = FakeLeaf("a", 1.0)
|
|
v = AdjustableVirtual(
|
|
[a],
|
|
foo_get_current_value=lambda x: x,
|
|
foo_set_target_value_current_value=lambda val: (val,),
|
|
name="v",
|
|
)
|
|
mon = v.set_current_value_callback(func="latest")
|
|
mon.start(add_current_value=True, with_ctrlvars=False, auto_monitor="something")
|
|
assert a.monitors[0].started_with["with_ctrlvars"] is False
|
|
assert a.monitors[0].started_with["auto_monitor"] == "something"
|
|
|
|
|
|
def test_adjustable_virtual_refuses_to_monitor_a_non_monitorable_parent():
|
|
a = FakeLeaf("a", 1.0)
|
|
b = NonMonitorableLeaf("b", 2.0)
|
|
v = AdjustableVirtual(
|
|
[a, b],
|
|
foo_get_current_value=lambda x, y: x + y,
|
|
foo_set_target_value_current_value=lambda val: (val, val),
|
|
name="v",
|
|
)
|
|
with pytest.raises(NotImplementedError, match="b"):
|
|
v.set_current_value_callback()
|
|
|
|
|
|
def test_nested_adjustable_virtual_monitoring():
|
|
"""A virtual built from another virtual - recursion works because
|
|
CallbackComposedValue is itself a valid MonitorableValueUpdate parent."""
|
|
a = FakeLeaf("a", 1.0)
|
|
b = FakeLeaf("b", 2.0)
|
|
inner = AdjustableVirtual(
|
|
[a, b],
|
|
foo_get_current_value=lambda x, y: x + y,
|
|
foo_set_target_value_current_value=lambda val: (val, val),
|
|
name="inner",
|
|
)
|
|
c = FakeLeaf("c", 100.0)
|
|
outer = AdjustableVirtual(
|
|
[inner, c],
|
|
foo_get_current_value=lambda x, y: x * y,
|
|
foo_set_target_value_current_value=lambda val: (val, val),
|
|
name="outer",
|
|
)
|
|
mon = outer.set_current_value_callback(func="latest")
|
|
mon.start(add_current_value=True)
|
|
assert mon.data["value"] == 3.0 * 100.0 # (a + b) * c = 3 * 100
|
|
|
|
a.monitors[0].fire(value=10.0)
|
|
assert mon.data["value"] == (10.0 + 2.0) * 100.0
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# DetectorVirtual - same contract, no set_target_value needed
|
|
|
|
|
|
def test_detector_virtual_monitors_when_all_parents_are_monitorable():
|
|
a = FakeLeaf("a", 1.0)
|
|
b = FakeLeaf("b", 2.0)
|
|
v = DetectorVirtual([a, b], foo_get_current_value=lambda x, y: x + y, name="v")
|
|
mon = v.set_current_value_callback(func="latest")
|
|
assert isinstance(mon, CallbackComposedValue)
|
|
mon.start(add_current_value=False)
|
|
|
|
a.monitors[0].fire(value=10.0)
|
|
assert mon.data["value"] == 12.0
|
|
|
|
|
|
def test_detector_virtual_seed_recomputes_from_current_values():
|
|
a = FakeLeaf("a", 1.0)
|
|
b = FakeLeaf("b", 2.0)
|
|
v = DetectorVirtual([a, b], foo_get_current_value=lambda x, y: x + y, name="v")
|
|
mon = v.set_current_value_callback(func="latest")
|
|
mon.start(add_current_value=True)
|
|
assert mon.data["value"] == 3.0
|
|
|
|
|
|
def test_detector_virtual_refuses_to_monitor_a_non_monitorable_parent():
|
|
a = FakeLeaf("a", 1.0)
|
|
b = NonMonitorableLeaf("b", 2.0)
|
|
v = DetectorVirtual([a, b], foo_get_current_value=lambda x, y: x + y, name="v")
|
|
with pytest.raises(NotImplementedError, match="b"):
|
|
v.set_current_value_callback()
|
|
|
|
|
|
def test_detector_virtual_of_adjustable_virtual_monitoring():
|
|
"""Mixed nesting: a DetectorVirtual built on top of an AdjustableVirtual
|
|
parent - the protocol check is generic, not type-specific."""
|
|
a = FakeLeaf("a", 1.0)
|
|
b = FakeLeaf("b", 2.0)
|
|
inner = AdjustableVirtual(
|
|
[a, b],
|
|
foo_get_current_value=lambda x, y: x + y,
|
|
foo_set_target_value_current_value=lambda val: (val, val),
|
|
name="inner",
|
|
)
|
|
outer = DetectorVirtual([inner], foo_get_current_value=lambda x: x * 2, name="outer")
|
|
mon = outer.set_current_value_callback(func="latest")
|
|
mon.start(add_current_value=True)
|
|
assert mon.data["value"] == 6.0 # (a + b) * 2 = 3 * 2
|
|
|
|
a.monitors[0].fire(value=10.0)
|
|
assert mon.data["value"] == 24.0 # (10 + 2) * 2
|