-
-
Notifications
You must be signed in to change notification settings - Fork 272
Add Joulescope energy analyzer driver and EnergyAnalyzerProtocol #1920
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -765,6 +765,39 @@ Arguments: | |
| Used by: | ||
| - `SigrokDriver`_ | ||
|
|
||
| JoulescopeDevice | ||
| ~~~~~~~~~~~~~~~~ | ||
| A :any:`JoulescopeDevice` resource describes a *Joulescope* energy analyzer | ||
| (JS110, JS220 or JS320). It is a USB resource, so a specific device is selected | ||
| via udev matching when more than one Joulescope is connected; with a single | ||
| Joulescope an empty match is sufficient. The device is then addressed through | ||
| ``pyjoulescope_driver``. | ||
|
|
||
| .. code-block:: yaml | ||
|
|
||
| JoulescopeDevice: | ||
| match: | ||
| ID_SERIAL_SHORT: 'S3C8' | ||
|
|
||
| You need to ensure proper udev permissions: | ||
|
|
||
| .. code-block:: bash | ||
|
|
||
| wget https://raw.githubusercontent.com/jetperch/joulescope_driver/refs/heads/main/72-joulescope.rules | ||
| sudo cp 72-joulescope.rules /etc/udev/rules.d/ | ||
| sudo udevadm control --reload-rules | ||
|
|
||
| Arguments: | ||
| - match (dict): key and value pairs for a udev match, see `udev Matching`_ | ||
|
|
||
| Used by: | ||
| - `JoulescopeDriver`_ | ||
|
|
||
| NetworkJoulescopeDevice | ||
| ~~~~~~~~~~~~~~~~~~~~~~~~~ | ||
| A :any:`NetworkJoulescopeDevice` resource describes a `JoulescopeDevice`_ resource | ||
| available on a remote computer. | ||
|
|
||
| IMXUSBLoader | ||
| ~~~~~~~~~~~~ | ||
| An :any:`IMXUSBLoader` resource describes a USB device in the imx loader state. | ||
|
|
@@ -3191,6 +3224,50 @@ samples is an iterable of samples. | |
| This driver relies on buffering of the subprocess call. | ||
| Reading a few samples will very likely work - but obtaining a lot of samples may stall. | ||
|
|
||
| JoulescopeDriver | ||
| ~~~~~~~~~~~~~~~~ | ||
| The :any:`JoulescopeDriver` uses a `JoulescopeDevice`_ or | ||
| `NetworkJoulescopeDevice`_ resource to measure current, voltage and power, | ||
| accumulate charge and energy, capture high-rate samples to a JLS file, and switch | ||
| downstream power by connecting/disconnecting the device current path. | ||
|
|
||
| Binds to: | ||
| device: | ||
| - `JoulescopeDevice`_ | ||
| - `NetworkJoulescopeDevice`_ | ||
|
|
||
| Implements: | ||
| - :any:`EnergyAnalyzerProtocol` | ||
| - :any:`PowerProtocol` | ||
|
|
||
| .. code-block:: yaml | ||
|
|
||
| JoulescopeDriver: | ||
| frequency: 10.0 | ||
| delay: 2.0 | ||
|
|
||
| Arguments: | ||
| - frequency (float, default=2.0): statistics update frequency in Hz | ||
| - delay (float, default=2.0): delay in seconds between off and on during a | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The parameter documentation needs to be adjusted. Are the units here really Hz?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, Hz.. |
||
| power cycle | ||
|
|
||
| The latest measurement is read with ``get_statistics()``, which returns a dict | ||
| with ``current``, ``voltage`` and ``power`` sub-dicts (each with ``avg``, | ||
| ``std``, ``min`` and ``max``) plus the accumulated ``charge_C`` (Coulombs) and | ||
| ``energy_J`` (Joules). | ||
| ``start()`` and ``stop()`` bracket a charge/energy accumulation window; | ||
| ``stop()`` returns the accumulated ``energy_J``, ``charge_C`` and the | ||
| ``duration_s`` of the window. | ||
| ``capture(filename, signals=None, duration=..., frequency=None)`` records | ||
| high-rate samples to a JLS file for the requested duration. ``frequency`` (in | ||
| Hz) sets the device sample rate and is sticky: once set it stays in effect for | ||
| later captures on the same activated driver until changed again, rather than | ||
| reverting to the device default. | ||
|
|
||
| Power switching via ``on()``, ``off()`` and ``cycle()`` connects and | ||
| disconnects the device current path, controlling downstream power to the | ||
| device under test. | ||
|
|
||
| USBSDMuxDriver | ||
| ~~~~~~~~~~~~~~ | ||
| The :any:`USBSDMuxDriver` uses a `USBSDMuxDevice`_ resource to control a | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import os | ||
| import time | ||
| import uuid | ||
|
|
||
| import attr | ||
|
|
||
| from ..factory import target_factory | ||
| from ..protocol import EnergyAnalyzerProtocol, PowerProtocol | ||
| from ..resource.joulescope import JoulescopeDevice | ||
| from ..resource.remote import NetworkJoulescopeDevice | ||
| from ..step import step | ||
| from ..util.agentwrapper import AgentWrapper | ||
| from ..util.ssh import sshmanager | ||
| from .common import Driver | ||
|
|
||
|
|
||
| @target_factory.reg_driver | ||
| @attr.s(eq=False) | ||
| class JoulescopeDriver(Driver, EnergyAnalyzerProtocol, PowerProtocol): | ||
| """The JoulescopeDriver controls a Joulescope energy analyzer. | ||
|
|
||
| It wraps ``pyjoulescope_driver`` to stream measurement statistics | ||
| (current, voltage, power and accumulated charge/energy), to capture | ||
| high-rate samples to a JLS file, and to connect/disconnect the device | ||
| current path (downstream power) as a :class:`PowerProtocol` power switch. | ||
|
|
||
| ``pyjoulescope_driver`` runs on the host the Joulescope is attached to | ||
| through labgrid's agent mechanism, so the same driver works for a locally | ||
| attached device and for one shared over the distributed infrastructure via | ||
| a :class:`~labgrid.resource.remote.NetworkJoulescopeDevice`. Only the host | ||
| with the device attached needs the ``joulescope`` extra installed. | ||
|
|
||
| Power switching (``on``/``off``/``cycle``) controls downstream power to the | ||
| device under test: the JS110 uses the current range ``select`` and the JS220 | ||
| and JS320 use the current range ``mode``. | ||
|
|
||
| Args: | ||
| frequency (float): statistics update frequency in Hz | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After analyzing the output data, I realized that this should actually be in 1MHz.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is in Hz. The calculation is in util.agents.JoulescopeSession._configure_statistics. What are you seeing that make think otherwise? Note that statistics frequency (provided here) and sample freqeucny (provided to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for your explanation. I think I got confused between the sample rate frequency (the parameter passed to |
||
| delay (float): delay between off and on during a power cycle | ||
| """ | ||
|
|
||
| bindings = {"device": {JoulescopeDevice, NetworkJoulescopeDevice}} | ||
| frequency = attr.ib(default=2.0, validator=attr.validators.instance_of(float)) | ||
| delay = attr.ib(default=2.0, validator=attr.validators.instance_of(float)) | ||
|
|
||
| def __attrs_post_init__(self): | ||
| super().__attrs_post_init__() | ||
| self.wrapper = None | ||
| self.proxy = None | ||
|
|
||
| # -- life cycle --------------------------------------------------------- | ||
|
|
||
| def on_activate(self): | ||
| host = self.device.host if isinstance(self.device, NetworkJoulescopeDevice) else None | ||
| self.wrapper = AgentWrapper(host) | ||
| try: | ||
| self.proxy = self.wrapper.load("joulescope") | ||
| self.proxy.open(self.device.serial, self.device.model, self.frequency) | ||
| except Exception: | ||
| # on_deactivate() only runs once the driver is active, so clean up | ||
| # the (possibly remote) agent subprocess if opening the device fails. | ||
| self.wrapper.close() | ||
| self.wrapper = None | ||
| self.proxy = None | ||
| raise | ||
|
|
||
| def on_deactivate(self): | ||
| try: | ||
| self.proxy.close(self.device.serial, self.device.model) | ||
| finally: | ||
| self.wrapper.close() | ||
| self.wrapper = None | ||
| self.proxy = None | ||
|
|
||
| # -- statistics --------------------------------------------------------- | ||
|
|
||
| @Driver.check_active | ||
| @step(result=True) | ||
| def get_statistics(self): | ||
| return self.proxy.get_statistics(self.device.serial, self.device.model) | ||
|
|
||
| @Driver.check_active | ||
| @step() | ||
| def start(self): | ||
| self.proxy.start(self.device.serial, self.device.model) | ||
|
|
||
| @Driver.check_active | ||
| @step(result=True) | ||
| def stop(self): | ||
| return self.proxy.stop(self.device.serial, self.device.model) | ||
|
|
||
| # -- sample capture ----------------------------------------------------- | ||
|
|
||
| @Driver.check_active | ||
| @step(args=["filename", "duration"]) | ||
| def capture(self, filename, signals=None, duration=None, frequency=None): | ||
| if duration is None: | ||
| raise ValueError("capture() requires a duration in seconds") | ||
| if isinstance(self.device, NetworkJoulescopeDevice): | ||
| # Record on the host the device is attached to, then copy the JLS | ||
| # file back to the client and remove the remote copy. | ||
| remote = f"/tmp/labgrid-joulescope-{uuid.uuid4()}.jls" | ||
| self.proxy.capture(self.device.serial, self.device.model, remote, signals, duration, frequency) | ||
| try: | ||
| sshmanager.get_file(self.device.host, remote, filename) | ||
| finally: | ||
| self.proxy.remove(remote) | ||
| else: | ||
| self.proxy.capture( | ||
| self.device.serial, self.device.model, os.fspath(filename), signals, duration, frequency | ||
| ) | ||
|
|
||
| # -- power switch (PowerProtocol) -------------------------------------- | ||
|
|
||
| @Driver.check_active | ||
| @step() | ||
| def on(self): | ||
| self.proxy.set_power(self.device.serial, self.device.model, True) | ||
|
|
||
| @Driver.check_active | ||
| @step() | ||
| def off(self): | ||
| self.proxy.set_power(self.device.serial, self.device.model, False) | ||
|
|
||
| @Driver.check_active | ||
| @step() | ||
| def cycle(self): | ||
| self.off() | ||
| time.sleep(self.delay) | ||
| self.on() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import abc | ||
|
|
||
|
|
||
| class EnergyAnalyzerProtocol(abc.ABC): | ||
| """Protocol for energy analyzers such as the Joulescope. | ||
|
|
||
| An energy analyzer continuously measures current, voltage and power and | ||
| accumulates charge and energy. Drivers implementing this protocol expose | ||
| the latest statistics, an accumulation window (start/stop) for charge and | ||
| energy, and high-rate sample capture to a file. | ||
| """ | ||
|
|
||
| @abc.abstractmethod | ||
| def get_statistics(self): | ||
| """Return the latest measurement statistics as a dict. | ||
|
|
||
| The returned dict contains ``current``, ``voltage`` and ``power`` | ||
| sub-dicts (each with ``avg``, ``std``, ``min`` and ``max`` keys) as | ||
| well as the accumulated ``charge_C`` (Coulombs) and ``energy_J`` | ||
| (Joules). Convenience values such as average current are read from | ||
| this return value rather than via dedicated accessors. | ||
| """ | ||
| raise NotImplementedError | ||
|
|
||
| @abc.abstractmethod | ||
| def start(self): | ||
| """Begin a charge/energy accumulation window.""" | ||
| raise NotImplementedError | ||
|
|
||
| @abc.abstractmethod | ||
| def stop(self): | ||
| """End the accumulation window started by :meth:`start`. | ||
|
|
||
| Returns a dict with the accumulated ``energy_J`` (Joules), | ||
| ``charge_C`` (Coulombs) and the ``duration_s`` (seconds) of the window. | ||
| """ | ||
| raise NotImplementedError | ||
|
|
||
| @abc.abstractmethod | ||
| def capture(self, filename, signals=None, duration=None, frequency=None): | ||
| """Capture high-rate samples to a file for the given duration. | ||
|
|
||
| ``frequency`` (in Hz) sets the device sample rate for the capture. It | ||
| is sticky: once set it remains in effect for subsequent captures on the | ||
| same activated driver until changed again, rather than reverting to the | ||
| device default. | ||
| """ | ||
| raise NotImplementedError |
Uh oh!
There was an error while loading. Please reload this page.