seq-quince 0.1.1__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.
seq_quince/__init__.py ADDED
File without changes
@@ -0,0 +1,129 @@
1
+ import logging
2
+ import os
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ import yaml
6
+
7
+ from seq_quince.loader import j2_yaml_loader
8
+ from seq_quince.sequence import MoveStep, build_sequence
9
+ from seq_quince.snapshot import SnapshotManager
10
+ from seq_quince.tools import get_scheduler_config_dir, merge_replacements
11
+
12
+ log = logging.getLogger("seq_quince.loader")
13
+
14
+ #TODO: Expand macros
15
+ #TODO: Templates with Jinja2
16
+ #TODO: Defaults
17
+ class SchedulerConfigManager:
18
+ def __init__(
19
+ self,
20
+ config_dir: Optional[str] = None,
21
+ replacements: Optional[Dict[str, str]] = None,
22
+ config_file: str = 'beamline.yaml',
23
+ preset_file: str = 'presets.yaml',
24
+ log_level: int = logging.INFO,
25
+ ):
26
+ log.setLevel(log_level)
27
+ self.replacements = replacements or {}
28
+ self.config_dir = (
29
+ config_dir or
30
+ os.environ.get("SCHEDULER_CONFIG_DIR") or
31
+ get_scheduler_config_dir()
32
+ )
33
+ log.info(f"Scheduler config directory: {self.config_dir}")
34
+ self.config_path = os.path.join(self.config_dir, config_file)
35
+ self.snapshot_manager = SnapshotManager(config_dir=self.config_dir, preset_file=preset_file, replacements=self.replacements)
36
+ self.config = self._load_config()
37
+ log.info(f"Scheduler config loaded: {self.config_path}")
38
+ self.devices_cfg = self.config.get("devices", [])
39
+ self.macros = self.config.get("macros", {})
40
+ self.presets = self.config.get("presets", {})
41
+ self.sequence = self.config.get("sequence", {})
42
+
43
+ def _load_config(self) -> Dict[str, Any]:
44
+ log.info(f"yaml_load: {self.config_path}, {self.replacements}")
45
+ cfg = j2_yaml_loader(self.config_path, self.replacements)
46
+ log.debug(f"cfg: {cfg}")
47
+ user_presets = self.snapshot_manager.load_snapshot()
48
+
49
+ # Merge presets: user_presets overrides cfg
50
+ cfg_presets = cfg.get("presets", {})
51
+ user_presets_only = user_presets.get("presets", {})
52
+ merged_presets = {**cfg_presets, **user_presets_only}
53
+
54
+ # Insert merged presets back into cfg
55
+ cfg["presets"] = merged_presets
56
+ log.debug(f"cfg merged: {cfg}")
57
+ return cfg
58
+
59
+ def get_device_configs(self) -> List[Dict[str, Any]]:
60
+ return self.devices_cfg
61
+
62
+ def create_devices(self, factory) -> Dict[str, Any]:
63
+ names = set()
64
+ for cfg in self.devices_cfg:
65
+ name = cfg.get("name")
66
+ if name in names:
67
+ raise ValueError(f"Duplicate device name '{name}' in config.")
68
+ names.add(name)
69
+ for cfg in self.devices_cfg:
70
+ merge_replacements(cfg, self.replacements)
71
+ devices = [factory.create(cfg) for cfg in self.devices_cfg]
72
+ return {d.name: d for d in devices}
73
+
74
+ def get_device_by_name(self, name: str) -> Optional[Dict[str, Any]]:
75
+ return next((cfg for cfg in self.devices_cfg if cfg.get("name") == name), None)
76
+
77
+ def get_preset(self, preset_name: str) -> Dict[str, Any]:
78
+ if preset_name not in self.presets:
79
+ raise ValueError(f"Preset '{preset_name}' not found in config.")
80
+ return self.presets[preset_name]
81
+
82
+ def get_sequence_config(self) -> Dict[str, Any]:
83
+ return self.sequence
84
+
85
+ def get_macros(self) -> Dict[str, str]:
86
+ return self.macros
87
+
88
+ def build_sequence_from_preset(
89
+ self,
90
+ preset_name: str,
91
+ devices: dict,
92
+ *,
93
+ default_grouping: str = "parallel",
94
+ build_sequence_func=None, # allow for test/mock override
95
+ ) -> List["MoveStep"]:
96
+ """
97
+ Build a MoveStep sequence for a given preset using the provided devices dict.
98
+ """
99
+ # Get the preset mapping
100
+ preset = self.get_preset(preset_name)
101
+ if preset is None:
102
+ raise ValueError(f"Preset '{preset_name}' not found in config.")
103
+ # Use external or default build_sequence function
104
+ if build_sequence_func is None:
105
+ build_sequence_func = build_sequence
106
+ # Use the preset as the 'target'
107
+ return build_sequence_func(devices, preset, default_grouping=default_grouping)
108
+
109
+ # TODO: Implement a method to update full config (takes effact before device creation)
110
+ def update_preset_item(self, preset_name: str, device_name: str, updates: dict) -> None:
111
+ """
112
+ Update one device's preset entry with new values.
113
+ Example: update_preset_item('init', 'energy_scan', {'start': 355.0})
114
+ """
115
+ preset = self.presets.setdefault(preset_name, {})
116
+ device_preset = preset.setdefault(device_name, {})
117
+ if not isinstance(device_preset, dict):
118
+ raise TypeError(f"Preset entry for {device_name} in '{preset_name}' must be a dict.")
119
+ device_preset.update(updates)
120
+
121
+ def save_snapshot(self, name: str, structure: Dict[str, Dict[str, float]]) -> None:
122
+ self.snapshot_manager.save_snapshot(name, structure)
123
+
124
+ def save_config(self, path: Optional[str] = None) -> None:
125
+ path = path or self.config_path
126
+ # TODO: If you have self.replacements, replace macros before writing if needed
127
+ with open(path, "w") as f:
128
+ yaml.dump(self.config, f, default_flow_style=False)
129
+
File without changes
@@ -0,0 +1,96 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Dict, Optional, Type, TypeVar
3
+
4
+
5
+ def get_param(config: Dict[str, Any], key: str, typ, default=None, required=False):
6
+ value = config.get(key, default)
7
+ if required and value is None:
8
+ raise ValueError(f"Missing required parameter: '{key}'")
9
+ if value is not None and not isinstance(value, typ):
10
+ try:
11
+ value = typ(value)
12
+ except (TypeError, ValueError) as e:
13
+ raise TypeError(
14
+ f"Parameter '{key}' expects type {typ.__name__} "
15
+ f"but got {value!r} ({e})"
16
+ ) from e
17
+ return value
18
+
19
+
20
+
21
+ T = TypeVar("T", bound="DeviceABC")
22
+
23
+ class DeviceABC(ABC):
24
+ """Abstract base class for devices."""
25
+
26
+ # init free configurable
27
+ # ------ Data & state ----------------------------------------------------
28
+ name: str # human-readable identifier
29
+
30
+
31
+ @classmethod
32
+ @abstractmethod
33
+ def from_config_dict(cls: Type[T], config: Dict[str, Any]) -> T:
34
+ """
35
+ Create an instance of the device from a config dictionary.
36
+ """
37
+
38
+ # ------ Motion primitives ----------------------------------------------
39
+ # async def move_to_pose(self, pose: Dict[str, float]) -> None:
40
+ @abstractmethod
41
+ async def process(self, pose: Dict[str, float]) -> None:
42
+ """
43
+ Drive the device so every axis reaches the *pose* target (value in
44
+ engineering units).
45
+ Must raise error *only* after an orderly rollback has completed or is impossible.
46
+ """
47
+
48
+ @abstractmethod
49
+ async def snapshot(self) -> Dict[str, float]:
50
+ """Return the *current* read-back values for every axis."""
51
+
52
+ @property
53
+ @abstractmethod
54
+ def busy(self) -> bool:
55
+ """True while **any** axis is still moving or the device is faulted."""
56
+
57
+ @abstractmethod
58
+ async def stop(self) -> None:
59
+ """
60
+ Best-effort immediate stop (e.g. user pressed Ctrl-C). Must leave the
61
+ device in a recoverable, well-defined state.
62
+ """
63
+
64
+ async def close(self) -> None:
65
+ """
66
+ Release owned resources after operations settle; safe to repeat.
67
+ """
68
+ from seq_quince.device.lifecycle import close_resources
69
+
70
+ await close_resources(self)
71
+
72
+ def get_capability(self, name: str) -> Optional[Any]:
73
+ """Return the capability object by name, or None if not present."""
74
+ return None # Default: no capability
75
+ def supports_rollback(self) -> bool:
76
+ """
77
+ Whether the scheduler should attempt rollback for this device.
78
+
79
+ Motion devices usually return True.
80
+ Wait-only / monitor-only devices should return False.
81
+ """
82
+ return True
83
+
84
+ def scheduler_timeout(self, pose: Optional[Dict[str, Any]] = None) -> Optional[float]:
85
+ """
86
+ Optional per-step timeout override used by Scheduler.
87
+
88
+ Return:
89
+ * None -> inherit the scheduler's global step_timeout
90
+ * > 0 -> scheduler-enforced timeout in seconds
91
+ * <= 0 -> treated as disabled (no scheduler timeout)
92
+
93
+ The default is None, so the global timeout applies when configured.
94
+ """
95
+ return None
96
+
@@ -0,0 +1,211 @@
1
+ """Load explicitly exported devices from SEQ_QUINCE_DEVICE_PATH directories."""
2
+
3
+ import hashlib
4
+ import importlib.util
5
+ import inspect
6
+ import os
7
+ import sys
8
+ from importlib.abc import ExecutionLoader
9
+ from importlib.machinery import ModuleSpec
10
+ from pathlib import Path
11
+ from types import ModuleType
12
+ from typing import Any, Dict, List, Mapping, Set, Tuple, Type
13
+
14
+ from seq_quince.device.device_abs import DeviceABC
15
+
16
+
17
+ class DeviceExtensionError(RuntimeError):
18
+ """An extension could not be imported or registered."""
19
+
20
+
21
+ class DeviceExtensionLoader:
22
+ """
23
+ Finds additional external device implementations.
24
+ One thread at a time can perform registry operations.
25
+ """
26
+
27
+ def __init__(self) -> None:
28
+ self._loaded_paths: Set[Path] = set()
29
+ self._loading = False
30
+
31
+ def load(self, registry: Mapping[str, Type[DeviceABC]]) -> Dict[str, Type[DeviceABC]]:
32
+ """Return validated additions, without modifying *registry*."""
33
+ if self._loading:
34
+ raise DeviceExtensionError(
35
+ "Recursive device extension discovery: extension imports must "
36
+ "not create devices or query the factory during discovery"
37
+ )
38
+ self._loading = True
39
+ try:
40
+ return self._load(registry)
41
+ finally:
42
+ self._loading = False
43
+
44
+ def _load(self, registry: Mapping[str, Type[DeviceABC]]) -> Dict[str, Type[DeviceABC]]:
45
+ roots = self._new_roots()
46
+ if not roots:
47
+ return {}
48
+ additions: Dict[str, Type[DeviceABC]] = {}
49
+ sources: Dict[str, Path] = {}
50
+ namespaces = [self._namespace(root) for root in roots]
51
+ previous_modules = {
52
+ name: module
53
+ for name, module in sys.modules.copy().items()
54
+ if self._belongs_to(name, namespaces)
55
+ }
56
+ try:
57
+ # associate each extension directory with its corresponding Python module
58
+ for root, namespace in zip(roots, namespaces):
59
+ self._create_namespace(root, namespace)
60
+ for entry, is_package in self._entries(root):
61
+ module = self._import_entry(entry, namespace, is_package)
62
+ if "DEVICE_CLASSES" not in vars(module):
63
+ continue
64
+ exports = vars(module)["DEVICE_CLASSES"]
65
+ for name, device_cls in self._validate_exports(exports, entry):
66
+ if name in registry or name in additions:
67
+ source = (
68
+ str(sources[name]) if name in additions
69
+ else self._class_source(registry[name])
70
+ )
71
+ raise DeviceExtensionError(
72
+ f"Duplicate device type {name!r} in extension "
73
+ f"'{entry}'; already registered by '{source}'"
74
+ )
75
+ additions[name] = device_cls
76
+ sources[name] = entry
77
+ except BaseException:
78
+ # Roll back only our module namespaces, including imported helpers.
79
+ # Preserve KeyboardInterrupt/SystemExit while cleaning up as well.
80
+ for name in list(sys.modules):
81
+ if self._belongs_to(name, namespaces):
82
+ del sys.modules[name]
83
+ sys.modules.update(previous_modules)
84
+ raise
85
+
86
+ self._loaded_paths.update(roots)
87
+ return additions
88
+
89
+ def _new_roots(self) -> List[Path]:
90
+ roots: List[Path] = []
91
+ seen: Set[Path] = set()
92
+ for value in os.environ.get("SEQ_QUINCE_DEVICE_PATH", "").split(os.pathsep):
93
+ if not value:
94
+ continue
95
+ root = Path(os.path.realpath(os.path.expanduser(value)))
96
+ if root in seen or root in self._loaded_paths:
97
+ continue
98
+ if not root.is_dir():
99
+ raise DeviceExtensionError(
100
+ f"SEQ_QUINCE_DEVICE_PATH entry '{value}' is not a directory "
101
+ f"(resolved to '{root}')"
102
+ )
103
+ seen.add(root)
104
+ roots.append(root)
105
+ return roots
106
+
107
+ @staticmethod
108
+ def _namespace(root: Path) -> str:
109
+ digest = hashlib.sha256(os.fsencode(str(root))).hexdigest()
110
+ return "_seq_quince_device_extensions_" + digest
111
+
112
+ @staticmethod
113
+ def _belongs_to(name: str, namespaces: List[str]) -> bool:
114
+ return any(name == prefix or name.startswith(prefix + ".")
115
+ for prefix in namespaces)
116
+
117
+ @staticmethod
118
+ def _create_namespace(root: Path, namespace: str) -> None:
119
+ if namespace in sys.modules:
120
+ return
121
+ module = ModuleType(namespace)
122
+ spec = ModuleSpec(namespace, loader=None, is_package=True)
123
+ spec.submodule_search_locations = [str(root)]
124
+ module.__spec__ = spec
125
+ module.__package__ = namespace
126
+ module.__path__ = [str(root)]
127
+ sys.modules[namespace] = module
128
+
129
+ @staticmethod
130
+ def _entries(root: Path) -> List[Tuple[Path, bool]]:
131
+ """ Cheks if each path is a package directory """
132
+ try:
133
+ children = sorted(root.iterdir())
134
+ entries: List[Tuple[Path, bool]] = []
135
+ names: Set[str] = set()
136
+ for entry in children:
137
+ if entry.name.startswith(("_", ".")):
138
+ continue
139
+ is_package = entry.is_dir() and (entry / "__init__.py").is_file()
140
+ if not is_package and not (entry.is_file() and entry.suffix == ".py"):
141
+ continue
142
+ name = entry.name if is_package else entry.stem
143
+ if not name.isidentifier():
144
+ raise DeviceExtensionError(f"Extension '{entry}' must have a valid Python module name")
145
+ if name in names:
146
+ raise DeviceExtensionError(
147
+ f"Ambiguous extension module name {name!r} in '{root}' "
148
+ "(both a Python file and package directory exist)"
149
+ )
150
+ names.add(name)
151
+ entries.append((entry, is_package))
152
+ return entries
153
+ except OSError as exc:
154
+ raise DeviceExtensionError(f"Cannot scan extension directory '{root}': {exc}") from exc
155
+
156
+ @staticmethod
157
+ def _import_entry(entry: Path, namespace: str, is_package: bool) -> ModuleType:
158
+ """ Loads a Python file or package from a given path and returns the module object """
159
+ name = entry.name if is_package else entry.stem
160
+ module_name = namespace + "." + name
161
+ if module_name in sys.modules:
162
+ return sys.modules[module_name]
163
+ source = entry / "__init__.py" if is_package else entry
164
+ try:
165
+ # import both, files and directories
166
+ spec = importlib.util.spec_from_file_location(
167
+ module_name, str(source),
168
+ submodule_search_locations=[str(entry)] if is_package else None,
169
+ )
170
+ if spec is None or not isinstance(spec.loader, ExecutionLoader): # for pyright: SourceFileLoader provides the methods expected from an ExecutionLoader
171
+ raise ImportError(f"Cannot create an executable import specification for '{source}'")
172
+ module = importlib.util.module_from_spec(spec)
173
+ sys.modules[module_name] = module # register loaded module
174
+ spec.loader.exec_module(module) # Execute the code of the module object
175
+ parent = sys.modules[namespace] # seq_quince.device.extensions
176
+ setattr(parent, name, module)
177
+ return module
178
+ except Exception as exc:
179
+ raise DeviceExtensionError(
180
+ f"Cannot import device extension '{source}': "
181
+ f"{type(exc).__name__}: {exc}"
182
+ ) from exc
183
+
184
+ @staticmethod
185
+ def _validate_exports(
186
+ exports: Any, entry: Path
187
+ ) -> List[Tuple[str, Type[DeviceABC]]]:
188
+ if not isinstance(exports, Mapping):
189
+ raise DeviceExtensionError(f"DEVICE_CLASSES in extension '{entry}' must be a mapping")
190
+ validated: List[Tuple[str, Type[DeviceABC]]] = []
191
+ for name, device_cls in exports.items():
192
+ if not isinstance(name, str) or not name.strip():
193
+ raise DeviceExtensionError(
194
+ f"DEVICE_CLASSES key {name!r} in extension '{entry}' "
195
+ "must be a nonempty string"
196
+ )
197
+ if (not inspect.isclass(device_cls)
198
+ or not issubclass(device_cls, DeviceABC)
199
+ or inspect.isabstract(device_cls)):
200
+ raise DeviceExtensionError(
201
+ f"DEVICE_CLASSES[{name!r}] in extension '{entry}' "
202
+ "must be a concrete DeviceABC subclass"
203
+ )
204
+ validated.append((name, device_cls))
205
+ return validated
206
+
207
+ @staticmethod
208
+ def _class_source(device_cls: Type[DeviceABC]) -> str:
209
+ module = sys.modules.get(device_cls.__module__)
210
+ source = getattr(module, "__file__", device_cls.__module__)
211
+ return f"{source} ({device_cls.__name__})"
@@ -0,0 +1,75 @@
1
+ from threading import RLock
2
+ from typing import Any, Dict, List, Type
3
+
4
+ from seq_quince.device.device_abs import DeviceABC
5
+ from seq_quince.device.extensions import DeviceExtensionLoader
6
+ from seq_quince.device.lifecycle import own_device
7
+ from seq_quince.device_concrete.actuator import ActuatorDevice
8
+ from seq_quince.device_concrete.hexapod import HexapodDevice
9
+ from seq_quince.device_concrete.mono_zero_order import MonoZeroOrderDevice
10
+ from seq_quince.device_concrete.ophyd_motor import OphydMotorDevice
11
+ from seq_quince.device_concrete.scan import ScanDevice
12
+ from seq_quince.device_concrete.sequence import SequenceDevice
13
+ from seq_quince.device_concrete.sim import SimDevice
14
+ from seq_quince.device_concrete.toggle import ToggleDevice
15
+ from seq_quince.device_concrete.wait_condition import WaitConditionDevice
16
+
17
+ DEVICE_REGISTRY: Dict[str, Type[DeviceABC]] = {
18
+ "OphydMotorDevice": OphydMotorDevice,
19
+ "ScanDevice": ScanDevice,
20
+ "ToggleDevice": ToggleDevice,
21
+ "SequenceDevice": SequenceDevice,
22
+ "ActuatorDevice": ActuatorDevice,
23
+ "HexapodDevice": HexapodDevice,
24
+ "SimDevice": SimDevice,
25
+ "MonoZeroOrderDevice": MonoZeroOrderDevice,
26
+ "WaitConditionDevice": WaitConditionDevice,
27
+ }
28
+
29
+ _extension_loader = DeviceExtensionLoader()
30
+ _registry_lock = RLock()
31
+
32
+ # ---- Factory Class ---------------------------------------------------------
33
+
34
+ class DeviceFactory:
35
+ @classmethod
36
+ def load_extensions(cls) -> None:
37
+ """
38
+ Register devices exported by new SEQ_QUINCE_DEVICE_PATH directories.
39
+
40
+ Discovery happens automatically during creation and type listing. This
41
+ method also lets callers check extensions before creating any devices.
42
+ """
43
+ with _registry_lock:
44
+ DEVICE_REGISTRY.update(_extension_loader.load(DEVICE_REGISTRY))
45
+
46
+ @classmethod
47
+ def register(cls, name: str, device_cls: Type[DeviceABC]) -> None:
48
+ # Preserve the existing explicit-registration replacement behavior.
49
+ with _registry_lock:
50
+ DEVICE_REGISTRY[name] = device_cls
51
+
52
+ @classmethod
53
+ def create(cls, config: Dict[str, Any]) -> DeviceABC:
54
+ """Create a device from config dict. Must include 'type' key."""
55
+ device_type = config.get("type")
56
+ if not device_type:
57
+ raise ValueError("Device config missing required key: 'type'")
58
+
59
+ with _registry_lock:
60
+ cls.load_extensions()
61
+ device_cls = DEVICE_REGISTRY.get(device_type)
62
+ if not device_cls:
63
+ raise ValueError(f"Unknown device type '{device_type}'. Available: {list(DEVICE_REGISTRY.keys())}")
64
+
65
+ # Use the device's own config loader
66
+ device = device_cls.from_config_dict(config)
67
+ own_device(device)
68
+ return device
69
+
70
+ @classmethod
71
+ def available_types(cls) -> List[str]:
72
+ with _registry_lock:
73
+ cls.load_extensions()
74
+ return list(DEVICE_REGISTRY.keys())
75
+