nbs-bl 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.
Files changed (64) hide show
  1. nbs_bl/__init__.py +15 -0
  2. nbs_bl/beamline.py +450 -0
  3. nbs_bl/configuration.py +838 -0
  4. nbs_bl/detectors.py +89 -0
  5. nbs_bl/devices/__init__.py +12 -0
  6. nbs_bl/devices/detectors.py +154 -0
  7. nbs_bl/devices/motors.py +242 -0
  8. nbs_bl/devices/sampleholders.py +360 -0
  9. nbs_bl/devices/shutters.py +120 -0
  10. nbs_bl/devices/slits.py +51 -0
  11. nbs_bl/gGrEqns.py +171 -0
  12. nbs_bl/geometry/__init__.py +0 -0
  13. nbs_bl/geometry/affine.py +197 -0
  14. nbs_bl/geometry/bars.py +189 -0
  15. nbs_bl/geometry/frames.py +534 -0
  16. nbs_bl/geometry/linalg.py +138 -0
  17. nbs_bl/geometry/polygons.py +56 -0
  18. nbs_bl/help.py +126 -0
  19. nbs_bl/hw.py +270 -0
  20. nbs_bl/load.py +113 -0
  21. nbs_bl/motors.py +19 -0
  22. nbs_bl/planStatus.py +5 -0
  23. nbs_bl/plans/__init__.py +8 -0
  24. nbs_bl/plans/batches.py +174 -0
  25. nbs_bl/plans/conditions.py +77 -0
  26. nbs_bl/plans/flyscan_base.py +180 -0
  27. nbs_bl/plans/groups.py +55 -0
  28. nbs_bl/plans/maximizers.py +423 -0
  29. nbs_bl/plans/metaplans.py +179 -0
  30. nbs_bl/plans/plan_stubs.py +246 -0
  31. nbs_bl/plans/preprocessors.py +160 -0
  32. nbs_bl/plans/scan_base.py +58 -0
  33. nbs_bl/plans/scan_decorators.py +524 -0
  34. nbs_bl/plans/scans.py +145 -0
  35. nbs_bl/plans/suspenders.py +87 -0
  36. nbs_bl/plans/time_estimation.py +168 -0
  37. nbs_bl/plans/xas.py +123 -0
  38. nbs_bl/printing.py +221 -0
  39. nbs_bl/qt/models/beamline.py +11 -0
  40. nbs_bl/qt/models/energy.py +53 -0
  41. nbs_bl/qt/widgets/energy.py +225 -0
  42. nbs_bl/queueserver.py +249 -0
  43. nbs_bl/redisDevice.py +96 -0
  44. nbs_bl/run_engine.py +63 -0
  45. nbs_bl/samples.py +130 -0
  46. nbs_bl/settings.py +68 -0
  47. nbs_bl/shutters.py +39 -0
  48. nbs_bl/sim/__init__.py +2 -0
  49. nbs_bl/sim/config/polphase.nc +0 -0
  50. nbs_bl/sim/energy.py +403 -0
  51. nbs_bl/sim/manipulator.py +14 -0
  52. nbs_bl/sim/utils.py +36 -0
  53. nbs_bl/startup.py +27 -0
  54. nbs_bl/status.py +114 -0
  55. nbs_bl/tests/__init__.py +0 -0
  56. nbs_bl/tests/modify_regions.py +160 -0
  57. nbs_bl/tests/test_frames.py +99 -0
  58. nbs_bl/tests/test_panels.py +69 -0
  59. nbs_bl/utils.py +235 -0
  60. nbs_bl-0.2.0.dist-info/METADATA +71 -0
  61. nbs_bl-0.2.0.dist-info/RECORD +64 -0
  62. nbs_bl-0.2.0.dist-info/WHEEL +4 -0
  63. nbs_bl-0.2.0.dist-info/entry_points.txt +2 -0
  64. nbs_bl-0.2.0.dist-info/licenses/LICENSE +13 -0
