kiwi-scan 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kiwi_scan/__init__.py +192 -0
- kiwi_scan/actuator/factory.py +69 -0
- kiwi_scan/actuator/multi.py +116 -0
- kiwi_scan/actuator/single.py +216 -0
- kiwi_scan/actuator_concrete/single_epics.py +512 -0
- kiwi_scan/actuator_concrete/single_simulation.py +377 -0
- kiwi_scan/actuator_concrete/undulator.py +119 -0
- kiwi_scan/actuator_runner.py +613 -0
- kiwi_scan/dataloader.py +95 -0
- kiwi_scan/datamodels.py +355 -0
- kiwi_scan/epics_wrapper.py +243 -0
- kiwi_scan/manifestfiles.py +118 -0
- kiwi_scan/manifestwriter.py +537 -0
- kiwi_scan/metadata_loader.py +150 -0
- kiwi_scan/monitor/__init__.py +0 -0
- kiwi_scan/monitor/base.py +26 -0
- kiwi_scan/monitor/factory.py +51 -0
- kiwi_scan/monitor_concrete/__init__.py +0 -0
- kiwi_scan/monitor_concrete/print.py +186 -0
- kiwi_scan/monitor_concrete/queue_plotter.py +138 -0
- kiwi_scan/plotter.py +259 -0
- kiwi_scan/plugin/__init__.py +0 -0
- kiwi_scan/plugin/base.py +160 -0
- kiwi_scan/plugin/registry.py +33 -0
- kiwi_scan/plugin_concrete/__init__.py +0 -0
- kiwi_scan/plugin_concrete/jog_pid.py +165 -0
- kiwi_scan/plugin_concrete/logging.py +58 -0
- kiwi_scan/postmortem.py +77 -0
- kiwi_scan/scan/__init__.py +0 -0
- kiwi_scan/scan/common.py +1215 -0
- kiwi_scan/scan/metadata_monitor.py +258 -0
- kiwi_scan/scan/range_exit_detector.py +57 -0
- kiwi_scan/scan/registry.py +131 -0
- kiwi_scan/scan/scan_abs.py +98 -0
- kiwi_scan/scan/subscription_manager.py +511 -0
- kiwi_scan/scan/sync_controller.py +123 -0
- kiwi_scan/scan/tools.py +130 -0
- kiwi_scan/scan/trigger_manager.py +149 -0
- kiwi_scan/scan_concrete/__init__.py +0 -0
- kiwi_scan/scan_concrete/approach.py +53 -0
- kiwi_scan/scan_concrete/cm.py +206 -0
- kiwi_scan/scan_concrete/linear.py +114 -0
- kiwi_scan/scan_concrete/poll.py +177 -0
- kiwi_scan/scan_runner.py +316 -0
- kiwi_scan/scanplotter_cli.py +184 -0
- kiwi_scan/stats.py +162 -0
- kiwi_scan/test_support.py +198 -0
- kiwi_scan/yaml_loader.py +114 -0
- kiwi_scan-0.2.0.dist-info/LICENSE +21 -0
- kiwi_scan-0.2.0.dist-info/METADATA +813 -0
- kiwi_scan-0.2.0.dist-info/RECORD +54 -0
- kiwi_scan-0.2.0.dist-info/WHEEL +5 -0
- kiwi_scan-0.2.0.dist-info/entry_points.txt +5 -0
- kiwi_scan-0.2.0.dist-info/top_level.txt +1 -0
kiwi_scan/__init__.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Helmholtz-Zentrum Berlin für Materialien und Energie GmbH
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
This module provides the startup helpers for the import of scans and plugins.
|
|
6
|
+
|
|
7
|
+
Loading order:
|
|
8
|
+
1. Built-in
|
|
9
|
+
2. Optional
|
|
10
|
+
|
|
11
|
+
__all__:
|
|
12
|
+
- Plugin classes register themselves via decorators at import time.
|
|
13
|
+
- If a module is not imported, its class is not registered.
|
|
14
|
+
- Debug logging here helps diagnose missing registrations
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import importlib
|
|
21
|
+
import importlib.util
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import pkgutil
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Set
|
|
28
|
+
from kiwi_scan.scan.registry import load_all_scan_types
|
|
29
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
30
|
+
|
|
31
|
+
__all__ = ["__version__", "load_all_plugins", "load_all_scan_types"]
|
|
32
|
+
try:
|
|
33
|
+
__version__ = version("kiwi-scan")
|
|
34
|
+
except PackageNotFoundError:
|
|
35
|
+
# fallback for editable installs / no metadata
|
|
36
|
+
__version__ = "0+unknown"
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
PLUGIN_ENVVAR = "KIWI_SCAN_PLUGIN_PATH"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _module_suffix_for_path(pyfile: Path) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Avoids module-name collisions when two different plugin directories
|
|
46
|
+
contain files with the same basename.
|
|
47
|
+
"""
|
|
48
|
+
resolved = str(pyfile.resolve())
|
|
49
|
+
digest = hashlib.sha1(resolved.encode("utf-8")).hexdigest()[:12]
|
|
50
|
+
return f"{pyfile.stem}_{digest}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _import_plugin_file(pyfile: Path, raise_on_error: bool = False) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Import plugin module.
|
|
56
|
+
"""
|
|
57
|
+
pyfile = pyfile.resolve()
|
|
58
|
+
module_name = f"kiwi_scan_ext_{_module_suffix_for_path(pyfile)}"
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
logger.debug("Importing plugin file: %s as %s", pyfile, module_name)
|
|
62
|
+
|
|
63
|
+
spec = importlib.util.spec_from_file_location(module_name, pyfile)
|
|
64
|
+
if spec is None or spec.loader is None:
|
|
65
|
+
raise ImportError(f"Could not create import spec for {pyfile}")
|
|
66
|
+
|
|
67
|
+
module = importlib.util.module_from_spec(spec)
|
|
68
|
+
sys.modules[module_name] = module
|
|
69
|
+
spec.loader.exec_module(module)
|
|
70
|
+
|
|
71
|
+
logger.debug("Imported plugin file successfully: %s", pyfile)
|
|
72
|
+
|
|
73
|
+
except Exception:
|
|
74
|
+
logger.exception("Failed to import plugin file %s", pyfile)
|
|
75
|
+
if raise_on_error:
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _import_package_modules(package_name: str, raise_on_error: bool = False) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Import all modules provided by kiwi scan
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
package = importlib.import_module(package_name)
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception("Failed to import package %s", package_name)
|
|
87
|
+
if raise_on_error:
|
|
88
|
+
raise
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
package_path = getattr(package, "__path__", None)
|
|
92
|
+
if not package_path:
|
|
93
|
+
logger.debug("Package %s has no __path__; nothing to scan", package_name)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
modules = sorted(pkgutil.iter_modules(package_path), key=lambda m: m.name)
|
|
97
|
+
logger.debug("Scanning built-in plugin package %s (%d modules)", package_name, len(modules))
|
|
98
|
+
|
|
99
|
+
for module_info in modules:
|
|
100
|
+
if module_info.name.startswith("_"):
|
|
101
|
+
logger.debug("Skipping private module %s.%s", package_name, module_info.name)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
fullname = f"{package_name}.{module_info.name}"
|
|
105
|
+
try:
|
|
106
|
+
logger.debug("Importing built-in plugin module %s", fullname)
|
|
107
|
+
importlib.import_module(fullname)
|
|
108
|
+
logger.debug("Imported built-in plugin module %s", fullname)
|
|
109
|
+
except Exception:
|
|
110
|
+
logger.exception("Failed to import built-in plugin module %s", fullname)
|
|
111
|
+
if raise_on_error:
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_all_plugins(raise_on_error: bool = False) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Import all plugin modules.
|
|
118
|
+
"""
|
|
119
|
+
package_root = __package__ or __name__
|
|
120
|
+
builtin_package = f"{package_root}.plugin_concrete"
|
|
121
|
+
|
|
122
|
+
raw = os.environ.get(PLUGIN_ENVVAR, "")
|
|
123
|
+
|
|
124
|
+
logger.debug("Starting plugin loading")
|
|
125
|
+
logger.debug("Builtin plugin package: %s", builtin_package)
|
|
126
|
+
logger.debug("%s=%r", PLUGIN_ENVVAR, raw)
|
|
127
|
+
|
|
128
|
+
# Always load the plugins that ship with kiwi_scan.
|
|
129
|
+
_import_package_modules(builtin_package, raise_on_error=raise_on_error)
|
|
130
|
+
|
|
131
|
+
# Then load any external plugins configured by the user.
|
|
132
|
+
if not raw.strip():
|
|
133
|
+
logger.debug("%s not set; built-in plugins loaded only", PLUGIN_ENVVAR)
|
|
134
|
+
else:
|
|
135
|
+
seen: Set[Path] = set()
|
|
136
|
+
|
|
137
|
+
for entry in raw.split(os.pathsep):
|
|
138
|
+
entry = entry.strip()
|
|
139
|
+
if not entry:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
p = Path(entry).expanduser().resolve()
|
|
143
|
+
logger.debug("Considering plugin path entry: %s", p)
|
|
144
|
+
|
|
145
|
+
if not p.exists():
|
|
146
|
+
logger.warning("Plugin path does not exist: %s", p)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if p.is_file():
|
|
150
|
+
if p.suffix != ".py":
|
|
151
|
+
logger.debug("Skipping non-Python file: %s", p)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if p in seen:
|
|
155
|
+
logger.debug("Skipping duplicate plugin file: %s", p)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
_import_plugin_file(p, raise_on_error=raise_on_error)
|
|
159
|
+
seen.add(p)
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if p.is_dir():
|
|
163
|
+
pyfiles = sorted(
|
|
164
|
+
candidate
|
|
165
|
+
for candidate in p.glob("*.py")
|
|
166
|
+
if not candidate.name.startswith("_")
|
|
167
|
+
)
|
|
168
|
+
logger.debug("Scanning plugin directory %s (%d files)", p, pyfiles.__len__())
|
|
169
|
+
|
|
170
|
+
for pyfile in pyfiles:
|
|
171
|
+
pyfile = pyfile.resolve()
|
|
172
|
+
if pyfile in seen:
|
|
173
|
+
logger.debug("Skipping already imported plugin file: %s", pyfile)
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
_import_plugin_file(pyfile, raise_on_error=raise_on_error)
|
|
177
|
+
seen.add(pyfile)
|
|
178
|
+
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
logger.warning("Plugin path is neither a file nor directory: %s", p)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
from kiwi_scan.plugin.registry import PLUGIN_REGISTRY
|
|
185
|
+
|
|
186
|
+
logger.debug(
|
|
187
|
+
"Plugin registry now contains %d entries: %s",
|
|
188
|
+
len(PLUGIN_REGISTRY),
|
|
189
|
+
sorted(PLUGIN_REGISTRY.keys()),
|
|
190
|
+
)
|
|
191
|
+
except Exception:
|
|
192
|
+
logger.exception("Could not inspect plugin registry after loading")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Helmholtz-Zentrum Berlin für Materialien und Energie GmbH
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from typing import Type, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from kiwi_scan.datamodels import ActuatorConfig
|
|
7
|
+
from kiwi_scan.actuator_concrete.single_epics import EpicsActuator
|
|
8
|
+
from kiwi_scan.actuator_concrete.single_simulation import SimulatedActuator
|
|
9
|
+
from kiwi_scan.actuator.single import AbstractActuator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ActuatorFactory:
|
|
13
|
+
"""
|
|
14
|
+
Factory for creating actuator instances based on configuration.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
# simple flag-based simulation
|
|
18
|
+
actuator = ActuatorFactory.create(config, use_simulation=True)
|
|
19
|
+
|
|
20
|
+
# config-based (EPICS)
|
|
21
|
+
actuator = ActuatorFactory.create(config)
|
|
22
|
+
"""
|
|
23
|
+
# Registry mapping type keys to actuator classes
|
|
24
|
+
_registry: Dict[str, Type[AbstractActuator]] = {
|
|
25
|
+
'epics': EpicsActuator,
|
|
26
|
+
'sim': SimulatedActuator,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def create(
|
|
31
|
+
cls,
|
|
32
|
+
config: ActuatorConfig,
|
|
33
|
+
use_simulation: Optional[bool] = None,
|
|
34
|
+
actuator_type: Optional[str] = None,
|
|
35
|
+
) -> AbstractActuator:
|
|
36
|
+
"""
|
|
37
|
+
Create an actuator based on:
|
|
38
|
+
- explicit `use_simulation` flag (overrides everything)
|
|
39
|
+
- `actuator_type` argument ('epics' or 'sim')
|
|
40
|
+
- `config.actuator_type` attribute, if defined in your datamodel
|
|
41
|
+
|
|
42
|
+
Falls back to EpicsActuator.
|
|
43
|
+
"""
|
|
44
|
+
# Determine type key
|
|
45
|
+
if use_simulation is True:
|
|
46
|
+
key = 'sim'
|
|
47
|
+
elif use_simulation is False:
|
|
48
|
+
key = 'epics'
|
|
49
|
+
else:
|
|
50
|
+
# try explicit override
|
|
51
|
+
key = actuator_type or getattr(config, 'actuator_type', None)
|
|
52
|
+
# default if missing
|
|
53
|
+
key = key or 'epics'
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
actuator_cls = cls._registry[key]
|
|
57
|
+
except KeyError:
|
|
58
|
+
raise ValueError(f"Unknown actuator type '{key}'. "
|
|
59
|
+
f"Supported types: {list(cls._registry)}")
|
|
60
|
+
|
|
61
|
+
return actuator_cls(config)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def create_actuator(config: ActuatorConfig) -> AbstractActuator:
|
|
65
|
+
key = config.type # “epics” or “sim”
|
|
66
|
+
cls = ActuatorFactory._registry.get(key)
|
|
67
|
+
if cls is None:
|
|
68
|
+
raise ValueError(f"Unknown type {key}")
|
|
69
|
+
return cls(config)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Helmholtz-Zentrum Berlin für Materialien und Energie GmbH
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from typing import Sequence, Optional, Any
|
|
5
|
+
import time
|
|
6
|
+
from kiwi_scan.actuator.single import AbstractActuator
|
|
7
|
+
from kiwi_scan.datamodels import ActuatorConfig
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from typing import Sequence, Optional, Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from kiwi_scan.actuator.single import AbstractActuator
|
|
15
|
+
from kiwi_scan.datamodels import ActuatorConfig
|
|
16
|
+
from kiwi_scan.epics_wrapper import EpicsPV
|
|
17
|
+
|
|
18
|
+
class MultiActuator(AbstractActuator):
|
|
19
|
+
"""
|
|
20
|
+
A composite actuator that lets you jog N child actuators in one call.
|
|
21
|
+
"""
|
|
22
|
+
def __init__(self, actuators: Sequence[AbstractActuator], config: ActuatorConfig):
|
|
23
|
+
super().__init__(config)
|
|
24
|
+
self._axes = list(actuators)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def pvname(self) -> Sequence[str]:
|
|
28
|
+
return [ax.pvname for ax in self._axes]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def rbv(self) -> Sequence[Any]:
|
|
32
|
+
return [ax.rbv for ax in self._axes]
|
|
33
|
+
|
|
34
|
+
@rbv.setter
|
|
35
|
+
def rbv(self, values: Sequence[Any]) -> None:
|
|
36
|
+
if len(values) != len(self._axes):
|
|
37
|
+
raise ValueError(f"Expected {len(self._axes)} values, got {len(values)}")
|
|
38
|
+
for ax, v in zip(self._axes, values):
|
|
39
|
+
ax.rbv = v
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def cmdv(self) -> Sequence[Any]:
|
|
43
|
+
return [ax.cmdv for ax in self._axes]
|
|
44
|
+
|
|
45
|
+
@cmdv.setter
|
|
46
|
+
def cmdv(self, values: Sequence[Any]) -> None:
|
|
47
|
+
if len(values) != len(self._axes):
|
|
48
|
+
raise ValueError(f"Expected {len(self._axes)} values, got {len(values)}")
|
|
49
|
+
for ax, v in zip(self._axes, values):
|
|
50
|
+
ax.cmdv = v
|
|
51
|
+
|
|
52
|
+
# Implemented abstract methods:
|
|
53
|
+
def set_velocity(self, velocities: Sequence[float]) -> None:
|
|
54
|
+
if len(velocities) != len(self._axes):
|
|
55
|
+
raise ValueError(f"Need {len(self._axes)} velocities, got {len(velocities)}")
|
|
56
|
+
for ax, v in zip(self._axes, velocities):
|
|
57
|
+
ax.set_velocity(v)
|
|
58
|
+
|
|
59
|
+
def get_velocity(self) -> Sequence[Optional[float]]:
|
|
60
|
+
return [ax.get_velocity() for ax in self._axes]
|
|
61
|
+
|
|
62
|
+
def move(self, positions: Sequence[float]) -> None:
|
|
63
|
+
# non-blocking multi-axis move
|
|
64
|
+
self.run_move(positions, sync=False)
|
|
65
|
+
|
|
66
|
+
def rel_move(self, deltas: Sequence[float]) -> None:
|
|
67
|
+
"""Non-blocking multi-axis relative move."""
|
|
68
|
+
self.run_rel_move(deltas, sync=False)
|
|
69
|
+
|
|
70
|
+
def run_move(self, positions: Sequence[float], sync: bool = True) -> None:
|
|
71
|
+
if len(positions) != len(self._axes):
|
|
72
|
+
raise ValueError(f"Expected {len(self._axes)} positions, got {len(positions)}")
|
|
73
|
+
# issue each child move without blocking
|
|
74
|
+
for ax, pos in zip(self._axes, positions):
|
|
75
|
+
ax.run_move(pos, sync=False)
|
|
76
|
+
if sync:
|
|
77
|
+
# wait until all axes are ready
|
|
78
|
+
while not self.is_ready():
|
|
79
|
+
time.sleep(0.01)
|
|
80
|
+
|
|
81
|
+
def run_rel_move(self, deltas: Sequence[float], sync: bool = True) -> None:
|
|
82
|
+
if len(deltas) != len(self._axes):
|
|
83
|
+
raise ValueError(f"Expected {len(self._axes)} deltas, got {len(deltas)}")
|
|
84
|
+
# issue each child relative move without blocking
|
|
85
|
+
for ax, d in zip(self._axes, deltas):
|
|
86
|
+
ax.run_rel_move(float(d), sync=False)
|
|
87
|
+
if sync:
|
|
88
|
+
while not self.is_ready():
|
|
89
|
+
time.sleep(0.01)
|
|
90
|
+
|
|
91
|
+
def jog(self, velocities: Sequence[float], sync: bool = True) -> None:
|
|
92
|
+
if len(velocities) != len(self._axes):
|
|
93
|
+
raise ValueError(f"Need {len(self._axes)} velocities, got {len(velocities)}")
|
|
94
|
+
for ax, v in zip(self._axes, velocities):
|
|
95
|
+
ax.jog(v, sync=False)
|
|
96
|
+
if sync:
|
|
97
|
+
while not self.is_ready():
|
|
98
|
+
time.sleep(0.01)
|
|
99
|
+
|
|
100
|
+
def stop(self) -> None:
|
|
101
|
+
for ax in self._axes:
|
|
102
|
+
ax.stop()
|
|
103
|
+
|
|
104
|
+
def is_ready(self) -> bool:
|
|
105
|
+
# wait for all single actuator conditions
|
|
106
|
+
return all(ax.is_ready() for ax in self._axes)
|
|
107
|
+
|
|
108
|
+
def is_in_position(self, targets: Sequence[float], in_position_band: Optional[float] = None) -> bool:
|
|
109
|
+
band = in_position_band if in_position_band is not None else self.in_position_band
|
|
110
|
+
return all(ax.is_in_position(t, band) for ax, t in zip(self._axes, targets))
|
|
111
|
+
|
|
112
|
+
def wait_until_done(self, positions: Optional[Sequence[float]] = None) -> None:
|
|
113
|
+
# wait until all axes have stopped, single actuators provide in position band etc.
|
|
114
|
+
while not self.is_ready():
|
|
115
|
+
time.sleep(0.01)
|
|
116
|
+
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Helmholtz-Zentrum Berlin für Materialien und Energie GmbH
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional, Any, Callable, Dict, Mapping
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from kiwi_scan.datamodels import ActuatorConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PvEvent:
|
|
16
|
+
"""
|
|
17
|
+
Representation of one PV update.
|
|
18
|
+
This decouples from the raw pyepics callback kwargs.
|
|
19
|
+
"""
|
|
20
|
+
pvname: str
|
|
21
|
+
value: Any
|
|
22
|
+
source: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
# Time
|
|
25
|
+
timestamp: Optional[float] = None # seconds since epoch (float)
|
|
26
|
+
posixseconds: Optional[int] = None
|
|
27
|
+
nanoseconds: Optional[int] = None
|
|
28
|
+
|
|
29
|
+
# Alarm / status
|
|
30
|
+
severity: Optional[int] = None
|
|
31
|
+
status: Optional[int] = None
|
|
32
|
+
|
|
33
|
+
# Anything else provided by the backend
|
|
34
|
+
raw: Mapping[str, Any] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
MonitorCallback = Callable[[PvEvent], None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AbstractActuator(ABC):
|
|
41
|
+
"""
|
|
42
|
+
Defines the interface for an actuator.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: ActuatorConfig):
|
|
46
|
+
self.config = config
|
|
47
|
+
# Expose common config parameters
|
|
48
|
+
self.backlash: float = config.backlash
|
|
49
|
+
self.in_position_band: float = config.in_position_band
|
|
50
|
+
self.dwell_time: float = config.dwell_time
|
|
51
|
+
self.ready_value: Any = config.ready_value
|
|
52
|
+
self.startup_timeout: float = config.startup_timeout
|
|
53
|
+
self.q_delay: float = config.queueing_delay
|
|
54
|
+
|
|
55
|
+
# --- Monitoring -----------------------
|
|
56
|
+
self._monitors: Dict[str, Any] = {} # pvname -> backend monitor object (e.g. EpicsPV)
|
|
57
|
+
self._monitor_callbacks: Dict[str, list[MonitorCallback]] = {}
|
|
58
|
+
self._last_events: Dict[str, PvEvent] = {}
|
|
59
|
+
self._monitor_lock = threading.RLock()
|
|
60
|
+
|
|
61
|
+
# --------------------- monitor/event helpers ----------------------------
|
|
62
|
+
|
|
63
|
+
def supports_monitors(self) -> bool:
|
|
64
|
+
"""Override in backends that can subscribe to PV updates (e.g. EPICS)."""
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def add_monitor(self, pvname: str, user_callback: Optional[MonitorCallback] = None, **kwargs) -> Any:
|
|
68
|
+
"""
|
|
69
|
+
Subscribe to PV updates. Concrete backends (EpicsActuator) should override.
|
|
70
|
+
Returns a backend-specific monitor handle/object (e.g. EpicsPV).
|
|
71
|
+
"""
|
|
72
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not support PV monitors")
|
|
73
|
+
|
|
74
|
+
def remove_monitor(self, pvname: str) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Remove/close a monitor subscription for pvname if supported by backend.
|
|
77
|
+
Concrete backends should override if they can actually clear CA subscriptions.
|
|
78
|
+
"""
|
|
79
|
+
with self._monitor_lock:
|
|
80
|
+
self._monitors.pop(pvname, None)
|
|
81
|
+
self._monitor_callbacks.pop(pvname, None)
|
|
82
|
+
self._last_events.pop(pvname, None)
|
|
83
|
+
|
|
84
|
+
def clear_monitors(self) -> None:
|
|
85
|
+
"""Remove all monitors."""
|
|
86
|
+
with self._monitor_lock:
|
|
87
|
+
pvs = list(self._monitors.keys())
|
|
88
|
+
for pv in pvs:
|
|
89
|
+
self.remove_monitor(pv)
|
|
90
|
+
|
|
91
|
+
def on_pv_event(self, pvname: str, cb: MonitorCallback) -> None:
|
|
92
|
+
"""Register an additional listener for pvname (even if monitor already exists)."""
|
|
93
|
+
with self._monitor_lock:
|
|
94
|
+
self._monitor_callbacks.setdefault(pvname, []).append(cb)
|
|
95
|
+
|
|
96
|
+
def get_last_event(self, pvname: str) -> Optional[PvEvent]:
|
|
97
|
+
with self._monitor_lock:
|
|
98
|
+
return self._last_events.get(pvname)
|
|
99
|
+
|
|
100
|
+
def _dispatch_pv_update(self, pvname: str, value: Any, **kw: Any) -> PvEvent:
|
|
101
|
+
"""
|
|
102
|
+
Backend-facing entry point: call this from EPICS callbacks.
|
|
103
|
+
Stores last event and notifies listeners.
|
|
104
|
+
"""
|
|
105
|
+
ev = PvEvent(
|
|
106
|
+
pvname=pvname,
|
|
107
|
+
value=value,
|
|
108
|
+
timestamp=kw.get("timestamp", None),
|
|
109
|
+
posixseconds=kw.get("posixseconds", None),
|
|
110
|
+
nanoseconds=kw.get("nanoseconds", None),
|
|
111
|
+
severity=kw.get("severity", None),
|
|
112
|
+
status=kw.get("status", None),
|
|
113
|
+
source=kw.get("source", None),
|
|
114
|
+
raw=dict(kw) if kw else {},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
with self._monitor_lock:
|
|
118
|
+
self._last_events[pvname] = ev
|
|
119
|
+
listeners = list(self._monitor_callbacks.get(pvname, []))
|
|
120
|
+
|
|
121
|
+
# Call listeners outside the lock
|
|
122
|
+
for cb in listeners:
|
|
123
|
+
try:
|
|
124
|
+
cb(ev)
|
|
125
|
+
except Exception:
|
|
126
|
+
# Keep it non-fatal: monitor callbacks must never kill control logic
|
|
127
|
+
logging.exception(f"Monitor callback failed for {pvname}")
|
|
128
|
+
|
|
129
|
+
return ev
|
|
130
|
+
@property
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def pvname(self) -> str:
|
|
133
|
+
"""The primary PV name for the actuator."""
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
@abstractmethod
|
|
138
|
+
def rbv(self) -> Optional[Any]:
|
|
139
|
+
"""Read-back value shortcut property."""
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
@rbv.setter
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def rbv(self, value: Any) -> None:
|
|
145
|
+
"""Read-back value shortcut property setter."""
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
@abstractmethod
|
|
150
|
+
def cmdv(self) -> Optional[Any]:
|
|
151
|
+
"""Commanded value shortcut property."""
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
@cmdv.setter
|
|
155
|
+
@abstractmethod
|
|
156
|
+
def cmdv(self, value: Any) -> None:
|
|
157
|
+
"""Commanded value shortcut property setter."""
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
def set_velocity(self, velocity: float) -> None:
|
|
162
|
+
"""Set the actuator velocity (for move and jog)."""
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
@abstractmethod
|
|
166
|
+
def get_velocity(self) -> Optional[float]:
|
|
167
|
+
"""Get the current actuator velocity."""
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
@abstractmethod
|
|
171
|
+
def move(self, position: float) -> None:
|
|
172
|
+
"""Issue a move command without waiting."""
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
@abstractmethod
|
|
176
|
+
def rel_move(self, delta: float) -> None:
|
|
177
|
+
"""Issue a *relative* move command without waiting (incremental move)."""
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
@abstractmethod
|
|
181
|
+
def run_move(self, position: float, sync: bool = True) -> None:
|
|
182
|
+
"""Move the actuator, optionally waiting until completion."""
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
@abstractmethod
|
|
186
|
+
def run_rel_move(self, delta: float, sync: bool = True) -> None:
|
|
187
|
+
"""Relative move, optionally waiting until completion."""
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
@abstractmethod
|
|
191
|
+
def jog(self, velocity: float, sync: bool = True) -> None:
|
|
192
|
+
"""Continuous jog at given velocity."""
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
@abstractmethod
|
|
196
|
+
def is_ready(self) -> bool:
|
|
197
|
+
"""Return True if actuator is ready (not is_movingy())."""
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
def is_moving(self) -> bool:
|
|
201
|
+
return not self.is_ready()
|
|
202
|
+
|
|
203
|
+
@abstractmethod
|
|
204
|
+
def is_in_position(self, target: float, in_position_band: float) -> bool:
|
|
205
|
+
"""Check if readback is within band of target."""
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
@abstractmethod
|
|
209
|
+
def wait_until_done(self, position: float) -> None:
|
|
210
|
+
"""Block until actuator has reached target."""
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
@abstractmethod
|
|
214
|
+
def stop(self) -> None:
|
|
215
|
+
"""Stop motion immediately."""
|
|
216
|
+
pass
|