Instrumental icon indicating copy to clipboard operation
Instrumental copied to clipboard

Mock Instruments

Open jondoesntgit opened this issue 6 years ago • 22 comments

Hey,

I would like to write a functionality to mock some instruments based on this framework. Something akin to what QCoDeS refers to as a Manual Instrument. Would you be willing to merge this in if I wrote it?

Jonathan

jondoesntgit avatar Jul 17 '18 00:07 jondoesntgit

Would this require something similar to the VisaMixin class in instrumental.drivers.__init__.py?

jondoesntgit avatar Jul 24 '18 22:07 jondoesntgit

A mixin class might be the right way to define mock/manual instruments, though it might be enough to define some kind of MockFacet.

Could you give an example of what you'd like to achieve/how you'd use such an instrument, and what features (methods/behaviors) you'd like it to support? That'll give me a better idea of which approach would be better.

natezb avatar Jul 25 '18 00:07 natezb

Sure.

Implementation A

from instrumental import mock_instrument

power_supply = mock_instrument()
power_supply.voltage = MockFacet(units=ureg.volts)
power_supply.current = MockFacet(units=ureg.milliamps)

... walk over in the lab to tweak some settings

power_supply.voltage = ureg.Quantity('1.2 volt')
power_supply.current = ureg.Quantity('20 milliamps')

Implementation B

// mock_instruments.json
{
    "name":"power_supply",
    "facets": [
    {"name": "voltage", "units": "volts"},
    {"name": "current", "units": "milliamps"}
    ]
}
power_supply = load_instrument('power_supply')
power_supply.voltage = ureg.Quantity('1.2 volt')
power_supply.current = ureg.Quantity('20 milliamps')

... And then of course, you'd be able to save and load the instrument as you normally do for other instruments. I haven't looked into the actual file format you use for serializing saved instruments...

jondoesntgit avatar Jul 25 '18 03:07 jondoesntgit

Here's an example implementation.

Suppose that we have some laser diode, that is driven by a laser diode driver. The laser diode itself is a tiny 50-gram piece of equipment that sits in a larger chassis that delivers power to it from the driver. The laser's output, linewidth, wavelength, etc... depend on the the current it receives from the laser diode driver as well as its temperature.

Often, we are more concerned about getting a specific power out of the laser than knowing its drive current. One temptation would be to put a helper function in the laser diode driver, but the current depends on which laser is being hooked up.

Using a mock instrument might allow one to apply some sort of Model to the laser diode + laser diode driver system. Here is what it might look like in code.

ldd = load_instrument('MyLaserDiodeDriver')
lucent = load_mock_instrument('MyLucentLaser')

lucent.current = ldd.current
lucent.temperature = ldd.temperature

def power_model(self):
    threshold = Q_('10 mA')
    power = (self.current - threshold) * Q_('1 mW/10 mA').to('mW')
    return power

from types import MethodType # in order to bind the function so we can use self
lucent.power = MethodType(power_model, lucent)

# then
ldd.set_current = Q_('20 mA')
print(lucent.power)
# 1 mW

Eventually, it'd be nice to write code so that lucent could influence ldd. Perhaps through a setter and getter method.

jondoesntgit avatar Jul 25 '18 03:07 jondoesntgit

I'll write up a longer response with some suggestions when I get a chance, but first another question: are you expecting to use this for mocking a "real" instrument (for testing higher-level code, etc.), creating a "manual" instrument for devices that cannot be computer controlled, or for both purposes?

natezb avatar Jul 26 '18 01:07 natezb

There's probably pros and cons to combining vs a different class for "manual" and "mock" instruments. Combining means less complexity, less testing, less documentation, and I think if we're clever, we can write a broad enough class to handle both. At this point, people seem to have been using Instrumental for 5+ years without this feature, so I think for now, having one class should be fine.

jondoesntgit avatar Jul 26 '18 03:07 jondoesntgit

Let's start with the idea of a "manual" instrument, since that seems more fundamental to me. A "mock" instrument could then be a manual instrument that's defined (manually or perhaps automatically) to mimic the interface of an existing "real" instrument.

