Skip to content

Commit

Permalink
remove pid dependcy, vendor our own with derivative smoothign
Browse files Browse the repository at this point in the history
  • Loading branch information
CamDavidsonPilon committed Jul 6, 2023
1 parent 90949ea commit f31735a
Show file tree
Hide file tree
Showing 7 changed files with 96 additions and 68 deletions.
1 change: 1 addition & 0 deletions pioreactor/background_jobs/dosing_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ def start_dosing_control(
"--automation-name",
help="set the automation of the system: turbidostat, morbidostat, silent, etc.",
show_default=True,
required=True,
)
@click.option(
"--duration",
Expand Down
1 change: 1 addition & 0 deletions pioreactor/background_jobs/led_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def start_led_control(
"--automation-name",
help="set the automation of the system: silent, etc.",
show_default=True,
required=True,
)
@click.option("--duration", default=60.0, help="Time, in minutes, between every monitor check")
@click.option(
Expand Down
72 changes: 34 additions & 38 deletions pioreactor/background_jobs/temperature_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,15 @@ class TemperatureController(BackgroundJob):
"timestamp": <ISO 8601 timestamp>
}
If you have your own thermo-couple, you can publish to this topic, with the same schema
and all should just work™️. Set `using_third_party_thermocouple` to True in the class creation, too.
Parameters
------------
using_third_party_thermocouple: bool
True if supplying an external thermometer that will publish to MQTT.
"""

MAX_TEMP_TO_REDUCE_HEATING = (
63.0 # ~PLA glass transition temp, and I've gone safely above this an it's not a problem.
)
MAX_TEMP_TO_DISABLE_HEATING = 65.0
MAX_TEMP_TO_DISABLE_HEATING = 65.0 # probably okay, but can't stay here for too long
MAX_TEMP_TO_SHUTDOWN = 66.0

INFERENCE_SAMPLES_EVERY_T_SECONDS: float = 5.0
Expand All @@ -100,8 +95,6 @@ def __init__(
automation_name: str,
unit: str,
experiment: str,
eval_and_publish_immediately: bool = True,
using_third_party_thermocouple: bool = False,
**kwargs,
) -> None:
super().__init__(unit=unit, experiment=experiment)
Expand All @@ -121,25 +114,23 @@ def __init__(
else:
from TMP1075 import TMP1075 # type: ignore

self.using_third_party_thermocouple = using_third_party_thermocouple
self.pwm = self.setup_pwm()
self.update_heater(0)

if not self.using_third_party_thermocouple:
self.tmp_driver = TMP1075(address=hardware.TEMP)
self.read_external_temperature_timer = RepeatedTimer(
53,
self.read_external_temperature,
run_immediately=False,
).start()

self.publish_temperature_timer = RepeatedTimer(
int(self.INFERENCE_EVERY_N_SECONDS),
self.infer_temperature,
run_after=self.INFERENCE_EVERY_N_SECONDS
- self.inference_total_time, # This gives an automation a "full" PWM cycle to be on before an inference starts.
run_immediately=True,
).start()
self.heating_pcb_tmp_driver = TMP1075(address=hardware.TEMP)
self.read_external_temperature_timer = RepeatedTimer(
53,
self.read_external_temperature,
run_immediately=False,
).start()

self.publish_temperature_timer = RepeatedTimer(
int(self.INFERENCE_EVERY_N_SECONDS),
self.infer_temperature,
run_after=self.INFERENCE_EVERY_N_SECONDS
- self.inference_total_time, # This gives an automation a "full" PWM cycle to be on before an inference starts.
run_immediately=True,
).start()

try:
automation_class = self.available_automations[automation_name]
Expand All @@ -164,17 +155,16 @@ def __init__(
raise e
self.automation_name = self.automation.automation_name

if not self.using_third_party_thermocouple:
if whoami.is_testing_env() or self._seconds_since_last_active() >= 10:
# if we turn off heating and turn on again, without some sort of time to cool, the first temperature looks wonky
self.temperature = Temperature(
temperature=self.read_external_temperature(),
timestamp=current_utc_datetime(),
)
if whoami.is_testing_env() or self.seconds_since_last_active_heating() >= 10:
# if we turn off heating and turn on again, without some sort of time to cool, the first temperature looks wonky
self.temperature = Temperature(
temperature=self.read_external_temperature(),
timestamp=current_utc_datetime(),
)

@staticmethod
def _seconds_since_last_active() -> float:
with local_intermittent_storage("last_heating_timestamp") as cache:
def seconds_since_last_active_heating() -> float:
with local_intermittent_storage("temperature_and_heating") as cache:
if "last_heating_timestamp" in cache:
return (
current_utc_datetime() - to_datetime(cache["last_heating_timestamp"])
Expand Down Expand Up @@ -223,7 +213,7 @@ def _read_external_temperature(self) -> float:
try:
# check temp is fast, let's do it a few times to reduce variance.
for i in range(5):
running_sum += self.tmp_driver.get_temperature()
running_sum += self.heating_pcb_tmp_driver.get_temperature()
running_count += 1
sleep(0.05)

Expand All @@ -241,6 +231,10 @@ def _read_external_temperature(self) -> float:
self._update_heater(0.0)
self.set_automation(TemperatureAutomation(automation_name="only_record_temperature"))

with local_intermittent_storage("temperature_and_heating") as cache:
cache["heating_pcb_temperature"] = averaged_temp
cache["heating_pcb_temperature_at"] = current_utc_timestamp()

return averaged_temp

##### internal and private methods ########
Expand Down Expand Up @@ -311,6 +305,10 @@ def _update_heater(self, new_duty_cycle: float) -> bool:
self.heater_duty_cycle = clamp(0.0, round(float(new_duty_cycle), 2), 100.0)
self.pwm.change_duty_cycle(self.heater_duty_cycle)

if self.heater_duty_cycle == 0.0:
with local_intermittent_storage("temperature_and_heating") as cache:
cache["last_heating_timestamp"] = current_utc_timestamp()

return True

def _check_if_exceeds_max_temp(self, temp: float) -> float:
Expand Down Expand Up @@ -367,9 +365,6 @@ def on_disconnected(self) -> None:
with suppress(AttributeError):
self.automation_job.clean_up()

with local_intermittent_storage("last_heating_timestamp") as cache:
cache["last_heating_timestamp"] = current_utc_timestamp()

def setup_pwm(self) -> PWM:
hertz = 8 # technically this doesn't need to be high: it could even be 1hz. However, we want to smooth it's
# impact (mainly: current sink), over the second. Ex: imagine freq=1hz, dc=40%, and the pump needs to run for
Expand Down Expand Up @@ -583,6 +578,7 @@ def start_temperature_control(
"--automation-name",
help="set the automation of the system",
show_default=True,
required=True,
)
@click.pass_context
def click_temperature_control(ctx, automation_name: str) -> None:
Expand Down
3 changes: 2 additions & 1 deletion pioreactor/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ def local_intermittent_storage(
Opening the same cache in a context manager is tricky, and should be avoided.
"""
# TMPDIR is in OSX and Pioreactor img (we provide it), TMP is windows
# gettempdir find the directory named by the TMPDIR environment variable.
# TMPDIR is set in the Pioreactor img.
tmp_dir = tempfile.gettempdir()
with Cache(f"{tmp_dir}/{cache_name}") as cache:
yield cache # type: ignore
Expand Down
85 changes: 58 additions & 27 deletions pioreactor/utils/streaming_calculations.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from threading import Timer
from typing import Optional

from pioreactor.pubsub import publish
from pioreactor.pubsub import create_client


class ExponentialMovingAverage:
Expand Down Expand Up @@ -376,49 +376,80 @@ def _is_positive_definite(A) -> bool:


class PID:
# used in dosing_control classes

def __init__(
self,
Kp: float,
Ki: float,
Kd: float,
setpoint: float,
K0: float = 0,
output_limits=(None, None),
output_limits: Optional[tuple[float, float]] = None,
sample_time: Optional[float] = None,
unit: Optional[str] = None,
experiment: Optional[str] = None,
job_name: Optional[str] = None,
target_name: Optional[str] = None,
**kwargs,
derivative_smoothing=0.1,
):
from simple_pid import PID as simple_PID

self.K0 = K0
self.pid = simple_PID(
Kp,
Ki,
Kd,
setpoint=setpoint,
output_limits=output_limits,
sample_time=sample_time,
**kwargs,
)
# PID coefficients
self.Kp = Kp
self.Ki = Ki
self.Kd = Kd
self.setpoint = setpoint

# The windup limit for integral term
self.output_limits = output_limits

# Smoothing factor for derivative term
self.derivative_smoothing = derivative_smoothing

# State variables
self.error_prev = 0.0
self.error_sum = 0.0
self.derivative_prev = 0.0

self.unit = unit
self.experiment = experiment
self.target_name = target_name
self.job_name = job_name
self.client = create_client(client_id=f"pid-{self.unit}-{self.experiment}")

def set_setpoint(self, new_setpoint) -> None:
self.pid.setpoint = new_setpoint
def reset(self):
"""
Resets the state variables.
"""
self.error_prev = 0.0
self.error_sum = 0.0
self.derivative_prev = 0.0

def set_setpoint(self, new_setpoint: float) -> None:
self.setpoint = new_setpoint

def update(self, input_: float, dt: float = 1.0):
"""
Updates the controller's internal state with the current error and time step,
and returns the controller output.
"""

error = self.setpoint - input_
# Update error sum with clamping for anti-windup
self.error_sum += error * dt
if self.output_limits is not None:
self.error_sum = max(min(self.error_sum, self.output_limits[1]), self.output_limits[0])

# Calculate error derivative with smoothing
derivative = (error - self.error_prev) / dt
derivative = (
self.derivative_smoothing * derivative
+ (1.0 - self.derivative_smoothing) * self.derivative_prev
)

# Update state variables
self.error_prev = error
self.derivative_prev = derivative

# Calculate PID output
output = self.Kp * error + self.Ki * self.error_sum + self.Kd * derivative

def update(self, input_: float, dt=None) -> float:
output = self.pid(input_, dt=dt)
if output is not None:
output += self.K0
else:
output = 0
self.publish_pid_stats()
return output

Expand All @@ -440,4 +471,4 @@ def publish_pid_stats(self):
"job_name": self.job_name,
"target_name": self.target_name,
}
publish(f"pioreactor/{self.unit}/{self.experiment}/pid_log", dumps(to_send))
self.client.publish(f"pioreactor/{self.unit}/{self.experiment}/pid_log", dumps(to_send))
1 change: 0 additions & 1 deletion requirements/requirements_worker.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
RPi.GPIO==0.7.1; platform_machine == "armv7l"
adafruit-circuitpython-ads1x15==2.2.12
simple-pid==1.0.1
DAC43608==0.2.6
TMP1075==0.2.1
rpi-hardware-pwm==0.1.3
Expand Down
1 change: 0 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
"DAC43608==0.2.7",
"TMP1075==0.2.1",
"rpi-hardware-pwm==0.1.4",
"simple-pid==1.0.1",
"plotext>=5.2.8",
]

Expand Down

0 comments on commit f31735a

Please sign in to comment.