apsbits 1.0.0rc2__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 (44) 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 +38 -0
  5. apsbits/core/catalog_init.py +29 -0
  6. apsbits/core/run_engine_init.py +71 -0
  7. apsbits/demo_instrument/README.md +1 -0
  8. apsbits/demo_instrument/__init__.py +3 -0
  9. apsbits/demo_instrument/callbacks/__init__.py +1 -0
  10. apsbits/demo_instrument/callbacks/nexus_data_file_writer.py +60 -0
  11. apsbits/demo_instrument/callbacks/spec_data_file_writer.py +93 -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 +81 -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 +63 -0
  22. apsbits/tests/__init__.py +1 -0
  23. apsbits/tests/conftest.py +32 -0
  24. apsbits/tests/test_device_factories.py +44 -0
  25. apsbits/tests/test_general.py +83 -0
  26. apsbits/tests/test_stored_dict.py +139 -0
  27. apsbits/utils/__init__.py +1 -0
  28. apsbits/utils/aps_functions.py +67 -0
  29. apsbits/utils/config_loaders.py +61 -0
  30. apsbits/utils/context_aware.py +187 -0
  31. apsbits/utils/controls_setup.py +113 -0
  32. apsbits/utils/create_new_instrument.py +144 -0
  33. apsbits/utils/helper_functions.py +113 -0
  34. apsbits/utils/logging_setup.py +211 -0
  35. apsbits/utils/make_devices_yaml.py +127 -0
  36. apsbits/utils/metadata.py +101 -0
  37. apsbits/utils/sim_creator.py +199 -0
  38. apsbits/utils/stored_dict.py +192 -0
  39. apsbits-1.0.0rc2.dist-info/LICENSE +48 -0
  40. apsbits-1.0.0rc2.dist-info/METADATA +349 -0
  41. apsbits-1.0.0rc2.dist-info/RECORD +44 -0
  42. apsbits-1.0.0rc2.dist-info/WHEEL +5 -0
  43. apsbits-1.0.0rc2.dist-info/entry_points.txt +2 -0
  44. apsbits-1.0.0rc2.dist-info/top_level.txt +1 -0
