-
Notifications
You must be signed in to change notification settings - Fork 2
/
05_Device_Model.tex
382 lines (277 loc) · 24.6 KB
/
05_Device_Model.tex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
\chapter{Writing a Model for the Device}\label{ch:device-model}
\section{Introduction}\label{sec:device-model-introduction}
Diligent thought is the key successfully developing a model for a device. You need to know what you expect from the device how you are planning to use it. The first time you start to think about models, it can be quite overwhelming, because you'll need to anticipate your future needs. For example, the {PFTL DAQ} device doesn't handle units. It would be great if the model would allow you to specify the output voltage instead of converting it to an integer, because that's what the driver uses. This requirement seems trivial and is probably the first one that comes to mind. Later, once you start using the program, you'll notice that some other useful options were missing, and you could have saved time had you thought about them.
There's no magical recipe to teach precisely how to develop models for devices. Each device and each experiment is unique; the best you can do is to focus on the task at hand. You can extrapolate the rest to other devices and experiments. Once you understand the role that models play, you can use them for very different purposes, without needing to re-write the entire program. With a simple device and a simple experiment, you may not see immediate gains from having models separated from controllers. Still, as soon as the complexity grows, this value becomes apparent.
\warningInfo{Device Manuals}{It's impossible to overestimate the importance of reading the manuals for your devices. Hardware in the lab is not the same as consumer hardware. Things can break, signals may not make sense, and you could run into a host of other issues. Always be sure that you understand the limits under which your components operate.}
\section{Developing the Device Model}\label{sec:device-model}
The first thing you should think about is how you want to interact with a particular device. Of course, you would like to initialize it, set a voltage, read a voltage, and then finalize the device. However, you don't simply want to repeat what the Controller can do. Rather, when you initialize or finalize the device, you want to be sure that the output voltages are at $0\,\textrm{V}$. In this way, you can ensure that no current flows through the LED unless you explicitly want it to do so. You also want to be able to input values in volts when setting an output, and get values in volts when reading a voltage.
With these things in mind, you can develop a skeleton for the Model. You can use empty methods to get an idea of what you'll need to develop, as well as the arguments and outputs of each method. Start by creating a file \textbf{analog\_daq.py} in the \emph{Model} folder and add the following code:
\begin{minted}{python}
class AnalogDaq:
def __init__(self, port):
pass
def initialize(self):
pass
def get_voltage(self, channel):
pass
def set_voltage(self, channel, volts):
pass
def finalize(self):
pass
\end{minted}
Now you can add code to the file line by line. You'll start very similar to how you started the Controller. The \py{AnalogDaq} class takes \py{port} as an argument for initializing. The main difference is that you don't use PySerial directly, but instead you use the Controller. You can start improving your code like this:
\begin{minted}{python}
from PythonForTheLab.Controller.pftl_daq import Device
class AnalogDaq:
def __init__(self, port):
self.port = port
self.driver = Device(self.port)
def initialize(self):
self.driver.initialize()
self.set_voltage(0, 0)
self.set_voltage(1, 0)
\end{minted}
You initialize the class by storing the \py{port} and creating a \py{self.driver} attribute. Remember that the \py{Device} has a separate method for initializing. The \py{.initialize()} method now not only initializes the driver itself, but also sets the output voltages to 0. You haven't developed a way of setting voltages yet, but you can see the flow. The same works for the \py{.finalize()} method:
\begin{minted}{python}
def finalize(self):
self.set_voltage(0, 0)
self.set_voltage(1, 0)
self.driver.finalize()
\end{minted}
You first set the voltages to $0$, and then you finalize the Controller. This is a clear example of how you might impose your own logic onto the device. In some cases, you won't want to set the voltage to 0 when stopping communication. Perhaps you're just switching on a laser, and you want it to stay on even if you switch off the computer. Or perhaps you're using piezo stages where it's not recommended to suddenly shake them by setting a different voltage; it's better to just leave a voltage applied to them. Scenarios like these show why adding these features to the Controller would imply violating the separation of models and controllers. What you do with the voltages is part of the logic, not the device itself.
Now that you have this code, you can also add an example to the end of the file, to show you how to use the Model:
\begin{minted}{python}
if __name__ == "__main__":
daq = AnalogDaq('/dev/ttyACM0')
daq.initialize()
print(input_volts)
daq.finalize()
\end{minted}
The last missing bits are the methods for getting and setting a voltage. What you do in these steps was discussed in Section~\ref{subsec:adc-dca}. To set a voltage, you first need to transform a number in the range $0-3.3$ to an integer in the range $0-4095$. Then, you apply it:
\begin{minted}{python}
def set_voltage(self, channel, volts):
voltage_bits = volts*4095/3.3
self.driver.set_analog_output(channel, voltage_bits)
\end{minted}
You can do the same for the \py{.get()} method:
\begin{minted}{python}
def get_voltage(self, channel):
voltage_bits = self.driver.get_analog_input(channel)
voltage = voltage_bits*3.3/1023
return voltage
\end{minted}
That is all that's needed. You can now run the code and see that you can read and set voltages. Because of how the experiment works, the values you get at the analog input are going to be very small (in the order of a few tens of millivolts), but it's enough to be detected by the {PFTL DAQ}.
\section{Developing the Base Model}\label{sec:base-model}
In this book, you're working with only one device, so you're using only one model. However, if you want to make your program compatible with more devices, you'd need to start developing models for each new device. Since you already have one working model, it would probably be more reasonable to copy it and adapt the methods based on what the new drivers allow you to do. Another option is to create a base class that all other models inherit from. In this way, you know that all the methods are defined. Perhaps they don't do anything, but at least they're there!
What you'll do in this section is not a requirement for you to keep going, but it's important for you to see this pattern. That's because sooner or later, when the program grows, it will become a handy approach. Create a file called \textbf{base\_daq.py} inside the \emph{Controller} folder and add the following code to it:
\begin{minted}{python}
class DAQBase:
def __init__(self, port):
self.port = port
def initialize(self):
pass
def get_voltage(self, channel):
pass
def set_voltage(self, channel, volts):
pass
def finalize(self):
pass
\end{minted}
This class doesn't do anything by itself. It is only the \textbf{schematic} of what a model should contain. You added \py{pass} after every function definition to take care of the places where nothing happens. You can see that you also specify the arguments that each method takes: \py{.initialize()} takes a \py{port}, \py{.set_voltage()} takes \py{channel} and \py{value}, and so forth. In programming, this is also called an \textbf{Application Programming Interface} (or API for short). The base class defines the interface that all the DAQ models use. There's an initialize method, a get and set analog, and a finalize. Just by looking at this simple example, you already know how things are going to work and what you need to do to make them run.
As an example, let's create a dummy DAQ that can generate random values when requested. Since it's not connected to any real device, it doesn't do anything else. You can add the following code to \textbf{dummy\_daq.py} in the \emph{Model} folder:
\begin{minted}{python}
from random import random
from PythonForTheLab.Model.base_daq import DAQBase
class DummyDaq(DAQBase):
def get_analog_value(self, channel):
return random()
\end{minted}
If you copy the example code from your real DAQ, things are still going to work fine:
\begin{minted}{python}
if __name__ == "__main__":
daq = DummyDaq('/dev/ttyACM0')
daq.initialize()
voltage = 3
daq.set_voltage(0, voltage)
input_volts = daq.get_voltage(0)
print(input_volts)
daq.finalize()
\end{minted}
Of course, when you set a voltage, initialize, or finalize the Model, nothing happens; but when you ask for a value, you get one. You see that the way of using this class is the same as the real Model, and it took you only 3 lines of code to develop! Perhaps in the future you might move to a more complex DAQ, such as an oscilloscope. If you maintain the same names for the methods of the Model, then everything will keep working in the same way.
\questionInfo{Exercise}{Update the real Model to inherit from the base class.}
\section{Adding Real Units to the Code}\label{sec:pint}
Your Model for the {PFTL DAQ} device is working great, but it does have one problem. It allows you to set the output in volts and read a value in volts, but if you ever were to make the mistake of supplying the value in millivolts, then the program wouldn't work as expected. In real cases, it's tough to remember the units that every method should take. Sometimes you have very different outputs and inputs, with each one taking on different units. Imagine that you want to make a periodic signal, where perhaps the device asks for the frequency, perhaps for the period.
In Python, you can overcome the limitations of working with plain numbers by using a package called \emph{Pint}, which allows you to work with \emph{real} units. Let's quickly see how Pint can be used with a simple example:
\begin{minted}{pycon}
>>> import pint
>>> ur = pint.UnitRegistry()
>>> meter = ur('meter')
>>> b = 5*meter
>>> type(b)
<class 'pint.quantity.build_quantity_class.<locals>.Quantity'>
>>> print(b)
5 meter
>>> c = b.to('inch')
>>> print(c)
196.8503937007874 inch
\end{minted}
First, you import the package and start the \py{.UnitRegistry()}. In principle, Pint allows you to work with custom-made units, but the fundamental ones are already included in the \py{.UnitRegistry()}. Then, because of convenience, you define the variable \py{meter} as actually the unit meter. Finally, you assign the value of 5 meters to \py{b}. In this case, \py{b} is of type \py{Quantity}. Therefore, it's not just a number, but a number with a unit attached to it. \emph{Pint} allows you to convert between units, and this is how you create the variable \py{c}, which is 5 meters converted to inches. With this, things can get very interesting:
\begin{minted}{pycon}
>>> b == c
True
\end{minted}
Even if the numeric values of \py{b} and \py{c} are different, they're still equal to each other, exactly as you would have imagined. You can also work with more complex units:
\begin{minted}{pycon}
>>> d = c*b
>>> print(d.to('m**2'))
25.0 meter ** 2
>>> print(d.to('in**2'))
38750.07750015501 inch ** 2
>>> t = 2.5*ur('s')
>>> v = c/t
>>> print(v.to('in/s'))
78.74015748031496 inch / second
\end{minted}
So far, you have always been transforming between units of the same type (for example, a length in meters to a length in inches). But Pint can also handle combined units like you would see with voltage, current, and resistance:
\begin{minted}{pycon}
>>> current = 5*ur('A')
>>> res = 10*ur('ohm')
>>> voltage = current*res
>>> print(voltage)
50 ampere * ohm
>>> print(voltage.to('V'))
50.0 volt
>>> print(voltage.m_as('mV'))
50000.0
\end{minted}
The snippet above shows you that Pint can understand the relationship between Amperes, Ohms, and Volts.
Pint also has one more useful feature: it can parse strings to separate the units from the numbers. For example, you can do the following:
\begin{minted}{pycon}
>>> current = ur('5 A')
>>> resistance = ur('10 ohm')
\end{minted}
Having the ability to parse strings so easily will make your life much easier when dealing with user input.
Now you've seen how to handle \emph{real} units in your code. However, the device still requires that you set the output using plain numbers. In the context of Pint, the number on its own without units is called the \textbf{magnitude}. To get the magnitude of a quantity, you can do the following:
\begin{minted}{pycon}
>>> current = ur('5 A')
>>> current_mag = current.m
>>> print(current)
5 ampere
>>> print(current_mag)
5
>>> current_ma = current.m_as('mA')
>>> print(current_ma)
5000.0
\end{minted}
You start with a quantity called \py{current} of $5\,\textrm{A}$. If you just append the \py{.m} to the variable, you get the magnitude in whatever unit it's already expressed. If you want to be sure to get the magnitude in a specific unit, you use the command \py{.m_as()}. In this case, you will need to transform the user input to an integer. You won't need to assume it's in volts, since you can transform it to volts before converting it to an integer. The \py{.set_voltage()} method would look like this:
\begin{minted}{python}
def set_voltage(self, channel, volts):
value_volts = volts.m_as('V')
value_int = round(value_volts / 3.3 * 4095)
self.driver.set_analog_output(channel, value_int)
\end{minted}
You transform the value to volts and get only the magnitude. Then, you transform that value to bits, using \py{round()} to get an integer after the operation. You use that rounded value to set the output on the device. This program is now very flexible since the user can provide the output value in whatever units she pleases, provided that Pint can transform them into volts.
\questionInfo{Exercise}{Update the method \py{.get_voltage()} so it generates output in volts. Pay attention to the fact that you need to import Pint and create the unit registry before you define your class to be able to use it.}
You should also update the method for getting a voltage to return a voltage and not a plain number. The code below only shows the parts that have changed or been added, not the entire class:
%! Suppress = Ellipsis
\begin{minted}{python}
import pint
ur = pint.UnitRegistry()
[...]
def get_voltage(self, channel):
voltage_bits = self.driver.get_analog_input(channel)
voltage = voltage_bits * ur('3.3V')/1023
return voltage
\end{minted}
However, you also need to update the \py{.initialize()} and \py{.finalize()} methods in order to use units and not plain numbers:
\begin{minted}{python}
def initialize(self):
self.driver.initialize()
self.set_voltage(0, ur('0V'))
self.set_voltage(1, ur('0V'))
def finalize(self):
self.set_voltage(0, ur('0V'))
self.set_voltage(1, ur('0V'))
self.driver.finalize()
\end{minted}
The class is complete. You need to update the example code at the bottom of the file in order to use the real units:
\begin{minted}{python}
if __name__ == "__main__":
daq = AnalogDaq('/dev/ttyACM0')
daq.initialize()
voltage = ur('3000mV')
daq.set_voltage(0, voltage)
input_volts = daq.get_voltage(0)
print(input_volts)
daq.finalize()
\end{minted}
If you're hesitant about the impact that different unit systems can have, there's a great example involving a multi-million dollar satellite\footnote{You can check the article on LA times: https://bit.ly/la-times-mo} that you can use as a reference. The Mars Climate Orbiter fell from its orbit because engineers from the US failed at using the established units of measure in their software, resulting in a mix of metric and imperial systems.
\section{Testing the DAQ Model}\label{sec:testing-the-daq-model}
At this point, you have a very functional program! You can handle units, and you've separated the logic of the units from the driver, meaning that you can easily share your code with colleagues (or even the rest of the world).
It's time to test your program. One of the reasons you created the \emph{Examples} folder was to be able to add extra Python files that don't belong to your core program. In this folder, create a file called \textbf{test\_daq.py}, and try to start using the Model to do some measurements:
%! Suppress = Ellipsis
\begin{minted}{python}
import numpy as np
import pint
from PythonForTheLab.Model.analog_daq import AnalogDaq
ur = pint.UnitRegistry()
V = ur('V')
daq = AnalogDaq('/dev/ttyACM0') # <-- Remember to change the port
daq.initialize()
# 11 Values with units in a numpy array... 0, 0.3, 0.6, etc.
volt_range = np.linspace(0, 3, 11) * V
currents = [] # Empty list to store the values
for volt in volt_range:
daq.set_voltage(0, volt)
currents.append(daq.get_voltage(0))
print(current)
\end{minted}
You can run the code above, but you'll notice that you're printing currents with units of volts. This can be very confusing for someone looking at your results, or even for your future self. You have to remember that you can transform volts to amperes by dividing them with the resistance you're using. If you have a $100\,\textrm{Ohm}$ resistance, you can do the following:
\begin{minted}{python}
for volt in volt_range:
daq.set_voltage(0, volt)
measured_voltage = daq.get_voltage(0)
current = measured_voltage/ur('100ohm')
currents.append(current)
\end{minted}
If you run the code with the changes above, you will get the following error:
%! Suppress = Ellipsis
\begin{minted}{python}
[...]
ValueError: Cannot operate with Quantity and Quantity of different registries.
\end{minted}
The error is descriptive, but it's still hard to understand if you don't know the underlying principles of Pint. The unit registry is a collection of rules that allows you to transform one quantity into another. Still, these rules belong to a unit registry. In principle, two distinct unit registries hold rules for different sets of units. This means that you can't convert units across unit registries. You need to use only \emph{one} registry throughout the program. Right now, you're creating the registry in two different places: the device model and the example.
Since units belong to the entire program, it might be a good idea to define the unit registry at the root. In other words, you can create it directly in the \textbf{\_\_init\_\_.py} file that you placed in the \emph{PythonForTheLab} folder:
\begin{minted}{python}
import pint
ur = pint.UnitRegistry()
\end{minted}
Every time you want to use units and the unit registry, you can do the following:
\begin{minted}{python}
from PythonForTheLab import ur
\end{minted}
\questionInfo{Exercise}{Improve the DAQ model to use the central unit registry and not the one defined locally.}
\questionInfo{Exercise}{Modify the example that you developed for testing the DAQ model so that it uses the central unit registry, and see that it works as expected.}
After completing the exercises above, you should be able to run the example and get the values you wanted. You're getting currents in amperes, and you're setting voltages in volts. You're still missing some details, such as saving data, but the core of the measurement is already there.
\section{Appending to the PATH at Runtime}\label{sec:appending-path}
In Section~\ref{sec:path}, you saw how to add the root folder of the project to the computer's PYTHONPATH, which allows Python to find your program and allows you to import packages and modules very easily. However, altering environment variables in different operating systems is not only cumbersome, but it can also lead to unwanted results. For example, you may be overwriting something important if there are any name clashes between the program you develop and some other library on the computer.
Therefore, you can go a different route and add the folder to the path directly from within Python. This change is not permanent, and it's in place only while the program runs, but no longer. First, you need to learn how to identify the folder you want to add to the path. For this, Python offers a module called \py{os}. The code below looks cumbersome, but you'll see an explanation in just a moment. You can add the following line at the beginning of the \textbf{test\_daq.py} file:
\begin{minted}{python}
import os
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
\end{minted}
Let's start from the innermost part of the function. The \py{__file__} variable is a way of letting Python know that you're interested in the current file, which in this case is \py{test_daq.py}. First, you get the absolute path to this file, including all the folders you'd need to traverse to get there. Next, you grab the directory that holds the file, which would be the \emph{Examples} folder. Then, you grab the directory that contains the examples, which in this case is the root folder or \py{base_dir} it's called here.
Finally, you need to add the \py{base_dir} to the path. For this, you use another package called \py{sys}, which is a wrapper for the operating system. It means that this package adapts accordingly to the operating system you're using to run the program. To add the folder, you only need to do the following:
\begin{minted}{python}
import sys
sys.path.append(base_dir)
\end{minted}
That's it! It doesn't matter if you modify the PYTHONPATH variable anymore, as you can always run the \py{test_daq.py} file.
\warningInfo{The PATH}{Since appending to path only works while the program runs, if you try to run the package files independently, then Python won't know where to find the modules. The test files you created in \emph{Examples} are what are called \textbf{entry points}, and the program should be run directly from them.}
\section{Brainstorming a Real World Example}\label{sec:real-world-model}
It might still be difficult for you to truly grasp the usefulness of models at this point. You may be tempted to define units and transformations in the Controller itself. After all, you're the only one who's going to be using the program, and only for one experiment. While this may be fine when you start out, at some point, it's highly like that your code is going grow, especially as the experiment progresses and gets more complex. As an example, at Python for the Lab, we've developed software for controlling a microscope using a camera. However, there were several cameras available, some which were more expensive and more powerful, and they were all shared between people and experiments.
Therefore, having a flexible way of using different cameras for the same experiment became mandatory. Sometimes we would use a Hamamatsu, other times a Basler, and still other times a Photonics Science. However, each camera had an incredibly different way of working. First, Hamamatsu didn't provide any drivers written in Python. Photonics Science shared an internal tool they used, and Basler had an entire package called PyPylon to control their cameras. The controller layer, therefore, was not developed by us, but was provided for us.
At the model level, however, we made sure that all cameras would work in the same way. They all had the same method for setting the exposure or changing the region of interest. Therefore, the only thing that we needed to change to run an experiment with one or the other camera was the Model we were importing. The rest of the code stayed the same. If you want to see the real code, you can head to the repository for the UUTrack project\footnote{https://github.com/uetke/UUTrack}.
\section{Conclusions}\label{sec:device-model-conclusions2}
In this chapter, you learned more about the \emph{Model} component of the {MVC} pattern. You focused on adding features with regard to how the device works, such as switching off the outputs when you finish using the device. You've included real-world units by using \textbf{Pint} and learned some of its quirks regarding the unit registry.
You've also covered how to append folders to the PATH through Python. This allows you to run all the import statements you could want without having to alter the environment variables of the operating system manually. On the one hand, this is useful because your code runs unaltered on Linux, Windows, and Mac. On the other hand, you don't make any permanent changes to the configuration of the computer. For example, it's a possibility that at some point you could have two projects with the same name, projects that contain code you wrote for two different but very similar experiments, but you still want to be sure you're importing the correct one.