apsbits 1.0.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.
Files changed (47) hide show
  1. apsbits/__init__.py +18 -0
  2. apsbits/_version.py +21 -0
  3. apsbits/core/__init__.py +11 -0
  4. apsbits/core/best_effort_init.py +51 -0
  5. apsbits/core/catalog_init.py +36 -0
  6. apsbits/core/run_engine_init.py +118 -0
  7. apsbits/demo_instrument/README.md +1 -0
  8. apsbits/demo_instrument/__init__.py +22 -0
  9. apsbits/demo_instrument/callbacks/__init__.py +1 -0
  10. apsbits/demo_instrument/callbacks/nexus_data_file_writer.py +58 -0
  11. apsbits/demo_instrument/callbacks/spec_data_file_writer.py +97 -0
  12. apsbits/demo_instrument/configs/__init__.py +1 -0
  13. apsbits/demo_instrument/configs/devices.yml +52 -0
  14. apsbits/demo_instrument/configs/devices_aps_only.yml +6 -0
  15. apsbits/demo_instrument/configs/iconfig.yml +82 -0
  16. apsbits/demo_instrument/configs/logging.yml +41 -0
  17. apsbits/demo_instrument/devices/__init__.py +1 -0
  18. apsbits/demo_instrument/plans/__init__.py +8 -0
  19. apsbits/demo_instrument/plans/dm_plans.py +111 -0
  20. apsbits/demo_instrument/plans/sim_plans.py +69 -0
  21. apsbits/demo_instrument/startup.py +76 -0
  22. apsbits/demo_qserver/qs-config.yml +51 -0
  23. apsbits/demo_qserver/qs_host.sh +231 -0
  24. apsbits/demo_qserver/user_group_permissions.yaml +46 -0
  25. apsbits/tests/__init__.py +1 -0
  26. apsbits/tests/conftest.py +39 -0
  27. apsbits/tests/test_config.py +113 -0
  28. apsbits/tests/test_device_factories.py +44 -0
  29. apsbits/tests/test_general.py +98 -0
  30. apsbits/tests/test_stored_dict.py +139 -0
  31. apsbits/utils/__init__.py +1 -0
  32. apsbits/utils/aps_functions.py +67 -0
  33. apsbits/utils/config_loaders.py +169 -0
  34. apsbits/utils/controls_setup.py +107 -0
  35. apsbits/utils/create_new_instrument.py +129 -0
  36. apsbits/utils/helper_functions.py +123 -0
  37. apsbits/utils/logging_setup.py +211 -0
  38. apsbits/utils/make_devices.py +162 -0
  39. apsbits/utils/metadata.py +96 -0
  40. apsbits/utils/sim_creator.py +202 -0
  41. apsbits/utils/stored_dict.py +174 -0
  42. apsbits-1.0.0.dist-info/METADATA +195 -0
  43. apsbits-1.0.0.dist-info/RECORD +47 -0
  44. apsbits-1.0.0.dist-info/WHEEL +5 -0
  45. apsbits-1.0.0.dist-info/entry_points.txt +2 -0
  46. apsbits-1.0.0.dist-info/licenses/LICENSE +48 -0
  47. apsbits-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,107 @@