At the moment, I don't see an immediate benefit to an inheritable ManualInstrument class (but I'm open to the idea). Instead, you could just define an ordinary Instrument that has ManualFacets, e.g.

class MyPowerSupply(Instrument):
    voltage = ManualFacet(units='volts')
    current = ManualFacet(units='amps')
>>> ps = MyPowerSupply()
>>> ps.voltage = '12 V'
>>> ps.voltage
<Quantity(12, 'volt')>

ManualFacet should be easy to create by inheriting from Facet and automatically supplying an fset and fget that simply store and retrieve the value from the instrument instance.

I imagine you may want to create small manual Instrument subclasses that aren't intended for inclusion in Instrumental's set of drivers. I imagine you would simply have a local module/package that defines these classes outside of the Instrumental package. I think all of this should work ok.

The next step would be to consider whether it's useful or worth it to allow registering of (or auto-register) these Instrument subclasses so that they can be opened by instrument(). Probably not a priority, but worth thinking about. This integration might allow you to save instruments as well, so they're easily visible/accessible through list_instrument().

natezb avatar Jul 26 '18 19:07 natezb

I created a new branch manual-inst and added a prototype ManualFacet and added some changes to allow use of Instrument classes provided by external packages. I've only tested it a bit, and I'm sure there are some things that will have to be cleaned up (as far as external-package drivers), but it lets you do the following:

from instrumental.drivers import Instrument, ManualFacet

class MyPowerSupply(Instrument):
    _INST_PARAMS_ = ['name']
    voltage = ManualFacet(units='volts')
    current = ManualFacet(units='amps')

    def _initialize(self):
        self.name = self._paramset['name']

You can then use the instrument as you'd expect:

>>> ps = MyPowerSupply('myps')
>>> ps.voltage
<Quantity(0, 'volt')>
>>> ps.voltage = '12 V'
>>> ps.voltage
<Quantity(12, 'volt')>
>>> ps.save_instrument('my_power_supply')
>>> ps = instrument('my_power_supply')
>>> ps.current
<Quantity(0, 'ampere')>

When you load a saved instrument, it tries to import the module in which its Instrument subclass was defined, so this should not change. Therefore the module with your class definition should either be on the PYTHONPATH (perhaps in site-packages) or in the working directory where you intend to use it.

Take a look and let me know what you think.

natezb avatar Jul 27 '18 18:07 natezb

Thanks! I just got back from a vacation. This looks great!

I'm going to work on writing some models this week that implement this code, and will report back some comments here!

jondoesntgit avatar Jul 31 '18 00:07 jondoesntgit

Finally looking at this now.

Am I supposed to manually register this new driver with instrumental somehow. I’m getting this error:

---------------------------------------------------------------------------Exception Traceback (most recent call last) in ----> 1 ps = MyPowerSupply('foo') 2 ps.voltage ~/Dropbox/digonnet/code/instrumental/instrumental/drivers/init.py in new(cls, inst, **kwds) 572 kwds['module'] = driver_submodule_name(cls.module) 573 kwds['classname'] = cls.name--> 574 return instrument(inst, **kwds) 575 576 def _initialize(self, **settings): ~/Dropbox/digonnet/code/instrumental/instrumental/drivers/init.py in instrument(inst, **kwargs) 1311 if isinstance(inst, Instrument): 1312 return inst-> 1313 params, alias = _extract_params(inst, kwargs) 1314 1315 if 'server' in params: ~/Dropbox/digonnet/code/instrumental/instrumental/drivers/init.py in _extract_params(inst, kwargs) 983 if raw_params is None: 984 raise Exception("Instrument with alias {} not ".format(name) +--> 985 "found in config file") 986 987 params = ParamSet(**raw_params) # Copy first to avoid modifying input dicts Exception: Instrument with alias foo not found in config file

On July 27, 2018 at 11:52:06 AM, Nate Bogdanowicz ([email protected]) wrote:

