Is there a relay module/controller

Hi is there a module for relay control?

I asked Bard:

Considering https://docs.pioreactor.com/, https://github.com/Pioreactor/pioreactor and specifcally https://github.com/camdavidsonpilon/pioreactor-relay-plugin please write all the code for an automation with a controller that, by default, turns on a relay for 10s every hour, and incorporates get_unit_name and get_latest_experiment_name

It gave me:

import time
from pioreactor import relays, get_unit_name, get_latest_experiment_name
from pioreactor.logging import create_logger

logger = create_logger("hourly_relay")

def activate_relay(relay_name, duration):
    try:
        relays.activate(relay_name)
        logger.info(f"Activated relay '{relay_name}' for {duration} seconds")
        time.sleep(duration)
        relays.deactivate(relay_name)
        logger.info(f"Deactivated relay '{relay_name}'")
    except Exception as e:
        logger.error(f"Error activating relay: {e}")

def hourly_relay_controller():
    relay_name = "my_relay"  # Replace with the actual relay name
    duration = 10  # Duration in seconds

    unit_name = get_unit_name()
    latest_experiment_name = get_latest_experiment_name()

    logger.info(f"Running for unit: {unit_name}, latest experiment: {latest_experiment_name}")

    while True:
        activate_relay(relay_name, duration)
        logger.info("Waiting for 1 hour")
        time.sleep(3600)

if __name__ == "__main__":
    hourly_relay_controller()

Which I eventually ran as

import time

from pioreactor import relay
from pioreactor.whoami import get_unit_name, get_latest_experiment_name
from pioreactor.logging import create_logger

logger = create_logger("hourly_relay")

def activate_relay(relay_name, duration):
    try:
        relay.activate(relay_name)
        logger.info(f"Activated relay '{relay_name}' for {duration} seconds")
        time.sleep(duration)
        relay.deactivate(relay_name)
        logger.info(f"Deactivated relay '{relay_name}'")
    except Exception as e:
        logger.error(f"Error activating relay: {e}")