@@ -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 .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())
@@ -0,0 +1,127 @@
1
+ """
2
+ Make devices from YAML files
3
+ =============================
4
+
5
+ Construct ophyd-style devices from simple specifications in YAML files.
6
+
7
+ .. autosummary::
8
+ :nosignatures:
9
+
10
+ ~make_devices
11
+ ~Instrument
12
+ """
13
+
14
+ import logging
15
+ import pathlib
16
+ import sys
17
+ import time
18
+
19
+ import guarneri
20
+ from apstools.plans import run_blocking_function
21
+ from apstools.utils import dynamic_import
22
+ from bluesky import plan_stubs as bps
23
+
24
+ from apsbits.utils.aps_functions import host_on_aps_subnet
25
+ from apsbits.utils.config_loaders import load_config_yaml
26
+ from apsbits.utils.context_aware import iconfig
27
+ from apsbits.utils.context_aware import resolve_path
28
+ from apsbits.utils.controls_setup import oregistry # noqa: F401
29
+
30
+ logger = logging.getLogger(__name__)
31
+ logger.bsdev(__file__)
32
+
33
+ # Get the main module (same as before)
34
+ main_namespace = sys.modules["__main__"]
35
+
36
+ # Resolve device files directly using resolve_path
37
+ local_control_devices_file = resolve_path(iconfig["DEVICES_FILE"])
38
+ aps_control_devices_file = resolve_path(iconfig["APS_DEVICES_FILE"])
39
+
40
+
41
+ def make_devices(*, pause: float = 1):
42
+ """
43
+ (plan stub) Create the ophyd-style controls for this instrument.
44
+
45
+ Feel free to modify this plan to suit the needs of your instrument.
46
+
47
+ EXAMPLE::
48
+
49
+ RE(make_devices())
50
+
51
+ PARAMETERS
52
+
53
+ pause : float
54
+ Wait 'pause' seconds (default: 1) for slow objects to connect.
55
+
56
+ """
57
+ logger.debug("(Re)Loading local control objects.")
58
+ yield from run_blocking_function(_loader, local_control_devices_file, main=True)
59
+
60
+ if host_on_aps_subnet():
61
+ yield from run_blocking_function(_loader, aps_control_devices_file, main=True)
62
+
63
+ if pause > 0:
64
+ logger.debug(
65
+ "Waiting %s seconds for slow objects to connect.",
66
+ pause,
67
+ )
68
+ yield from bps.sleep(pause)
69
+
70
+ # Configure any of the controls here, or in plan stubs
71
+
72
+
73
+ def _loader(yaml_device_file, main=True):
74
+ """
75
+ Load our ophyd-style controls as described in a YAML file.
76
+
77
+ PARAMETERS
78
+
79
+ yaml_device_file : str or pathlib.Path
80
+ YAML file describing ophyd-style controls to be created.
81
+ main : bool
82
+ If ``True`` add these devices to the ``__main__`` namespace.
83
+
84
+ """
85
+ logger.debug("Devices file %r.", str(yaml_device_file))
86
+ t0 = time.time()
87
+ _instr.load(yaml_device_file)
88
+ logger.debug("Devices loaded in %.3f s.", time.time() - t0)
89
+
90
+ if main:
91
+ for label in oregistry.device_names:
92
+ # add to __main__ namespace
93
+ setattr(main_namespace, label, oregistry[label])
94
+
95
+
96
+ class Instrument(guarneri.Instrument):
97
+ """Custom YAML loader for guarneri."""
98
+
99
+ def parse_yaml_file(self, config_file: pathlib.Path | str) -> list[dict]:
100
+ """Read device configurations from YAML format file."""
101
+ if isinstance(config_file, str):
102
+ config_file = pathlib.Path(config_file)
103
+
104
+ def parser(creator, specs):
105
+ if creator not in self.device_classes:
106
+ self.device_classes[creator] = dynamic_import(creator)
107
+ entries = [
108
+ {
109
+ "device_class": creator,
110
+ "args": (), # ALL specs are kwargs!
111
+ "kwargs": table,
112
+ }
113
+ for table in specs
114
+ ]
115
+ return entries
116
+
117
+ devices = [
118
+ device
119
+ # parse the file
120
+ for k, v in load_config_yaml(config_file).items()
121
+ # each support type (class, factory, function, ...)
122
+ for device in parser(k, v)
123
+ ]
124
+ return devices
125
+
126
+
127
+ _instr = Instrument({}, registry=oregistry) # singleton
@@ -0,0 +1,101 @@
1
+ """
2
+ RunEngine Metadata
3
+ ==================
4
+
5
+ .. autosummary::
6
+ ~MD_PATH
7
+ ~get_md_path
8
+ ~re_metadata
9
+ """
10
+
11
+ import getpass
12
+ import logging
13
+ import os
14
+ import pathlib
15
+ import socket
16
+ import sys
17
+
18
+ import apstools
19
+ import bluesky
20
+ import databroker
21
+ import epics
22
+ import h5py
23
+ import intake
24
+ import matplotlib
25
+ import numpy
26
+ import ophyd
27
+ import pyRestTable
28
+ import pysumreg
29
+ import spec2nexus
30
+
31
+ import apsbits
32
+ from apsbits.utils.config_loaders import iconfig
33
+
34
+ logger = logging.getLogger(__name__)
35
+ logger.bsdev(__file__)
36
+
37
+ re_config = iconfig.get("RUN_ENGINE", {})
38
+
39
+ DEFAULT_MD_PATH = pathlib.Path.home() / ".config" / "Bluesky_RunEngine_md"
40
+ HOSTNAME = socket.gethostname() or "localhost"
41
+ USERNAME = getpass.getuser() or "Bluesky user"
42
+ VERSIONS = dict(
43
+ apstools=apstools.__version__,
44
+ bluesky=bluesky.__version__,
45
+ databroker=databroker.__version__,
46
+ epics=epics.__version__,
47
+ h5py=h5py.__version__,
48
+ intake=intake.__version__,
49
+ matplotlib=matplotlib.__version__,
50
+ numpy=numpy.__version__,
51
+ ophyd=ophyd.__version__,
52
+ pyRestTable=pyRestTable.__version__,
53
+ python=sys.version.split(" ")[0],
54
+ pysumreg=pysumreg.__version__,
55
+ spec2nexus=spec2nexus.__version__,
56
+ apsbits=apsbits.__version__,
57
+ )
58
+ RE_CONFIG = iconfig.get("RUN_ENGINE", {})
59
+
60
+
61
+ def get_md_path():
62
+ """
63
+ Get path for RE metadata.
64
+
65
+ ============== ==============================================
66
+ support path
67
+ ============== ==============================================
68
+ PersistentDict Directory where dictionary keys are stored in separate files.
69
+ StoredDict File where dictionary is stored as YAML.
70
+ ============== ==============================================
71
+
72
+ In either case, the 'path' can be relative or absolute. Relative
73
+ paths are with respect to the present working directory when the
74
+ bluesky session is started.
75
+ """
76
+ md_path_name = RE_CONFIG.get("MD_PATH", DEFAULT_MD_PATH)
77
+ path = pathlib.Path(md_path_name)
78
+ logger.info("RunEngine metadata saved in directory: %s", str(path))
79
+ return str(path)
80
+
81
+
82
+ def re_metadata(cat=None):
83
+ """Programmatic metadata for the RunEngine."""
84
+ md = {
85
+ "login_id": f"{USERNAME}@{HOSTNAME}",
86
+ "versions": VERSIONS,
87
+ "pid": os.getpid(),
88
+ "iconfig": iconfig,
89
+ }
90
+ if cat is not None:
91
+ md["databroker_catalog"] = cat.name
92
+ md.update(RE_CONFIG.get("DEFAULT_METADATA", {}))
93
+
94
+ conda_prefix = os.environ.get("CONDA_PREFIX")
95
+ if conda_prefix is not None:
96
+ md["conda_prefix"] = conda_prefix
97
+ return md
98
+
99
+
100
+ MD_PATH = get_md_path()
101
+ """Storage path to save RE metadata between sessions."""
@@ -0,0 +1,199 @@
1
+ """
2
+ Device factories
3
+ ================
4
+
5
+ Device factories are used to:
6
+
7
+ * *Create* several similar ophyd-style devices (such as ``ophyd.Device``
8
+ or ``ophyd.Signal``) that fit a pattern.
9
+
10
+ * *Import* a device which is pre-defined in a module, such as the
11
+ ophyd simulators in ``ophyd.sim``.
12
+
13
+ .. autosummary::
14
+
15
+ ~factory_base
16
+ ~motors
17
+ ~predefined_device
18
+ """
19
+
20
+ import logging
21
+
22
+ from apstools.utils import dynamic_import
23
+
24
+ logger = logging.getLogger(__name__)
25
+ logger.bsdev(__file__)
26
+
27
+
28
+ def predefined_device(*, name="", creator=""):
29
+ """
30
+ Provide a predefined device such as from the 'ophyd.sim' module.
31
+
32
+ PARAMETERS
33
+
34
+ creator : str
35
+ Name of the predefined device to be used
36
+ name : str
37
+ Simulator will be assigned this name. (default: use existing name)
38
+
39
+ Example entry in `devices.yml` file:
40
+
41
+ .. code-block:: yaml
42
+ :linenos:
43
+
44
+ instrument.devices.factories.predefined_device:
45
+ - {creator: ophyd.sim.motor, name: sim_motor}
46
+ - {creator: ophyd.sim.noisy_det, name: sim_det}
47
+ """
48
+ if creator == "":
49
+ raise ValueError("Must provide a value for 'creator'.")
50
+ device = dynamic_import(creator)
51
+ if name != "":
52
+ device.name = name
53
+ logger.debug(device)
54
+ yield device
55
+
56
+
57
+ def factory_base(
58
+ *,
59
+ prefix=None,
60
+ names="object{}",
61
+ first=0,
62
+ last=0,
63
+ creator="ophyd.Signal",
64
+ **kwargs,
65
+ ):
66
+ """
67
+ Make one or more objects using 'creator'.
68
+
69
+ PARAMETERS
70
+
71
+ prefix : str
72
+ Prefix *pattern* for the EPICS PVs (default: ``None``).
73
+
74
+ names : str
75
+ Name *pattern* for the objects. The default pattern is ``"object{}"`` which
76
+ produces devices named ``object1, object2, ..., ```. If a formatting
77
+ specification (``{}``) is not provided, it is appended. Each object
78
+ will be named using this code: ``names.format(number)``, such as::
79
+
80
+ In [23]: "object{}".format(22)
81
+ Out[23]: 'object22'
82
+
83
+ first : int
84
+ The first object number in the continuous series from 'first' through
85
+ 'last' (inclusive).
86
+
87
+ last : int
88
+ The first object number in the continuous series from 'first' through
89
+ 'last' (inclusive).
90
+
91
+ creator : str
92
+ Name of the *creator* code that will be used to construct each device.
93
+ (default: ``"ophyd.Signal"``)
94
+
95
+ kwargs : dict
96
+ Dictionary of additional keyword arguments. This is included
97
+ when creating each object.
98
+ """
99
+ if "{" not in names:
100
+ names += "{}"
101
+ if prefix is not None and "{" not in prefix:
102
+ prefix += "{}"
103
+
104
+ klass = dynamic_import(creator)
105
+
106
+ first, last = sorted([first, last])
107
+ for i in range(first, 1 + last):
108
+ keywords = {"name": names.format(i)}
109
+ if prefix is not None:
110
+ keywords["prefix"] = prefix.format(i)
111
+ keywords.update(kwargs)
112
+ device = klass(**keywords)
113
+ logger.debug(device)
114
+ yield device
115
+
116
+
117
+ def motors(
118
+ *,
119
+ prefix=None,
120
+ names="m{}",
121
+ first=0,
122
+ last=0,
123
+ **kwargs,
124
+ ):
125
+ """
126
+ Make one or more '``ophyd.EpicsMotor``' objects.
127
+
128
+ Example entry in `devices.yml` file:
129
+
130
+ .. code-block:: yaml
131
+ :linenos:
132
+
133
+ instrument.devices.factories.motors:
134
+ - {prefix: "ioc:m", first: 1, last: 4, labels: ["motor"]}
135
+ # skip m5 & m6
136
+ - {prefix: "ioc:m", first: 7, last: 22, labels: ["motor"]}
137
+
138
+ Uses this pattern:
139
+
140
+ .. code-block:: py
141
+ :linenos:
142
+
143
+ ophyd.EpicsMotor(
144
+ prefix=prefix.format(i),
145
+ name=names.format(i),
146
+ **kwargs,
147
+ )
148
+
149
+ where ``i`` iterates from 'first' through 'last' (inclusive).
150
+
151
+ PARAMETERS
152
+
153
+ prefix : str
154
+ Name *pattern* for the EPICS PVs. There is no default pattern. If a
155
+ formatting specification (``{}``) is not provided, it is appended (as
156
+ with other ophyd devices). Each motor will be configured with this
157
+ prefix: ``prefix.format(number)``, such as::
158
+
159
+ In [23]: "ioc:m{}".format(22)
160
+ Out[23]: 'ioc:m22'
161
+
162
+ names : str
163
+ Name *pattern* for the motors. The default pattern is ``"m{}"`` which
164
+ produces motors named ``m1, m2, ..., m22, m23, ...```. If a formatting
165
+ specification (``{}``) is not provided, it is appended. Each motor
166
+ will be named using this code: ``names.format(number)``, such as::
167
+
168
+ In [23]: "m{}".format(22)
169
+ Out[23]: 'm22'
170
+
171
+ first : int
172
+ The first motor number in the continuous series from 'first' through
173
+ 'last' (inclusive).
174
+
175
+ last : int
176
+ The first motor number in the continuous series from 'first' through
177
+ 'last' (inclusive).
178
+
179
+ kwargs : dict
180
+ Dictionary of additional keyword arguments. This is included
181
+ with each EpicsMotor object.
182
+ """
183
+ if prefix is None:
184
+ raise ValueError("Must define a string value for 'prefix'.")
185
+
186
+ kwargs["names"] = names or "m{}"
187
+ kwargs["prefix"] = prefix
188
+ kwargs.update(
189
+ {
190
+ "prefix": prefix,
191
+ "names": names or "m{}",
192
+ "first": first,
193
+ "last": last,
194
+ "creator": "ophyd.EpicsMotor",
195
+ }
196
+ )
197
+
198
+ for motor in factory_base(**kwargs):
199
+ yield motor