I created a new branch manual-inst https://github.com/mabuchilab/Instrumental/tree/manual-inst and added a prototype ManualFacet and added some changes to allow use of Instrument classes provided by external packages. I've only tested it a bit, and I'm sure there are some things that will have to be cleaned up (as far as external-package drivers), but it lets you do the following:

from instrumental.drivers import Instrument, ManualFacet

class MyPowerSupply(Instrument): INST_PARAMS = ['name'] voltage = ManualFacet(units='volts') current = ManualFacet(units='amps')

def _initialize(self):
    self.name = self._paramset['name']

You can then use the instrument as you'd expect:

ps = MyPowerSupply('myps') ps.voltage <Quantity(0, 'volt')> ps.voltage = '12 V' ps.voltage <Quantity(12, 'volt')> ps.save_instrument('my_power_supply')

ps = instrument('my_power_supply') ps.current <Quantity(0, 'ampere')>

When you load a saved instrument, it tries to import the module in which its Instrument subclass was defined, so this should not change. Therefore the module with your class definition should either be on the PYTHONPATH (perhaps in site-packages) or in the working directory where you intend to use it.

Take a look and let me know what you think.

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/mabuchilab/Instrumental/issues/56#issuecomment-408508240, or mute the thread https://github.com/notifications/unsubscribe-auth/AHIjaFweAOA1GSzj8ULNgpG0tS8_n-yyks5uK2FVgaJpZM4VR_Yd .

jondoesntgit avatar Dec 18 '18 19:12 jondoesntgit

I'm traveling today, but I'll give it a look when I get a chance.

In the meantime, make sure you're using the manual-inst branch and that your driver's module is importable as described above.

natezb avatar Dec 18 '18 21:12 natezb

I think the problem is that I didn’t figure out how to save the driver in the right place. I’ll have to tinker with this a little more. I was merely trying to define the driver int he same script as execution

jondoesntgit avatar Dec 20 '18 01:12 jondoesntgit

I got it working with the external package. Two things I'm working on now:

  • I wasn't able to get the state to persist when saving the file.
  • After opening a saved file, I'm unsure how to save it a second time, or whether that is intuitively what we want to do anyway?

jondoesntgit avatar Dec 20 '18 07:12 jondoesntgit

I'm not exactly sure what your most recent comment means. By persistent state, do you mean e.g. the voltage values? That's not currently something that's supported, but I'm open to it. And what is the "file" you're talking about saving. Are you talking about a saved instrument? At this point, saved instruments are merely a set of parameters that are used to locate and open a device (obviously, this was originally built for real hardware).

natezb avatar Dec 21 '18 01:12 natezb

Yes, I was imagining that when I set the value of a ManualFacet, that the value would be written through to the disk, and available every time it’s loaded.

Yes, I meant “saved instrument” by file.

jondoesntgit avatar Dec 21 '18 15:12 jondoesntgit

I see now. That's not something that's implemented yet, but it certainly could be.

I think it's probably best to keep this out of instrumental.conf (where the saved instruments are stored), but could either be a single file (JSON maybe) with settings for all mock instruments, or one file per instrument. I'm leaning towards the latter, as it prevents separate instruments in separate processes from trying to write to the same file.

The first challenge is probably to decide how best to uniquely identify each mock instrument. We could use "name" (alias) and class/module name. They could be used as a unique pair, or we could just use the alias and optionally verify that it was created with the same class that we're trying to use to open it with now. Or the check could be more "structural" or duck-typed, where it need only have (a subset of) fields that match the class's.

natezb avatar Dec 21 '18 15:12 natezb

I agree that since these manual instruments are categorically different from everything else that instrumental does, for instrumental to treat them differently. I think the biggest difference is that most of the conventional instruments that instrumental interfaces keep track of their own state.

python
>>> from instrumetal import instrument
>>> ps = instrument('power_supply_with_remote_interface')
>>> ps.voltage = '12 V'
>>> exit()

python
>>> from instrumetal import instrument
>>> ps = instrument('power_supply_with_remote_interface')
>>> ps.voltage
Quantity(12, 'Volts')

