Skip to content

Commit

Permalink
allow for fractions in light-dark cycle
Browse files Browse the repository at this point in the history
  • Loading branch information
CamDavidsonPilon committed Jul 12, 2023
1 parent d2b29bb commit d2ebf2a
Show file tree
Hide file tree
Showing 8 changed files with 182 additions and 34 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ repos:
hooks:
- id: mypy
additional_dependencies: [
msgspec==0.16.0,
msgspec==0.17.0,
types-pkg_resources==0.1.3,
types-paho-mqtt==1.6.0.6
]
Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
### Upcoming

- improved sensitivity of self-test `test_REF_is_in_correct_position`
- executing experiment profiles now check for required plugins
- improved sensitivity of self-test `test_REF_is_in_correct_position`.
- executing experiment profiles now check for required plugins.
- Plugins can be built with a flag file LEADER_ONLY to only be installed on the leader Pioreactor.
- Light/Dark cycle accepts fractional hours as well as integer hours. Note we changed the API for this automation slightly!

### 23.6.27

Expand Down
55 changes: 40 additions & 15 deletions pioreactor/automations/led/light_dark_cycle.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import annotations

from typing import Optional

from pioreactor.automations import events
from pioreactor.automations.led.base import LEDAutomationJob
from pioreactor.types import LedChannel
Expand Down Expand Up @@ -31,46 +33,69 @@ def __init__(
**kwargs,
):
super().__init__(**kwargs)
self.hours_online: int = -1
self.minutes_online: int = -1
self.light_active: bool = False
self.channels: list[LedChannel] = ["D", "C"]
self.set_light_intensity(light_intensity)
self.light_duration_hours = float(light_duration_hours)
self.dark_duration_hours = float(dark_duration_hours)

def execute(self) -> events.AutomationEvent:
self.hours_online += 1
return self.trigger_leds(self.hours_online)
if 0 < self.light_duration_hours < 1 / 60.0 or 0 < self.dark_duration_hours < 1 / 60.0:
# users can input 0 - that's fine and deliberate. It's when they put in 0.01 that it makes no sense.
self.logger.error("Durations must be at least 1 minute long.")
raise ValueError("Durations must be at least 1 minute long.")

def execute(self) -> Optional[events.AutomationEvent]:
# runs every minute
self.minutes_online += 1
return self.trigger_leds(self.minutes_online)

def trigger_leds(self, minutes: int) -> Optional[events.AutomationEvent]:
"""
Changes the LED state based on the current minute in the cycle.
The light and dark periods are calculated as multiples of 60 minutes, forming a cycle.
Based on where in this cycle the current minute falls, the light is either turned ON or OFF.
Args:
minutes: The current minute of the cycle.
def trigger_leds(self, hours: int) -> events.AutomationEvent:
cycle_duration = self.light_duration_hours + self.dark_duration_hours
Returns:
An instance of AutomationEvent, indicating that LEDs' status might have changed.
Returns None if the LEDs' state didn't change.
"""
cycle_duration_min = int((self.light_duration_hours + self.dark_duration_hours) * 60)

if ((hours % cycle_duration) < self.light_duration_hours) and (not self.light_active):
if ((minutes % cycle_duration_min) < (self.light_duration_hours * 60)) and (
not self.light_active
):
self.light_active = True

for channel in self.channels:
self.set_led_intensity(channel, self.light_intensity)

return events.ChangedLedIntensity(f"{hours}h: turned on LEDs.")
return events.ChangedLedIntensity(f"{minutes/60:.1f}h: turned on LEDs.")

elif ((hours % cycle_duration) >= self.light_duration_hours) and (self.light_active):
elif ((minutes % cycle_duration_min) >= (self.light_duration_hours * 60)) and (
self.light_active
):
self.light_active = False
for channel in self.channels:
self.set_led_intensity(channel, 0)
return events.ChangedLedIntensity(f"{hours}h: turned off LEDs.")
return events.ChangedLedIntensity(f"{minutes/60:.1f}h: turned off LEDs.")

else:
return events.NoEvent(f"{hours}h: no change.")
return None

def set_dark_duration_hours(self, hours: int):
self.dark_duration_hours = hours

self.trigger_leds(self.hours_online)
self.trigger_leds(self.minutes_online)

def set_light_duration_hours(self, hours: int):
self.light_duration_hours = hours

self.trigger_leds(self.hours_online)
self.trigger_leds(self.minutes_online)

def set_light_intensity(self, intensity: float | str):
# this is the settr of light_intensity attribute, eg. called when updated over MQTT
Expand All @@ -83,6 +108,6 @@ def set_light_intensity(self, intensity: float | str):
pass