def hourly_relay_controller():
    relay_name = "relay"  # Replace with the actual relay name
    duration = 10  # Duration in seconds

    unit_name = get_unit_name()
    latest_experiment_name = get_latest_experiment_name()

    logger.info(f"Running for unit: {unit_name}, latest experiment: {latest_exp>

    while True:
        activate_relay(relay_name, duration)
        logger.info("Waiting for 1 hour")
        time.sleep(3600)

if __name__ == "__main__":
    hourly_relay_controller()

But of course there is no relay or relays module. I tried from the docs to work out how to do this using the DosingController, but it seems to want to deal with ml rather than pump on/off and has no ‘activate’ or ‘deactivate’ functions.

I also tried Bing with GPT-4 with the same prompt and it gave:

from pioreactor.whoami import get_unit_name, get_latest_experiment_name
from pioreactor.background_jobs.base import BackgroundJob
from pioreactor.pubsub import publish
from time import sleep

class RelayController(BackgroundJob):
    def __init__(self, unit=None, experiment=None):
        self.unit = unit or get_unit_name()
        self.experiment = experiment or get_latest_experiment_name()
        super().__init__(job_name="RelayController", unit=self.unit, experiment=self.experiment)

    def execute(self):
        while True:
            # Turn on the relay
            publish("pioreactor/{unit}/{experiment}/relay/1/state".format(unit=self.unit, experiment=self.experiment), "ON")
            sleep(10)  # Keep the relay on for 10 seconds
            # Turn off the relay
            publish("pioreactor/{unit}/{experiment}/relay/1/state".format(unit=self.unit, experiment=self.experiment), "OFF")
            sleep(3600)  # Wait for an hour before turning on the relay again

if __name__ == "__main__":
    rc = RelayController()
    rc.start()

At least it knew to import get_unit_name from whoami, but running that gave me an error that I find harder to decipher:

pioreactor@pio001:~ $ python3 hourly_relay_controller.py
Traceback (most recent call last):
  File "/home/pioreactor/hourly_relay_controller.py", line 22, in <module>
    rc = RelayController()
         ^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/dist-packages/pioreactor/background_jobs/base.py", line 98, in __call__
    obj = type.__call__(cls, *args, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pioreactor/hourly_relay_controller.py", line 10, in __init__
    super().__init__(job_name="RelayController", unit=self.unit, experiment=self.experiment)
TypeError: BackgroundJob.__init__() got an unexpected keyword argument 'job_name'

It kinda looks like code for Pioreactor, but the code is too far off that it’s better to start over.

The automation / controller pair is used for LEDs, dosing, and temperature only - it’s backend is more complex but that’s so writing new {LED, dosing, temperature} automations is simpler. There’s not a generic one for controlling PWMs.

There’s a few ways to solve your original query. I suggest writing a new BackgroundJob that controls the PWM, and turns it on/off every 60m.

Add the following Python to as a .py in your ~/.pioreactor/plugins folder

# -*- coding: utf-8 -*-
from __future__ import annotations
from time import sleep

import click
from pioreactor.background_jobs.base import BackgroundJobContrib
from pioreactor.config import config
from pioreactor.hardware import PWM_TO_PIN
from pioreactor.utils.pwm import PWM
from pioreactor.whoami import get_latest_experiment_name
from pioreactor.whoami import get_unit_name
from pioreactor.utils.timing import RepeatedTimer

__plugin_summary__ = "A relay that turns on for X seconds every hour."
__plugin_version__ = "0.0.1"
__plugin_name__ = "Schedualed Relay"
__plugin_author__ = "Cam DP"

class SchedualedRelay(BackgroundJobContrib):

    job_name = "schedualed_relay"
    relay_on_for = 10 # seconds

    def __init__(self, unit: str, experiment: str) -> None:
        super().__init__(unit=unit, experiment=experiment, plugin_name="schedualed_relay")

        # looks at config.ini/configuration on UI to match
        pwm_pin = PWM_TO_PIN[config.get("PWM_reverse", "relay")]

        self.pwm = PWM(
            pwm_pin, hz=10, unit=unit, experiment=experiment
        )  # since we also go 100% high or 0% low, we don't need hz, but some systems don't allow a very low hz (like hz=1).
        self.pwm.lock()
        self.pwm.start(0)

        # this is core logic to turn on every hour: run `turn_relay_on_for_period_of_time` every 60*60 seconds
        self.repeated_thread= RepeatedTimer(
            60 * 60, # seconds
            self.turn_relay_on_for_period_of_time,
            job_name=self.job_name
        ).start()

    def turn_relay_on_for_period_of_time(self):
        self.logger.info(f"Turning on relay for {self.relay_on_for}s.")
        self.pwm.change_duty_cycle(100)
        sleep(self.relay_on_for)
        self.pwm.change_duty_cycle(0)

    def on_ready_to_sleeping(self) -> None:
        self.repeated_thread.pause()

    def on_sleeping_to_ready(self) -> None:
        self.repeated_thread.unpause()

    def on_disconnected(self) -> None:
        self.repeated_thread.cancel()
        self.pwm.clean_up()


@click.command(name="schedualed_relay")
def click_schedualed_relay() -> None:
    """
    Start the relay
    """
    job = SchedualedRelay(
        unit=get_unit_name(),
        experiment=get_latest_experiment_name(),
    )
    job.block_until_disconnected()

Then you can test it on the command line with:

pio run schedualed_relay

To put it into the UI as an activity you can start/pause/stop, you can follow the steps here.


Edit: code above has been edited with below comments

Generally, your question is “is there a module for relay control?” and the answer is, no. I’ve hacked this by using the PWM class and setting duty_cycle to 0 and 100 (effectively on / off). That being said, this pattern is used often, so maybe in the future we create a simple Relay wrapper around PWM that simplifies this. For now, using PWM is pretty straight forward though.

Thanks again Cam. I’m afraid that while it says in the logs “71.67 h pio001 schedualed relay Turning on relay for 10s.” (or 30s when I changed to that, after changing to a 60s cycle time (turn_relay_on_for_period_of_time) the relay isn’t clicking and the gas isn’t flowing.

More surprisingly, I stopped the script, turned on the relay in ACTIVITIES but it wouldn’t turn off again.

Here’s a screen capture with my ramblings, if it helps.

I then rebooted the Pioreactor and the relay was off on startup, START worked again (after a ~10s delay which seems usual) but again STOP does nothing.

Hm, I’ll check tomorrow with my system, but some things to check:

  1. you have a relay under some output in the [PWM] config section? And your relay is connected to that output?

  2. Oh it could be that I didn’t include a “self.pwm.start(0)”. Try the following change:

         self.pwm = PWM(
             pwm_pin, hz=10, unit=unit, experiment=experiment
         )  # since we also go 100% high or 0% low, we don't need hz, but some systems don't allow a very low hz (like hz=1).
         self.pwm.lock()
         self.pwm.start(0) # NEW 
         ...
    

    If it is that (I’ll check later too), I’ll change this to throw an error to make it more clear to call start before change_duty_cycle.

  3. Note that this is a different activity than “Relay” (the plugin you have installed). You’ll need to add this as a new activity (my link in the previous post should give you some details on how to do that, but basically you need to create a yaml file for schedualed_relay)

Hint: the yaml file should look something like

---
display_name: My Schedualed Relay
job_name: schedualed_relay
display: true  # true to display on the /Pioreactors card
source: Schedualed Relay
description: This description is displayed with the start/stop buttons in Manage / Activities.
published_settings: []

and be added to /home/pioreactor/.pioreactor/plugins/ui/contrib/jobs

2 fixed it

And the good news before that was that the ACTIVITY relay is working again, although the Pioreactor is now throwing “temperature_control encountered an error in pio001” - possibly because the temperature rapidly dropped to 20°C perhaps because the CO2 isn’t warm & it was stuck on for a while. OD dropped to a new low at the same time though which is a little suspicious.

To answer your questions:

  1. Yes:
[PWM]
# map the PWM channels to externals.
# hardware PWM are available on channels 2 & 4.
1=stirring
2=media
3=relay
4=waste
5=heating
  1. Yes that fixed it

  2. Yes so far I’m just testing over SSH. I had prepared this YAML file which I’m going to save as ~/.pioreactor/plugins//ui/contrib/jobs/schedualed_relay.yaml

---
display_name: Schedualed Relay
job_name: schedualed_relay
display: true  # true to display on the /Pioreactors card
source: schedualed_relay
description: turn on relay for X s every Y h.
published_settings:
  - key: relay_on_for   # as defined in Python
    unit: s  #
    label: Relay on for # human readable name
    description: Change the number of seconds that the relay stays on for.
    type: numeric  # one of numeric, bool, string, json
    default: 10
    display: true # true to display on the /Pioreactors card
  - key: turn_relay_on_for_period_of_time
    unit: s
    label: Relay cycle time
    description: Change the number of seconds between each relay-on period.
    type: numeric # one of numeric, bool, string, json
    default: 3600
    display: true # true to display on the /Pioreactors card

Possibly unrelated but I just tried exporting the data so I could work out the optimal time for sparging (rather than just once per hour) but got this:

[32m2024-01-06T23:04:02+0000 INFO [export_experiment_data] Starting export of table: pioreactor_unit_activity_data_rollup.e[0m Traceback (most recent call last): File "/usr/local/bin/pio", line 8, in <module> sys.exit(pio()) ^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 1157, in __call__ return self.main(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 1078, in main rv = self.invoke(ctx) ^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 1688, in invoke return _process_result(sub_ctx.command.invoke(sub_ctx)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 1688, in invoke return _process_result(sub_ctx.command.invoke(sub_ctx)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 1434, in invoke return ctx.invoke(self.callback, **ctx.params) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 783, in invoke return __callback(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/pioreactor/actions/leader/export_experiment_data.py", line 165, in click_export_experiment_data export_experiment_data(experiment, output, partition_by_unit, tables) File "/usr/local/lib/python3.11/dist-packages/pioreactor/actions/leader/export_experiment_data.py", line 73, in export_experiment_data with zipfile.ZipFile(output, mode="w", compression=zipfile.ZIP_DEFLATED) as zf, closing( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.11/zipfile.py", line 1281, in __init__ self.fp = io.open(file, filemode) ^^^^^^^^^^^^^^^^^^^^^^^ FileNotFoundError: [Errno 2] No such file or directory: '/var/www/pioreactorui/static/exports/export_Coe001_20240106230401.zip'
72.58 h	pio001	export experiment data	Starting export of table: pioreactor_unit_activity_data_rollup.
72.58 h	pio001	export datasets	e[32m2024-01-06T23:03:49+0000 INFO [export_experiment_data] Starting export of table: pioreactor_unit_activity_data.e[0m Traceback (most recent call last): File "/usr/local/bin/pio", line 8, in <module> sys.exit(pio()) ^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 1157, in __call__ return self.main(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 1078, in main rv = self.invoke(ctx) ^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 1688, in invoke return _process_result(sub_ctx.command.invoke(sub_ctx)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 1688, in invoke return _process_result(sub_ctx.command.invoke(sub_ctx)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 1434, in invoke return ctx.invoke(self.callback, **ctx.params) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/click/core.py", line 783, in invoke return __callback(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/dist-packages/pioreactor/actions/leader/export_experiment_data.py", line 165, in click_export_experiment_data export_experiment_data(experiment, output, partition_by_unit, tables) File "/usr/local/lib/python3.11/dist-packages/pioreactor/actions/leader/export_experiment_data.py", line 73, in export_experiment_data with zipfile.ZipFile(output, mode="w", compression=zipfile.ZIP_DEFLATED) as zf, closing( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.11/zipfile.py", line 1281, in __init__ self.fp = io.open(file, filemode) ^^^^^^^^^^^^^^^^^^^^^^^ FileNotFoundError: [Errno 2] No such file or directory: '/var/www/pioreactorui/static/exports/export_Coe001_20240106230348.zip'
72.58 h	pio001	export experiment data	Starting export of table: pioreactor_unit_activity_data.```

Okay glad it works!

If that temperature error keeps occurring, let me know. I’m not sure why it might have failed, but yes, could be a sudden drop in temp if the CO2 is very cold.

The top of YAML looks fine, but your Python plugin didn’t define any published_settings, so those won’t work without some edits to the plugin. Ex:

class SchedualedRelay(BackgroundJobContrib):

    job_name = "schedualed_relay"
    
    published_settings = {
        'relay_on_for':  {"datatype": "float", "settable": True, "unit": "s"},
    }

    def __init__(self, unit: str, experiment: str) -> None:
        super().__init__(unit=unit, experiment=experiment, plugin_name="schedualed_relay")

        self.relay_on_for = 10

This sets 10s as the default, and is now available to be edited externally. Then your yaml can look like:

...
published_settings:
  - key: relay_on_for   # as defined in Python
    unit: s  #
    label: Relay on for # human readable name
    description: Change the number of seconds that the relay stays on for.
    type: numeric  # one of numeric, bool, string, json
    display: true # true to display on the /Pioreactors card

Note that changing the turn_relay_on_for_period_of_time dynamically requires more complex Python since you need to tell RepeatTimer that the duration has been updated. See the “info” section here.

1 Like

?? that looks like that bug from 23.11.18! /var/www/pioreactorui/static/exports/ should exist in newer versions - I thought I addressed that! Anyways this is the fix: New Pioreactor release: 23.11.18 - #2 by CamDavidsonPilon

I’ll think about why this happened and how to prevent it permanently.


Ack, I reintroduced this bug… :sweat_smile: fixed for future releases.

1 Like

Ah, that’s a pitty. I imagine the turn_relay_on_for_period_of_time will be the most commonly edited of the two parameters. Once you know you have enough CO2, stripping, mixing, etc in a sparge, I imagine you’ll leave that set. You’ll then likely need to edit the cycle period to optimise for the current bugs. For example, I just read off the graphs that my first sparge to plateaux time was 2.21 h, then 4.43 h, and most recently 4.88 h. No problem with me changing it in the code for now, certainly not a weekend job, but long term it would be good for users to be able to tweak this in the interface.

Yup that fixed the export, and sorry, I think you gave me the same fix for 23.11.18 - I should have searched for it…

It’s not hard, but requires some decisions:

    def set_turn_relay_on_for_period_of_time(self, value)
        value = float(value)
        self.repeated_thread.interval = value

It can be as easy as that, but what about the edge cases where:

  • a cycle is about to happen and you set it to 30m - should it wait 30m, or still cycle?
  • what if I change the duration mid-way through a cycle?
  • etc…

I think the current way repeated_thread works is that it will finish the existing cycle, then start with the new duration.

Ah, good point. I’d suggest a warning in the interface that says something along the lines of “changing the cycle duration will add that duration to the current off-period, so it’s best to change just after a relay-on period.”

Better still give an additional option to skip next off-period, as in change cycle duration and relay-on now. I realise that’s extra complexity, but I recall your having something similar elsewhere.

Hmm I seem to now have a greyed out Relay on toggle in SETTINGS and when I try to change Relay on for, I get schedualed_relay encountered an error in pio001 'SchedualedRelay' object has no attribute 'relay_on_for' although I’ve checked in nano, and it definitely appears to…

You’ll probably be glad to hear that I’m calling it a night & am off tomorrow. I’ve hopefully set it to go on for 20s every 5h with

~/.pioreactor/plugins/schedualed_relay.py

# -*- coding: utf-8 -*-
from __future__ import annotations
from time import sleep

import click
from pioreactor.background_jobs.base import BackgroundJobContrib
from pioreactor.config import config
from pioreactor.hardware import PWM_TO_PIN
from pioreactor.utils.pwm import PWM
from pioreactor.whoami import get_latest_experiment_name
from pioreactor.whoami import get_unit_name
from pioreactor.utils.timing import RepeatedTimer

__plugin_summary__ = "A relay that turns on for X seconds every hour."
__plugin_version__ = "0.0.1"
__plugin_name__ = "Schedualed Relay"
__plugin_author__ = "Cam DP"


class SchedualedRelay(BackgroundJobContrib):

    job_name = "schedualed_relay"
    
    published_settings = {
        'relay_on_for':  {"datatype": "float", "settable": True, "unit": "s"},
    }

    def __init__(self, unit: str, experiment: str) -> None:
        super().__init__(unit=unit, experiment=experiment, plugin_name="schedualed_relay")

        self.relay_on_for = 20 # seconds

    def __init__(self, unit: str, experiment: str) -> None:
        super().__init__(unit=unit, experiment=experiment, plugin_name="schedualed_relay")


        # looks at config.ini/configuration on UI to match
        self.pwm_pin = PWM_TO_PIN[config.get("PWM_reverse", "relay")]

        self.pwm = PWM(
            self.pwm_pin, hz=10, unit=unit, experiment=experiment
        )  # since we also go 100% high or 0% low, we don't need hz, but some systems don't allow a very low hz (like hz=1).
        self.pwm.lock()

        # this is core logic to turn on every hour: run `turn_relay_on_for_period_of_time` every 60*60 seconds
        self.repeated_thread= RepeatedTimer(
            5 * 60 * 60, # seconds
            self.turn_relay_on_for_period_of_time,
            job_name=self.job_name
        ).start()

    def turn_relay_on_for_period_of_time(self):
        self.logger.info(f"Turning on relay for {self.relay_on_for}s.")
        self.pwm.change_duty_cycle(100)
        sleep(self.relay_on_for)
        self.pwm.change_duty_cycle(0)

    def on_ready_to_sleeping(self) -> None:
        self.repeated_thread.pause()

    def on_sleeping_to_ready(self) -> None:
        self.repeated_thread.unpause()

    def on_disconnected(self) -> None:
        self.repeated_thread.cancel()
        self.pwm.clean_up()


@click.command(name="schedualed_relay")
def click_schedualed_relay() -> None:
    """
    Start the relay
    """
    job = SchedualedRelay(
        unit=get_unit_name(),
        experiment=get_latest_experiment_name(),
    )
    job.block_until_disconnected()

and
~/.pioreactor/plugins/ui/contrib/jobs/schedualed_relay.yaml

---
display_name: Schedualed Relay
job_name: schedualed_relay
display: true  # true to display on the /Pioreactors card
source: schedualed_relay
description: turn on relay for X s every Y h.
published_settings:
  - key: relay_on_for   # as defined in Python
    unit: s  #
    label: Relay on for # human readable name
    description: Change the number of seconds that the relay stays on for.
    type: numeric  # one of numeric, bool, string, json
    display: true # true to display on the /Pioreactors card

Your code looks like:

My apologies, I should have been more clear in my snippet. You have two __init__ there, when you should have one. The code should look like:

class SchedualedRelay(BackgroundJobContrib):

    job_name = "schedualed_relay"
    
    published_settings = {
        'relay_on_for':  {"datatype": "float", "settable": True, "unit": "s"},
    }

    def __init__(self, unit: str, experiment: str) -> None:
        super().__init__(unit=unit, experiment=experiment, plugin_name="schedualed_relay")

        self.relay_on_for = 20 # seconds

        # looks at config.ini/configuration on UI to match
        self.pwm_pin = PWM_TO_PIN[config.get("PWM_reverse", "relay")]
        # ... 

That’s because that toggle is tied to the Relay activity, and not your new SchedualedRelay activity (we didn’t define any way to control that toggle). Take a look at the Relay plugin code and it’s yaml to see how you can define the toggle action.

Brilliant, that’s fixed now. And completely my fault, your snippet was fine, I just somehow pasted without overtyping & then was in too much rush to read the final code…

And yes the toggle now makes sense. I’m wondering if it makes sense to add the Schedualed (I’ve been wondering all evening if that’s a Canadian spelling…?) Relay functionality into the Relay plug-in to avoid confusion. Or should I just delete the Relay plugin? Otherwise I’m sure at some point in the future I’ll start up and turn on the Relay since it comes before the Schedualed Relay in the list.

lol it’s a Cameron-only spelling

I don’t think we’ll add more functionality to the existing Relay plugin, I want it to be pretty “skinny”. Users now have this forum thread to find a scheduled version :slight_smile:

I don’t get this… I’m seeing PWM 3 intensity of 100% in Pioreactors Settings but measuring ~0V across the PWM 3 pins. When I run the waste pump I get 11.94V using my relay’s barrel connector.