Thus, even when python closes, the state is preserved across sessions.

I would advocate for pickling the manual instruments. There may be situations where I may want to save, for example, an IV curve in a manual instrument as an additional property of the of the class. JSON would probably not be able to capture this.

Definitely agree with one file per instrument, for the reasons that you described, and also portability of individual files.

jondoesntgit avatar Dec 21 '18 16:12 jondoesntgit

Yes, pickling could work well, especially if we require the instrument's class/module to be importable. It's worth considering whether we should use an older pickle protocol to allow these files to be read across python versions.

It looks like the current implementation has no notion of a manual/mock instrument, but merely manual facets. So, perhaps we save the status of any ManualFacet under it's instrument's alias?

A major question here is how we integrate this with the notion of an instrument's "alias". As I mentioned above, passing an alias to instrument() simply checks the instrumental.conf file for an entry matching this alias, then uses these params to look for and open an instrument. This means that there can be multiple different aliases used for a single instrument, perhaps with different settings attached to them. I think the current implementation will attach this alias to the Instrument instance once it's opened (or when it's saved). If the instrument is not opened via an alias, it has no alias.

So, it may make sense to restrict saving of ManualFacets to instances that have an alias. We might add two different hooks to save the data:

  1. Save an instrument's facets just before it is closed, or just as it goes out of scope or the interpreter is closing.
  2. Add an option to ManualFacet to save every time its value is set.

natezb avatar Dec 21 '18 17:12 natezb

I know that older versions of pickle cannot handle lambda functions, etc.... I would have to monkey around with some more use cases to figure out if we could handle everything via Python2's pickle, but right now I think the answer is yes.

Is there a need to mix ManualFacets with the conventional facets? I.e. can an instrument be part manual, and part conventional? My first guess is no, in which case we could just worry about manual/mock instruments all together, without having to worry about instrumental.conf at all...

jondoesntgit avatar Dec 21 '18 17:12 jondoesntgit

I think it's possible but unlikely (or uncommon) that an instrument could have some remote control, with a few things on the front panel that can only be switched manually. I can't think of any such specific device at the moment, but it seems possible.

If we were to make manual/mock and conventional instruments mutually exclusive, how would you propose implementing the saving feature?

natezb avatar Dec 22 '18 00:12 natezb

We could have a separate directory (perhaps determined by some conf/env variable) where all the pickled objects could go.

jondoesntgit avatar Dec 25 '18 19:12 jondoesntgit

I just pushed some changes that add save-state functionality. Some notes:

  • Instruments can save and load their state, provided they have an alias (which they will have when saved via save_instrument() or loaded by alias).
  • ManualFacets have a new setting to enable autosaving of their instrument's state upon setting the facet's value.
  • I'm pickling the instrument's __dict__ instead of the instrument itself due to complications in the instrument creation process (caused b/c we reimplement __new__()). This means you don't unpickle the instrument directly, but instead open it via alias, then load its state. We could add a setting to enable automatic loading of this state, if desired.

If you modify the MyPowerSupply implementation to pass save_on_set=True for the ManualFacets, you should now be able to do something like this:

>>> ps = MyPowerSupply('whatever')
>>> ps.save_instrument('myps')
>>> ps.current = '500 mA'  # _save_state() auto-called if save_on_set is True

Close the interpreter and reopen,

>>> ps = instrument('myps')
>>> ps.current
<Quantity(0, 'ampere')>
>>> ps._load_state()
>>> ps.current
<Quantity(0.5, 'ampere')>

Note that the power supply's name parameter has nothing to do with its alias. A real implementation probably shouldn't have such a name parameter.

Some additional things we might consider doing:

  • [ ] Add general setting to auto-load saved instrument state
  • [ ] Add auto-saving before close() is called
  • [ ] Expose load/save as public methods?
  • [ ] Add conf setting for the saved-state directory
  • [ ] Determine what the default saved-state directory should be called

Let me know what you think

natezb avatar Dec 30 '18 18:12 natezb