def set_duration(self, duration: float) -> None:
if duration != 60:
self.logger.warning("Duration should be set to 60.")
if duration != 1:
self.logger.warning("Duration should be set to 1.")
super().set_duration(duration)
11 changes: 8 additions & 3 deletions pioreactor/background_jobs/od_reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ def __init__(
fake_data: bool = False,
interval: Optional[float] = 1.0,
dynamic_gain: bool = True,
penalizer: float = 625.0,
oversampling_count: int = 28,
penalizer: float = 700.0,
oversampling_count: int = 30,
) -> None:
super().__init__()
self.fake_data = fake_data
Expand Down Expand Up @@ -435,7 +435,7 @@ def take_reading(self) -> PdChannelToVoltage:
0,
-time_sampling_took_to_run() # the time_sampling_took_to_run() reduces the variance by accounting for the duration of each sampling.
+ 0.85 / (oversampling_count - 1)
+ 0.0015
+ 0.0012
* (
(counter * 0.618034) % 1
), # this is to artificially jitter the samples, so that we observe less aliasing. That constant is phi.
Expand Down Expand Up @@ -807,6 +807,11 @@ def __init__(
self.ir_led_intensity: pt.LedIntensityValue = config.getfloat(
"od_config", "ir_led_intensity"
)
if self.ir_led_intensity > 90:
self.logger.warning(
f"The value for the IR LED, {self.ir_led_intensity}%, is very high. We suggest a value 90% or less to avoid damaging the LED."
)

self.non_ir_led_channels: list[pt.LedChannel] = [
ch for ch in led_utils.ALL_LED_CHANNELS if ch != self.ir_channel
]
Expand Down
130 changes: 122 additions & 8 deletions pioreactor/tests/test_led_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@

import time

import pytest
from msgspec.json import encode

from pioreactor import pubsub
from pioreactor import structs
from pioreactor.actions.led_intensity import lock_leds_temporarily
from pioreactor.automations import events
from pioreactor.automations.led.base import LEDAutomationJob
from pioreactor.automations.led.light_dark_cycle import LightDarkCycle
from pioreactor.background_jobs.led_control import LEDController
from pioreactor.utils import local_intermittent_storage
from pioreactor.utils.timing import current_utc_datetime
Expand Down Expand Up @@ -135,9 +138,13 @@ def test_light_dark_cycle_turns_off_after_N_cycles() -> None:
unit=unit,
experiment=experiment,
) as lc:
while lc.automation_job.hours_online < 17:
while lc.automation_job.minutes_online < 0:
pass

pause()
lc.automation_job.minutes_online = 16 * 60 + 58
pause()

assert not lc.automation_job.light_active
with local_intermittent_storage("leds") as c:
assert c["D"] == 0.0
Expand All @@ -149,20 +156,24 @@ def test_dark_duration_hour_to_zero() -> None:
unit = get_unit_name()
with LEDController(
"light_dark_cycle",
duration=0.01,
duration=0.005,
light_intensity=50,
light_duration_hours=16,
dark_duration_hours=8,
unit=unit,
experiment=experiment,
) as lc:
while lc.automation_job.hours_online < 17:
while lc.automation_job.minutes_online < 0:
pass

assert not lc.automation_job.light_active
pause()
lc.automation_job.minutes_online = 15 * 60 + 58
pause()

assert not lc.automation_job.light_active
pause()
lc.automation_job.set_dark_duration_hours(0)

pause()
assert lc.automation_job.light_active

with local_intermittent_storage("leds") as c:
Expand Down Expand Up @@ -202,9 +213,13 @@ def test_add_dark_duration_hours() -> None:
unit=unit,
experiment=experiment,
) as lc:
while lc.automation_job.hours_online < 17:
while lc.automation_job.minutes_online < 0:
pass

pause()
lc.automation_job.minutes_online = 15 * 60 + 59
pause()

assert not lc.automation_job.light_active

lc.automation_job.set_dark_duration_hours(10)
Expand All @@ -221,16 +236,24 @@ def test_remove_dark_duration_hours() -> None:
unit = get_unit_name()
with LEDController(
"light_dark_cycle",
duration=0.01,
duration=0.005,
light_intensity=50,
light_duration_hours=16,
dark_duration_hours=8,
unit=unit,
experiment=experiment,
) as lc:
while lc.automation_job.hours_online < 20:
while lc.automation_job.minutes_online < 0:
pass

pause()
lc.automation_job.minutes_online = 15 * 60 + 58
pause()

pause()
lc.automation_job.minutes_online = 20 * 60 + 58
pause()

assert not lc.automation_job.light_active

lc.automation_job.set_dark_duration_hours(3)
Expand All @@ -240,3 +263,94 @@ def test_remove_dark_duration_hours() -> None:
with local_intermittent_storage("leds") as c:
assert c["D"] == 50.0
assert c["C"] == 50.0