@@ -0,0 +1,360 @@
1
+ from ophyd import PseudoPositioner, Device, Component as Cpt
2
+ from ophyd.pseudopos import (
3
+ pseudo_position_argument,
4
+ real_position_argument,
5
+ PseudoSingle,
6
+ )
7
+ from nbs_bl.geometry.affine import Frame, find_rotation
8
+ from nbs_bl.devices import FlyableMotor
9
+ import numpy as np
10
+ import copy
11
+
12
+
13
+ class SampleHolderBase(Device):
14
+ def __init__(self, *args, attachment_point, holder=None, use_redis=True, **kwargs):
15
+ """
16
+ Parameters
17
+ ----------
18
+ holder : GeometryBase, optional
19
+ A GeometryBase subclass that defines the geometry of a samplebar.
20
+ If None, a holder will need to be set later via set_holder
21
+ attachment_point : tuple
22
+ Coordinates of the attachment point in beam coordinates.
23
+ I.e, the motor coordinates that will bring the attachment point into the beam.
24
+ use_redis : bool, optional
25
+ If True, uses Redis-backed status containers where appropriate
26
+ """
27
+ super().__init__(*args, **kwargs)
28
+ self.manip_frame = Frame(origin=np.zeros_like(attachment_point))
29
+ self.attachment_frame = self.manip_frame.make_child_frame(
30
+ origin=attachment_point
31
+ )
32
+
33
+ # Request status containers from global manager
34
+ self.samples = {}
35
+ # GLOBAL_USER_STATUS.request_status_dict(
36
+ # f"{self.name.upper()}_SAMPLES", use_redis=use_redis
37
+ # )
38
+ # current_sample contains metadata, can use Redis
39
+ self.current_sample = {}
40
+ # GLOBAL_USER_STATUS.request_status_dict(
41
+ # f"{self.name.upper()}_CURRENT", use_redis=use_redis
42
+ # )
43
+
44
+ self.sample_frames = {}
45
+ self.holder_md = {}
46
+ self.holder_frames = {}
47
+ self.current_frame = self.attachment_frame
48
+ self.set_holder(holder)
49
+
50
+ def set_holder(self, holder):
51
+ self.holder = holder
52
+ if holder is not None:
53
+ self.holder.attach_manipulator(self.attachment_frame)
54
+ holder_md, holder_frames = self.holder.get_geometry()
55
+ self.holder_md = holder_md
56
+ self.holder_frames = holder_frames
57
+ else:
58
+ self.holder_md = {}
59
+ self.holder_frames = {}
60
+
61
+ def clear_holder(self):
62
+ self.set_holder(None)
63
+ self.samples.clear()
64
+ self.sample_frames.clear()
65
+ self.current_sample.clear()
66
+
67
+ def clear_samples(self):
68
+ self.samples.clear()
69
+ self.sample_frames.clear()
70
+ self.current_sample.clear()
71
+
72
+ def add_sample(self, name, id, position, description="", origin="holder", **kwargs):
73
+ if origin == "absolute":
74
+ sample_frame = position
75
+ else:
76
+ sample_frame = self.holder.make_sample_frame(position)
77
+
78
+ self.samples[id] = {
79
+ "name": name,
80
+ "description": description,
81
+ "position": position,
82
+ "sample_id": id,
83
+ "origin": origin,
84
+ **kwargs,
85
+ }
86
+ self.sample_frames[id] = sample_frame
87
+
88
+ def remove_sample(self, sample_id):
89
+ self.samples.pop(sample_id, None)
90
+ self.sample_frames.pop(sample_id, None)
91
+ if self.current_sample.get("sample_id") == sample_id:
92
+ self.current_sample.clear()
93
+
94
+ def set_sample(self, sample_id):
95
+ if sample_id in self.sample_frames:
96
+ self.current_frame = self.sample_frames[sample_id]
97
+ self.current_sample.clear()
98
+ self.current_sample.update(self.samples[sample_id])
99
+ elif sample_id in self.holder_frames:
100
+ self.current_frame = self.holder_frames[sample_id]
101
+ self.current_sample.clear()
102
+ self.current_sample.update(self.holder_md[sample_id])
103
+ else:
104
+ raise KeyError(
105
+ f"Sample ID {sample_id} not found in sample frames or holder frames"
106
+ )
107
+
108
+ def load_sample_dict(self, samples, clear=True):
109
+ """
110
+ Create a sample dictionary into a sampleholder
111
+
112
+ Parameters
113
+ ----------
114
+ samples : Dict
115
+ A dictionary whose keys are sample_id keys, and entries that are dictionaries containing
116
+ at least 'name' and 'position' keys, and optionally a 'description' key. The format of
117
+ the 'position' dictionary depends on the sampleholder that is being used. Additional items in the
118
+ dictionary will be passed to the sampleholder add_sample function.description
119
+ clear : Bool
120
+ If True, clear existing samples from the sampleholder.
121
+ """
122
+ if clear:
123
+ self.clear_samples()
124
+ for sample_id, s in samples.items():
125
+ sdict = copy.deepcopy(s)
126
+ name = sdict.pop("name")
127
+ description = sdict.pop("description", name)
128
+ position = sdict.pop("position")
129
+ # add_sample_to_globals(
130
+ # sample_id, name, position, side, thickness, description, **sdict
131
+ # )
132
+ self.add_sample(
133
+ name,
134
+ sample_id,
135
+ position,
136
+ description=description,
137
+ **sdict,
138
+ )
139
+ return
140
+
141
+ def load_sample_file(self, filename, clear=True):
142
+ samples = self.holder.read_sample_file(filename)
143
+ self.load_sample_dict(samples, clear=clear)
144
+
145
+ def reload_sample_frames(self):
146
+ """Reload sample frames from the persisted samples dictionary.
147
+
148
+ This is useful when samples are loaded from a persistent database but
149
+ the corresponding frames need to be reconstructed, such as when
150
+ creating a new SampleHolder instance.
151
+
152
+ Note: This requires a holder to be set and will skip any samples
153
+ with origin="absolute" since those frames are stored directly.
154
+ """
155
+ self.sample_frames.clear()
156
+ for sample_id, sample in self.samples.items():
157
+ if self.holder is None and sample["origin"] != "absolute":
158
+ continue
159
+
160
+ if sample["origin"] == "absolute":
161
+ self.sample_frames[sample_id] = sample["position"]
162
+ else:
163
+ self.sample_frames[sample_id] = self.holder.make_sample_frame(
164
+ sample["position"]
165
+ )
166
+
167
+ def move_sample(self, sample_id, **positions):
168
+ position = self.get_sample_position(sample_id, **positions)
169
+ return self.move(position)
170
+
171
+ def get_sample_position(self, sample_id, **positions):
172
+ raise NotImplementedError("This method should be implemented by the subclass")
173
+
174
+
175
+ class Manipulator1AxBase(PseudoPositioner, SampleHolderBase):
176
+ sx = Cpt(PseudoSingle)
177
+
178
+ def __init__(self, *args, origin: float = 0, **kwargs):
179
+ super().__init__(*args, attachment_point=[origin], **kwargs)
180
+
181
+ def add_current_position_as_sample(self, name, id, description, **kwargs):
182
+ coordinates = tuple(self.real_position)
183
+ origin = "absolute"
184
+ self.add_sample(
185
+ name, id, {"coordinates": coordinates}, description, origin=origin, **kwargs
186
+ )
187
+
188
+ @pseudo_position_argument
189
+ def forward(self, pp):
190
+ """
191
+ Takes a sample frame position and converts it into real manipulator coordinates
192
+ """
193
+ if isinstance(self.current_frame, Frame):
194
+ # If current_frame is a Frame object, use the existing conversion
195
+ (position,) = self.current_frame.to_frame(pp, self.manip_frame)
196
+ else:
197
+ # If current_frame is a coordinate dict, add its value to pp
198
+ frame_coords = self.current_frame.get("coordinates", [0])
199
+ position = pp.sx + frame_coords[0]
200
+
201
+ return self.RealPosition(position)
202
+
203
+ @real_position_argument
204
+ def inverse(self, rp):
205
+ if isinstance(self.current_frame, Frame):
206
+ # If current_frame is a Frame object, use the existing conversion
207
+ (position,) = self.manip_frame.to_frame(rp, self.current_frame)
208
+ else:
209
+ # If current_frame is a coordinate dict, subtract its value from rp
210
+ frame_coords = self.current_frame.get("coordinates", [0])
211
+ position = rp.sx - frame_coords[0]
212
+ return self.PseudoPosition(position)
213
+
214
+ def get_sample_position(self, sample_id=None, position=0):
215
+ if sample_id is not None:
216
+ self.set_sample(sample_id)
217
+ return position
218
+
219
+
220
+ class Manipulator4AxBase(PseudoPositioner, SampleHolderBase):
221
+ sx = Cpt(PseudoSingle)
222
+ sy = Cpt(PseudoSingle)
223
+ sz = Cpt(PseudoSingle)
224
+ sr = Cpt(PseudoSingle)
225
+
226
+ """
227
+ An X, Y, Z, R sample positioner
228
+ """
229
+
230
+ # Really need a discrete manipulator that can be set to
231
+ # one of several sample positions. May just be a sampleholder
232
+ # Good argument for having sampleholder contain the motors, not
233
+ # the other way around...?
234
+ def __init__(self, *args, beam_direction=(0, -1, 0), rotation_ax=2, **kwargs):
235
+ super().__init__(*args, **kwargs)
236
+ self.beam_direction = beam_direction
237
+ self.rotation_ax = rotation_ax
238
+ self.ax1 = (rotation_ax + 1) % 3
239
+ self.ax2 = (rotation_ax + 2) % 3
240
+ self.default_coords = [0, 0, 0, 45]
241
+
242
+ @pseudo_position_argument
243
+ def forward(self, pp):
244
+ """
245
+ Takes a sample frame position and converts it into real manipulator coordinates
246
+
247
+ bp = self.holder.frame_to_beam(*pp)
248
+ if isinstance(bp, (float, int)):
249
+ bp = (bp,)
250
+ rp = self.beam_to_manip_frame(*bp)
251
+ return self.RealPosition(*rp)
252
+ """
253
+ sample_coords = pp[:-1]
254
+ r = pp[-1]
255
+
256
+ if isinstance(self.current_frame, Frame):
257
+ # If current_frame is a Frame object, use the existing conversion
258
+ xp, yp, zp = self.current_frame.to_frame(
259
+ sample_coords,
260
+ self.manip_frame,
261
+ )
262
+
263
+ r = self.sample_rotation_to_manip_rotation(r)
264
+ x, y, z = self.manip_frame.rotate_in_plane(
265
+ (xp, yp, zp), r * np.pi / 180.0, ax1=self.ax1, ax2=self.ax2
266
+ )
267
+ else:
268
+ # If current_frame is a coordinate dict, add its values to sample_coords
269
+ frame_coords = self.current_frame.get("coordinates", [0, 0, 0, 0])
270
+ x = sample_coords[0] + frame_coords[0]
271
+ y = sample_coords[1] + frame_coords[1]
272
+ z = sample_coords[2] + frame_coords[2]
273
+ r += frame_coords[3]
274
+
275
+ return self.RealPosition(x, y, z, r)
276
+
277
+ @real_position_argument
278
+ def inverse(self, rp):
279
+ """
280
+ Takes a real manipulator position and converts into frame coordinates
281
+
282
+ bp = self.manip_to_beam_frame(*rp)
283
+ pp = self.holder.beam_to_frame(*bp)
284
+ if isinstance(pp, (float, int)):
285
+ pp = (pp,)
286
+ return self.PseudoPosition(*pp)
287
+ """
288
+ real_coords = rp[:-1]
289
+
290
+ if isinstance(self.current_frame, Frame):
291
+ r = self.manip_rotation_to_sample_rotation(rp[-1])
292
+ xp, yp, zp = self.manip_frame.rotate_in_plane(
293
+ real_coords, -rp[-1] * np.pi / 180.0, ax1=self.ax1, ax2=self.ax2
294
+ )
295
+ # If current_frame is a Frame object, use the existing conversion
296
+ x, y, z = self.manip_frame.to_frame((xp, yp, zp), self.current_frame)
297
+ else:
298
+ # If current_frame is a coordinate dict, subtract its values from xp, yp, zp, r
299
+ frame_coords = self.current_frame.get("coordinates", [0, 0, 0, 0])
300
+ x = rp[0] - frame_coords[0]
301
+ y = rp[1] - frame_coords[1]
302
+ z = rp[2] - frame_coords[2]
303
+ r = rp[3] - frame_coords[3]
304
+
305
+ return self.PseudoPosition(x, y, z, r)
306
+
307
+ def add_current_position_as_sample(self, name, id, description, **kwargs):
308
+ coordinates = tuple(self.real_position)
309
+ origin = "absolute"
310
+ self.add_sample(
311
+ name, id, {"coordinates": coordinates}, description, origin=origin, **kwargs
312
+ )
313
+
314
+ def get_sample_position(self, sample_id=None, **positions):
315
+ if sample_id is not None:
316
+ self.set_sample(sample_id)
317
+ if isinstance(self.current_frame, Frame):
318
+ position = [p for p in self.default_coords]
319
+ else:
320
+ position = [0, 0, 0, 0]
321
+ if "x" in positions:
322
+ position[0] = positions["x"]
323
+ if "y" in positions:
324
+ position[1] = positions["y"]
325
+ if "z" in positions:
326
+ position[2] = positions["z"]
327
+ if "r" in positions:
328
+ position[3] = positions["r"]
329
+ return position
330
+
331
+ def sample_rotation_to_manip_rotation(self, r):
332
+ # Assumes that z-axis is the surface normal!!
333
+ grazing = find_rotation(
334
+ self.current_frame,
335
+ (1, 0, 0),
336
+ self.manip_frame,
337
+ self.beam_direction,
338
+ self.rotation_ax,
339
+ )
340
+ return grazing * 180.0 / np.pi + r
341
+
342
+ def manip_rotation_to_sample_rotation(self, r):
343
+ grazing = find_rotation(
344
+ self.current_frame,
345
+ (1, 0, 0),
346
+ self.manip_frame,
347
+ self.beam_direction,
348
+ self.rotation_ax,
349
+ )
350
+ return r - grazing * 180.0 / np.pi
351
+
352
+
353
+ def manipulatorFactory4Ax(xPV, yPV, zPV, rPV):
354
+ class Manipulator(Manipulator4AxBase):
355
+ x = Cpt(FlyableMotor, xPV, name="x", kind="hinted")
356
+ y = Cpt(FlyableMotor, yPV, name="y", kind="hinted")
357
+ z = Cpt(FlyableMotor, zPV, name="z", kind="hinted")
358
+ r = Cpt(FlyableMotor, rPV, name="r", kind="hinted")
359
+
360
+ return Manipulator
@@ -0,0 +1,120 @@
1
+ from ophyd import Device, EpicsSignal, Component as Cpt, PVPositionerPC
2
+ from ophyd.status import SubscriptionStatus
3
+ import bluesky.plan_stubs as bps
4
+ import time
5
+
6
+
7
+ class EPS_Shutter(Device):
8
+ state = Cpt(EpicsSignal, "Pos-Sts")
9
+ cls = Cpt(EpicsSignal, "Cmd:Cls-Cmd")
10
+ opn = Cpt(EpicsSignal, "Cmd:Opn-Cmd")
11
+ error = Cpt(EpicsSignal, "Err-Sts")
12
+ maxcount = 3
13
+ # openval = 1 # normal shutter values, FS1 is reversed
14
+ # closeval = 0
15
+
16
+ def __init__(self, *args, openval=1, closeval=0, shutter_type=None, **kwargs):
17
+ super().__init__(*args, **kwargs)
18
+ self.openval = openval
19
+ self.closeval = closeval
20
+ self.shutter_type = shutter_type
21
+
22
+ def status(self):
23
+ if self.state.get() == self.closeval:
24
+ return "closed"
25
+ else:
26
+ return "open"
27
+
28
+ def open(self):
29
+ count = 0
30
+ while self.state.get() != self.openval:
31
+ count += 1
32
+ print("\u231b", end=" ", flush=True)
33
+ yield from bps.mv(self.opn, 1)
34
+ if count >= self.maxcount:
35
+ print(
36
+ "tried %d times and failed to open %s %s" % (count, self.name, ":(")
37
+ ) # u'\u2639' unicode frown
38
+ return (yield from bps.null())
39
+ yield from bps.sleep(1.5)
40
+ print("Opened {}".format(self.name))
41
+
42
+ def close(self):
43
+ count = 0
44
+ while self.state.get() != self.closeval:
45
+ count += 1
46
+ print("\u231b", end=" ", flush=True)
47
+ yield from bps.mv(self.cls, 1)
48
+ if count >= self.maxcount:
49
+ print(
50
+ "tried %d times and failed to close %s %s"
51
+ % (count, self.name, ":(")
52
+ )
53
+ return (yield from bps.null())
54
+ yield from bps.sleep(1.5)
55
+ print("Closed {}".format(self.name))
56
+
57
+ def open_nonplan(self):
58
+ self.read()
59
+ if self.state.get() != self.openval:
60
+ count = 0
61
+ while self.state.get() != self.openval:
62
+ count += 1
63
+ print("\u231b", end=" ", flush=True)
64
+ self.opn.set(1)
65
+ if count >= self.maxcount:
66
+ print(
67
+ "tried %d times and failed to open %s %s"
68
+ % (count, self.name, ":(")
69
+ )
70
+ return
71
+ time.sleep(1.5)
72
+ self.read()
73
+ print(" Opened {}".format(self.name))
74
+ else:
75
+ print("{} is open".format(self.name))
76
+
77
+ def close_nonplan(self):
78
+ self.read()
79
+ if self.state.get() != self.closeval:
80
+ count = 0
81
+ while self.state.get() != self.closeval:
82
+ count += 1
83
+ print("\u231b", end=" ", flush=True)
84
+ self.cls.set(1)
85
+ if count >= self.maxcount:
86
+ print(
87
+ "tried %d times and failed to close %s %s"
88
+ % (count, self.name, ":(")
89
+ )
90
+ return
91
+ time.sleep(1.5)
92
+ self.read()
93
+ print(" Closed {}".format(self.name))
94
+ else:
95
+ print("{} is closed".format(self.name))
96
+
97
+
98
+ class ShutterSet(PVPositionerPC):
99
+ """
100
+ Used for set of Diode box shutters
101
+ """
102
+
103
+ readback = Cpt(EpicsSignal, "-RB")
104
+ setpoint = Cpt(EpicsSignal, "-SP")
105
+
106
+ def set(self, value, *args, **kwargs):
107
+ if value is None:
108
+ saw_rise = False
109
+
110
+ def watcher(*, old_value, value, **kwargs):
111
+ nonlocal saw_rise
112
+ if value == 1:
113
+ saw_rise = True
114
+ return False
115
+ if value == 0 and saw_rise:
116
+ return True
117
+
118
+ return SubscriptionStatus(self.readback, watcher)
119
+ else:
120
+ return super().set(value, *args, **kwargs)
@@ -0,0 +1,51 @@
1
+ from ophyd import EpicsMotor, PseudoPositioner, PseudoSingle, Component as Cpt
2
+ from ophyd.pseudopos import pseudo_position_argument, real_position_argument
3
+ from nbs_bl.printing import boxed_text
4
+
5
+
6
+ class Slits(PseudoPositioner):
7
+ def __init__(self, *args, **kwargs):
8
+ super().__init__(*args, **kwargs)
9
+
10
+ def where(self):
11
+ print("%s:" % self.name)
12
+ text1 = " vertical size = %7.3f mm\n" % (self.vsize.position)
13
+ text1 += " vertical center = %7.3f mm\n" % (self.vcenter.position)
14
+ text2 = " horizontal size = %7.3f mm\n" % (self.hsize.position)
15
+ text2 += " horizontal center = %7.3f mm\n" % (self.hcenter.position)
16
+ return text1 + text2
17
+
18
+ def wh(self):
19
+ boxed_text(self.name, self.where(), "cyan") # bruce's
20
+
21
+ # The pseudo positioner axes:
22
+ vsize = Cpt(PseudoSingle, limits=(-1, 20), kind="hinted")
23
+ vcenter = Cpt(PseudoSingle, limits=(-10, 10), kind="normal")
24
+ hsize = Cpt(PseudoSingle, limits=(-1, 20), kind="hinted")
25
+ hcenter = Cpt(PseudoSingle, limits=(-10, 10), kind="normal")
26
+
27
+ # The real (or physical) positioners:
28
+ top = Cpt(EpicsMotor, "T}Mtr", kind="normal")
29
+ bottom = Cpt(EpicsMotor, "B}Mtr", kind="normal")
30
+ inboard = Cpt(EpicsMotor, "I}Mtr", kind="normal")
31
+ outboard = Cpt(EpicsMotor, "O}Mtr", kind="normal")
32
+
33
+ @pseudo_position_argument
34
+ def forward(self, pseudo_pos):
35
+ """Run a forward (pseudo -> real) calculation"""
36
+ return self.RealPosition(
37
+ top=pseudo_pos.vcenter + pseudo_pos.vsize / 2,
38
+ bottom=pseudo_pos.vcenter - pseudo_pos.vsize / 2,
39
+ outboard=pseudo_pos.hcenter + pseudo_pos.hsize / 2,
40
+ inboard=pseudo_pos.hcenter - pseudo_pos.hsize / 2,
41
+ )
42
+
43
+ @real_position_argument
44
+ def inverse(self, real_pos):
45
+ """Run an inverse (real -> pseudo) calculation"""
46
+ return self.PseudoPosition(
47
+ hsize=real_pos.outboard - real_pos.inboard,
48
+ hcenter=(real_pos.outboard + real_pos.inboard) / 2,
49
+ vsize=real_pos.top - real_pos.bottom,
50
+ vcenter=(real_pos.top + real_pos.bottom) / 2,
51
+ )