1
+ """
2
+ EPICS & ophyd related setup
3
+ ===========================
4
+
5
+ .. autosummary::
6
+ ~oregistry
7
+ ~set_control_layer
8
+ ~set_timeouts
9
+ ~epics_scan_id_source
10
+ ~connect_scan_id_pv
11
+ """
12
+
13
+ import logging
14
+
15
+ import ophyd
16
+ from ophyd.signal import EpicsSignalBase
17
+ from ophydregistry import Registry
18
+
19
+ logger = logging.getLogger(__name__)
20
+ logger.bsdev(__file__)
21
+
22
+
23
+ DEFAULT_CONTROL_LAYER = "PyEpics"
24
+ DEFAULT_TIMEOUT = 60 # default used next...
25
+
26
+
27
+ def epics_scan_id_source(_md):
28
+ """
29
+ Callback function for RunEngine. Returns *next* scan_id to be used.
30
+
31
+ * Ignore metadata dictionary passed as argument.
32
+ * Get current scan_id from PV.
33
+ * Apply lower limit of zero.
34
+ * Increment (so that scan_id numbering starts from 1).
35
+ * Set PV with new value.
36
+ * Return new value.
37
+
38
+ Exception will be raised if PV is not connected when next
39
+ ``bps.open_run()`` is called.
40
+ """
41
+ scan_id_epics = oregistry.find(name="scan_id_epics")
42
+ new_scan_id = max(scan_id_epics.get(), 0) + 1
43
+ scan_id_epics.put(new_scan_id)
44
+ return new_scan_id
45
+
46
+
47
+ def connect_scan_id_pv(RE, pv: str = None, scan_id_pv: str = None):
48
+ """
49
+ Define a PV to use for the RunEngine's `scan_id`.
50
+ """
51
+ from ophyd import EpicsSignal
52
+
53
+ pv = pv or scan_id_pv
54
+ if pv is None:
55
+ return
56
+
57
+ try:
58
+ scan_id_epics = EpicsSignal(pv, name="scan_id_epics")
59
+ except TypeError: # when Sphinx substitutes EpicsSignal with _MockModule
60
+ return
61
+ logger.info("Using EPICS PV %r for RunEngine 'scan_id'", pv)
62
+
63
+ # Setup the RunEngine to call epics_scan_id_source()
64
+ # which uses the EPICS PV to provide the scan_id.
65
+ RE.scan_id_source = epics_scan_id_source
66
+
67
+ scan_id_epics.wait_for_connection()
68
+ try:
69
+ RE.md["scan_id_pv"] = scan_id_epics.pvname
70
+ RE.md["scan_id"] = scan_id_epics.get() # set scan_id from EPICS
71
+ except TypeError:
72
+ pass # Ignore PersistentDict errors that only raise when making the docs
73
+
74
+
75
+ def set_control_layer(control_layer: str = DEFAULT_CONTROL_LAYER):
76
+ """
77
+ Communications library between ophyd and EPICS Channel Access.
78
+
79
+ Choices are: PyEpics (default) or caproto.
80
+
81
+ OPHYD_CONTROL_LAYER is an application of "lessons learned."
82
+
83
+ Only used in a couple rare cases where PyEpics code was failing.
84
+ It's defined here since it was difficult to find how to do this
85
+ in the ophyd documentation.
86
+ """
87
+
88
+ ophyd.set_cl(control_layer.lower())
89
+
90
+ logger.info("using ophyd control layer: %r", ophyd.cl.name)
91
+
92
+
93
+ def set_timeouts(timeouts):
94
+ """Set default timeout for all EpicsSignal connections & communications."""
95
+ if not EpicsSignalBase._EpicsSignalBase__any_instantiated:
96
+ # Only BEFORE any EpicsSignalBase (or subclass) are created!
97
+ EpicsSignalBase.set_defaults(
98
+ auto_monitor=True,
99
+ timeout=timeouts.get("PV_READ", DEFAULT_TIMEOUT),
100
+ write_timeout=timeouts.get("PV_WRITE", DEFAULT_TIMEOUT),
101
+ connection_timeout=timeouts.get("PV_CONNECTION", DEFAULT_TIMEOUT),
102
+ )
103
+
104
+
105
+ oregistry = Registry(auto_register=True)
106
+ """Registry of all ophyd-style Devices and Signals."""
107
+ oregistry.warn_duplicates = False
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Create a new instrument from a fixed template.
4
+
5
+ Copies the template directory and updates pyproject.toml and .templatesyncignore.
6
+ """
7
+
8
+ __version__ = "1.0.0"
9
+
10
+ import argparse
11
+ import os
12
+ import re
13
+ import shutil
14
+ import sys
15
+ from pathlib import Path
16
+
17
+
18
+ def copy_instrument(destination_dir: Path) -> None:
19
+ """
20
+ Copy template directory to the destination.
21
+
22
+ :param template_dir: Path to the template directory.
23
+ :param destination_dir: Path to the new instrument directory.
24
+ :return: None
25
+ """
26
+
27
+ demo_template_path: Path = (
28
+ Path(__file__).resolve().parent.parent / "demo_instrument"
29
+ ).resolve()
30
+
31
+ shutil.copytree(str(demo_template_path), str(destination_dir))
32
+
33
+
34
+ def create_qserver(qserver_dir: Path, name: str) -> None:
35
+ """
36
+ Create a qserver config file in the destination directory.
37
+ """
38
+
39
+ demo_qserver_path: Path = (
40
+ Path(__file__).resolve().parent.parent / "demo_qserver"
41
+ ).resolve()
42
+
43
+ os.makedirs(qserver_dir, exist_ok=True)
44
+ # Copy all yml files from demo_qserver to destination qserver dir
45
+ for yml_file in demo_qserver_path.glob("*"):
46
+ shutil.copy2(yml_file, qserver_dir)
47
+
48
+ # Update startup module in qs-config.yml
49
+ qs_config_path = qserver_dir / "qs-config.yml"
50
+
51
+ with open(qs_config_path, "r") as f:
52
+ config_contents = f.read()
53
+ # Replace demo_instrument with new name in startup module path
54
+ updated_contents = config_contents.replace(
55
+ "startup_module: demo_instrument.startup", f"startup_module: {name}.startup"
56
+ )
57
+
58
+ with open(qs_config_path, "w") as f:
59
+ f.write(updated_contents)
60
+
61
+ new_script_path = qserver_dir / "qs_host.sh"
62
+
63
+ # Read script contents
64
+ with open(new_script_path, "r") as src:
65
+ script_contents = src.read()
66
+
67
+ # Replace demo package name with new instrument name
68
+ updated_contents = script_contents.replace("demo_instrument", name)
69
+
70
+ # Write updated script
71
+ with open(new_script_path, "w") as dest:
72
+ dest.write(updated_contents)
73
+
74
+ # Make script executable
75
+ os.chmod(new_script_path, new_script_path.stat().st_mode | 0o755)
76
+
77
+
78
+ def main() -> None:
79
+ """
80
+ Parse arguments and create the instrument.
81
+
82
+ :return: None
83
+ """
84
+ parser = argparse.ArgumentParser(
85
+ description="Create an instrument from a fixed template."
86
+ )
87
+ parser.add_argument(
88
+ "name", type=str, help="New instrument name; must be a valid package name."
89
+ )
90
+ args = parser.parse_args()
91
+
92
+ if re.fullmatch(r"[a-z][_a-z0-9]*", args.name) is None:
93
+ print(f"Error: Invalid instrument name '{args.name}'.", file=sys.stderr)
94
+ sys.exit(1)
95
+
96
+ main_path: Path = Path(os.getcwd()).resolve()
97
+
98
+ new_instrument_dir: Path = main_path / "src" / args.name
99
+
100
+ new_qserver_dir: Path = main_path / "src" / f"{args.name}_qserver"
101
+
102
+ print(
103
+ f"Creating instrument '{args.name}' from demo_instrument into \
104
+ '{new_instrument_dir}'."
105
+ )
106
+
107
+ if new_instrument_dir.exists():
108
+ print(f"Error: Destination '{new_instrument_dir}' exists.", file=sys.stderr)
109
+ sys.exit(1)
110
+
111
+ try:
112
+ copy_instrument(new_instrument_dir)
113
+ print(f"Template copied to '{new_instrument_dir}'.")
114
+ except Exception as exc:
115
+ print(f"Error copying instrument: {exc}", file=sys.stderr)
116
+ sys.exit(1)
117
+
118
+ try:
119
+ create_qserver(new_qserver_dir, args.name)
120
+ print(f"Qserver config created in '{new_qserver_dir}'.")
121
+ except Exception as exc:
122
+ print(f"Error creating qserver config: {exc}", file=sys.stderr)
123
+ sys.exit(1)
124
+
125
+ print(f"Instrument '{args.name}' created.")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -0,0 +1,123 @@
1
+ """
2
+ Generic utility helper functions
3
+ ================================
4
+
5
+ .. autosummary::
6
+ ~register_bluesky_magics
7
+ ~running_in_queueserver
8
+ ~debug_python
9
+ ~mpl_setup
10
+ ~is_notebook
11
+ """
12
+
13
+ import logging
14
+ import os
15
+
16
+ import matplotlib as mpl
17
+ import matplotlib.pyplot as plt
18
+ from bluesky.magics import BlueskyMagics
19
+ from IPython import get_ipython
20
+
21
+ from apsbits.utils.config_loaders import get_config
22
+
23
+ logger = logging.getLogger(__name__)
24
+ logger.bsdev(__file__)
25
+
26
+
27
+ def register_bluesky_magics() -> None:
28
+ """Register Bluesky IPython magics."""
29
+ try:
30
+ ip = get_ipython()
31
+ if ip is not None:
32
+ ip.register_magics(BlueskyMagics)
33
+ logger.info("Registered Bluesky IPython magics")
34
+ except Exception as e:
35
+ logger.warning("Could not register Bluesky IPython magics: %s", e)
36
+
37
+
38
+ def running_in_queueserver() -> bool:
39
+ """
40
+ Check if we are running in a Bluesky queueserver.
41
+
42
+ Returns:
43
+ True if running in a queueserver, False otherwise.
44
+ """
45
+ return os.environ.get("QSERVER_URI") is not None
46
+
47
+
48
+ def get_xmode_level() -> str:
49
+ """
50
+ Get the current XMode debug level.
51
+
52
+ Returns:
53
+ The current XMode debug level.
54
+ """
55
+ iconfig = get_config()
56
+ xmode_level: str = iconfig.get("XMODE_DEBUG_LEVEL", "Plain")
57
+ return xmode_level
58
+
59
+
60
+ def debug_python(xmode_level: str = "Plain") -> None:
61
+ """
62
+ Enable detailed debugging for Python exceptions in the IPython environment.
63
+
64
+ This function adjusts the xmode settings for exception tracebacks based on
65
+ the provided xmode_level argument.
66
+
67
+ Args:
68
+ xmode_level (str): The level of detail for exception tracebacks.
69
+ Defaults to "Minimal".
70
+ """
71
+ ipython = get_ipython()
72
+ if ipython is not None:
73
+ xmode_level: str = get_xmode_level()
74
+ ipython.run_line_magic("xmode", xmode_level)
75
+ print("\nEnd of IPython settings\n")
76
+ logger.bsdev("xmode exception level: '%s'", xmode_level)
77
+
78
+
79
+ def is_notebook() -> bool:
80
+ """
81
+ Detect if the current environment is a Jupyter Notebook.
82
+
83
+ Returns:
84
+ bool: True if running in a notebook (Jupyter notebook or qtconsole),
85
+ False otherwise.
86
+ """
87
+ try:
88
+ shell: str = get_ipython().__class__.__name__
89
+ if shell == "ZMQInteractiveShell":
90
+ return True # Jupyter notebook or qtconsole
91
+ elif shell == "TerminalInteractiveShell":
92
+ return False # Terminal running IPython
93
+ else:
94
+ return False # Other type
95
+ except NameError:
96
+ return False # Standard Python interpreter
97
+
98
+
99
+ def mpl_setup() -> None:
100
+ """
101
+ Configure the Matplotlib backend based on the current environment.
102
+
103
+ For non-queueserver and non-notebook environments, attempts to use the 'qtAgg'
104
+ backend.
105
+ If 'qtAgg' is not available due to missing dependencies, falls back to the 'Agg'
106
+ backend.
107
+
108
+ Returns:
109
+ None
110
+ """
111
+ if not running_in_queueserver():
112
+ if not is_notebook():
113
+ try:
114
+ mpl.use("qtAgg")
115
+ plt.ion()
116
+ logger.bsdev("Using qtAgg backend for matplotlib.")
117
+ except Exception as exc:
118
+ logger.error(
119
+ "qtAgg backend is not available, falling back to Agg backend. \
120
+ Error: %s",
121
+ exc,
122
+ )
123
+ mpl.use("Agg")
@@ -0,0 +1,211 @@
1
+ """
2
+ Configure logging for this session.
3
+
4
+ .. rubric:: Public
5
+ .. autosummary::
6
+ ~configure_logging
7
+
8
+ .. rubric:: Internal
9
+ .. autosummary::
10
+ ~_setup_console_logger
11
+ ~_setup_file_logger
12
+ ~_setup_ipython_logger
13
+ ~_setup_module_logging
14
+
15
+ .. seealso:: https://blueskyproject.io/bluesky/main/debugging.html
16
+ """
17
+
18
+ import logging
19
+ import logging.handlers
20
+ import os
21
+ import pathlib
22
+
23
+ BYTE = 1
24
+ kB = 1024 * BYTE
25
+ MB = 1024 * kB
26
+
27
+ BRIEF_DATE = "%a-%H:%M:%S"
28
+ BRIEF_FORMAT = "%(levelname)-.1s %(asctime)s.%(msecs)03d: %(message)s"
29
+ DEFAULT_CONFIG_FILE = (
30
+ pathlib.Path(__file__).parent.parent / "demo_instrument" / "configs" / "logging.yml"
31
+ )
32
+
33
+
34
+ # Add your custom logging level at the top-level, before configure_logging()
35
+ def addLoggingLevel(levelName, levelNum, methodName=None):
36
+ """
37
+ Comprehensively adds a new logging level to the `logging` module and the
38
+ currently configured logging class.
39
+
40
+ `levelName` becomes an attribute of the `logging` module with the value
41
+ `levelNum`. `methodName` becomes a convenience method for both `logging`
42
+ itself and the class returned by `logging.getLoggerClass()` (usually just
43
+ `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is
44
+ used.
45
+
46
+ To avoid accidental clobberings of existing attributes, this method will
47
+ raise an `AttributeError` if the level name is already an attribute of the
48
+ `logging` module or if the method name is already present
49
+
50
+ Example
51
+ -------
52
+ >>> addLoggingLevel('TRACE', logging.INFO - 5)
53
+ >>> logging.getLogger(__name__).setLevel("TEST")
54
+ >>> logging.getLogger(__name__).test('that worked')
55
+ >>> logging.test('so did this')
56
+ >>> logging.TEST
57
+ 5
58
+
59
+ """
60
+ if not methodName:
61
+ methodName = levelName.lower()
62
+
63
+ if hasattr(logging, levelName):
64
+ raise AttributeError("{} already defined in logging module".format(levelName))
65
+ if hasattr(logging, methodName):
66
+ raise AttributeError("{} already defined in logging module".format(methodName))
67
+ if hasattr(logging.getLoggerClass(), methodName):
68
+ raise AttributeError("{} already defined in logger class".format(methodName))
69
+
70
+ # This method was inspired by the answers to Stack Overflow post
71
+ # http://stackoverflow.com/q/2183233/2988730, especially
72
+ # http://stackoverflow.com/a/13638084/2988730
73
+ def logForLevel(self, message, *args, **kwargs):
74
+ if self.isEnabledFor(levelNum):
75
+ self._log(levelNum, message, args, **kwargs)
76
+
77
+ def logToRoot(message, *args, **kwargs):
78
+ logging.log(levelNum, message, *args, **kwargs)
79
+
80
+ logging.addLevelName(levelNum, levelName)
81
+ setattr(logging, levelName, levelNum)
82
+ setattr(logging.getLoggerClass(), methodName, logForLevel)
83
+ setattr(logging, methodName, logToRoot)
84
+
85
+
86
+ addLoggingLevel("BSDEV", logging.INFO - 5)
87
+
88
+
89
+ def configure_logging():
90
+ """Configure logging as described in file."""
91
+ from apsbits.utils.config_loaders import load_config_yaml
92
+
93
+ # (Re)configure the root logger.
94
+ logger = logging.getLogger(__name__).root
95
+ logger.debug("logger=%r", logger)
96
+
97
+ config_file = os.environ.get("BLUESKY_INSTRUMENT_CONFIG_FILE")
98
+ if config_file is None:
99
+ config_file = DEFAULT_CONFIG_FILE
100
+ else:
101
+ config_file = pathlib.Path(config_file)
102
+
103
+ logging_configuration = load_config_yaml(config_file)
104
+ for part, cfg in logging_configuration.items():
105
+ logging.debug("%r - %s", part, cfg)
106
+
107
+ if part == "console_logs":
108
+ _setup_console_logger(logger, cfg)
109
+
110
+ elif part == "file_logs":
111
+ _setup_file_logger(logger, cfg)
112
+
113
+ elif part == "ipython_logs":
114
+ _setup_ipython_logger(logger, cfg)
115
+
116
+ elif part == "modules":
117
+ _setup_module_logging(cfg)
118
+
119
+
120
+ def _setup_console_logger(logger, cfg):
121
+ """
122
+ Reconfigure the root logger as configured by the user.
123
+
124
+ We can't apply user configurations in ``configure_logging()`` above
125
+ because the code to read the config file triggers initialization of
126
+ the logging system.
127
+
128
+ .. seealso:: https://docs.python.org/3/library/logging.html#logging.basicConfig
129
+ """
130
+ logging.basicConfig(
131
+ encoding="utf-8",
132
+ level=cfg["root_level"].upper(),
133
+ format=cfg["log_format"],
134
+ datefmt=cfg["date_format"],
135
+ force=True, # replace any previous setup
136
+ )
137
+ h = logger.handlers[0]
138
+ h.setLevel(cfg["level"].upper())
139
+
140
+
141
+ def _setup_file_logger(logger, cfg):
142
+ """Record log messages in file(s)."""
143
+ formatter = logging.Formatter(
144
+ fmt=cfg["log_format"],
145
+ datefmt=cfg["date_format"],
146
+ style="%",
147
+ validate=True,
148
+ )
149
+ formatter.default_msec_format = "%s.%03d"
150
+
151
+ backupCount = cfg.get("backupCount", 9)
152
+ maxBytes = cfg.get("maxBytes", 1 * MB)
153
+ log_path = pathlib.Path(cfg.get("log_directory", ".logs")).resolve()
154
+ if not log_path.exists():
155
+ os.makedirs(str(log_path))
156
+
157
+ file_name = log_path / cfg.get("log_filename_base", "logging.log")
158
+ if maxBytes > 0 or backupCount > 0:
159
+ backupCount = max(backupCount, 1) # impose minimum standards
160
+ maxBytes = max(maxBytes, 100 * kB)
161
+ handler = logging.handlers.RotatingFileHandler(
162
+ file_name,
163
+ maxBytes=maxBytes,
164
+ backupCount=backupCount,
165
+ )
166
+ else:
167
+ handler = logging.FileHandler(file_name)
168
+ handler.setFormatter(formatter)
169
+ if cfg.get("rotate_on_startup", False):
170
+ handler.doRollover()
171
+ logger.addHandler(handler)
172
+ logger.info("%s Bluesky Startup Initialized", "*" * 40)
173
+ logger.bsdev(__file__)
174
+ logger.bsdev("Log file: %s", file_name)
175
+
176
+
177
+ def _setup_ipython_logger(logger, cfg):
178
+ """
179
+ Internal: Log IPython console session In and Out to a file.
180
+
181
+ See ``logrotate?`` int he IPython console for more information.
182
+ """
183
+ log_path = pathlib.Path(cfg.get("log_directory", ".logs")).resolve()
184
+ try:
185
+ from IPython import get_ipython
186
+
187
+ # start logging console to file
188
+ # https://ipython.org/ipython-doc/3/interactive/magics.html#magic-logstart
189
+ _ipython = get_ipython()
190
+ log_file = log_path / cfg.get("log_filename_base", "ipython_log.py")
191
+ log_mode = cfg.get("log_mode", "rotate")
192
+ options = cfg.get("options", "-o -t")
193
+ if _ipython is not None:
194
+ print(
195
+ "\nBelow are the IPython logging settings for your session."
196
+ "\nThese settings have no impact on your experiment.\n"
197
+ )
198
+ _ipython.run_line_magic("logstart", f"{options} {log_file} {log_mode}")
199
+ if logger is not None:
200
+ logger.bsdev("Console logging: %s", log_file)
201
+ except Exception as exc:
202
+ if logger is None:
203
+ print(f"Could not setup console logging: {exc}")
204
+ else:
205
+ logger.exception("Could not setup console logging.")
206
+
207
+
208
+ def _setup_module_logging(cfg):
209
+ """Internal: Set logging level for each named module."""
210
+ for module, level in cfg.items():
211
+ logging.getLogger(module).setLevel(level.upper())