def test_fractional_hours() -> None:
experiment = "test_fractional_hours"
unit = get_unit_name()
with LEDController(
"light_dark_cycle",
duration=0.005,
light_intensity=50,
light_duration_hours=0.9,
dark_duration_hours=0.1,
unit=unit,
experiment=experiment,
) as lc:
while lc.automation_job.minutes_online < 0:
pass

while lc.automation_job.minutes_online < 10:
pass
assert lc.automation_job.light_active

while lc.automation_job.minutes_online < 55:
pass
assert not lc.automation_job.light_active

while lc.automation_job.minutes_online < 62:
pass
assert lc.automation_job.light_active


@pytest.fixture
def light_dark_cycle():
return LightDarkCycle(
duration=1,
unit=get_unit_name(),
experiment="test_light_dark_cycle",
light_intensity=100,
light_duration_hours=1,
dark_duration_hours=1,
)


def test_light_turns_on_in_light_period(light_dark_cycle):
# Setting the minutes to 30 (inside the light period of 1 hour)
light_dark_cycle.minutes_online = 30

# In this case, light should be turned on
event = light_dark_cycle.trigger_leds(light_dark_cycle.minutes_online)

# Check that the LEDs were turned on
assert isinstance(event, events.ChangedLedIntensity)
assert "turned on LEDs" in event.message
assert light_dark_cycle.light_active


def test_light_stays_on_in_light_period(light_dark_cycle):
# Setting the minutes to 30 (inside the light period of 1 hour) and light_active to True
light_dark_cycle.minutes_online = 30
light_dark_cycle.light_active = True

# In this case, light should stay on
event = light_dark_cycle.trigger_leds(light_dark_cycle.minutes_online)

# Check that no change in LED status occurred
assert event is None


def test_light_turns_off_in_dark_period(light_dark_cycle):
# Setting the minutes to 60 (inside the dark period of 1 hour, after the light period of 1 hour)
light_dark_cycle.light_active = True
light_dark_cycle.minutes_online = 60

# In this case, light should be turned off
event = light_dark_cycle.trigger_leds(light_dark_cycle.minutes_online)

# Check that the LEDs were turned off
assert isinstance(event, events.ChangedLedIntensity)
assert "turned off LEDs" in event.message
assert light_dark_cycle.light_active


def test_light_stays_off_in_dark_period(light_dark_cycle):
# Setting the minutes to 90 (inside the dark period of 1 hour) and light_active to False
light_dark_cycle.minutes_online = 90
light_dark_cycle.light_active = False

# In this case, light should stay off
event = light_dark_cycle.trigger_leds(light_dark_cycle.minutes_online)

# Check that no change in LED status occurred
assert event is None
9 changes: 6 additions & 3 deletions pioreactor/utils/streaming_calculations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ class ExponentialMovingAverage:
"""
Models the following:
y_n = (1 - )·x + ·y_{n-1}
y_n = (1 - alpha)·x + alpha·y_{n-1}
If alpha = 0, use latest value only.
Ex: if alpha = 0, use latest value only.
"""

def __init__(self, alpha: float):
Expand Down Expand Up @@ -405,6 +405,7 @@ def __init__(

# State variables
self.error_prev: Optional[float] = None
self._last_input: Optional[float] = None
self.error_sum = 0.0
self.derivative_prev = 0.0

Expand Down Expand Up @@ -443,7 +444,9 @@ def update(self, input_: float, dt: float = 1.0) -> float:
self.error_sum = min(self.error_sum, self.output_limits[1])

# Calculate error derivative with smoothing
derivative = ((error - self.error_prev) if self.error_prev is not None else 0) / dt
# derivative = ((error - self.error_prev) if self.error_prev is not None else 0) / dt
# http://brettbeauregard.com/blog/2011/04/improving-the-beginner%e2%80%99s-pid-derivative-kick/
derivative = -(input_ - self._last_input) / dt if self._last_input is not None else 0
derivative = (
1 - self.derivative_smoothing
) * derivative + self.derivative_smoothing * self.derivative_prev
Expand Down
2 changes: 1 addition & 1 deletion requirements/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ sh==1.14.2
JSON-log-formatter==0.4.0
rpi_hardware_pwm==0.1.3
colorlog==6.6.0
msgspec==0.16.0
msgspec==0.17.0
diskcache==5.6.1
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"sh==1.14.3",
"JSON-log-formatter==0.5.1",
"colorlog==6.7.0",
"msgspec==0.16.0",
"msgspec==0.17.0",
"diskcache==5.6.1",
"wheel==0.38.4",
"crudini==0.9.4",
Expand Down

0 comments on commit d2ebf2a

Please sign in to comment.