epics-bridge 1.0.0__tar.gz

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.
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: epics-bridge
3
+ Version: 1.0.0
4
+ Summary: A generic bridge between EPICS IOCs and Python logic.
5
+ Author-email: Hugo Valim <hugo.valim@ess.eu>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy
9
+ Requires-Dist: p4p
10
+
11
+ # EPICS Bridge
12
+
13
+ ![Coverage](resources/coverage.svg)
14
+ ![Python](https://img.shields.io/badge/python-3.8%2B-blue)
15
+ ![License](https://img.shields.io/badge/license-MIT-green)
16
+
17
+ **EPICS Bridge** is a high-availability Python framework designed for implementing a robust EPICS-Python interface. It provides a structured environment for bridging external control logic with the EPICS control system, emphasizing synchronous execution, fault tolerance, and strict process monitoring.
18
+
19
+ This library addresses the common reliability challenges like preventing silent stalls ("zombie processes") and handling network IO failures deterministically.
20
+
21
+
22
+ ## System Architecture
23
+
24
+ The core of `epics-bridge` relies on a **Twin-Thread Architecture** that decouples the control logic from the monitoring signal.
25
+
26
+ ### 1. Synchronous Control Loop (Main Thread)
27
+ The primary thread executes the user-defined logic in a strict, synchronous cycle:
28
+ 1. **Trigger:** Waits for an input event or timer.
29
+ 2. **Run Task:** Executes user-defined task
30
+ 3. **Acknowledge:** Updates the task status and completes the handshake.
31
+
32
+ ### 2. Isolated Heartbeat Monitor (Daemon Thread)
33
+ A separate, isolated thread acts as an internal watchdog. It monitors the activity timestamp of the Main Thread.
34
+ * **Operational:** Pulses the `Heartbeat` PV as long as the Main Thread is active.
35
+ * **Stalled (Zombie Protection):** If the Main Thread hangs (e.g., infinite loop, deadlocked IO) for longer than the defined tolerance, the Heartbeat thread ceases pulsing immediately. This alerts external watchdogs (e.g., the IOC or alarm handler) that the process is unresponsive.
36
+
37
+ ### 3. Automatic Recovery ("Suicide Pact")
38
+ To support containerized environments (Docker, Kubernetes) or systemd supervisors, the daemon implements a fail-fast mechanism. If network connectivity is lost or IO errors persist beyond a configurable threshold (`max_stuck_cycles`), the process voluntarily terminates (`exit(0)`). This allows the external supervisor to perform a clean restart of the service.
39
+
40
+
41
+ ### 4. Logger
42
+ Output important messages in the daemon shell to a configured log file.
43
+
44
+
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ # Install the package
50
+ pip install .
51
+
52
+ # Install test dependencies
53
+ pip install -r requirements-test.txt
54
+ ```
55
+
56
+
57
+
58
+
59
+ ## Project Structure
60
+
61
+ - **epics_bridge.daemon**
62
+ Main control loop, heartbeat logic, and failure handling
63
+
64
+ - **epics_bridge.io**
65
+ Synchronous P4P client wrapper with strict error handling
66
+
67
+ - **epics_bridge.base_pv_interface**
68
+ PV template definitions and prefix validation
69
+
70
+ - **epics_bridge.utils**
71
+ Utilities for converting P4P data into native Python types
72
+
73
+
74
+ ---
75
+ ## Quick Start
76
+ ### 1. EPICS Interface
77
+ There should be a standard epics db to handle the basic functionalities of the daemon and any amount of specialized dbs to fulfill the intended functionality.
78
+
79
+ The standard db should always be loaded by the IOC that interfaces with the daemon.
80
+ These are its contents:
81
+
82
+ ```epics
83
+ record(bo, "$(P)Trigger") {
84
+ field(DESC, "Start Task")
85
+ field(ZNAM, "Idle")
86
+ field(ONAM, "Run")
87
+ }
88
+
89
+ record(bi, "$(P)Busy") {
90
+ field(DESC, "Task Running Status")
91
+ field(ZNAM, "Idle")
92
+ field(ONAM, "Busy")
93
+ }
94
+
95
+ record(bi, "$(P)Heartbeat") {
96
+ field(DESC, "Daemon Heartbeat")
97
+ }
98
+
99
+ record(mbbi, "$(P)TaskStatus") {
100
+ field(DESC, "Last Cycle Result")
101
+ field(DTYP, "Raw Soft Channel")
102
+
103
+ # State 0: Success (Green)
104
+ field(ZRVL, "0")
105
+ field(ZRST, "Success")
106
+ field(ZRSV, "NO_ALARM")
107
+
108
+ # State 1: Logic Failure (Yellow - e.g. Interlock)
109
+ field(ONVL, "1")
110
+ field(ONST, "Task Fail")
111
+ field(ONSV, "MINOR")
112
+
113
+ # State 2: EPICS IO Failure (Yellow - e.g. PV Read/Write Error)
114
+ field(TWVL, "2")
115
+ field(TWST, "IO Failure")
116
+ field(TWSV, "MINOR")
117
+
118
+ # State 3: Exception (Red - Software/Hardware Crash)
119
+ field(THVL, "3")
120
+ field(THST, "Code Crash")
121
+ field(THSV, "MAJOR")
122
+ }
123
+
124
+ record(ai, "$(P)TaskDuration") {
125
+ field(DESC, "Task duration")
126
+ field(PREC, "2")
127
+ field(EGU, "s")
128
+ }
129
+ ```
130
+
131
+
132
+ ### 2. Define a Python PV Interface
133
+
134
+ Use a dataclass to define EPICS PV templates.
135
+ Standard PVs (trigger, busy, heartbeat, task_status) are provided automatically.
136
+
137
+ ```python
138
+ from dataclasses import dataclass
139
+ from epics_bridge.base_pv_interface import BasePVInterface
140
+
141
+ @dataclass
142
+ class MotorInterface(BasePVInterface):
143
+ position_rbv: str = "{main}Pos:RBV"
144
+ velocity_sp: str = "{main}Vel:SP"
145
+ temperature: str = "{sys}Temp:Mon"
146
+
147
+ ```
148
+
149
+ ### 3. Implement Control Logic
150
+
151
+ Subclass BridgeDaemon and implement the synchronous execute() method.
152
+
153
+ ```python
154
+ from epics_bridge.daemon import BridgeDaemon, TaskStatus
155
+
156
+ class MotorControlDaemon(BridgeDaemon):
157
+ def run_task(self, inputs=None) -> TaskStatus:
158
+ velocity = self.io.pvget(self.interface.velocity_sp)
159
+
160
+ if velocity is None:
161
+ return TaskStatus.ERROR
162
+
163
+ new_position = velocity * 0.5
164
+
165
+ self.io.pvput({
166
+ self.interface.position_rbv: new_position
167
+ })
168
+
169
+ return TaskStatus.DONE
170
+ ```
171
+
172
+ ### 4. Run the Daemon
173
+
174
+ ```python
175
+
176
+ def main():
177
+
178
+ prefixes = {
179
+ "main": "IOC:MOTOR:01:",
180
+ "sys": "IOC:SYS:"
181
+ }
182
+
183
+ interface = MotorInterface(prefixes=prefixes)
184
+
185
+ daemon = MotorControlDaemon(
186
+ interface=interface,
187
+ )
188
+
189
+ daemon.start()
190
+
191
+ if __name__ == "__main__":
192
+ main()
193
+ ```
@@ -0,0 +1,183 @@
1
+ # EPICS Bridge
2
+
3
+ ![Coverage](resources/coverage.svg)
4
+ ![Python](https://img.shields.io/badge/python-3.8%2B-blue)
5
+ ![License](https://img.shields.io/badge/license-MIT-green)
6
+
7
+ **EPICS Bridge** is a high-availability Python framework designed for implementing a robust EPICS-Python interface. It provides a structured environment for bridging external control logic with the EPICS control system, emphasizing synchronous execution, fault tolerance, and strict process monitoring.
8
+
9
+ This library addresses the common reliability challenges like preventing silent stalls ("zombie processes") and handling network IO failures deterministically.
10
+
11
+
12
+ ## System Architecture
13
+
14
+ The core of `epics-bridge` relies on a **Twin-Thread Architecture** that decouples the control logic from the monitoring signal.
15
+
16
+ ### 1. Synchronous Control Loop (Main Thread)
17
+ The primary thread executes the user-defined logic in a strict, synchronous cycle:
18
+ 1. **Trigger:** Waits for an input event or timer.
19
+ 2. **Run Task:** Executes user-defined task
20
+ 3. **Acknowledge:** Updates the task status and completes the handshake.
21
+
22
+ ### 2. Isolated Heartbeat Monitor (Daemon Thread)
23
+ A separate, isolated thread acts as an internal watchdog. It monitors the activity timestamp of the Main Thread.
24
+ * **Operational:** Pulses the `Heartbeat` PV as long as the Main Thread is active.
25
+ * **Stalled (Zombie Protection):** If the Main Thread hangs (e.g., infinite loop, deadlocked IO) for longer than the defined tolerance, the Heartbeat thread ceases pulsing immediately. This alerts external watchdogs (e.g., the IOC or alarm handler) that the process is unresponsive.
26
+
27
+ ### 3. Automatic Recovery ("Suicide Pact")
28
+ To support containerized environments (Docker, Kubernetes) or systemd supervisors, the daemon implements a fail-fast mechanism. If network connectivity is lost or IO errors persist beyond a configurable threshold (`max_stuck_cycles`), the process voluntarily terminates (`exit(0)`). This allows the external supervisor to perform a clean restart of the service.
29
+
30
+
31
+ ### 4. Logger
32
+ Output important messages in the daemon shell to a configured log file.
33
+
34
+
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ # Install the package
40
+ pip install .
41
+
42
+ # Install test dependencies
43
+ pip install -r requirements-test.txt
44
+ ```
45
+
46
+
47
+
48
+
49
+ ## Project Structure
50
+
51
+ - **epics_bridge.daemon**
52
+ Main control loop, heartbeat logic, and failure handling
53
+
54
+ - **epics_bridge.io**
55
+ Synchronous P4P client wrapper with strict error handling
56
+
57
+ - **epics_bridge.base_pv_interface**
58
+ PV template definitions and prefix validation
59
+
60
+ - **epics_bridge.utils**
61
+ Utilities for converting P4P data into native Python types
62
+
63
+
64
+ ---
65
+ ## Quick Start
66
+ ### 1. EPICS Interface
67
+ There should be a standard epics db to handle the basic functionalities of the daemon and any amount of specialized dbs to fulfill the intended functionality.
68
+
69
+ The standard db should always be loaded by the IOC that interfaces with the daemon.
70
+ These are its contents:
71
+
72
+ ```epics
73
+ record(bo, "$(P)Trigger") {
74
+ field(DESC, "Start Task")
75
+ field(ZNAM, "Idle")
76
+ field(ONAM, "Run")
77
+ }
78
+
79
+ record(bi, "$(P)Busy") {
80
+ field(DESC, "Task Running Status")
81
+ field(ZNAM, "Idle")
82
+ field(ONAM, "Busy")
83
+ }
84
+
85
+ record(bi, "$(P)Heartbeat") {
86
+ field(DESC, "Daemon Heartbeat")
87
+ }
88
+
89
+ record(mbbi, "$(P)TaskStatus") {
90
+ field(DESC, "Last Cycle Result")
91
+ field(DTYP, "Raw Soft Channel")
92
+
93
+ # State 0: Success (Green)
94
+ field(ZRVL, "0")
95
+ field(ZRST, "Success")
96
+ field(ZRSV, "NO_ALARM")
97
+
98
+ # State 1: Logic Failure (Yellow - e.g. Interlock)
99
+ field(ONVL, "1")
100
+ field(ONST, "Task Fail")
101
+ field(ONSV, "MINOR")
102
+
103
+ # State 2: EPICS IO Failure (Yellow - e.g. PV Read/Write Error)
104
+ field(TWVL, "2")
105
+ field(TWST, "IO Failure")
106
+ field(TWSV, "MINOR")
107
+
108
+ # State 3: Exception (Red - Software/Hardware Crash)
109
+ field(THVL, "3")
110
+ field(THST, "Code Crash")
111
+ field(THSV, "MAJOR")
112
+ }
113
+
114
+ record(ai, "$(P)TaskDuration") {
115
+ field(DESC, "Task duration")
116
+ field(PREC, "2")
117
+ field(EGU, "s")
118
+ }
119
+ ```
120
+
121
+
122
+ ### 2. Define a Python PV Interface
123
+
124
+ Use a dataclass to define EPICS PV templates.
125
+ Standard PVs (trigger, busy, heartbeat, task_status) are provided automatically.
126
+
127
+ ```python
128
+ from dataclasses import dataclass
129
+ from epics_bridge.base_pv_interface import BasePVInterface
130
+
131
+ @dataclass
132
+ class MotorInterface(BasePVInterface):
133
+ position_rbv: str = "{main}Pos:RBV"
134
+ velocity_sp: str = "{main}Vel:SP"
135
+ temperature: str = "{sys}Temp:Mon"
136
+
137
+ ```
138
+
139
+ ### 3. Implement Control Logic
140
+
141
+ Subclass BridgeDaemon and implement the synchronous execute() method.
142
+
143
+ ```python
144
+ from epics_bridge.daemon import BridgeDaemon, TaskStatus
145
+
146
+ class MotorControlDaemon(BridgeDaemon):
147
+ def run_task(self, inputs=None) -> TaskStatus:
148
+ velocity = self.io.pvget(self.interface.velocity_sp)
149
+
150
+ if velocity is None:
151
+ return TaskStatus.ERROR
152
+
153
+ new_position = velocity * 0.5
154
+
155
+ self.io.pvput({
156
+ self.interface.position_rbv: new_position
157
+ })
158
+
159
+ return TaskStatus.DONE
160
+ ```
161
+
162
+ ### 4. Run the Daemon
163
+
164
+ ```python
165
+
166
+ def main():
167
+
168
+ prefixes = {
169
+ "main": "IOC:MOTOR:01:",
170
+ "sys": "IOC:SYS:"
171
+ }
172
+
173
+ interface = MotorInterface(prefixes=prefixes)
174
+
175
+ daemon = MotorControlDaemon(
176
+ interface=interface,
177
+ )
178
+
179
+ daemon.start()
180
+
181
+ if __name__ == "__main__":
182
+ main()
183
+ ```
@@ -0,0 +1,5 @@
1
+ from .base_pv_interface import BasePVInterface
2
+ from .daemon import BridgeDaemon
3
+ from .daemon_status import TaskStatus
4
+
5
+ __all__ = ["BridgeDaemon", "BasePVInterface", "TaskStatus"]
@@ -0,0 +1,83 @@
1
+ from dataclasses import dataclass, field, fields
2
+ from typing import Dict
3
+
4
+
5
+ @dataclass
6
+ class BasePVInterface:
7
+ """
8
+ A base configuration class for EPICS PV interfaces.
9
+
10
+ This class handles the dynamic construction of PV names based on provided
11
+ prefixes. It supports inheritance, allowing users to define sets of PVs
12
+ (templates) and instantiate them with specific device prefixes.
13
+
14
+ Attributes:
15
+ prefixes (Dict[str, str]):
16
+ A dictionary of prefixes used to format the PV templates.
17
+ Example: {'sys': 'VAC:01:', 'main': 'PUMP:A:'}
18
+ """
19
+
20
+ # 1. Configuration: Holds the prefixes used for formatting
21
+ prefixes: Dict[str, str] = field(default_factory=dict)
22
+
23
+ # 2. Default PV Templates (Note: These use the '{main}' key)
24
+ trigger: str = "{main}Trigger"
25
+ heartbeat: str = "{main}Heartbeat"
26
+ busy: str = "{main}Busy"
27
+ task_status: str = "{main}TaskStatus"
28
+ task_duration: str = "{main}TaskDuration"
29
+
30
+ def __post_init__(self):
31
+ """
32
+ Post-initialization hook.
33
+ Iterates through all string fields in the instance. If a field contains
34
+ Python format placeholders (e.g., "{sys}Name"), it formats the string
35
+ using the provided `prefixes` dictionary.
36
+ """
37
+ missing_keys = []
38
+
39
+ for f in fields(self):
40
+ # Skip internal configuration fields
41
+ if f.name == "prefixes":
42
+ continue
43
+
44
+ # Get the raw value (the template, e.g., "{sys}Trigger")
45
+ raw_template = getattr(self, f.name)
46
+
47
+ # Only process strings that look like templates
48
+ if isinstance(raw_template, str) and "{" in raw_template:
49
+ try:
50
+ # FIX: Changed 'self.context' to 'self.prefixes'
51
+ formatted_pv = raw_template.format(**self.prefixes)
52
+ setattr(self, f.name, formatted_pv)
53
+ except KeyError as e:
54
+ # Collect errors to show them all at once
55
+ missing_keys.append(f"Field '{f.name}' requires prefix key {e}")
56
+
57
+ if missing_keys:
58
+ raise ValueError(
59
+ "Configuration Error: Missing prefixes for PV templates.\n"
60
+ + "\n".join(missing_keys)
61
+ )
62
+
63
+ @property
64
+ def as_dict(self) -> Dict[str, str]:
65
+ """Returns a dictionary of PVs, excluding the internal 'prefixes'."""
66
+ # vars(self) is faster than asdict(self)
67
+ # We use .copy() to ensure we don't modify the actual object
68
+ data = vars(self).copy()
69
+
70
+ # Remove the configuration field so you only get PVs
71
+ if "prefixes" in data:
72
+ del data["prefixes"]
73
+
74
+ return data
75
+
76
+ @property
77
+ def pv_to_attr(self) -> Dict[str, str]:
78
+ """
79
+ Returns a reverse mapping: {Formatted_PV_Name: Attribute_Name}.
80
+ Example: {'VAC:01:Trigger': 'trigger'}
81
+ """
82
+ # Invert the dictionary: value becomes key, key becomes value
83
+ return {v: k for k, v in self.as_dict.items()}